Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/buzz-dev-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl DevMcp {

#[tool(
name = "shell",
description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
description = "Run a bash command (or the shell configured via `BUZZ_SHELL`). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
)]
async fn shell(
&self,
Expand Down
12 changes: 6 additions & 6 deletions crates/buzz-dev-mcp/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ pub(crate) fn resolve_path(root: &Path, path: &str) -> Result<PathBuf, String> {
/// - UNC: `//server/share/x` -> `\\server\share\x`.
///
/// A third form — root-anchored `/tmp`, `/usr/...`, `/bin` — maps under the
/// MSYS install root (the bundled `git-bash` dir), which this process does not
/// reliably know. We deliberately do NOT guess it: such a path falls through
/// untranslated and fails with the clear `path not accessible` error rather than
/// being silently mis-mapped to the wrong location. Resolving it correctly would
/// require shelling out to the bundled `cygpath`; that is out of scope here and
/// these paths are not a normal target for agent file I/O.
/// MSYS install root (from the host's Git for Windows install), which this
/// process does not reliably know. We deliberately do NOT guess it: such a path
/// falls through untranslated and fails with the clear `path not accessible`
/// error rather than being silently mis-mapped to the wrong location. Resolving
/// it correctly would require shelling out to `cygpath`; that is out of scope
/// here and these paths are not a normal target for agent file I/O.
#[cfg(windows)]
fn msys_to_windows(path: &str) -> String {
// UNC: exactly two leading slashes then a non-empty host segment.
Expand Down
171 changes: 105 additions & 66 deletions crates/buzz-dev-mcp/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl SharedState {

fn build_bootstrap(cwd: &Path) -> String {
let stack = detect_stack(cwd);
let shell = resolved_shell_name();
let buzz_hint =
if std::env::var("BUZZ_RELAY_URL").is_ok() && std::env::var("BUZZ_PRIVATE_KEY").is_ok() {
"\nBuzz relay configured. Run `buzz --help` to see available commands.\n"
Expand All @@ -69,6 +70,7 @@ fn build_bootstrap(cwd: &Path) -> String {
format!(
"Working directory: {}\n\
Detected stack: {}\n\
Shell: {shell} (set BUZZ_SHELL to override) — write command strings in that shell's syntax.\n\
Pass `workdir` per call rather than `cd`.\n\
{buzz_hint}",
cwd.display(),
Expand Down Expand Up @@ -147,8 +149,9 @@ pub async fn run(
Ok(path) => path,
Err(msg) => return Ok(CallToolResult::error(vec![Content::text(msg)])),
};
let shell_arg = shell_flag(&bash);
let mut cmd = Command::new(&bash);
cmd.arg("-c").arg(&p.command);
cmd.arg(shell_arg).arg(&p.command);
cmd.current_dir(&workdir);
cmd.env("PATH", &state.shim.path_env);
// NOSTR_PRIVATE_KEY is already removed from this process's env (shim.rs).
Expand Down Expand Up @@ -306,29 +309,53 @@ pub async fn run(
Ok(CallToolResult::success(vec![Content::text(text)]))
}

/// The bundled bash subtree's directory name under the install root, and the
/// relative path to its `bash.exe`. This is the THREE-FILE PATH CONTRACT — it must
/// stay byte-identical with:
/// 1. `scripts/bundle-sidecars.sh` — stages the bash tree to
/// `desktop/src-tauri/binaries/git-bash/` (the bundle-source dir).
/// 2. `desktop/scripts/build-release-config.mjs` — emits the Windows-only
/// `bundle.resources` Map `{ "binaries/git-bash": "git-bash" }`, whose TARGET
/// (`git-bash`) is what Tauri's NSIS/MSI installer stages next to the exe.
/// 3. this resolver — joins `current_exe().parent()` + `git-bash\bin\bash.exe`.
/// The flag used to pass a command string to the shell.
///
/// Drift between (2)'s target and this string ships a working bundle but a broken
/// runtime path. Keep all three in lockstep.
/// bash/zsh/sh: `-c`
/// cmd.exe: `/C`
/// powershell/pwsh: `-Command`
///
/// The bundled constant points at `bin\bash.exe` — the git-for-windows launcher,
/// NOT `usr\bin\bash.exe`. The bundle now ships the WHOLE PortableGit tree
/// (including `mingw64/`), so it has the sibling `mingw64\bin` the launcher needs:
/// the launcher is the correct entry because it sets `MSYSTEM=MINGW64` and prepends
/// `mingw64\bin` to the in-shell PATH, which is what makes `git`/`jq`/`curl` resolve
/// inside the agent's shell with no Rust-side PATH injection. The installed-Git
/// branch below uses the SAME `bin\bash.exe` entry for the same reason — same tree
/// shape, same correct entry point.
#[cfg(windows)]
const BUNDLED_BASH_REL: &str = r"git-bash\bin\bash.exe";
/// We currently only resolve bash variants (installed Git for Windows) and never
/// default to cmd/PowerShell, so this is always `-c` in practice. The dispatch is
/// here so a future `BUZZ_SHELL=pwsh` override works without spawning incorrectly.
fn shell_flag(shell: &Path) -> &'static str {
match shell
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("cmd") => "/C",
Some("powershell" | "pwsh") => "-Command",
_ => "-c",
}
}

/// Human-readable name for the shell that will execute commands.
///
/// Used in bootstrap instructions so the model knows which dialect to write.
/// Resolution order mirrors `resolve_bash`: `BUZZ_SHELL` wins, then platform
/// defaults. Does NOT perform file-existence checks — this is a display hint,
/// not a resolved path.
pub fn resolved_shell_name() -> &'static str {
if let Some(p) = std::env::var_os("BUZZ_SHELL") {
let path = PathBuf::from(&p);
match path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("pwsh") => return "pwsh",
Some("powershell") => return "powershell",
Some("cmd") => return "cmd",
Some("zsh") => return "zsh",
Some("sh") => return "sh",
_ => return "bash",
}
}
"bash"
}

/// Resolve a genuine, non-WSL bash to an absolute path so we spawn it directly
/// instead of letting `Command::new("bash")` re-enter PATH search — on Windows
Expand All @@ -339,19 +366,37 @@ const BUNDLED_BASH_REL: &str = r"git-bash\bin\bash.exe";
/// resolver is a no-op there. The probe logic is Windows-only.
#[cfg(not(windows))]
fn resolve_bash(_path_env: &str) -> Result<PathBuf, String> {
// Honor BUZZ_SHELL on Unix too so power users can opt into zsh or another shell.
if let Some(p) = std::env::var_os("BUZZ_SHELL").map(PathBuf::from) {
if p.is_file() {
return Ok(p);
}
}
Ok(PathBuf::from("bash"))
}

/// Windows bash resolution. Probe order (first hit wins):
/// 1. `GIT_BASH` env override (escape hatch / explicit operator choice).
/// 2. Installed Git for Windows (fast path when the user has Git).
/// 3. The bundled bash staged next to our exe (guaranteed target — this
/// is what makes a bare, Git-less host work since the app is self-contained).
/// 1. `BUZZ_SHELL` env override — explicit operator choice, any shell.
/// 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, EXCLUDING System32 (so we never resolve WSL's `bash.exe`).
///
/// No bash found -> actionable error returned BEFORE spawn.
/// 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> {
// BUZZ_SHELL: explicit operator override — use whatever shell is specified,
// including cmd or PowerShell for advanced users. Validated to exist.
if let Some(p) = std::env::var_os("BUZZ_SHELL").map(PathBuf::from) {
if p.is_file() {
return Ok(p);
}
}

// 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() {
return Ok(p);
Expand All @@ -372,37 +417,17 @@ fn resolve_bash(path_env: &str) -> Result<PathBuf, String> {
}
}

// Bundled bash, located relative to OUR OWN executable. On Windows, Tauri
// stages `bundle.resources` flat in the directory that contains the exe
// (tauri 2.11.2 `resource_dir()` == exe parent on Windows), and every sidecar
// — including this one — lives in that same dir. This relative-to-self resolution
// is Windows-ONLY: macOS stages resources to `../Resources` and Linux to
// `usr/lib/<app>`, so a cross-platform "resource relative to exe" helper would
// be wrong on those platforms.
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if let Some(p) = bundled_bash(dir) {
return Ok(p);
}
}
}

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

Err("no bash found: install Git for Windows, or set GIT_BASH to a bash.exe path".into())
}

/// Compute the bundled bash path relative to the install dir (the exe's parent),
/// `is_file`-gated so dev/CI builds without a staged resource return None and let
/// the caller fall through cleanly — never returning a non-existent path that
/// would fail later at spawn with a worse message.
#[cfg(windows)]
fn bundled_bash(install_dir: &Path) -> Option<PathBuf> {
let bundled = install_dir.join(BUNDLED_BASH_REL);
bundled.is_file().then_some(bundled)
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 absolute path of any bash-compatible executable."
.into(),
)
}

/// True if `dir` is `root` or lives under it, comparing path components
Expand Down Expand Up @@ -870,23 +895,37 @@ mod windows_resolver_tests {
}

#[test]
fn bundled_branch_returns_none_when_path_absent() {
// Dev/CI: no staged resource next to the exe -> the bundled branch must
// yield None so the resolver falls through instead of returning a
// non-existent path that would fail at spawn.
fn buzz_shell_override_wins_over_everything() {
// BUZZ_SHELL pointing at a real file must be returned without probing
// the standard Git-for-Windows locations or PATH.
let dir = tempdir().expect("tempdir");
assert!(bundled_bash(dir.path()).is_none());
let fake_bash = dir.path().join("my-bash.exe");
touch(&fake_bash);
// Temporarily set BUZZ_SHELL; clean up after the test.
env::set_var("BUZZ_SHELL", &fake_bash);
let result = resolve_bash("");
env::remove_var("BUZZ_SHELL");
let resolved = result.expect("BUZZ_SHELL override should resolve");
assert_eq!(resolved, fake_bash);
}

#[test]
fn bundled_branch_returns_absolute_path_when_staged() {
// A staged PortableGit bash runtime next to the exe resolves to the absolute bash path.
let dir = tempdir().expect("tempdir");
let bash = dir.path().join(BUNDLED_BASH_REL);
touch(&bash);
let resolved = bundled_bash(dir.path()).expect("bundled bash");
assert!(resolved.is_absolute());
assert_eq!(resolved, bash);
fn buzz_shell_override_skipped_when_path_absent() {
// If BUZZ_SHELL points at a non-existent path the resolver must fall
// through rather than returning a dead path.
env::set_var("BUZZ_SHELL", r"C:\does\not\exist\bash.exe");
// We cannot easily assert the fallback here without a full Git install,
// but we can assert the override itself is not returned.
let result = resolve_bash("");
env::remove_var("BUZZ_SHELL");
if let Ok(resolved) = result {
assert_ne!(
resolved.to_str().unwrap_or(""),
r"C:\does\not\exist\bash.exe",
"non-existent BUZZ_SHELL must not be returned"
);
}
// An Err is also acceptable (no Git installed on test host).
}

#[test]
Expand Down
23 changes: 0 additions & 23 deletions desktop/scripts/build-release-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,29 +52,6 @@ const releaseConfig = {
},
};

// Windows-only: bundle the full PortableGit toolchain (bash runtime + git +
// curl/coreutils in mingw64/, plus a vendored standalone jq.exe) as a resource so
// the MCP shell tool always has a genuine, non-WSL bash AND a real dev toolchain
// to spawn on a bare host (the app must be self-contained — we cannot assume Git
// for Windows is installed).
//
// This is emitted ONLY on the Windows runner because the static tauri.conf.json
// uses `targets: "all"` with a shared bundle block — a bare `resources` entry
// there would ship the (now ~350MB+) tree into the macOS .dmg and Linux packages
// too. The release build runs THIS generator on each platform's own runner and
// merges the output via --config, so guarding on process.platform keeps the tree
// off mac/Linux.
//
// PATH CONTRACT (keep byte-identical across three files):
// - source `binaries/git-bash` (relative to src-tauri/) is staged by
// scripts/bundle-sidecars.sh.
// - target `git-bash` is the install-root subdir; Tauri's Windows installer
// stages it next to the exe, and crates/buzz-dev-mcp/src/shell.rs resolves
// `git-bash\bin\bash.exe` relative to its own executable at runtime.
if (process.platform === "win32") {
releaseConfig.bundle.resources = { "binaries/git-bash": "git-bash" };
}

console.log(`Updater enabled -> ${updaterEndpoint}`);

writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`);
Expand Down
66 changes: 66 additions & 0 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,75 @@ pub fn discover_managed_agent_prereqs(
.filter(|value| !value.is_empty())
.unwrap_or("");

// On Windows, check whether a bash-compatible shell is available. The probe
// mirrors the resolver in buzz-dev-mcp/src/shell.rs (same priority: BUZZ_SHELL
// > GIT_BASH > Program Files\Git > LocalAppData\Programs\Git). This surfaces
// the prerequisite in the agent-creation UI before the user hits a runtime error.
#[cfg(windows)]
let bash = Some(detect_windows_bash());
#[cfg(not(windows))]
let bash = None;

ManagedAgentPrereqsInfo {
acp: command_availability(acp_command),
mcp: command_availability(mcp_command),
bash,
}
}

/// Probe for a bash-compatible shell on Windows, mirroring the resolver in
/// `buzz-dev-mcp/src/shell.rs`. Returns a `CommandAvailabilityInfo` so the
/// frontend can surface a prereq hint in the same shape as acp/mcp.
#[cfg(windows)]
fn detect_windows_bash() -> crate::managed_agents::CommandAvailabilityInfo {
// 1. BUZZ_SHELL override.
if let Some(p) = std::env::var_os("BUZZ_SHELL").map(std::path::PathBuf::from) {
if p.is_file() {
return crate::managed_agents::CommandAvailabilityInfo {
command: "bash".to_string(),
available: true,
resolved_path: Some(p.display().to_string()),
};
}
}

// 2. GIT_BASH legacy override.
if let Some(p) = std::env::var_os("GIT_BASH").map(std::path::PathBuf::from) {
if p.is_file() {
return crate::managed_agents::CommandAvailabilityInfo {
command: "bash".to_string(),
available: true,
resolved_path: Some(p.display().to_string()),
};
}
}

// 3. Installed Git for Windows.
for root in ["ProgramFiles", "LocalAppData"] {
if let Some(base) = std::env::var_os(root) {
let candidate = match root {
"LocalAppData" => {
std::path::PathBuf::from(&base).join("Programs").join("Git")
}
_ => std::path::PathBuf::from(&base).join("Git"),
}
.join("bin")
.join("bash.exe");
if candidate.is_file() {
return crate::managed_agents::CommandAvailabilityInfo {
command: "bash".to_string(),
available: true,
resolved_path: Some(candidate.display().to_string()),
};
}
}
}

// Not found.
crate::managed_agents::CommandAvailabilityInfo {
command: "bash".to_string(),
available: false,
resolved_path: None,
}
}

Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ pub struct DiscoverManagedAgentPrereqsRequest {
pub struct ManagedAgentPrereqsInfo {
pub acp: CommandAvailabilityInfo,
pub mcp: CommandAvailabilityInfo,
/// Windows only: whether a bash-compatible shell is available on the host.
/// Always `None` on non-Windows platforms.
pub bash: Option<CommandAvailabilityInfo>,
}

/// Patch request for updating a managed agent's mutable fields.
Expand Down
Loading
Loading