mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
refactor(shell): drop bundled PortableGit, add BUZZ_SHELL override + dialect hint (#1536)
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
71265ca361
commit
cfa2089831
+27
-78
@@ -837,89 +837,38 @@ jobs:
|
||||
- name: Test (buzz-dev-mcp)
|
||||
# The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests
|
||||
# only gate if this crate is tested ON Windows.
|
||||
run: cargo test -p buzz-dev-mcp --target $env:TARGET
|
||||
# The bundled-bash staging (PortableGit download + SFX extract of the WHOLE
|
||||
# tree, including mingw64/) runs only in release.yml on tag — so without this
|
||||
# step the agent's only Windows transport would ship UNEXERCISED until a
|
||||
# tagged release hits users. Stage the tree and spawn the LAUNCHER bash
|
||||
# (bin/bash.exe, the entry the resolver now uses) to prove all of: the SFX
|
||||
# `-o` POSIX-path extraction works; the launcher spawns and sets up
|
||||
# MSYSTEM/PATH; the lazily-loaded MSYS DLL closure survives; and — the whole
|
||||
# point of bundling the full toolchain — git/jq/curl resolve from the bundled
|
||||
# mingw64/bin and git is functional (a real commit round-trips). The launcher
|
||||
# prepending mingw64/bin to PATH under `-c` is the load-bearing behavior that
|
||||
# only a real Windows host can confirm; this gate is where it gets proven.
|
||||
# (End-to-end nostr-SIGNED commits compose build_git_env's GIT_CONFIG_* with
|
||||
# the git-sign-nostr helper — covered by buzz-dev-mcp unit tests + a bare-host
|
||||
# check, not here, since this job stages only empty sidecar placeholders.)
|
||||
- name: Smoke-test bundled bash + git toolchain staging
|
||||
# Serial: windows_resolver_tests mutate process-global env
|
||||
# (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads.
|
||||
run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1
|
||||
# Smoke-test the new host-prereq contract: Git for Windows (which provides
|
||||
# bash) is available on the runner, a shell command round-trips, and bash
|
||||
# does NOT resolve from System32 (so WSL's launcher is never picked up).
|
||||
# windows-latest runners have Git for Windows pre-installed; the unit tests
|
||||
# above exercise the MCP resolver itself. This step verifies the host env.
|
||||
- name: Smoke-test host Git Bash prereq (host env check)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stage_dir="$RUNNER_TEMP/git-bash"
|
||||
scripts/stage-windows-bash.sh "$stage_dir"
|
||||
launcher="$stage_dir/bin/bash.exe"
|
||||
[[ -f "$launcher" ]] || { echo "launcher bash missing: $launcher" >&2; exit 1; }
|
||||
# Git for Windows ships bash.exe under its bin/ directory; confirm it
|
||||
# resolves from the standard location the runtime resolver probes first.
|
||||
bash_path=$(command -v bash 2>/dev/null || true)
|
||||
[[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; }
|
||||
echo "Resolved bash: $bash_path"
|
||||
[[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; }
|
||||
|
||||
# Faithfulness to the agent: shell.rs spawns the launcher with PATH set
|
||||
# wholesale to the shim PATH (shim tempdir + the MCP process's inherited
|
||||
# PATH). We strip PATH down to just the Windows system dir before
|
||||
# spawning — stricter than a bare host (ambient PATH stripped to force
|
||||
# resolution through the launcher). This stops the runner's ambient
|
||||
# git/jq from masking the launcher's own mingw64/bin prepend, so anything
|
||||
# that resolves had to come from the launcher itself. The fidelity gap is
|
||||
# benign: the real shim tempdir only ever holds rg/tree/buzz and the two
|
||||
# nostr helpers, never git/jq/curl, so a richer real PATH cannot shadow
|
||||
# mingw64/bin for these three tools.
|
||||
win_root="${SYSTEMROOT:-${SystemRoot:-C:\\Windows}}"
|
||||
bare_path="$win_root\\System32;$win_root"
|
||||
# Run a basic pipeline through the resolved bash (same invocation the
|
||||
# agent uses: bash -c '...').
|
||||
out=$(bash -c 'echo hello | tr a-z A-Z')
|
||||
[[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; }
|
||||
|
||||
# Coreutils pipeline through the launcher (the resolver's entry point).
|
||||
out=$(PATH="$bare_path" "$launcher" -c 'echo hello | tr a-z A-Z')
|
||||
[[ "$out" == "HELLO" ]] || { echo "staged bash pipeline failed: got '$out'" >&2; exit 1; }
|
||||
|
||||
# The full-toolchain payoff: git/jq/curl must resolve from the bundle's
|
||||
# mingw64/bin or usr/bin. Use NON-login `-c` to match the agent's actual
|
||||
# invocation (shell.rs spawns `bash -c`): if the launcher only fixes PATH
|
||||
# under a login shell, `-lc` would pass here while the real agent stays
|
||||
# broken — the exact green-but-broken trap this gate guards.
|
||||
#
|
||||
# Anchor to the ACTUAL staged tree, not a mount-shape: command -v reports
|
||||
# the MSYS mount path (/mingw64/bin/git), so we cygpath -m it back to the
|
||||
# real Windows location and assert it sits under $stage_dir. A bare
|
||||
# `/mingw64/*` shape check would pass for any /mingw64-rooted mount; the
|
||||
# cygpath round-trip proves the tool is literally inside the bundle we
|
||||
# just staged. cygpath ships in the bundle's usr/bin (MSYS2 core, same
|
||||
# tier as the `tr` proven above), so it resolves under the launcher.
|
||||
#
|
||||
# curl is the one tool of the three with a System32 twin
|
||||
# (C:\Windows\System32\curl.exe, on $bare_path) — if a future PortableGit
|
||||
# ever drops curl from mingw64/bin, the launcher would resolve the
|
||||
# System32 twin and this REDs on a non-bug; git/jq have no such twin.
|
||||
stage_m=$("$launcher" -c "cygpath -m '$stage_dir'")
|
||||
for tool in git jq curl; do
|
||||
located=$(PATH="$bare_path" "$launcher" -c "command -v $tool >/dev/null 2>&1 && cygpath -m \"\$(command -v $tool)\"" || true)
|
||||
[[ -n "$located" ]] || { echo "$tool did not resolve in bundled bash" >&2; exit 1; }
|
||||
case "$located" in
|
||||
"$stage_m"/*) ;;
|
||||
*) echo "$tool resolved outside the staged bundle: $located (stage: $stage_m)" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "$tool -> $located"
|
||||
done
|
||||
|
||||
# git is not just present but functional: a real commit round-trips,
|
||||
# again through non-login `-c` with the bare PATH. Single-quoted on
|
||||
# purpose — this script body runs in the launcher's shell, not ours.
|
||||
# shellcheck disable=SC2016
|
||||
PATH="$bare_path" "$launcher" -c '
|
||||
set -euo pipefail
|
||||
repo=$(mktemp -d)
|
||||
cd "$repo"
|
||||
git init -q
|
||||
git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke
|
||||
git log -1 --format=%s | grep -qx smoke
|
||||
' || { echo "bundled git failed a commit round-trip" >&2; exit 1; }
|
||||
echo "staged launcher bash spawned; git/jq/curl resolve from the bundle; git commit works"
|
||||
# Confirm git itself works — agents run git commands frequently.
|
||||
git --version
|
||||
repo=$(mktemp -d)
|
||||
cd "$repo"
|
||||
git init -q
|
||||
git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke
|
||||
git log -1 --format=%s | grep -qx smoke
|
||||
echo "Host bash resolved and functional; git commit round-trip passed"
|
||||
- name: Check (Tauri crate)
|
||||
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
|
||||
env:
|
||||
|
||||
@@ -135,6 +135,16 @@ For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSO
|
||||
|
||||
---
|
||||
|
||||
## Windows prerequisites
|
||||
|
||||
The agent shell tool runs commands under bash. On macOS and Linux that's already there; on Windows you need to bring it.
|
||||
|
||||
Install [Git for Windows](https://git-scm.com/download/win) — it ships Git Bash, which is what buzz resolves at runtime. Once it's installed, everything works the same as on other platforms.
|
||||
|
||||
If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL` to its path (e.g. `BUZZ_SHELL=C:\path\to\bash.exe`). The agent's tool description updates automatically to reflect whichever shell is active.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@ You are operating inside the Buzz platform — a Nostr-based messaging platform
|
||||
|
||||
## Buzz CLI
|
||||
|
||||
The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON — pipe through `jq` as needed.
|
||||
The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON.
|
||||
|
||||
| Group | Key commands |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -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 shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct SharedState {
|
||||
pub shim: Shim,
|
||||
pub session_dir: TempDir,
|
||||
pub bootstrap_instructions: String,
|
||||
/// The shell resolved at construction: `Ok((path, display_name))` when a shell
|
||||
/// is available, `Err(msg)` when none was found. Stored once so both the
|
||||
/// bootstrap hint and every `run()` call read the SAME resolution — no drift.
|
||||
pub resolved_shell: Result<(PathBuf, String), String>,
|
||||
pub artifacts: Mutex<VecDeque<PathBuf>>,
|
||||
next_call_id: Mutex<u64>,
|
||||
}
|
||||
@@ -37,12 +41,22 @@ impl SharedState {
|
||||
let session_dir = tempfile::Builder::new()
|
||||
.prefix("buzz-dev-mcp-session-")
|
||||
.tempdir()?;
|
||||
let bootstrap_instructions = build_bootstrap(&cwd);
|
||||
// Resolve the shell ONCE using the same PATH the spawn will use.
|
||||
// Both the bootstrap dialect hint and every run() call read this result,
|
||||
// so they can never disagree. A failed resolution is stored as Err and
|
||||
// surfaces as an actionable error on the first tool call.
|
||||
let resolved_shell = resolve_bash(&shim.path_env);
|
||||
let shell_hint = match &resolved_shell {
|
||||
Ok((_, name)) => name.as_str(),
|
||||
Err(_) => "bash",
|
||||
};
|
||||
let bootstrap_instructions = build_bootstrap(&cwd, shell_hint);
|
||||
Ok(Self {
|
||||
cwd,
|
||||
shim,
|
||||
session_dir,
|
||||
bootstrap_instructions,
|
||||
resolved_shell,
|
||||
artifacts: Mutex::new(VecDeque::with_capacity(ARTIFACT_RING_SIZE)),
|
||||
next_call_id: Mutex::new(0),
|
||||
})
|
||||
@@ -58,7 +72,7 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bootstrap(cwd: &Path) -> String {
|
||||
fn build_bootstrap(cwd: &Path, shell_hint: &str) -> String {
|
||||
let stack = detect_stack(cwd);
|
||||
let buzz_hint =
|
||||
if std::env::var("BUZZ_RELAY_URL").is_ok() && std::env::var("BUZZ_PRIVATE_KEY").is_ok() {
|
||||
@@ -69,6 +83,7 @@ fn build_bootstrap(cwd: &Path) -> String {
|
||||
format!(
|
||||
"Working directory: {}\n\
|
||||
Detected stack: {}\n\
|
||||
Shell: {shell_hint} (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(),
|
||||
@@ -143,12 +158,13 @@ pub async fn run(
|
||||
));
|
||||
}
|
||||
|
||||
let bash = match resolve_bash(&state.shim.path_env) {
|
||||
Ok(path) => path,
|
||||
Err(msg) => return Ok(CallToolResult::error(vec![Content::text(msg)])),
|
||||
let bash = match &state.resolved_shell {
|
||||
Ok((path, _)) => path.clone(),
|
||||
Err(msg) => return Ok(CallToolResult::error(vec![Content::text(msg.clone())])),
|
||||
};
|
||||
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).
|
||||
@@ -167,7 +183,7 @@ pub async fn run(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"failed to spawn bash: {e}"
|
||||
"failed to spawn shell: {e}"
|
||||
))]));
|
||||
}
|
||||
};
|
||||
@@ -306,55 +322,119 @@ 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";
|
||||
/// The resolver supports `BUZZ_SHELL=cmd`/`pwsh` (explicit operator overrides
|
||||
/// resolve without the System32 exclusion, so these shells work). The dispatch
|
||||
/// here ensures each shell receives the correct flag regardless of which one
|
||||
/// was resolved.
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// that search finds `System32\bash.exe` (the WSL launcher), which fails at spawn
|
||||
/// with `0x8007072c` and can never run the agent's POSIX commands.
|
||||
/// Extract a short display name from a resolved shell path (e.g. `pwsh.exe` → `"pwsh"`).
|
||||
fn shell_name_from_path(p: &Path) -> String {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| "bash".to_string())
|
||||
}
|
||||
|
||||
/// Resolve the shell to spawn. On Unix, bash on PATH is correct and was never
|
||||
/// broken, so the resolver only needs to honor BUZZ_SHELL. The probe logic and
|
||||
/// System32 exclusion are Windows-only.
|
||||
///
|
||||
/// On Unix, bare `bash` resolved via PATH is correct and was never broken, so the
|
||||
/// resolver is a no-op there. The probe logic is Windows-only.
|
||||
/// Returns `(resolved_path, display_name)`. The display name is derived from the
|
||||
/// resolved path so the caller can use it for diagnostics without a second lookup.
|
||||
#[cfg(not(windows))]
|
||||
fn resolve_bash(_path_env: &str) -> Result<PathBuf, String> {
|
||||
Ok(PathBuf::from("bash"))
|
||||
fn resolve_bash(_path_env: &str) -> Result<(PathBuf, String), String> {
|
||||
// Honor BUZZ_SHELL on Unix so power users can opt into zsh or another shell.
|
||||
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 path: fall through to bash.
|
||||
} else {
|
||||
// Bare command name: scan the process PATH directly.
|
||||
let path_var = std::env::var_os("PATH").unwrap_or_default();
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
let candidate = dir.join(&p);
|
||||
if candidate.is_file() {
|
||||
let name = shell_name_from_path(&candidate);
|
||||
return Ok((candidate, name));
|
||||
}
|
||||
}
|
||||
// Not found: fall through to bash.
|
||||
}
|
||||
}
|
||||
Ok((PathBuf::from("bash"), "bash".to_string()))
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 4. PATH scan, EXCLUDING System32 (so we never resolve WSL's `bash.exe`).
|
||||
/// 1. `BUZZ_SHELL` env override — explicit operator choice, any shell (cmd,
|
||||
/// PowerShell, bash, etc.). Bare command names are resolved through PATH
|
||||
/// 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).
|
||||
///
|
||||
/// No bash found -> actionable error returned BEFORE spawn.
|
||||
/// 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> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
let name = shell_name_from_path(&p);
|
||||
return Ok((p, name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,42 +447,24 @@ fn resolve_bash(path_env: &str) -> Result<PathBuf, String> {
|
||||
.join("bin")
|
||||
.join("bash.exe");
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return Ok((candidate, "bash".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return Ok((p, "bash".to_string()));
|
||||
}
|
||||
|
||||
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 path of any bash-compatible executable (or a bare\n\
|
||||
command name like cmd or pwsh if it is on PATH)."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// True if `dir` is `root` or lives under it, comparing path components
|
||||
@@ -432,18 +494,38 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no
|
||||
/// extension), skipping any directory under `system_root` to avoid resolving
|
||||
/// WSL helpers. Returns the first absolute path found.
|
||||
#[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();
|
||||
for dir in std::env::split_paths(path_env) {
|
||||
if let Some(ref root) = system_root {
|
||||
// Skip System32 (and any other dir under %SystemRoot%) — that's where
|
||||
// WSL's bash.exe lives.
|
||||
if let Some(root) = system_root {
|
||||
if is_under_dir(&dir, root) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let candidate = dir.join("bash.exe");
|
||||
// Try as-is first.
|
||||
let candidate = dir.join(name);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
// On Windows, also try with .exe suffix when the name has no extension.
|
||||
if needs_exe {
|
||||
let mut with_exe = dir.join(name);
|
||||
with_exe.set_extension("exe");
|
||||
if with_exe.is_file() {
|
||||
return Some(with_exe);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -860,8 +942,14 @@ mod tests {
|
||||
mod windows_resolver_tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
// Process-global env mutation guard: tests that mutate BUZZ_SHELL,
|
||||
// SystemRoot, or GIT_BASH must hold this lock for the duration of the
|
||||
// test so parallel test threads cannot race on these env vars.
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("mkdir");
|
||||
@@ -870,23 +958,176 @@ 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() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// 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, _name) = 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.
|
||||
fn buzz_shell_override_skipped_when_path_absent() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// 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).
|
||||
}
|
||||
|
||||
/// Explicit BUZZ_SHELL bare name resolves through PATH and uses NO System32
|
||||
/// exclusion — cmd/pwsh live in System32 legitimately.
|
||||
#[test]
|
||||
fn buzz_shell_explicit_bare_name_resolves_from_system32() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// Simulate cmd.exe living in a dir that would be excluded by the WSL guard.
|
||||
// The explicit BUZZ_SHELL branch must NOT skip System32.
|
||||
let sys32 = tempdir().expect("sys32");
|
||||
let fake_cmd = sys32.path().join("cmd.exe");
|
||||
touch(&fake_cmd);
|
||||
|
||||
// Build a path_env with only sys32 (the WSL exclusion would skip this dir
|
||||
// for bash.exe, but must NOT skip it for an explicit BUZZ_SHELL).
|
||||
let path_env = env::join_paths([sys32.path().to_path_buf()]).expect("join");
|
||||
env::set_var("BUZZ_SHELL", "cmd");
|
||||
// Override SystemRoot so the exclusion would trigger on sys32 if applied.
|
||||
let old_sysroot = env::var_os("SystemRoot");
|
||||
env::set_var("SystemRoot", sys32.path());
|
||||
|
||||
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
||||
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
match old_sysroot {
|
||||
Some(v) => env::set_var("SystemRoot", v),
|
||||
None => env::remove_var("SystemRoot"),
|
||||
}
|
||||
|
||||
let (resolved, name) =
|
||||
result.expect("explicit BUZZ_SHELL=cmd should resolve even from System32-like dir");
|
||||
assert_eq!(resolved, fake_cmd);
|
||||
assert_eq!(name, "cmd");
|
||||
}
|
||||
|
||||
/// Implicit bash.exe scan still skips System32 (WSL guard intact).
|
||||
#[test]
|
||||
fn implicit_bash_scan_still_skips_system32() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// Same setup: bash.exe is only in a dir that is under SystemRoot.
|
||||
// Without an explicit BUZZ_SHELL, the fallback scan must skip it.
|
||||
let sys32 = tempdir().expect("sys32");
|
||||
touch(&sys32.path().join("bash.exe"));
|
||||
|
||||
let path_env = env::join_paths([sys32.path().to_path_buf()]).expect("join");
|
||||
// No BUZZ_SHELL — trigger the implicit bash fallback scan.
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
env::remove_var("GIT_BASH");
|
||||
// Point SystemRoot at sys32's parent so sys32 is "under SystemRoot".
|
||||
let parent = sys32.path().parent().unwrap().to_path_buf();
|
||||
let old_sysroot = env::var_os("SystemRoot");
|
||||
env::set_var("SystemRoot", &parent);
|
||||
|
||||
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
||||
|
||||
match old_sysroot {
|
||||
Some(v) => env::set_var("SystemRoot", v),
|
||||
None => env::remove_var("SystemRoot"),
|
||||
}
|
||||
|
||||
// Should be Err (no Git installed on test host, and the only bash.exe was
|
||||
// under SystemRoot so it was skipped). Ok is also acceptable if git bash
|
||||
// happens to be installed at the fixed Program Files path — we just assert
|
||||
// the System32 bash was NOT returned.
|
||||
if let Ok((resolved, _)) = result {
|
||||
assert!(
|
||||
!resolved.starts_with(sys32.path()),
|
||||
"implicit bash scan must not return the System32 bash: {resolved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bootstrap hint (resolved_shell field) and the spawn path are the
|
||||
/// same object — both come from SharedState.resolved_shell.
|
||||
/// Verify that constructing SharedState with BUZZ_SHELL set produces a
|
||||
/// resolved_shell whose display name appears in bootstrap_instructions.
|
||||
#[test]
|
||||
fn shared_state_bootstrap_hint_matches_resolved_shell() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
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);
|
||||
let fake_pwsh = dir.path().join("pwsh.exe");
|
||||
touch(&fake_pwsh);
|
||||
env::set_var("BUZZ_SHELL", &fake_pwsh);
|
||||
|
||||
let shim = crate::shim::Shim::install().expect("shim");
|
||||
let state = SharedState::new(dir.path().to_path_buf(), shim).expect("state");
|
||||
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
|
||||
let (_, name) = state.resolved_shell.as_ref().expect("resolved ok");
|
||||
assert_eq!(name, "pwsh");
|
||||
assert!(
|
||||
state.bootstrap_instructions.contains("pwsh"),
|
||||
"bootstrap must mention the resolved shell name"
|
||||
);
|
||||
}
|
||||
|
||||
/// F3: BUZZ_SHELL bare command name (e.g. "pwsh") resolved through PATH.
|
||||
/// When pwsh.exe is on PATH, resolve_bash must return it and report "pwsh".
|
||||
#[test]
|
||||
fn buzz_shell_bare_name_resolved_through_path_when_present() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let fake_pwsh = dir.path().join("pwsh.exe");
|
||||
touch(&fake_pwsh);
|
||||
|
||||
let path_env = env::join_paths([dir.path().to_path_buf()]).expect("join");
|
||||
env::set_var("BUZZ_SHELL", "pwsh");
|
||||
let result = resolve_bash(path_env.to_str().expect("utf8"));
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
|
||||
let (resolved, name) = result.expect("bare BUZZ_SHELL=pwsh should resolve from PATH");
|
||||
assert_eq!(resolved, fake_pwsh, "should resolve to pwsh.exe on PATH");
|
||||
assert_eq!(name, "pwsh", "display name must match resolved shell");
|
||||
}
|
||||
|
||||
/// F3: BUZZ_SHELL bare command name absent from PATH → fall through, do not
|
||||
/// report pwsh as the active shell.
|
||||
#[test]
|
||||
fn buzz_shell_bare_name_absent_from_path_falls_through() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// Set BUZZ_SHELL to a command that won't be on any real PATH.
|
||||
env::set_var("BUZZ_SHELL", "buzz-shell-does-not-exist-xyz");
|
||||
let result = resolve_bash("");
|
||||
env::remove_var("BUZZ_SHELL");
|
||||
if let Ok((resolved, name)) = result {
|
||||
assert_ne!(
|
||||
resolved.file_name().and_then(|n| n.to_str()).unwrap_or(""),
|
||||
"buzz-shell-does-not-exist-xyz.exe",
|
||||
"absent BUZZ_SHELL must not be returned as the resolved path"
|
||||
);
|
||||
assert_ne!(
|
||||
name, "buzz-shell-does-not-exist-xyz",
|
||||
"absent BUZZ_SHELL must not be reported as the shell name"
|
||||
);
|
||||
}
|
||||
// Err is also acceptable (no Git on test host).
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -947,4 +1188,27 @@ mod windows_resolver_tests {
|
||||
"case-divergent System32 must still be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
/// PATH-only discovery — a bash.exe custom-installed on PATH (not under
|
||||
/// the standard Program Files locations) must be found by the runtime
|
||||
/// resolver. This verifies the PATH fallback in resolve_bash: a custom
|
||||
/// install that lives outside Program Files is still usable as a shell.
|
||||
#[test]
|
||||
fn path_only_bash_is_found_by_scan() {
|
||||
// scan_path_for_bash is the runtime resolver's PATH fallback helper.
|
||||
// Verify it returns the bash.
|
||||
let real = tempdir().expect("real");
|
||||
let real_bash = real.path().join("bash.exe");
|
||||
touch(&real_bash);
|
||||
|
||||
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");
|
||||
assert_eq!(found, real_bash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -38,12 +38,3 @@ for bin in "${SIDECARS[@]}"; do
|
||||
cp "$SRC_DIR/${bin}${EXE}" "$BINARIES_DIR/${bin}-${TARGET}${EXE}"
|
||||
done
|
||||
echo "Sidecars bundled for $TARGET"
|
||||
|
||||
# Windows-only: stage a genuine, non-WSL bash plus the full git toolchain next to
|
||||
# the sidecars so the MCP shell tool works on a bare host. The download/extract
|
||||
# logic lives in a self-contained script (no release-binary precondition) so CI can
|
||||
# call it directly to exercise this path on a real Windows runner — see
|
||||
# scripts/stage-windows-bash.sh for the full rationale and the PATH CONTRACT.
|
||||
if [[ "$TARGET" == *windows* ]]; then
|
||||
"$(dirname "$0")/stage-windows-bash.sh" "$BINARIES_DIR/git-bash"
|
||||
fi
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Stage a genuine, non-WSL bash for the Windows MCP shell tool. The app is
|
||||
# self-contained — we cannot assume Git for Windows is installed — so we bundle
|
||||
# the full toolchain rather than probing for an install.
|
||||
#
|
||||
# There is no standalone "bash for Windows" upstream: working Windows bash ships
|
||||
# only inside git-for-windows. We download PortableGit and keep the WHOLE tree —
|
||||
# the MSYS2 bash runtime (`usr/` + `bin/`) AND the `mingw64/` subtree that carries
|
||||
# `git.exe` plus `curl`/full `sed`/`awk`/`grep`/`find` — so a bare host gets a
|
||||
# real dev env, not just bash + coreutils. `jq` is NOT in PortableGit, so we
|
||||
# additionally vendor a pinned standalone `jq.exe` into `mingw64/bin` (see below).
|
||||
# We do NOT trim INSIDE the tree: bash and git load `msys-2.0.dll` and other
|
||||
# libraries lazily, and load-bearing pieces (terminfo, gawk libs in `usr/`; git
|
||||
# templates, certs, DLLs in `mingw64/`) live alongside the docs, so a hand-trimmed
|
||||
# copy can pass an existence check yet fail mid-command with a cryptic error —
|
||||
# exactly the bug class this avoids. The retained runtime is the untouched,
|
||||
# complete closure git-for-windows maintains.
|
||||
#
|
||||
# Self-contained (no release-binary precondition) so CI can call it directly to
|
||||
# exercise the download/extract path on a real Windows runner — the only
|
||||
# automated gate on this logic before it ships to users.
|
||||
#
|
||||
# Single arg: the destination dir for the staged tree (the launcher bash lands at
|
||||
# <dest>/bin/bash.exe; git lands at <dest>/mingw64/bin/git.exe). Idempotent: a
|
||||
# versioned `.stage-complete-v2` marker, written last, proves a whole prior stage
|
||||
# and skips the re-download; a partial stage (or a stale v1 marker from the old
|
||||
# mingw64-dropped layout) lacks it and re-extracts cleanly.
|
||||
#
|
||||
# PATH CONTRACT (keep byte-identical across three files):
|
||||
# - dest `git-bash` (== desktop/src-tauri/binaries/git-bash) is the
|
||||
# `bundle.resources` SOURCE in desktop/scripts/build-release-config.mjs.
|
||||
# - that resource's TARGET `git-bash` is staged next to the exe by Tauri's
|
||||
# Windows installer, and crates/buzz-dev-mcp/src/shell.rs resolves
|
||||
# `git-bash\bin\bash.exe` relative to its own executable at runtime.
|
||||
|
||||
GIT_BASH_DIR=${1:?usage: stage-windows-bash.sh <dest-dir>}
|
||||
PORTABLEGIT_VERSION="2.54.0"
|
||||
PORTABLEGIT_TAG="v${PORTABLEGIT_VERSION}.windows.1"
|
||||
PORTABLEGIT_EXE="PortableGit-${PORTABLEGIT_VERSION}-64-bit.7z.exe"
|
||||
PORTABLEGIT_URL="https://github.com/git-for-windows/git/releases/download/${PORTABLEGIT_TAG}/${PORTABLEGIT_EXE}"
|
||||
|
||||
# jq is NOT shipped in PortableGit (it is an independent MSYS2 package, not a git
|
||||
# component), but agents need it for JSON piping, so we vendor the standalone
|
||||
# static jq.exe (single binary, no DLL closure) into the bundle's mingw64/bin so
|
||||
# it resolves alongside git/curl through the launcher. Pinned by SHA-256 — never
|
||||
# an unpinned fetch.
|
||||
JQ_VERSION="1.8.1"
|
||||
JQ_URL="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-windows-amd64.exe"
|
||||
JQ_SHA256="23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334"
|
||||
|
||||
STAGE_MARKER="$GIT_BASH_DIR/.stage-complete-v2"
|
||||
if [[ -f "$STAGE_MARKER" ]]; then
|
||||
echo "PortableGit bash already staged at $GIT_BASH_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Downloading PortableGit ${PORTABLEGIT_VERSION}..."
|
||||
tmp_dir=$(mktemp -d -t portablegit.XXXXXX)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
tmp_sfx="$tmp_dir/portablegit.7z.exe"
|
||||
extract_dir="$tmp_dir/extract"
|
||||
curl -fsSL "$PORTABLEGIT_URL" -o "$tmp_sfx"
|
||||
# PortableGit is a 7-Zip self-extracting archive; -o/-y are its SFX flags,
|
||||
# so we don't need a separate 7z on PATH.
|
||||
chmod +x "$tmp_sfx"
|
||||
"$tmp_sfx" -y "-o$extract_dir"
|
||||
|
||||
# Keep the whole extracted tree — bash runtime AND the mingw64/ git+toolchain
|
||||
# subtree — so the bundle is a real self-contained dev env.
|
||||
rm -rf "$GIT_BASH_DIR"
|
||||
mkdir -p "$GIT_BASH_DIR"
|
||||
cp -a "$extract_dir/." "$GIT_BASH_DIR/"
|
||||
|
||||
# Vendor the pinned standalone jq.exe into mingw64/bin (alongside git/curl) so it
|
||||
# resolves through the launcher. Verify the SHA-256 before it lands — a checksum
|
||||
# mismatch fails the stage so a tampered/wrong binary never reaches the bundle.
|
||||
echo "Downloading jq ${JQ_VERSION}..."
|
||||
jq_tmp="$tmp_dir/jq.exe"
|
||||
curl -fsSL "$JQ_URL" -o "$jq_tmp"
|
||||
actual_sha=$(sha256sum "$jq_tmp" | cut -d' ' -f1)
|
||||
[[ "$actual_sha" == "$JQ_SHA256" ]] || {
|
||||
echo "Error: jq.exe SHA-256 mismatch: got $actual_sha, expected $JQ_SHA256" >&2
|
||||
exit 1
|
||||
}
|
||||
cp "$jq_tmp" "$GIT_BASH_DIR/mingw64/bin/jq.exe"
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
trap - EXIT
|
||||
# Assert the load-bearing entry points landed: the launcher bash we resolve at
|
||||
# runtime, git.exe in mingw64/ (the binary the whole restore exists to ship), and
|
||||
# the vendored jq.exe. A stale or partial extract that lacks any must fail the
|
||||
# gate, not write the marker — otherwise the idempotency skip would lock in a
|
||||
# broken tree.
|
||||
for required in bin/bash.exe mingw64/bin/git.exe mingw64/bin/jq.exe; do
|
||||
[[ -f "$GIT_BASH_DIR/$required" ]] || {
|
||||
echo "Error: PortableGit extracted but $GIT_BASH_DIR/$required is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
# Written last, only after cp -a and the integrity checks all succeed, so it is
|
||||
# positive proof the whole tree landed. An interrupted stage never writes it, so
|
||||
# the idempotency skip falls through to a clean re-extract.
|
||||
touch "$STAGE_MARKER"
|
||||
echo "PortableGit full toolchain staged at $GIT_BASH_DIR (bash + git + jq + coreutils)"
|
||||
Reference in New Issue
Block a user