Files
buzz/desktop/src-tauri/src/managed_agents/git_bash.rs
2026-07-23 18:27:40 -04:00

677 lines
23 KiB
Rust

//! Git Bash discovery shared by Doctor and the buzz-agent readiness gate.
//!
//! The MCP child receives a deliberately small environment. Discovery inspects
//! exactly the shared resolver-key contract forwarded into that child, plus the
//! Git-for-Windows registry. A Doctor green state therefore means `buzz-dev-mcp`
//! can actually start its shell.
#[cfg(all(not(windows), test))]
use std::path::Path;
#[cfg(windows)]
use std::path::{Path, PathBuf};
/// A Git Bash installation the stripped MCP child can launch.
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct GitBashPrerequisite {
pub available: bool,
pub path: Option<String>,
pub install_instructions_url: String,
pub install_hint: String,
}
#[cfg(windows)]
const INSTALL_URL: &str = "https://git-scm.com/download/win";
#[cfg(windows)]
const INSTALL_HINT: &str =
"Install Git for Windows and select \"Git from the command line and also from 3rd-party software\" for its PATH option.";
/// Install hint for error messages when `install_shell_command` can't find a shell on Windows.
#[cfg(windows)]
pub(crate) const GIT_BASH_INSTALL_HINT: &str = INSTALL_HINT;
/// Resolve the Git Bash executable path using the same resolver chain as Doctor.
///
/// Returns `Some(path)` on Windows when a usable bash is found, `None` otherwise
/// (including all non-Windows platforms). Honors `BUZZ_SHELL` (any executable) —
/// correct for the Doctor readiness gate where any shell suffices.
#[allow(dead_code)] // used only on Windows; called by discover_git_bash()
pub(crate) fn resolve_git_bash_path() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
let env = GitBashEnv::from_process();
return resolve_git_bash(
&env.path,
env.shell_override,
env.git_bash_override,
env.system_root,
env.program_files,
env.program_files_x86,
env.local_app_data,
);
}
#[cfg(not(windows))]
None
}
/// Resolve a bash-compatible shell for install commands and login-shell discovery.
///
/// Unlike `resolve_git_bash_path`, this skips `BUZZ_SHELL` entirely — that override
/// intentionally accepts any executable (`cmd`, `pwsh`) for the MCP child, but install
/// commands and `login_shell_candidates` use bash-only `-l -c` syntax. Skipping the
/// override means the chain falls through to: `GIT_BASH` → PATH scan → derive-from-git
/// → well-known locations → registry.
#[allow(dead_code)] // used only on Windows, from install_shell_command + login_shell_candidates
pub(crate) fn resolve_bash_path() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
let env = GitBashEnv::from_process();
return resolve_git_bash(
&env.path,
None, // skip BUZZ_SHELL — install/login-shell callers require bash
env.git_bash_override,
env.system_root,
env.program_files,
env.program_files_x86,
env.local_app_data,
);
}
#[cfg(not(windows))]
None
}
pub(crate) fn discover_git_bash() -> Option<GitBashPrerequisite> {
#[cfg(windows)]
{
let path = resolve_git_bash_path();
return Some(GitBashPrerequisite {
available: path.is_some(),
path: path.map(|path| path.display().to_string()),
install_instructions_url: INSTALL_URL.to_string(),
install_hint: INSTALL_HINT.to_string(),
});
}
#[cfg(not(windows))]
None
}
#[cfg(windows)]
pub(crate) fn git_bash_available(overrides: &std::collections::BTreeMap<String, String>) -> bool {
let env = GitBashEnv::from_process_with_overrides(overrides);
resolve_git_bash(
&env.path,
env.shell_override,
env.git_bash_override,
env.system_root,
env.program_files,
env.program_files_x86,
env.local_app_data,
)
.is_some()
}
/// All process environment that Git Bash discovery may inspect. Its keys are
/// deliberately sourced from `buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV`,
/// the exact allowlist forwarded to the otherwise-cleared MCP child.
#[cfg(windows)]
struct GitBashEnv {
path: String,
shell_override: Option<PathBuf>,
git_bash_override: Option<PathBuf>,
system_root: Option<PathBuf>,
program_files: Option<PathBuf>,
program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
}
#[cfg(windows)]
impl GitBashEnv {
fn from_process() -> Self {
Self::from_process_with_overrides(&Default::default())
}
fn from_process_with_overrides(overrides: &std::collections::BTreeMap<String, String>) -> Self {
let values: std::collections::HashMap<_, _> = buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV
.iter()
.filter_map(|key| {
overrides
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
.map(|(_, value)| std::ffi::OsString::from(value))
.or_else(|| std::env::var_os(key))
.map(|value| (*key, value))
})
.collect();
Self::from_lookup(|key| values.get(key).cloned())
}
fn from_lookup(mut get: impl FnMut(&str) -> Option<std::ffi::OsString>) -> Self {
Self {
path: get("PATH")
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
shell_override: get("BUZZ_SHELL").map(PathBuf::from),
git_bash_override: get("GIT_BASH").map(PathBuf::from),
system_root: get("SystemRoot").map(PathBuf::from),
program_files: get("ProgramFiles").map(PathBuf::from),
program_files_x86: get("ProgramFiles(x86)").map(PathBuf::from),
local_app_data: get("LOCALAPPDATA").map(PathBuf::from),
}
}
}
#[cfg(windows)]
pub(crate) fn resolve_git_bash(
path_env: &str,
shell_override: Option<PathBuf>,
git_bash_override: Option<PathBuf>,
system_root: Option<PathBuf>,
program_files: Option<PathBuf>,
program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
) -> Option<PathBuf> {
resolve_git_bash_inner(
path_env,
shell_override,
git_bash_override,
system_root,
program_files,
program_files_x86,
local_app_data,
true,
)
}
/// Inner resolver with an explicit `check_registry` toggle so tests can
/// disable the ambient `HKLM/HKCU\SOFTWARE\GitForWindows` lookup.
#[cfg(windows)]
fn resolve_git_bash_inner(
path_env: &str,
shell_override: Option<PathBuf>,
git_bash_override: Option<PathBuf>,
system_root: Option<PathBuf>,
program_files: Option<PathBuf>,
program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
check_registry: bool,
) -> Option<PathBuf> {
let result = shell_override
.and_then(|path| resolve_shell_override(&path, path_env))
.or_else(|| git_bash_override.filter(|path| path.is_file()))
.or_else(|| scan_path_for_bash(path_env, system_root.as_deref()))
.or_else(|| {
scan_path_for_command(Path::new("git.exe"), path_env, None)
.and_then(|git| bash_from_git(&git))
})
.or_else(|| {
git_bash_from_standard_paths([program_files, program_files_x86, local_app_data])
});
if result.is_some() {
return result;
}
if check_registry {
return git_bash_from_registry();
}
None
}
/// Like `resolve_git_bash` but skips the ambient Windows registry lookup, so
/// tests can assert "no resolution" deterministically regardless of the CI
/// runner's installed software.
#[cfg(all(windows, test))]
pub(crate) fn resolve_git_bash_no_registry(
path_env: &str,
shell_override: Option<PathBuf>,
git_bash_override: Option<PathBuf>,
system_root: Option<PathBuf>,
program_files: Option<PathBuf>,
program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
) -> Option<PathBuf> {
resolve_git_bash_inner(
path_env,
shell_override,
git_bash_override,
system_root,
program_files,
program_files_x86,
local_app_data,
false,
)
}
/// Resolve `BUZZ_SHELL` with the same rooted/bare-name semantics as the MCP
/// resolver. This intentionally accepts any executable shell; its presence is
/// sufficient for the MCP child and therefore for the readiness gate.
#[cfg(windows)]
fn resolve_shell_override(shell: &Path, path_env: &str) -> Option<PathBuf> {
if shell.components().count() > 1 || shell.has_root() {
shell.is_file().then(|| shell.to_path_buf())
} else {
scan_path_for_command(shell, path_env, None)
}
}
#[cfg(windows)]
fn bash_from_git(git: &Path) -> Option<PathBuf> {
let bash = git.parent()?.parent()?.join("bin").join("bash.exe");
bash.is_file().then_some(bash)
}
#[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)
.filter(|p| !is_windows_apps_alias(p))
}
/// Return `true` when `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 (issue #2328).
///
/// The check is purely path-structural so it compiles and is testable on any host.
#[cfg(any(windows, test))]
pub(crate) 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
}
#[cfg(windows)]
fn scan_path_for_command(
name: &Path,
path_env: &str,
system_root: Option<&Path>,
) -> Option<PathBuf> {
let needs_exe = name.extension().is_none();
std::env::split_paths(path_env).find_map(|dir| {
if system_root.is_some_and(|root| is_under_dir(&dir, root)) {
return None;
}
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
if needs_exe {
let mut candidate = dir.join(name);
candidate.set_extension("exe");
if candidate.is_file() {
return Some(candidate);
}
}
None
})
}
#[cfg(windows)]
fn is_under_dir(dir: &Path, root: &Path) -> bool {
let mut dir_components = dir.components();
root.components().all(|root_component| {
dir_components.next().is_some_and(|dir_component| {
dir_component
.as_os_str()
.eq_ignore_ascii_case(root_component.as_os_str())
})
})
}
/// Probe machine and per-user Git for Windows registry keys after the standard
/// install-location fallback has been exhausted.
#[cfg(windows)]
fn git_bash_from_registry() -> Option<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE,
KEY_READ,
};
const KEY: &str = "SOFTWARE\\GitForWindows";
const VALUE: &str = "InstallPath";
let key: Vec<u16> = KEY.encode_utf16().chain(Some(0)).collect();
let value: Vec<u16> = VALUE.encode_utf16().chain(Some(0)).collect();
// SAFETY: Inputs are null-terminated UTF-16 for the duration of each call,
// and every successfully opened handle is closed before trying the next hive.
unsafe {
for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
let mut handle = std::ptr::null_mut();
if RegOpenKeyExW(hive, key.as_ptr(), 0, KEY_READ, &mut handle) != ERROR_SUCCESS {
continue;
}
let mut byte_len = 0;
let status = RegQueryValueExW(
handle,
value.as_ptr(),
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut byte_len,
);
if (status != ERROR_SUCCESS && status != ERROR_MORE_DATA) || byte_len == 0 {
RegCloseKey(handle);
continue;
}
let mut data = vec![0u16; (byte_len as usize).div_ceil(2)];
let status = RegQueryValueExW(
handle,
value.as_ptr(),
std::ptr::null(),
std::ptr::null_mut(),
data.as_mut_ptr().cast(),
&mut byte_len,
);
RegCloseKey(handle);
if status != ERROR_SUCCESS {
continue;
}
while data.last() == Some(&0) {
data.pop();
}
let bash = PathBuf::from(OsString::from_wide(&data))
.join("bin")
.join("bash.exe");
if bash.is_file() {
return Some(bash);
}
}
}
None
}
#[cfg(windows)]
fn git_bash_from_standard_paths(
[program_files, program_files_x86, local_app_data]: [Option<PathBuf>; 3],
) -> Option<PathBuf> {
[
program_files.map(|base| base.join("Git")),
program_files_x86.map(|base| base.join("Git")),
local_app_data.map(|base| base.join("Programs").join("Git")),
]
.into_iter()
.flatten()
.map(|install_root| install_root.join("bin").join("bash.exe"))
.find(|bash| bash.is_file())
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use tempfile::tempdir;
const DETECTOR_ENV_KEYS: &[&str] = &[
"PATH",
"BUZZ_SHELL",
"GIT_BASH",
"SystemRoot",
"ProgramFiles",
"ProgramFiles(x86)",
"LOCALAPPDATA",
];
#[test]
fn test_detector_env_keys_match_agent_shell_resolution_contract() {
assert_eq!(
DETECTOR_ENV_KEYS,
buzz_agent_pkg::WINDOWS_SHELL_RESOLUTION_ENV,
"Doctor and the env-cleared MCP child must inspect the same resolver inputs"
);
let env = GitBashEnv::from_lookup(|key| Some(key.into()));
assert_eq!(env.path, "PATH");
assert_eq!(env.shell_override, Some(PathBuf::from("BUZZ_SHELL")));
assert_eq!(env.git_bash_override, Some(PathBuf::from("GIT_BASH")));
assert_eq!(env.system_root, Some(PathBuf::from("SystemRoot")));
assert_eq!(env.program_files, Some(PathBuf::from("ProgramFiles")));
assert_eq!(
env.program_files_x86,
Some(PathBuf::from("ProgramFiles(x86)"))
);
assert_eq!(env.local_app_data, Some(PathBuf::from("LOCALAPPDATA")));
}
#[test]
fn test_git_cmd_on_path_resolves_sibling_bash() {
let temp = tempdir().expect("tempdir");
let git = temp
.path()
.join("Program Files")
.join("Git")
.join("cmd")
.join("git.exe");
let bash = temp
.path()
.join("Program Files")
.join("Git")
.join("bin")
.join("bash.exe");
std::fs::create_dir_all(git.parent().expect("git parent")).expect("mkdir git");
std::fs::create_dir_all(bash.parent().expect("bash parent")).expect("mkdir bash");
std::fs::write(&git, []).expect("git");
std::fs::write(&bash, []).expect("bash");
let path = std::env::join_paths([git.parent().expect("cmd dir")]).expect("PATH");
assert_eq!(
resolve_git_bash(
path.to_str().expect("utf8"),
None,
None,
None,
None,
None,
None
),
Some(bash)
);
}
#[test]
fn test_program_files_x86_git_bash_resolves() {
let temp = tempdir().expect("tempdir");
let program_files_x86 = temp.path().join("Program Files (x86)");
let bash = program_files_x86.join("Git").join("bin").join("bash.exe");
std::fs::create_dir_all(bash.parent().expect("bash parent")).expect("mkdir bash");
std::fs::write(&bash, []).expect("bash");
assert_eq!(
resolve_git_bash("", None, None, None, None, Some(program_files_x86), None,),
Some(bash)
);
}
#[test]
fn test_effective_buzz_shell_override_marks_agent_ready() {
let temp = tempdir().expect("tempdir");
let shell = temp.path().join("pwsh.exe");
std::fs::write(&shell, []).expect("shell");
let mut overrides = std::collections::BTreeMap::new();
overrides.insert("buzz_shell".to_string(), shell.display().to_string());
let env = GitBashEnv::from_process_with_overrides(&overrides);
assert_eq!(env.shell_override, Some(shell.clone()));
assert_eq!(
resolve_git_bash(
&env.path,
env.shell_override,
env.git_bash_override,
env.system_root,
env.program_files,
env.program_files_x86,
env.local_app_data,
),
Some(shell)
);
}
#[test]
fn test_buzz_shell_override_wins_over_git_bash_discovery() {
let temp = tempdir().expect("tempdir");
let shell = temp.path().join("pwsh.exe");
let bash = temp.path().join("bash.exe");
std::fs::write(&shell, []).expect("shell");
std::fs::write(&bash, []).expect("bash");
let path = std::env::join_paths([temp.path()]).expect("PATH");
assert_eq!(
resolve_git_bash(
path.to_str().expect("utf8"),
Some(shell.clone()),
Some(bash),
None,
None,
None,
None,
),
Some(shell)
);
}
// ── Regression: install/login-shell must skip non-bash BUZZ_SHELL ─────────
/// When BUZZ_SHELL=pwsh.exe, `resolve_git_bash` with `shell_override=None`
/// (the `resolve_bash_path` code path) skips it and falls through to the
/// bash.exe on PATH. The readiness gate (`shell_override=Some`) still
/// returns pwsh — both contracts hold simultaneously.
#[test]
fn test_install_path_skips_buzz_shell_pwsh() {
let temp = tempdir().expect("tempdir");
let pwsh = temp.path().join("pwsh.exe");
let bash = temp.path().join("bash.exe");
std::fs::write(&pwsh, []).expect("pwsh");
std::fs::write(&bash, []).expect("bash");
let path = std::env::join_paths([temp.path()]).expect("PATH");
let path_str = path.to_str().expect("utf8");
// Readiness gate: BUZZ_SHELL=pwsh accepted (Doctor green).
assert_eq!(
resolve_git_bash(path_str, Some(pwsh.clone()), None, None, None, None, None),
Some(pwsh),
"readiness gate must accept BUZZ_SHELL=pwsh"
);
// Install path: shell_override=None skips pwsh, finds bash on PATH.
assert_eq!(
resolve_git_bash(path_str, None, None, None, None, None, None),
Some(bash),
"install path must skip BUZZ_SHELL and find bash on PATH"
);
}
/// Same as above but with BUZZ_SHELL=cmd.exe.
#[test]
fn test_install_path_skips_buzz_shell_cmd() {
let temp = tempdir().expect("tempdir");
let cmd = temp.path().join("cmd.exe");
let bash = temp.path().join("bash.exe");
std::fs::write(&cmd, []).expect("cmd");
std::fs::write(&bash, []).expect("bash");
let path = std::env::join_paths([temp.path()]).expect("PATH");
let path_str = path.to_str().expect("utf8");
// Readiness gate: BUZZ_SHELL=cmd accepted.
assert_eq!(
resolve_git_bash(path_str, Some(cmd.clone()), None, None, None, None, None),
Some(cmd),
"readiness gate must accept BUZZ_SHELL=cmd"
);
// Install path: shell_override=None skips cmd, finds bash on PATH.
assert_eq!(
resolve_git_bash(path_str, None, None, None, None, None, None),
Some(bash),
"install path must skip BUZZ_SHELL and find bash on PATH"
);
}
}
// ── WindowsApps alias predicate — runs on all platforms ──────────────────────
//
// The predicate is path-structural; no filesystem or registry access.
// Tests run on macOS/Linux CI without a Windows target.
#[cfg(test)]
mod windows_apps_tests {
use super::is_windows_apps_alias;
use std::path::Path;
#[test]
fn test_windows_apps_alias_detected_typical_path() {
// Typical WSL alias location: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe
// Use forward-slash path so the test 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_unrelated_path() {
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_segment_match() {
// A directory named exactly "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_posix_style_path() {
// macOS/Linux CI: verify posix-style paths don't accidentally match.
assert!(
!is_windows_apps_alias(Path::new("/usr/bin/bash")),
"Unix bash must not be detected as a WindowsApps alias"
);
}
}