diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b577d9f02..ee02962a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: 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..71ea2c794 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -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 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..1bdc172fc 100755 --- a/scripts/stage-windows-bash.sh +++ b/scripts/stage-windows-bash.sh @@ -3,33 +3,37 @@ 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 /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 +41,16 @@ 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 @@ -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)"