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:
Will Pfleger
2026-07-13 16:53:02 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 51ee1c473d
commit 020ac7f405
22 changed files with 959 additions and 101 deletions
+24 -1
View File
@@ -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 {
+14
View File
@@ -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;
+23 -7
View File
@@ -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"
);
}
}
+1 -1
View File
@@ -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"] }
+191 -59
View File
@@ -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);
}
}
+14 -4
View File
@@ -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
+1 -1
View File
@@ -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}"))
}
+1
View File
@@ -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!({
+10
View File
@@ -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">
+22
View File
@@ -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")
+7
View File
@@ -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",
+4 -1
View File
@@ -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)