Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
86 changes: 76 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -704,22 +704,88 @@ jobs:
# 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, mingw64 drop)
# 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 staged bash on a real coreutils pipeline: this
# gates the SFX `-o` POSIX-path extraction, that bash.exe still spawns after
# mingw64/ is dropped, and that the lazily-loaded MSYS DLL closure (msys-2.0.dll,
# coreutils) survives — the exact behaviors unconfirmable off a non-Windows host.
- name: Smoke-test bundled bash staging
# 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
shell: bash
run: |
set -euo pipefail
stage_dir="$RUNNER_TEMP/git-bash"
scripts/stage-windows-bash.sh "$stage_dir"
out=$("$stage_dir/usr/bin/bash.exe" -c 'echo hello | tr a-z A-Z')
launcher="$stage_dir/bin/bash.exe"
[[ -f "$launcher" ]] || { echo "launcher bash missing: $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"

# 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; }
echo "staged bash spawned and ran a coreutils pipeline"

# 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"
- name: Check (Tauri crate)
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
env:
Expand Down
19 changes: 10 additions & 9 deletions crates/buzz-dev-mcp/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,20 +314,21 @@ pub async fn run(
/// 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\usr\bin\bash.exe`.
/// 3. this resolver — joins `current_exe().parent()` + `git-bash\bin\bash.exe`.
///
/// Drift between (2)'s target and this string ships a working bundle but a broken
/// runtime path. Keep all three in lockstep.
///
/// The bundled constant points at `usr\bin\bash.exe` (the real MSYS2 bash, which
/// boots from its co-located `msys-2.0.dll` and needs only `usr/`, no `mingw64/`)
/// — NOT the `bin\bash.exe` launcher shim, which refuses to start without a sibling
/// `mingw64\bin` marker the bundle deliberately drops. The installed-Git branch
/// below stays on `bin\bash.exe` on purpose: a real Git-for-Windows install has
/// `mingw64/`, so its launcher is the correct entry and sets up MSYSTEM/PATH.
/// Different tree shape -> different correct entry point.
/// 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\usr\bin\bash.exe";
const BUNDLED_BASH_REL: &str = r"git-bash\bin\bash.exe";

/// 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 Down
16 changes: 9 additions & 7 deletions desktop/scripts/build-release-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,18 @@ const releaseConfig = {
},
};

// Windows-only: bundle the PortableGit bash runtime as a resource so the MCP shell
// tool always has a genuine, non-WSL bash to spawn on a bare host (the app must
// be self-contained — we cannot assume Git for Windows is installed).
// 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 ~184MB 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.
// 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
Expand Down
8 changes: 4 additions & 4 deletions scripts/bundle-sidecars.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ for bin in "${SIDECARS[@]}"; do
done
echo "Sidecars bundled for $TARGET"

# Windows-only: stage a genuine, non-WSL bash next to the sidecars so the MCP
# shell tool works on a bare host. The download/extract/drop 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
# 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"
Expand Down
81 changes: 57 additions & 24 deletions scripts/stage-windows-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,54 @@ 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
# bash rather than probing for an install.
# 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 ONLY the MSYS2
# bash runtime (`usr/` + `bin/`), dropping the `mingw64/` git-program subtree as
# one separable unit (~200MB of git.exe etc. the MCP shell never invokes). We do
# NOT trim INSIDE `usr/`: bash loads `msys-2.0.dll` and other libraries lazily,
# and load-bearing pieces (terminfo, gawk libs) live alongside the docs there, so
# a hand-trimmed copy can pass an existence check yet fail mid-command with a
# cryptic error — exactly the bug class this fixes. The retained runtime is the
# untouched, complete closure git-for-windows maintains.
# 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/drop path on a real Windows runner — the only
# 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 real MSYS2 bash lands
# at <dest>/usr/bin/bash.exe). Idempotent: a `.stage-complete` marker, written last,
# proves a whole prior stage and skips the re-download; a partial stage lacks it
# and re-extracts cleanly.
# 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\usr\bin\bash.exe` relative to its own executable at runtime.
# `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}"

STAGE_MARKER="$GIT_BASH_DIR/.stage-complete"
# 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
Expand All @@ -54,20 +67,40 @@ curl -fsSL "$PORTABLEGIT_URL" -o "$tmp_sfx"
chmod +x "$tmp_sfx"
"$tmp_sfx" -y "-o$extract_dir"

# Keep the bash runtime whole, drop the separable git-program subtree.
rm -rf "$extract_dir/mingw64"
# 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/"

rm -rf "$tmp_dir"
trap - EXIT
[[ -f "$GIT_BASH_DIR/usr/bin/bash.exe" ]] || {
echo "Error: PortableGit extracted but $GIT_BASH_DIR/usr/bin/bash.exe is missing" >&2
# 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
}
# Written last, only after cp -a and the integrity check both succeed, so it is
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 bash staged at $GIT_BASH_DIR (mingw64/ dropped)"
echo "PortableGit full toolchain staged at $GIT_BASH_DIR (bash + git + jq + coreutils)"