From 78f193bf227bf5eaf08a8a064e7f62455c3da3c7 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 19 Jun 2026 13:14:47 -0400 Subject: [PATCH 1/3] feat(windows): bundle full Git-for-Windows toolchain for the shell tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows bundle shipped a stripped MSYS2-bash-only tree (stage-windows-bash.sh dropped mingw64/), so a bare host had bash + coreutils but no git/jq/curl — the first Windows agent confirmed all three absent. Keep the whole PortableGit tree instead and flip the resolver to the bin/bash.exe launcher, which sets MSYSTEM and prepends mingw64/bin to the in-shell PATH; that single entry-point change makes git/jq/curl resolve with no Rust-side PATH injection. Version the staging marker (.stage-complete-v2) so an upgrade over the old mingw64-less layout re-stages rather than short-circuiting behind a stale 'complete' marker, and assert git.exe landed before writing it. Extend the CI smoke-test to spawn the launcher under a bare PATH and prove git/jq/curl resolve from the bundle and git commits — the launcher's non-login -c PATH behavior still needs a real bare-Windows host to confirm end to end. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 70 ++++++++++++++++++++---- crates/buzz-dev-mcp/src/shell.rs | 19 ++++--- desktop/scripts/build-release-config.mjs | 15 ++--- scripts/bundle-sidecars.sh | 8 +-- scripts/stage-windows-bash.sh | 56 +++++++++++-------- 5 files changed, 114 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b577d9f02..aff32c0ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,22 +704,72 @@ 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) — on a bare host that does NOT contain mingw64/bin. The GitHub + # runner DOES have git/jq on its ambient PATH, which would mask the + # launcher's own mingw64/bin prepend and let this test pass for the wrong + # reason. So we strip PATH down to just the Windows system dir before + # spawning: now anything that resolves had to come from the launcher + # itself, exactly as on a bare host. + 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. 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. + for tool in git jq curl; do + resolved=$(PATH="$bare_path" "$launcher" -c "command -v $tool" || true) + [[ -n "$resolved" ]] || { echo "$tool did not resolve in bundled bash" >&2; exit 1; } + case "$resolved" in + */git-bash/*) ;; + *) echo "$tool resolved outside the bundle: $resolved" >&2; exit 1 ;; + esac + echo "$tool -> $resolved" + 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: diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 0b27b160d..76aec2c77 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -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 diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 1eea02ad0..160bdacce 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -52,16 +52,17 @@ 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 + +// jq/curl/coreutils in mingw64/) 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 diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 3db194a13..124059170 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -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" diff --git a/scripts/stage-windows-bash.sh b/scripts/stage-windows-bash.sh index ece5d08bf..0c3262886 100755 --- a/scripts/stage-windows-bash.sh +++ b/scripts/stage-windows-bash.sh @@ -3,33 +3,35 @@ 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 `jq`/`curl`/full `sed`/`awk`/`grep`/`find` — so a bare host gets a +# real dev env, not just bash + coreutils. 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 /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 +# /bin/bash.exe; git lands at /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 } PORTABLEGIT_VERSION="2.54.0" @@ -37,7 +39,7 @@ 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" +STAGE_MARKER="$GIT_BASH_DIR/.stage-complete-v2" if [[ -f "$STAGE_MARKER" ]]; then echo "PortableGit bash already staged at $GIT_BASH_DIR" exit 0 @@ -54,20 +56,26 @@ 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 - exit 1 -} -# Written last, only after cp -a and the integrity check both succeed, so it is +# Assert both load-bearing entry points landed: the launcher bash we resolve at +# runtime, and git.exe in mingw64/ (the binary the whole restore exists to ship). +# A stale or partial extract that lacks either 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; 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 + coreutils)" From 8180cb4072733288b31463148da63c3559e70d21 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 19 Jun 2026 13:22:59 -0400 Subject: [PATCH 2/3] fix(ci): match MSYS mount paths in the bundle git-resolution check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows smoke-test asserted resolved tools matched */git-bash/*, but under the launcher's MSYS environment command -v reports POSIX mount paths (/mingw64/bin/git, /usr/bin/jq) where /mingw64 and /usr are the staged tree's own subdirs — so the check rejected a correctly-bundled git. The launcher flip itself worked: CI proved git resolved to /mingw64/bin/git under a bare non-login -c spawn, confirming the launcher prepends mingw64/bin to PATH as designed. Accept the MSYS mounts (safe because PATH is stripped bare, so they can only be the bundle) and reject host /c/... drive paths. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aff32c0ab..426992939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -743,15 +743,22 @@ jobs: [[ "$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. 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. + # 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. + # + # Under the launcher's MSYS environment, bundled tools report POSIX mount + # paths — `/mingw64/bin/git`, `/usr/bin/jq` — where `/mingw64` and `/usr` + # are the staged tree's own subdirs (the launcher roots MSYS at itself). + # Because PATH is stripped bare above, those mounts can ONLY be the + # bundle; a host install would instead surface as a `/c/...` drive path. + # So: accept the `/mingw64` and `/usr` mounts, reject anything else. for tool in git jq curl; do resolved=$(PATH="$bare_path" "$launcher" -c "command -v $tool" || true) [[ -n "$resolved" ]] || { echo "$tool did not resolve in bundled bash" >&2; exit 1; } case "$resolved" in - */git-bash/*) ;; + /mingw64/* | /usr/*) ;; *) echo "$tool resolved outside the bundle: $resolved" >&2; exit 1 ;; esac echo "$tool -> $resolved" From 89e570126d459b17d2d56cc2335f0ff552b3522a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 19 Jun 2026 13:37:56 -0400 Subject: [PATCH 3/3] fix(windows): vendor pinned jq + anchor the CI origin check to the staged tree Three changes from review of the full-toolchain bundle: - jq is not shipped in PortableGit (verified at source against the 2.54.0 SFX: curl and cygpath present, jq absent), so vendor the pinned standalone jq.exe (1.8.1, SHA-256 verified) into mingw64/bin where it resolves alongside git/curl through the launcher. A checksum mismatch fails the stage. - Anchor the CI git/jq/curl origin check to the actual staged tree: cygpath -m the resolved tool back to its real path and assert it sits under $stage_dir, instead of a bare /mingw64|/usr mount-shape match that any such-shaped path would satisfy. - Note curl is the one tool with a System32 twin, and soften the bare-PATH comment to 'stricter than a bare host' since the test strips ambient PATH rather than mirroring it. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 45 ++++++++++++--------- desktop/scripts/build-release-config.mjs | 7 ++-- scripts/stage-windows-bash.sh | 51 ++++++++++++++++++------ 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 426992939..ee02962a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -729,12 +729,14 @@ jobs: # 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) — on a bare host that does NOT contain mingw64/bin. The GitHub - # runner DOES have git/jq on its ambient PATH, which would mask the - # launcher's own mingw64/bin prepend and let this test pass for the wrong - # reason. So we strip PATH down to just the Windows system dir before - # spawning: now anything that resolves had to come from the launcher - # itself, exactly as on a bare host. + # 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" @@ -748,20 +750,27 @@ jobs: # under a login shell, `-lc` would pass here while the real agent stays # broken — the exact green-but-broken trap this gate guards. # - # Under the launcher's MSYS environment, bundled tools report POSIX mount - # paths — `/mingw64/bin/git`, `/usr/bin/jq` — where `/mingw64` and `/usr` - # are the staged tree's own subdirs (the launcher roots MSYS at itself). - # Because PATH is stripped bare above, those mounts can ONLY be the - # bundle; a host install would instead surface as a `/c/...` drive path. - # So: accept the `/mingw64` and `/usr` mounts, reject anything else. + # 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 - resolved=$(PATH="$bare_path" "$launcher" -c "command -v $tool" || true) - [[ -n "$resolved" ]] || { echo "$tool did not resolve in bundled bash" >&2; exit 1; } - case "$resolved" in - /mingw64/* | /usr/*) ;; - *) echo "$tool resolved outside the bundle: $resolved" >&2; exit 1 ;; + 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 -> $resolved" + echo "$tool -> $located" done # git is not just present but functional: a real commit round-trips, diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 160bdacce..71ea2c794 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -53,9 +53,10 @@ const releaseConfig = { }; // Windows-only: bundle the full PortableGit toolchain (bash runtime + git + -// jq/curl/coreutils in mingw64/) 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). +// 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 diff --git a/scripts/stage-windows-bash.sh b/scripts/stage-windows-bash.sh index 0c3262886..1bdc172fc 100755 --- a/scripts/stage-windows-bash.sh +++ b/scripts/stage-windows-bash.sh @@ -8,13 +8,15 @@ set -euo pipefail # 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 `jq`/`curl`/full `sed`/`awk`/`grep`/`find` — so a bare host gets a -# real dev env, not just bash + coreutils. 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. +# `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 @@ -39,6 +41,15 @@ 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" @@ -62,13 +73,27 @@ 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 both load-bearing entry points landed: the launcher bash we resolve at -# runtime, and git.exe in mingw64/ (the binary the whole restore exists to ship). -# A stale or partial extract that lacks either 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; do +# 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 @@ -78,4 +103,4 @@ done # 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 + coreutils)" +echo "PortableGit full toolchain staged at $GIT_BASH_DIR (bash + git + jq + coreutils)"