mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): resolve Git Bash for Windows shell tool via PATH/git/registry fallback (#1821)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
51ee1c473d
commit
020ac7f405
@@ -113,6 +113,8 @@ pub(crate) enum RequirementPayload {
|
||||
/// One-line stderr excerpt identifying the parse error.
|
||||
diagnostic: String,
|
||||
},
|
||||
/// Git for Windows is missing; open Doctor for the installation guide.
|
||||
GitBash,
|
||||
}
|
||||
|
||||
impl RequirementPayload {
|
||||
@@ -181,6 +183,9 @@ impl RequirementPayload {
|
||||
config_file, diagnostic
|
||||
)
|
||||
}
|
||||
RequirementPayload::GitBash => {
|
||||
"install Git for Windows (open Doctor in Settings to diagnose)".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +250,10 @@ impl SetupPayload {
|
||||
.map(|r| format!("- {}", r.instruction()))
|
||||
.collect();
|
||||
|
||||
let has_doctor_requirement = self
|
||||
.requirements
|
||||
.iter()
|
||||
.any(|r| matches!(r, RequirementPayload::GitBash));
|
||||
let all_external = self
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -254,7 +263,9 @@ impl SetupPayload {
|
||||
.iter()
|
||||
.any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. }));
|
||||
|
||||
let footer = if all_external {
|
||||
let footer = if has_doctor_requirement {
|
||||
"Open Doctor in the Buzz app, install Git for Windows, then re-check and restart the agent.".to_string()
|
||||
} else if all_external {
|
||||
// All requirements are external config files — Edit Agent cannot
|
||||
// help. Don't send the user there.
|
||||
"Fix the config file(s) and restart the agent.".to_string()
|
||||
@@ -668,6 +679,18 @@ mod tests {
|
||||
assert_eq!(payload.requirements.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_payload_deserializes_git_bash_requirement() {
|
||||
let payload: SetupPayload = serde_json::from_str(
|
||||
r#"{"agent_name":"Buzz Agent","agent_pubkey":"test","requirements":[{"surface":"git_bash"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
payload.requirements.as_slice(),
|
||||
[RequirementPayload::GitBash]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudge_body_names_all_requirements() {
|
||||
let payload = SetupPayload {
|
||||
|
||||
@@ -15,6 +15,20 @@ pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MO
|
||||
pub use config::Provider;
|
||||
pub use types::AgentError;
|
||||
|
||||
/// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()`
|
||||
/// forwards every key in this list into its otherwise-cleared MCP child; Doctor
|
||||
/// uses the same contract so a ready agent can always start its shell tool.
|
||||
#[cfg(windows)]
|
||||
pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[
|
||||
"PATH",
|
||||
"BUZZ_SHELL",
|
||||
"GIT_BASH",
|
||||
"SystemRoot",
|
||||
"ProgramFiles",
|
||||
"ProgramFiles(x86)",
|
||||
"LOCALAPPDATA",
|
||||
];
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -63,9 +63,19 @@ const PASSTHROUGH_ENV: &[&str] = &[
|
||||
// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
|
||||
// std::env::temp_dir() consults — without them it falls back to C:\Windows,
|
||||
// which child processes can't write to (PermissionDenied). USERPROFILE is the
|
||||
// always-set floor. LOCALAPPDATA/APPDATA carry child-tool config (git, etc.).
|
||||
// always-set floor. APPDATA carries child-tool config (git, etc.).
|
||||
#[cfg(windows)]
|
||||
const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA"];
|
||||
const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "APPDATA"];
|
||||
|
||||
/// Environment retained by `spawn_one()` after `env_clear()` on Windows.
|
||||
/// Shell resolver keys are shared with Doctor through the public contract.
|
||||
#[cfg(windows)]
|
||||
fn windows_child_passthrough_env() -> impl Iterator<Item = &'static str> {
|
||||
PASSTHROUGH_ENV_WINDOWS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied())
|
||||
}
|
||||
|
||||
type Client = RunningService<RoleClient, ()>;
|
||||
|
||||
@@ -705,7 +715,7 @@ async fn spawn_one(
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
for k in PASSTHROUGH_ENV_WINDOWS {
|
||||
for k in windows_child_passthrough_env() {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
@@ -981,13 +991,19 @@ mod content_tests {
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_passthrough_includes_temp_dir_vars() {
|
||||
// std::env::temp_dir() consults these in order; without them it falls
|
||||
// back to C:\Windows, which children can't write to.
|
||||
fn windows_passthrough_includes_shell_resolution_vars() {
|
||||
// Temp directories and every resolver key must survive `env_clear()`.
|
||||
for var in ["TMP", "TEMP", "USERPROFILE"] {
|
||||
assert!(
|
||||
PASSTHROUGH_ENV_WINDOWS.contains(&var),
|
||||
"{var} must pass through or temp_dir() falls back to C:\\Windows"
|
||||
"{var} must pass through for Windows child processes"
|
||||
);
|
||||
}
|
||||
let child_env: Vec<_> = windows_child_passthrough_env().collect();
|
||||
for var in crate::WINDOWS_SHELL_RESOLUTION_ENV {
|
||||
assert!(
|
||||
child_env.contains(var),
|
||||
"{var} must pass through so the MCP shell resolver matches Doctor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ nix = { version = "0.31", default-features = false, features = ["signal", "proce
|
||||
# required because CreateJobObjectW takes a SECURITY_ATTRIBUTES parameter;
|
||||
# Win32_System_Threading supplies IO_COUNTERS inside the extended-limit struct.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading"] }
|
||||
|
||||
@@ -393,44 +393,33 @@ 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. Installed Git for Windows (fast path when the user has Git).
|
||||
/// 4. PATH scan for `bash.exe`, EXCLUDING System32 (so we never resolve WSL's
|
||||
/// `bash.exe` — the `0x8007072c` hazard).
|
||||
/// 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
|
||||
/// 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
|
||||
/// when the child inherited their parent environment.
|
||||
/// 6. Git for Windows's machine then user registry `InstallPath`.
|
||||
///
|
||||
/// Returns `(resolved_path, display_name)`. The display name is derived from the
|
||||
/// resolved path, guaranteeing the dialect hint and the spawned shell agree.
|
||||
///
|
||||
/// The previously-bundled PortableGit fallback (probe 3 in the old order) has
|
||||
/// been removed: Git for Windows is a documented host prerequisite, and shipping
|
||||
/// a multi-hundred-MB runtime contradicts the VISION_AGENT.md "minimal" principle.
|
||||
///
|
||||
/// No bash found -> actionable error pointing at the prerequisite.
|
||||
#[cfg(windows)]
|
||||
fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
|
||||
// BUZZ_SHELL: explicit operator override — any shell, including cmd or PowerShell.
|
||||
// Bare command names are resolved WITHOUT System32 exclusion: the operator
|
||||
// chose this shell on purpose, and cmd/pwsh legitimately live in System32.
|
||||
if let Some(raw) = std::env::var_os("BUZZ_SHELL") {
|
||||
let p = PathBuf::from(&raw);
|
||||
// Absolute / rooted path: must exist as a file.
|
||||
if p.components().count() > 1 || p.has_root() {
|
||||
if p.is_file() {
|
||||
let name = shell_name_from_path(&p);
|
||||
return Ok((p, name));
|
||||
}
|
||||
// Non-existent absolute path: fall through, do NOT report this shell.
|
||||
} else {
|
||||
// Bare command name (e.g. "pwsh", "cmd"): scan PATH, NO System32
|
||||
// exclusion — the operator explicitly wants this shell.
|
||||
if let Some(found) = scan_path_for_command(&p, path_env, None) {
|
||||
let name = shell_name_from_path(&found);
|
||||
return Ok((found, name));
|
||||
}
|
||||
// Not found on PATH: fall through.
|
||||
} else if let Some(found) = scan_path_for_command(&p, path_env, None) {
|
||||
let name = shell_name_from_path(&found);
|
||||
return Ok((found, name));
|
||||
}
|
||||
}
|
||||
|
||||
// GIT_BASH: legacy override kept for back-compat.
|
||||
if let Some(p) = std::env::var_os("GIT_BASH").map(PathBuf::from) {
|
||||
if p.is_file() {
|
||||
let name = shell_name_from_path(&p);
|
||||
@@ -438,33 +427,138 @@ fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
|
||||
}
|
||||
}
|
||||
|
||||
for root in ["ProgramFiles", "LocalAppData"] {
|
||||
if let Some(base) = std::env::var_os(root) {
|
||||
let candidate = match root {
|
||||
"LocalAppData" => PathBuf::from(&base).join("Programs").join("Git"),
|
||||
_ => PathBuf::from(&base).join("Git"),
|
||||
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(bash) = bash_from_git(&git) {
|
||||
return Ok((bash, "bash".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bash) = git_bash_from_standard_paths() {
|
||||
return Ok((bash, "bash".to_string()));
|
||||
}
|
||||
|
||||
if let Some(bash) = git_bash_from_registry() {
|
||||
return Ok((bash, "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, \\
|
||||
and HKLM/HKCU\\\\SOFTWARE\\\\GitForWindows. Git's \"Cmd\" PATH option adds \\
|
||||
Git\\\\cmd\\\\git.exe but not Git\\\\bin\\\\bash.exe; Buzz normally derives Git Bash from that git.exe. \\
|
||||
Install it from https://git-scm.com/download/win and select \"Git from the command line \\
|
||||
and also from 3rd-party software\", then relaunch Buzz. You can also set \\
|
||||
BUZZ_SHELL to a shell executable."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Git for Windows puts `git.exe` in `<install>\\cmd`; the MSYS bash binary is
|
||||
/// its stable sibling at `<install>\\bin\\bash.exe`.
|
||||
#[cfg(windows)]
|
||||
fn bash_from_git(git: &Path) -> Option<PathBuf> {
|
||||
let install_root = git.parent()?.parent()?;
|
||||
let bash = install_root.join("bin").join("bash.exe");
|
||||
bash.is_file().then_some(bash)
|
||||
}
|
||||
|
||||
/// Probe machine and per-user Git for Windows registry keys after the standard
|
||||
/// install-location fallback has been exhausted.
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
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;
|
||||
}
|
||||
.join("bin")
|
||||
.join("bash.exe");
|
||||
if candidate.is_file() {
|
||||
return Ok((candidate, "bash".to_string()));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PATH scan for bash.exe, skipping System32 to avoid WSL's bash.exe launcher.
|
||||
if let Some(p) = scan_path_for_bash(path_env, std::env::var_os("SystemRoot").map(PathBuf::from))
|
||||
{
|
||||
return Ok((p, "bash".to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
Err(
|
||||
"Git for Windows (git bash) is required but was not found.\n\
|
||||
Install it from https://git-scm.com/download/win and re-launch Buzz,\n\
|
||||
or set BUZZ_SHELL to the path of any bash-compatible executable (or a bare\n\
|
||||
command name like cmd or pwsh if it is on PATH)."
|
||||
.into(),
|
||||
)
|
||||
#[cfg(windows)]
|
||||
fn git_bash_from_standard_paths() -> Option<PathBuf> {
|
||||
git_bash_from_standard_path_bases([
|
||||
std::env::var_os("ProgramFiles").map(PathBuf::from),
|
||||
std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
|
||||
std::env::var_os("LocalAppData").map(PathBuf::from),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn git_bash_from_standard_path_bases(
|
||||
[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())
|
||||
}
|
||||
|
||||
/// True if `dir` is `root` or lives under it, comparing path components
|
||||
@@ -493,8 +587,8 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool {
|
||||
/// `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.
|
||||
#[cfg(windows)]
|
||||
fn scan_path_for_bash(path_env: &str, system_root: Option<PathBuf>) -> Option<PathBuf> {
|
||||
scan_path_for_command(Path::new("bash.exe"), path_env, system_root.as_deref())
|
||||
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_env` for `name` (or `name.exe` on Windows if `name` has no
|
||||
@@ -1130,6 +1224,53 @@ mod windows_resolver_tests {
|
||||
// Err is also acceptable (no Git on test host).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_cmd_on_path_resolves_sibling_git_bash() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let git = dir.path().join("Git").join("cmd").join("git.exe");
|
||||
let bash = dir.path().join("Git").join("bin").join("bash.exe");
|
||||
touch(&git);
|
||||
touch(&bash);
|
||||
|
||||
let path_env = env::join_paths([git.parent().expect("cmd dir")]).expect("join");
|
||||
let old_buzz_shell = env::var_os("BUZZ_SHELL");
|
||||
let old_git_bash = env::var_os("GIT_BASH");
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
env::remove_var("GIT_BASH");
|
||||
|
||||
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
||||
|
||||
match old_buzz_shell {
|
||||
Some(value) => env::set_var("BUZZ_SHELL", value),
|
||||
None => env::remove_var("BUZZ_SHELL"),
|
||||
}
|
||||
match old_git_bash {
|
||||
Some(value) => env::set_var("GIT_BASH", value),
|
||||
None => env::remove_var("GIT_BASH"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
result
|
||||
.expect("Git cmd PATH entry should resolve its sibling bash")
|
||||
.0,
|
||||
bash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_files_x86_git_bash_is_found() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let program_files_x86 = dir.path().join("Program Files (x86)");
|
||||
let bash = program_files_x86.join("Git").join("bin").join("bash.exe");
|
||||
touch(&bash);
|
||||
|
||||
assert_eq!(
|
||||
git_bash_from_standard_path_bases([None, Some(program_files_x86), None]),
|
||||
Some(bash)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_scan_skips_system32_and_returns_absolute() {
|
||||
// A bash.exe under %SystemRoot% (where WSL's launcher lives) must be
|
||||
@@ -1144,11 +1285,8 @@ mod windows_resolver_tests {
|
||||
env::join_paths([sys_root.path().join("System32"), real.path().to_path_buf()])
|
||||
.expect("join");
|
||||
|
||||
let found = scan_path_for_bash(
|
||||
path_env.to_str().expect("utf8"),
|
||||
Some(sys_root.path().to_path_buf()),
|
||||
)
|
||||
.expect("bash found outside System32");
|
||||
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
|
||||
.expect("bash found outside System32");
|
||||
assert!(found.is_absolute());
|
||||
assert!(!found.starts_with(sys_root.path()));
|
||||
assert_eq!(found, real_bash);
|
||||
@@ -1161,10 +1299,7 @@ mod windows_resolver_tests {
|
||||
touch(&sys_root.path().join("System32").join("bash.exe"));
|
||||
let path_env = env::join_paths([sys_root.path().join("System32")]).expect("join");
|
||||
|
||||
let found = scan_path_for_bash(
|
||||
path_env.to_str().expect("utf8"),
|
||||
Some(sys_root.path().to_path_buf()),
|
||||
);
|
||||
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()));
|
||||
assert!(found.is_none());
|
||||
}
|
||||
|
||||
@@ -1182,7 +1317,7 @@ mod windows_resolver_tests {
|
||||
touch(&sys32.join("bash.exe"));
|
||||
|
||||
let path_env = env::join_paths([sys32]).expect("join");
|
||||
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(root));
|
||||
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(&root));
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"case-divergent System32 must still be excluded"
|
||||
@@ -1204,11 +1339,8 @@ mod windows_resolver_tests {
|
||||
let path_env = env::join_paths([real.path().to_path_buf()]).expect("join");
|
||||
let sys_root = tempdir().expect("sysroot"); // empty — no System32 here
|
||||
|
||||
let found = scan_path_for_bash(
|
||||
path_env.to_str().expect("utf8"),
|
||||
Some(sys_root.path().to_path_buf()),
|
||||
)
|
||||
.expect("bash on PATH must be found");
|
||||
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
|
||||
.expect("bash on PATH must be found");
|
||||
assert_eq!(found, real_bash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,10 @@ const overrides = new Map([
|
||||
// +1: pub(crate) mod cli_probe declaration for doctor auth probe access.
|
||||
// +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and
|
||||
// make_codex_runtime stubs (new KnownAcpRuntime fields).
|
||||
["src-tauri/src/managed_agents/readiness.rs", 1754],
|
||||
// Git Bash readiness is intentionally colocated with buzz-agent's other
|
||||
// setup-mode requirements. The Windows-only requirement and serialization
|
||||
// test add eight lines; split remains queued with the existing file debt.
|
||||
["src-tauri/src/managed_agents/readiness.rs", 1762],
|
||||
// applyWorkspace reposDir parameter plus the validateReposDir binding,
|
||||
// threaded through Tauri invokes for configurable repos_dir, plus the
|
||||
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
|
||||
@@ -191,7 +194,9 @@ const overrides = new Map([
|
||||
// added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8).
|
||||
// codex-install-auto-restart: restarted_count + failed_restart_count added to
|
||||
// RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2).
|
||||
["src/shared/api/tauri.ts", 1282],
|
||||
// Git Bash Doctor discovery adds the raw Tauri response and its camelCase
|
||||
// mapper. This is the existing API boundary; split remains queued.
|
||||
["src/shared/api/tauri.ts", 1304],
|
||||
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
|
||||
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
|
||||
// doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/
|
||||
@@ -199,7 +204,9 @@ const overrides = new Map([
|
||||
// agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with
|
||||
// failed_restart_count (+2 lines). Queued to split with the rest of this list.
|
||||
// mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling.
|
||||
["src/shared/api/types.ts", 1031],
|
||||
// Git Bash prerequisite payload adds four fields to the shared Tauri API
|
||||
// contract. This is the canonical type location; split remains queued.
|
||||
["src/shared/api/types.ts", 1038],
|
||||
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
|
||||
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
|
||||
// shows required markers and credential amber rows (parity with
|
||||
@@ -385,7 +392,10 @@ const overrides = new Map([
|
||||
// cache tests replaced with 6 pure availability_drift predicate tests;
|
||||
// dead-pid non-happy-path added. All load-bearing correctness fixes.
|
||||
// (+17 lines net vs previous 1330 limit; rustfmt expanded some call sites)
|
||||
["src-tauri/src/commands/agent_discovery.rs", 1347],
|
||||
// Git Bash Doctor discovery exposes a narrow async Tauri command at the
|
||||
// existing discovery boundary. The ten-line addition preserves the platform
|
||||
// neutral frontend contract; split remains queued.
|
||||
["src-tauri/src/commands/agent_discovery.rs", 1357],
|
||||
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
|
||||
// + deps-array entry in submitMessage closes the never-persisted-boundary
|
||||
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
|
||||
|
||||
@@ -48,7 +48,7 @@ security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
|
||||
window-vibrancy = "0.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
|
||||
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1344,3 +1344,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP.
|
||||
/// `None` on other platforms keeps the shared Doctor surfaces platform-neutral.
|
||||
#[tauri::command]
|
||||
pub async fn discover_git_bash_prerequisite(
|
||||
) -> Result<Option<crate::managed_agents::GitBashPrerequisite>, String> {
|
||||
tokio::task::spawn_blocking(crate::managed_agents::discover_git_bash)
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||
}
|
||||
|
||||
@@ -697,6 +697,7 @@ pub fn run() {
|
||||
get_media_proxy_port,
|
||||
fetch_link_preview_title,
|
||||
discover_acp_providers,
|
||||
discover_git_bash_prerequisite,
|
||||
install_acp_runtime,
|
||||
discover_managed_agent_prereqs,
|
||||
sign_event,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
//! 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(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.";
|
||||
|
||||
pub(crate) fn discover_git_bash() -> Option<GitBashPrerequisite> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let env = GitBashEnv::from_process();
|
||||
let path = 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,
|
||||
);
|
||||
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)]
|
||||
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> {
|
||||
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])
|
||||
})
|
||||
.or_else(git_bash_from_registry)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod backend;
|
||||
pub(crate) mod config_bridge;
|
||||
mod discovery;
|
||||
mod env_vars;
|
||||
mod git_bash;
|
||||
pub(crate) mod global_config;
|
||||
mod nest;
|
||||
mod persona_avatars;
|
||||
@@ -44,6 +45,9 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
|
||||
pub use backend::*;
|
||||
pub use discovery::*;
|
||||
pub use env_vars::*;
|
||||
#[cfg(windows)]
|
||||
pub(crate) use git_bash::git_bash_available;
|
||||
pub(crate) use git_bash::{discover_git_bash, GitBashPrerequisite};
|
||||
pub(crate) use global_config::{
|
||||
load_global_agent_config, resolve_effective_model_provider, save_global_agent_config,
|
||||
validate_global_config, GlobalAgentConfig,
|
||||
|
||||
@@ -192,6 +192,9 @@ pub enum Requirement {
|
||||
/// Shown verbatim in the nudge so the user can identify the problem.
|
||||
diagnostic: String,
|
||||
},
|
||||
/// Git for Windows is missing, so buzz-agent cannot launch buzz-dev-mcp's
|
||||
/// Bash-based shell tool. Doctor owns installation and re-checking.
|
||||
GitBash,
|
||||
}
|
||||
|
||||
// ── AgentReadiness ────────────────────────────────────────────────────────────
|
||||
@@ -293,6 +296,11 @@ fn collect_missing_requirements(
|
||||
fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
if !crate::managed_agents::git_bash_available(&effective.env) {
|
||||
missing.push(Requirement::GitBash);
|
||||
}
|
||||
|
||||
// Provider is required — maps to BUZZ_AGENT_PROVIDER in the effective env.
|
||||
// An empty string is treated as absent: a key set to "" is not a valid
|
||||
// provider and must not pass the readiness gate.
|
||||
@@ -1310,6 +1318,12 @@ mod tests {
|
||||
assert_eq!(json["field"], "provider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_bash_requirement_serializes_correctly() {
|
||||
let json = serde_json::to_value(Requirement::GitBash).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "surface": "git_bash" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_key_requirement_serializes_correctly() {
|
||||
let r = Requirement::EnvKey {
|
||||
|
||||
@@ -1645,6 +1645,9 @@ pub fn spawn_agent_child(
|
||||
"setup_copy": setup_copy,
|
||||
"diagnostic": diagnostic,
|
||||
}),
|
||||
Requirement::GitBash => serde_json::json!({
|
||||
"surface": "git_bash",
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let payload = serde_json::json!({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
deleteManagedAgent,
|
||||
discoverAcpRuntimes,
|
||||
discoverBackendProviders,
|
||||
discoverGitBashPrerequisite,
|
||||
discoverManagedAgentPrereqs,
|
||||
getAgentConfigSurface,
|
||||
getBakedBuildEnvKeys,
|
||||
@@ -92,6 +93,7 @@ export const teamsQueryKey = ["teams"] as const;
|
||||
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
|
||||
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
|
||||
export const backendProvidersQueryKey = ["backend-providers"] as const;
|
||||
export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const;
|
||||
|
||||
async function invalidateAgentQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
@@ -169,6 +171,14 @@ export function useInstallAcpRuntimeMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitBashPrerequisiteQuery() {
|
||||
return useQuery({
|
||||
queryKey: gitBashPrerequisiteQueryKey,
|
||||
queryFn: discoverGitBashPrerequisite,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackendProvidersQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
enabled: options?.enabled ?? true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
useAcpRuntimesQuery,
|
||||
useInstallAcpRuntimeMutation,
|
||||
useGitBashPrerequisiteQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
|
||||
import {
|
||||
@@ -470,6 +471,64 @@ function RuntimeCard({
|
||||
);
|
||||
}
|
||||
|
||||
function GitBashPrerequisiteCard() {
|
||||
const query = useGitBashPrerequisiteQuery();
|
||||
const prerequisite = query.data;
|
||||
if (!prerequisite) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left sm:p-4",
|
||||
prerequisite.available
|
||||
? "border-primary/25 bg-primary/[0.055]"
|
||||
: "border-amber-500/30 bg-amber-500/5",
|
||||
)}
|
||||
data-testid="onboarding-git-bash"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{prerequisite.available ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
)}
|
||||
<h2 className="text-base font-medium">Git Bash</h2>
|
||||
{prerequisite.available ? (
|
||||
<Badge
|
||||
className="border border-primary/20 bg-primary/10 text-primary"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{prerequisite.available ? (
|
||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">
|
||||
{prerequisite.path}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Required for buzz-agent shell tools on Windows.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
{prerequisite.installHint}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => void openUrl(prerequisite.installInstructionsUrl)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> Install Git for Windows
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeProvidersSection({
|
||||
runtimeProviders,
|
||||
}: {
|
||||
@@ -522,6 +581,8 @@ function RuntimeProvidersSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GitBashPrerequisiteCard />
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{items.map((runtime) => (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import {
|
||||
useAcpRuntimesQuery,
|
||||
useInstallAcpRuntimeMutation,
|
||||
useGitBashPrerequisiteQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
|
||||
import type { AcpRuntimeCatalogEntry, AuthStatus } from "@/shared/api/types";
|
||||
@@ -360,8 +361,56 @@ function RuntimeRow({
|
||||
);
|
||||
}
|
||||
|
||||
function GitBashRow({
|
||||
prerequisite,
|
||||
}: {
|
||||
prerequisite: NonNullable<
|
||||
ReturnType<typeof useGitBashPrerequisiteQuery>["data"]
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-16 items-start gap-3 bg-amber-500/5 px-4 py-3 text-sm"
|
||||
data-testid="doctor-git-bash"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{prerequisite.available ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-status-added" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Git Bash</p>
|
||||
{prerequisite.available ? (
|
||||
<p className="mt-1 break-all font-mono text-2xs text-muted-foreground/80">
|
||||
{prerequisite.path}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Required for buzz-agent shell tools on Windows.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{prerequisite.installHint}
|
||||
</p>
|
||||
<button
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
onClick={() => void openUrl(prerequisite.installInstructionsUrl)}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> Install Git for Windows
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DoctorSettingsPanel() {
|
||||
const runtimesQuery = useAcpRuntimesQuery();
|
||||
const gitBashQuery = useGitBashPrerequisiteQuery();
|
||||
const runtimes = runtimesQuery.data ?? [];
|
||||
const isRefreshing = runtimesQuery.isFetching;
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
@@ -429,6 +478,7 @@ export function DoctorSettingsPanel() {
|
||||
onClick={() => {
|
||||
setInstallResults({});
|
||||
void runtimesQuery.refetch();
|
||||
void gitBashQuery.refetch();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -444,6 +494,17 @@ export function DoctorSettingsPanel() {
|
||||
|
||||
<div className="space-y-5">
|
||||
<SettingsOptionGroup>
|
||||
{gitBashQuery.data ? (
|
||||
<>
|
||||
<div className="px-4 py-3 text-sm">
|
||||
<h3 className="text-sm font-medium">System prerequisites</h3>
|
||||
<p className="mt-1 text-sm font-normal text-muted-foreground">
|
||||
Windows tools required by supported agents.
|
||||
</p>
|
||||
</div>
|
||||
<GitBashRow prerequisite={gitBashQuery.data} />
|
||||
</>
|
||||
) : null}
|
||||
<div className="px-4 py-3 text-sm">
|
||||
<h3 className="text-sm font-medium">Agent CLIs and ACP runtimes</h3>
|
||||
<p className="mt-1 text-sm font-normal text-muted-foreground">
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
AuthStatus,
|
||||
CommandAvailability,
|
||||
InstallRuntimeResult,
|
||||
GitBashPrerequisite,
|
||||
OpenDmInput,
|
||||
RuntimeConfigSurface,
|
||||
} from "@/shared/api/types";
|
||||
@@ -251,6 +252,13 @@ export type RawInstallRuntimeResult = {
|
||||
failed_restart_count: number;
|
||||
};
|
||||
|
||||
type RawGitBashPrerequisite = {
|
||||
available: boolean;
|
||||
path: string | null;
|
||||
install_instructions_url: string;
|
||||
install_hint: string;
|
||||
};
|
||||
|
||||
type RawCommandAvailability = {
|
||||
command: string;
|
||||
resolved_path: string | null;
|
||||
@@ -1064,6 +1072,20 @@ export async function getManagedAgentLog(pubkey: string, lineCount?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverGitBashPrerequisite(): Promise<GitBashPrerequisite | null> {
|
||||
const prerequisite = await invokeTauri<RawGitBashPrerequisite | null>(
|
||||
"discover_git_bash_prerequisite",
|
||||
);
|
||||
return (
|
||||
prerequisite && {
|
||||
available: prerequisite.available,
|
||||
path: prerequisite.path,
|
||||
installInstructionsUrl: prerequisite.install_instructions_url,
|
||||
installHint: prerequisite.install_hint,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function discoverAcpRuntimes(): Promise<AcpRuntimeCatalogEntry[]> {
|
||||
return (
|
||||
await invokeTauri<RawAcpRuntimeCatalogEntry[]>("discover_acp_providers")
|
||||
|
||||
@@ -541,6 +541,13 @@ export type ControlResultFrame = {
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
export type GitBashPrerequisite = {
|
||||
available: boolean;
|
||||
path: string | null;
|
||||
installInstructionsUrl: string;
|
||||
installHint: string;
|
||||
};
|
||||
|
||||
export type AcpAvailabilityStatus =
|
||||
| "available"
|
||||
| "adapter_missing"
|
||||
|
||||
@@ -54,6 +54,15 @@ test("extractConfigNudge parses normalized_field requirement", () => {
|
||||
assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload);
|
||||
});
|
||||
|
||||
test("extractConfigNudge parses git_bash requirement", () => {
|
||||
const payload = {
|
||||
agent_name: "Buzz Agent",
|
||||
agent_pubkey: ATLAS_PUBKEY,
|
||||
requirements: [{ surface: "git_bash" }],
|
||||
};
|
||||
assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload);
|
||||
});
|
||||
|
||||
test("extractConfigNudge parses cli_login requirement", () => {
|
||||
const payload = {
|
||||
agent_name: "Codex",
|
||||
|
||||
@@ -49,7 +49,8 @@ export type ConfigNudgeRequirement =
|
||||
setup_copy: string;
|
||||
/** One-line stderr excerpt from the CLI's parse error. */
|
||||
diagnostic: string;
|
||||
};
|
||||
}
|
||||
| { surface: "git_bash" };
|
||||
|
||||
/**
|
||||
* The structured payload embedded in the `buzz:config-nudge` sentinel block.
|
||||
@@ -145,6 +146,8 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement {
|
||||
r.availability === "cli_missing" ||
|
||||
r.availability === "not_installed")
|
||||
);
|
||||
case "git_bash":
|
||||
return true;
|
||||
case "cli_config_invalid":
|
||||
return (
|
||||
Array.isArray(r.probe_args) &&
|
||||
|
||||
@@ -26,7 +26,10 @@ globalThis.window = {
|
||||
dispatchEvent: _eventTarget.dispatchEvent.bind(_eventTarget),
|
||||
};
|
||||
|
||||
import { focusTargetForRequirement } from "./config-nudge-attachment.tsx";
|
||||
import {
|
||||
focusTargetForRequirement,
|
||||
shouldOpenDoctor,
|
||||
} from "./config-nudge-attachment.tsx";
|
||||
import {
|
||||
consumePendingOpenEditAgent,
|
||||
requestOpenEditAgent,
|
||||
@@ -34,6 +37,29 @@ import {
|
||||
|
||||
const AGENT_PUBKEY = "aabbccddeeff00112233445566778899";
|
||||
|
||||
// ── Doctor routing ────────────────────────────────────────────────────────────
|
||||
|
||||
test("shouldOpenDoctor_gitBashMixedWithEnvKey_routesToDoctor", () => {
|
||||
assert.equal(
|
||||
shouldOpenDoctor([
|
||||
{ surface: "git_bash" },
|
||||
{ surface: "env_key", key: "ANTHROPIC_API_KEY" },
|
||||
]),
|
||||
true,
|
||||
"a Git Bash requirement must route the whole mixed card to Doctor",
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldOpenDoctor_regularMixedRequirements_routesToEditAgent", () => {
|
||||
assert.equal(
|
||||
shouldOpenDoctor([
|
||||
{ surface: "env_key", key: "ANTHROPIC_API_KEY" },
|
||||
{ surface: "normalized_field", field: "model" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ── focusTargetForRequirement — pure function ─────────────────────────────────
|
||||
|
||||
test("focusTargetForRequirement_envKey_returnsEnvKeyTarget", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ function requirementKey(
|
||||
return `cli_login:${req.probe_args.join(",")}:${index}`;
|
||||
case "cli_config_invalid":
|
||||
return `cli_config_invalid:${req.probe_args.join(",")}:${index}`;
|
||||
case "git_bash":
|
||||
return `git_bash:${index}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +47,22 @@ function requirementKey(
|
||||
* (every row is `availability === "available"`) are purely informational and
|
||||
* do not route anywhere.
|
||||
*/
|
||||
function hasGitBashRequirement(
|
||||
reqs: ConfigNudgePayload["requirements"],
|
||||
): boolean {
|
||||
return reqs.some((r) => r.surface === "git_bash");
|
||||
}
|
||||
|
||||
function isAllCliLogin(reqs: ConfigNudgePayload["requirements"]): boolean {
|
||||
return reqs.length > 0 && reqs.every((r) => r.surface === "cli_login");
|
||||
}
|
||||
|
||||
export function shouldOpenDoctor(
|
||||
reqs: ConfigNudgePayload["requirements"],
|
||||
): boolean {
|
||||
return isAllCliLogin(reqs) || hasGitBashRequirement(reqs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the card is all-cli_login AND every requirement is in the
|
||||
* `available` state (tooling installed, just needs login). In this case Doctor
|
||||
@@ -149,23 +163,17 @@ export function focusTargetForRequirement(
|
||||
* the system.
|
||||
*
|
||||
* Routing:
|
||||
* (A) When ALL requirements are `cli_login` in an install state
|
||||
* (`not_installed` / `cli_missing` / `adapter_missing`): the card trigger
|
||||
* opens Settings → Doctor. A card-level "Open Doctor →" label in
|
||||
* `AttachmentActions` confirms the action at rest.
|
||||
* (A-auth) When ALL requirements are `cli_login` with `availability ===
|
||||
* "available"` (tooling installed, just needs login): Doctor has no auth
|
||||
* functionality and would be a misleading dead-end. The card is purely
|
||||
* informational — no trigger, no CTA, no pointer/hover affordance. The
|
||||
* inline copy (`setup_copy`) already tells the user the exact command.
|
||||
* (B) Otherwise (mixed card), the card trigger opens Edit Agent as the
|
||||
* card-level fallback. Each row carries its own inline CTA sharing one
|
||||
* right edge so the actions are clearly paired with their requirement:
|
||||
* - `cli_login` rows in an install state → "Open Doctor →" (Doctor).
|
||||
* - `cli_login` rows with `availability === "available"` → no per-row CTA.
|
||||
* - `env_key` / `normalized_field` rows → "Edit Agent →" (Edit Agent).
|
||||
* The `AttachmentActions` column is omitted on mixed cards — per-row CTAs
|
||||
* replace it.
|
||||
* (A) Any card with a `git_bash` requirement, or one whose requirements are all
|
||||
* install-state `cli_login`, opens Settings → Doctor. A card-level
|
||||
* "Open Doctor →" label in `AttachmentActions` confirms the action at rest.
|
||||
* (A-auth) A card whose requirements are all available `cli_login` surfaces is
|
||||
* purely informational: Doctor cannot authenticate a CLI, and `setup_copy`
|
||||
* already gives the needed command.
|
||||
* (B) Other mixed cards open Edit Agent as the card-level fallback. Their rows
|
||||
* carry inline CTAs for the matching destination: install-state `cli_login`
|
||||
* opens Doctor; `env_key` and `normalized_field` open Edit Agent. A
|
||||
* `git_bash` row is covered by the card-level Doctor route, so it does not
|
||||
* render a redundant row action.
|
||||
*/
|
||||
export function ConfigNudgeCard({
|
||||
className,
|
||||
@@ -178,6 +186,7 @@ export function ConfigNudgeCard({
|
||||
const { onOpenSettings } = useAppShell();
|
||||
|
||||
const allCliLogin = isAllCliLogin(nudge.requirements);
|
||||
const opensDoctor = shouldOpenDoctor(nudge.requirements);
|
||||
const authOnly = isAuthOnly(nudge.requirements);
|
||||
const allConfigInvalid = isAllConfigInvalid(nudge.requirements);
|
||||
// Any card that is purely informational (auth-only or all-config-invalid)
|
||||
@@ -199,10 +208,9 @@ export function ConfigNudgeCard({
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
if (allCliLogin) {
|
||||
// (A) Non-authOnly install-state all-cli_login card — route to Doctor.
|
||||
// AuthOnly cards never mount this trigger, so this branch only runs for
|
||||
// install-state cards where Doctor is the correct destination.
|
||||
if (shouldOpenDoctor(nudge.requirements)) {
|
||||
// Git Bash and install-state CLI requirements both resolve in Doctor.
|
||||
// Informational-only cards never mount this trigger.
|
||||
openDoctor();
|
||||
} else {
|
||||
// (B) Mixed card — card-level fallback: focus the first editable field.
|
||||
@@ -258,10 +266,9 @@ export function ConfigNudgeCard({
|
||||
))}
|
||||
</div>
|
||||
</AttachmentContent>
|
||||
{/* (A) Install-state all-cli_login card only — single card-level CTA
|
||||
confirming the action at rest. Informational-only cards have no CTA;
|
||||
mixed cards render per-row CTAs instead. */}
|
||||
{allCliLogin && !informationalOnly && (
|
||||
{/* (A) Doctor-routed cards have one card-level CTA. Informational-only
|
||||
cards have none; other mixed cards render their own row CTAs. */}
|
||||
{opensDoctor && !informationalOnly && (
|
||||
<AttachmentActions className="items-end self-end">
|
||||
<span className="text-xs text-muted-foreground">Open Doctor →</span>
|
||||
</AttachmentActions>
|
||||
@@ -270,7 +277,7 @@ export function ConfigNudgeCard({
|
||||
{!informationalOnly && (
|
||||
<AttachmentTrigger
|
||||
aria-label={
|
||||
allCliLogin
|
||||
opensDoctor
|
||||
? `Open Doctor settings for ${nudge.agent_name}`
|
||||
: `Open Edit Agent for ${nudge.agent_name}`
|
||||
}
|
||||
@@ -364,6 +371,14 @@ function RequirementRow({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "git_bash":
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs leading-4 text-muted-foreground">
|
||||
<span className="flex-1 [overflow-wrap:anywhere]">
|
||||
Git for Windows is required for buzz-agent shell tools
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "cli_config_invalid": {
|
||||
// Config-invalid rows are purely informational — the user must edit an
|
||||
// external file. No Doctor CTA (Doctor can't repair ~/.codex/config.toml)
|
||||
|
||||
Reference in New Issue
Block a user