Skip to content

Commit 1aec7ea

Browse files
authored
fix(desktop): resolve Doctor install shell and command detection on Windows (#1854)
Signed-off-by: Will Pfleger <[email protected]>
1 parent ff11f26 commit 1aec7ea

9 files changed

Lines changed: 750 additions & 35 deletions

File tree

‎desktop/scripts/check-file-sizes.mjs‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ const overrides = new Map([
163163
// Git Bash readiness is intentionally colocated with buzz-agent's other
164164
// setup-mode requirements. The Windows-only requirement and serialization
165165
// test add eight lines; split remains queued with the existing file debt.
166-
["src-tauri/src/managed_agents/readiness.rs", 1762],
166+
// Windows Doctor install fix: cli_install_commands_windows field added to test stubs.
167+
["src-tauri/src/managed_agents/readiness.rs", 1764],
167168
// applyWorkspace reposDir parameter plus the validateReposDir binding,
168169
// threaded through Tauri invokes for configurable repos_dir, plus the
169170
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
@@ -261,11 +262,24 @@ const overrides = new Map([
261262
// + updated adapter_availability_cached() signature (Option return, cold=None)
262263
// prevents false restart badge on newly restarted agents. Correctness fix;
263264
// load-bearing — required by Thufir's IMPORTANT findings. (+15 lines)
264-
["src-tauri/src/managed_agents/discovery.rs", 1245],
265+
// Windows Doctor install fix: cli_install_commands_windows field, impl block
266+
// for cli_install_commands_for_os(), command_basenames() + .cmd/.bat resolution,
267+
// Windows well-known dirs in common_binary_paths(), login_shell_candidates(),
268+
// path_candidates_from_env_raw(). Load-bearing Windows platform support.
269+
// +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None).
270+
// resolve_git_bash made pub(crate) for Windows test access.
271+
// +1: login_shell_candidates doc comment expanded for resolve_bash_path.
272+
["src-tauri/src/managed_agents/discovery.rs", 1366],
265273
// rebase over codex-acp-package-swap: its version-probe tests union with the
266274
// doctor-install-reliability nvm/login-shell/semver tests — each side alone
267275
// stayed under the 1000 default; the union exceeds it.
268-
["src-tauri/src/managed_agents/discovery/tests.rs", 1029],
276+
// Windows Doctor install fix: command_basenames, cli_install_commands_for_os,
277+
// and login_shell_candidates tests. Load-bearing platform-awareness coverage.
278+
// +132: pass 2 — five cfg(windows) behavioral tests: command_basenames .cmd/.bat
279+
// candidates, cli_install_commands_for_os PowerShell selection, login_shell_path
280+
// None regression, .cmd shim resolution, no-git-bash error hint.
281+
// +32: deterministic .cmd resolver + no-registry + install_shell_from tests.
282+
["src-tauri/src/managed_agents/discovery/tests.rs", 1270],
269283
// identity-import-keyring: the identity resolution state machine's behavioral
270284
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
271285
// adoption / read-back-corruption / marker-failure arms, recovery-mode
@@ -395,7 +409,14 @@ const overrides = new Map([
395409
// Git Bash Doctor discovery exposes a narrow async Tauri command at the
396410
// existing discovery boundary. The ten-line addition preserves the platform
397411
// neutral frontend contract; split remains queued.
398-
["src-tauri/src/commands/agent_discovery.rs", 1357],
412+
// Windows Doctor install fix: resolve_install_shell() + install_shell_command()
413+
// returns Result (Windows Git Bash resolution, CREATE_NO_WINDOW, taskkill timeout
414+
// kill), cli_install_commands_for_os() callsite, unit tests for shell selection
415+
// and per-OS install command accessor. Load-bearing Windows platform support.
416+
// +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with
417+
// Git, error hint content, install_shell_command succeeds).
418+
// +8: install_shell_from pure seam extracted for deterministic testing.
419+
["src-tauri/src/commands/agent_discovery.rs", 1523],
399420
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
400421
// + deps-array entry in submitMessage closes the never-persisted-boundary
401422
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to

‎desktop/src-tauri/src/commands/agent_config.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ mod tests {
582582
mcp_hooks: false,
583583
underlying_cli: None,
584584
cli_install_commands: &[],
585+
cli_install_commands_windows: &[],
585586
adapter_install_commands: &[],
586587
install_instructions_url: "",
587588
cli_install_hint: "",

‎desktop/src-tauri/src/commands/agent_discovery.rs‎

Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
152152
// preflight and classifier to this loop.
153153
if let Some(cli) = runtime.underlying_cli {
154154
if crate::managed_agents::resolve_command(cli).is_none() {
155-
for cmd in runtime.cli_install_commands {
155+
for cmd in runtime.cli_install_commands_for_os() {
156156
let result = run_install_command("cli", cmd);
157157
let success = result.success;
158158
steps.push(result);
@@ -541,14 +541,14 @@ fn persist_last_error_on_install(
541541
/// the shell selection and environment cleanup shared by `run_install_command`
542542
/// and `resolve_npm_prefix` — keeping them in sync so the hermit-strip list
543543
/// can't drift between the two paths.
544-
fn install_shell_command(command: &str) -> std::process::Command {
545-
let shell = if std::path::Path::new("/bin/zsh").exists() {
546-
"/bin/zsh"
547-
} else {
548-
"/bin/bash"
549-
};
544+
///
545+
/// On Windows, resolves Git Bash via `resolve_bash_path` (skips `BUZZ_SHELL`
546+
/// since install commands require bash syntax). Returns `Err` when no shell
547+
/// can be found.
548+
fn install_shell_command(command: &str) -> Result<std::process::Command, String> {
549+
let shell: std::path::PathBuf = resolve_install_shell()?;
550550

551-
let mut cmd = std::process::Command::new(shell);
551+
let mut cmd = std::process::Command::new(&shell);
552552
cmd.args(["-l", "-c", command]);
553553

554554
// Strip hermit env vars so npm/node use the user's normal registry and
@@ -575,11 +575,62 @@ fn install_shell_command(command: &str) -> std::process::Command {
575575
}
576576
}
577577

578-
cmd
578+
// Suppress the console window on Windows.
579+
#[cfg(windows)]
580+
{
581+
use std::os::windows::process::CommandExt;
582+
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
583+
cmd.creation_flags(CREATE_NO_WINDOW);
584+
}
585+
586+
Ok(cmd)
587+
}
588+
589+
/// Resolve the shell binary for install commands.
590+
///
591+
/// Unix: `/bin/zsh` if present, else `/bin/bash`.
592+
/// Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because install
593+
/// commands use bash-only `-l -c` syntax. A `BUZZ_SHELL=pwsh` user gets a green
594+
/// Doctor prereq (their agents work) but installs use the Git Bash fallback chain.
595+
fn resolve_install_shell() -> Result<std::path::PathBuf, String> {
596+
#[cfg(not(windows))]
597+
{
598+
if std::path::Path::new("/bin/zsh").exists() {
599+
return Ok(std::path::PathBuf::from("/bin/zsh"));
600+
}
601+
Ok(std::path::PathBuf::from("/bin/bash"))
602+
}
603+
604+
#[cfg(windows)]
605+
{
606+
install_shell_from(crate::managed_agents::git_bash::resolve_bash_path())
607+
}
608+
}
609+
610+
/// Pure mapping from a resolved bash path to the install-shell result.
611+
/// `None` → `Err(GIT_BASH_INSTALL_HINT)`, `Some(path)` → `Ok(path)`.
612+
#[cfg(windows)]
613+
pub(crate) fn install_shell_from(
614+
resolved: Option<std::path::PathBuf>,
615+
) -> Result<std::path::PathBuf, String> {
616+
resolved.ok_or_else(|| crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT.to_string())
579617
}
580618

581619
fn run_install_command(step: &str, command: &str) -> InstallStepResult {
582-
let mut cmd = install_shell_command(command);
620+
let mut cmd = match install_shell_command(command) {
621+
Ok(cmd) => cmd,
622+
Err(hint) => {
623+
return InstallStepResult {
624+
step: step.to_string(),
625+
command: command.to_string(),
626+
success: false,
627+
stdout: String::new(),
628+
stderr: "no suitable shell found for install commands".to_string(),
629+
exit_code: None,
630+
hint: Some(hint),
631+
};
632+
}
633+
};
583634

584635
let mut child = match cmd
585636
.stdin(std::process::Stdio::null())
@@ -641,6 +692,10 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult {
641692
unsafe {
642693
libc::kill(child_pid as i32, libc::SIGTERM);
643694
}
695+
#[cfg(windows)]
696+
{
697+
let _ = crate::managed_agents::taskkill_tree(child_pid);
698+
}
644699
drop(rx);
645700
let _ = wait_thread.join();
646701
let _ = stdout_thread.join();
@@ -776,7 +831,10 @@ enum NpmPrefix {
776831
/// `npm prefix -g` to discover where npm would install global packages.
777832
#[cfg(unix)]
778833
fn resolve_npm_prefix() -> NpmPrefix {
779-
let mut cmd = install_shell_command("npm prefix -g");
834+
let mut cmd = match install_shell_command("npm prefix -g") {
835+
Ok(cmd) => cmd,
836+
Err(_) => return NpmPrefix::Unavailable,
837+
};
780838
let mut child = match cmd
781839
.stdin(std::process::Stdio::null())
782840
.stdout(std::process::Stdio::piped())
@@ -1343,6 +1401,114 @@ mod tests {
13431401
"non-codex agent (None stamp) must never trigger drift badge"
13441402
);
13451403
}
1404+
1405+
// ── Phase A: install shell selection ─────────────────────────────────────
1406+
1407+
/// On Unix, resolve_install_shell always succeeds (returns zsh or bash).
1408+
#[cfg(unix)]
1409+
#[test]
1410+
fn test_resolve_install_shell_succeeds_on_unix() {
1411+
let result = super::resolve_install_shell();
1412+
assert!(result.is_ok(), "Unix must always resolve a shell");
1413+
let shell = result.unwrap();
1414+
assert!(
1415+
shell == std::path::Path::new("/bin/zsh") || shell == std::path::Path::new("/bin/bash"),
1416+
"expected /bin/zsh or /bin/bash, got {shell:?}"
1417+
);
1418+
}
1419+
1420+
/// install_shell_command returns a valid Command on Unix.
1421+
#[cfg(unix)]
1422+
#[test]
1423+
fn test_install_shell_command_returns_ok_on_unix() {
1424+
let result = super::install_shell_command("echo test");
1425+
assert!(result.is_ok(), "install_shell_command must succeed on Unix");
1426+
}
1427+
1428+
// ── Phase A: Windows install shell selection ───────────────────────────────
1429+
1430+
/// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds.
1431+
#[cfg(windows)]
1432+
#[test]
1433+
fn test_resolve_install_shell_succeeds_on_windows_with_git() {
1434+
let result = super::resolve_install_shell();
1435+
assert!(
1436+
result.is_ok(),
1437+
"Windows CI runner has Git — resolve_install_shell must succeed; got: {:?}",
1438+
result.err()
1439+
);
1440+
let shell = result.unwrap();
1441+
// The resolved path must end with bash.exe (Git Bash).
1442+
let fname = shell.file_name().and_then(|n| n.to_str()).unwrap_or("");
1443+
assert!(
1444+
fname.eq_ignore_ascii_case("bash.exe"),
1445+
"Windows install shell must be bash.exe, got: {shell:?}"
1446+
);
1447+
}
1448+
1449+
/// On Windows, when no Git Bash is found, the error carries the Doctor hint.
1450+
#[cfg(windows)]
1451+
#[test]
1452+
fn test_resolve_install_shell_error_contains_doctor_hint() {
1453+
// We can't force resolve_install_shell to fail on CI (Git is installed),
1454+
// but we can verify the error string it would use matches the hint.
1455+
let hint = crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT;
1456+
assert!(
1457+
hint.contains("Git for Windows"),
1458+
"GIT_BASH_INSTALL_HINT must mention Git for Windows; got: {hint}"
1459+
);
1460+
assert!(
1461+
hint.contains("PATH"),
1462+
"GIT_BASH_INSTALL_HINT must mention PATH option; got: {hint}"
1463+
);
1464+
}
1465+
1466+
/// install_shell_command returns a valid Command on Windows.
1467+
#[cfg(windows)]
1468+
#[test]
1469+
fn test_install_shell_command_returns_ok_on_windows() {
1470+
let result = super::install_shell_command("echo test");
1471+
assert!(
1472+
result.is_ok(),
1473+
"install_shell_command must succeed on Windows with Git; got: {:?}",
1474+
result.err()
1475+
);
1476+
}
1477+
1478+
// ── Phase B: per-OS install commands ──────────────────────────────────────
1479+
1480+
/// On non-Windows, cli_install_commands_for_os returns the default commands.
1481+
#[cfg(not(windows))]
1482+
#[test]
1483+
fn test_cli_install_commands_for_os_returns_default_on_unix() {
1484+
let claude = crate::managed_agents::known_acp_runtime_exact("claude").unwrap();
1485+
assert_eq!(
1486+
claude.cli_install_commands_for_os(),
1487+
claude.cli_install_commands,
1488+
"on Unix, cli_install_commands_for_os must return the default install.sh commands"
1489+
);
1490+
}
1491+
1492+
/// Goose install commands are the same on all platforms (script is Windows-aware).
1493+
#[test]
1494+
fn test_goose_install_commands_same_on_all_platforms() {
1495+
let goose = crate::managed_agents::known_acp_runtime_exact("goose").unwrap();
1496+
assert_eq!(
1497+
goose.cli_install_commands_for_os(),
1498+
goose.cli_install_commands,
1499+
"goose install commands must be identical across platforms"
1500+
);
1501+
}
1502+
1503+
/// buzz-agent has no install commands on any platform.
1504+
#[test]
1505+
fn test_buzz_agent_has_no_install_commands() {
1506+
let buzz = crate::managed_agents::known_acp_runtime_exact("buzz-agent").unwrap();
1507+
assert!(
1508+
buzz.cli_install_commands_for_os().is_empty(),
1509+
"buzz-agent ships with the app — must never have install commands"
1510+
);
1511+
}
13461512
}
13471513

13481514
/// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP.

‎desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ fn test_runtime() -> &'static KnownAcpRuntime {
3838
mcp_hooks: false,
3939
underlying_cli: None,
4040
cli_install_commands: &[],
41+
cli_install_commands_windows: &[],
4142
adapter_install_commands: &[],
4243
install_instructions_url: "",
4344
cli_install_hint: "",
@@ -611,6 +612,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime {
611612
mcp_hooks: false,
612613
underlying_cli: None,
613614
cli_install_commands: &[],
615+
cli_install_commands_windows: &[],
614616
adapter_install_commands: &[],
615617
install_instructions_url: "",
616618
cli_install_hint: "",

0 commit comments

Comments
 (0)