feat(desktop): bundle the ACP bridge tools on Windows

The windows-x86_64 build was the one supported target left out of the
ACP bundling series: no lock entries, no staged resources, and — since
the series made the claude/codex catalog entries platform-unconditional
(empty install commands, "ships with the Buzz desktop app" hint) — a
Windows user without their own claude-agent-acp hit a dead end that
reinstalling could never fix. Everything upstream already exists (both
bridges publish win32-x64 native packages; the release job already runs
under Git Bash; the desktop's resolution/spawn layer is Windows-aware),
so extend the bundle to Windows. The one genuinely Windows-shaped
problem is the runtime shim: the staged bridges are bash wrappers that
Windows cannot execute — and the resolver only looks for <binary>.exe
anyway. Solve it with a tiny compiled launcher instead of .cmd shims,
which would have rippled a two-candidate list (.exe, .cmd) through the
shared resolution code and left an intermediate cmd.exe in the spawn
chain that kill_on_drop cannot reap through.

- New root-workspace crate buzz-acp-node-launcher: reads the sibling
  <binary>.shim.json ({entrypoint, nodeEngine, requiredNodeMajor})
  written by the staging scripts, resolves node from PATH, enforces the
  lock's Node major with the exact wrapper-shim error message and exit
  codes (127 missing / 1 too old), then runs node on the vendored
  entrypoint, proxying stdio and the exit code. On Windows the child
  joins a Job Object with KILL_ON_JOB_CLOSE (mirroring buzz-dev-mcp's
  KillGroup) so terminating the launcher — as buzz-acp's kill_on_drop
  does — takes the node tree with it; on Unix it execs node like the
  bash shim, existing there so workspace clippy/tests pass everywhere.
  Unsafe is confined to the Win32 FFI, cfg-forbidden elsewhere, per the
  buzz-dev-mcp precedent.

- update-acp-tools-lock.mjs gains x86_64-pc-windows-msvc (npmOs win32,
  npmCpu x64, no libc; native executables claude.exe and
  vendor/x86_64-pc-windows-msvc/bin/codex.exe). The committed lock
  grows 8 -> 10 entries via a partial --target run: the two new
  Windows pins match the other targets' versions exactly (0.58.1 /
  1.1.2, sdk 0.3.205, codex 0.144.1-win32-x64) and the existing 8
  entries are preserved byte-identically.

- The staging scripts branch per target family through the shared
  wrapper lib: Unix targets keep the bash wrapper; Windows targets
  stage the launcher as <binary>.exe next to <binary>.shim.json
  (write_windows_node_launcher). The launcher builds via cargo on
  first use (ACP_NODE_LAUNCHER_EXE overrides; target dir resolved via
  cargo metadata, never ./target), a target with no locked tools still
  stages nothing without needing cargo, and dev-cache shims embed
  bin-dir-relative entrypoints because Git Bash absolute paths
  (/c/Users/...) are unresolvable to a native exe. The freshness path
  re-copies the launcher when the built binary changes (cmp-gated: a
  running agent's open .exe is never rewritten), the prune reduces
  <binary>[.exe][.stamp] and <binary>.shim.json to the lock's bare
  binary name, and harness-clis.json drops the .exe suffix from its
  cli keys so the app's bare-name auth probes ("claude", "codex")
  resolve the vendored CLIs on Windows too.

- release.yml's Windows job needs no extra wiring: the staging step
  added with the previous commit now finds lock entries and builds the
  launcher with the job's already-installed MSVC toolchain. The
  windows-rust CI job gains a cargo test step for the launcher so its
  spawn path gates on a real Windows runner.

Per the plan's risk note, codex-on-Windows maturity is a validate-
before-release concern: the lock's per-tool-per-target shape allows
dropping the codex-acp windows entry if a real session shakes out
badly. win32-arm64 packages exist but there is no arm64 Windows
release job; deferred until one exists. Node.js stays a user
prerequisite, surfaced by the existing node-runtime Doctor section.

Verification (macOS host):
- cargo test -p buzz-acp-node-launcher: 9 passed — manifest parsing,
  version parsing, path resolution, plus end-to-end launcher runs
  (arg/stdio/exit-code proxying via real node, missing-manifest,
  missing-entrypoint, and too-old-Node failures with the wrapper-shim
  message). cargo clippy --all-targets -D warnings and
  cargo fmt --all --check: clean.
- Cross-staged the Windows target end-to-end with
  ACP_NODE_LAUNCHER_EXE standing in for the MSVC launcher: both win32
  npm trees install and validate against the lock (integrity + X_OK on
  the vendored claude.exe/codex.exe, confirming the locked vendor
  paths), bin dir stages <binary>.exe + .exe.stamp + .shim.json with
  relative entrypoints, re-run performs zero installs, stray
  .exe/.shim.json artifacts are pruned, and prepare writes
  harness-clis.json with bare "claude"/"codex" keys mapping to the
  vendored .exe paths.
- Empty-target Windows staging (aarch64-pc-windows-msvc) exits 0 with
  the notice, without cargo on PATH.
- Darwin re-stage after the cross-stage: bash wrappers report 0.58.1 /
  1.1.2, manifests back to darwin shape — Unix staging unregressed.
- biome check on update-acp-tools-lock.mjs: clean.

Windows-runner validation (NSIS install, Doctor states, live claude +
codex sessions, auth probes against the vendored CLIs) needs a real
Windows machine and rides the first release train with these entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-14 16:19:27 +10:00
parent 056788026d
commit 64eae442e3
12 changed files with 640 additions and 8 deletions

View File

@@ -16,7 +16,10 @@ Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared
Buzz dev cache. The lockfile is target-specific; only entries matching the
requested target are prepared. Each tool is installed as a vendored npm
package tree with a small executable wrapper, validated against the locked
versions and integrity hashes.
versions and integrity hashes. Unix targets get a bash wrapper shim; Windows
targets get the compiled buzz-acp-node-launcher staged as <binary>.exe next
to a <binary>.shim.json manifest (built with cargo, override with
ACP_NODE_LAUNCHER_EXE).
Environment variables:
ACP_TOOLS_LOCK_FILE lockfile path (default: desktop/acp-tools.lock.json)
@@ -141,6 +144,15 @@ if [[ "$entry_count" == "0" ]]; then
exit 0
fi
# Windows targets stage the compiled launcher shim instead of a bash
# wrapper; it needs the repo's Rust toolchain. Built once up front — the
# per-tool loop below runs in a pipeline subshell.
launcher_exe=""
if acp_target_is_windows "$target"; then
require_tool cargo
launcher_exe="$(acp_node_launcher_exe "$target")"
fi
validate_npm_install() {
local install_dir="$1"
local package="$2"
@@ -285,7 +297,11 @@ for (const entry of entries) {
package_dir="$install_dir/node_modules/$package"
entrypoint="$package_dir/dist/index.js"
native_binary="$install_dir/node_modules/$native_package/$native_executable"
staged_bin="$bin_dir/$binary"
staged_bin="$bin_dir/$(acp_staged_binary_name "$binary" "$target")"
# Windows shims embed a bin-dir-relative entrypoint: under Git Bash the
# absolute cache path is POSIX-style (/c/Users/...), which the native
# launcher cannot resolve.
entrypoint_from_bin_dir="../../$target/$id/$version/npm/node_modules/$package/dist/index.js"
# The staged output is shared across lock versions, so its freshness stamp
# must live next to it, not in the per-version tool_dir: a per-version stamp
# stays self-consistent after a lock revert and would skip re-staging.
@@ -294,6 +310,12 @@ for (const entry of entries) {
# shellcheck disable=SC1090
source "$stamp"
if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then
# The npm tree is fresh, but the compiled launcher tracks the crate,
# not the lock, so the stamp cannot see it change — refresh it every
# run (the copy no-ops when already identical).
if acp_target_is_windows "$target"; then
write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine"
fi
continue
fi
fi
@@ -319,7 +341,11 @@ for (const entry of entries) {
npm "${npm_args[@]}" >&2
validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version"
write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine"
if acp_target_is_windows "$target"; then
write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine"
else
write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine"
fi
{
printf 'STAMP_TARGET=%q\n' "$target"
printf 'STAMP_PACKAGE=%q\n' "$package"
@@ -354,7 +380,12 @@ for (const entry of entries) console.log(entry.binary);
' "$lock_entries" | sort -u)"
find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do
name="$(basename "$staged_file")"
if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then
# Reduce every staged artifact shape to the lock's bare binary name:
# <binary>[.exe][.stamp] and the Windows launcher's <binary>.shim.json.
base="${name%.stamp}"
base="${base%.shim.json}"
base="${base%.exe}"
if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "$base"; then
rm -f -- "$staged_file"
fi
done

View File

@@ -4,6 +4,30 @@
# resources cannot drift (a drift would make dev and bundled installs fail
# differently on the same missing/old Node runtime).
#
# Unix targets stage a bash wrapper shim (write_node_wrapper); Windows
# targets stage the compiled buzz-acp-node-launcher as `<binary>.exe` next
# to a `<binary>.shim.json` manifest (write_windows_node_launcher) — Windows
# cannot execute bash shims, and the desktop's command resolution only looks
# for `<binary>.exe`. Both shims enforce the same lock-derived Node engine
# requirement.
#
# acp_target_is_windows <target-triple>
# Whether the Rust target triple names a Windows target.
acp_target_is_windows() {
[[ "$1" == *-windows-* ]]
}
# acp_staged_binary_name <binary> <target-triple>
# The filename a tool's shim is staged under for <target>: the lock's bare
# binary name on Unix, `<binary>.exe` on Windows.
acp_staged_binary_name() {
if acp_target_is_windows "$2"; then
printf '%s.exe\n' "$1"
else
printf '%s\n' "$1"
fi
}
#
# acp_required_node_major <node-engine>
# Prints the minimum Node.js major version implied by a ">=N..." engine
# range, defaulting to 22 when the range is not in that form. Shared by
@@ -59,3 +83,66 @@ write_node_wrapper() {
} > "$wrapper"
chmod +x "$wrapper"
}
# acp_node_launcher_exe <target-triple>
# Prints the path to the compiled Windows launcher shim for <target>,
# building it with cargo when needed (a no-op rebuild when up to date).
# ACP_NODE_LAUNCHER_EXE overrides the build entirely — for callers that
# already built the crate, and for cross-target staging tests on hosts
# without the Windows toolchain.
acp_node_launcher_exe() {
local target="$1"
if [[ -n "${ACP_NODE_LAUNCHER_EXE:-}" ]]; then
printf '%s\n' "$ACP_NODE_LAUNCHER_EXE"
return
fi
# The lib lives at desktop/scripts/lib; the launcher crate is in the repo
# root workspace so the Windows release job's warm target dir is reused.
local repo_root manifest target_dir
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
manifest="$repo_root/Cargo.toml"
echo "Building ACP node launcher shim for $target..." >&2
cargo build --release --manifest-path "$manifest" -p buzz-acp-node-launcher --target "$target" >&2
# Resolve the target dir instead of assuming ./target — CARGO_TARGET_DIR
# and .cargo/config redirects are common.
target_dir="$(cargo metadata --format-version 1 --no-deps --manifest-path "$manifest" \
| node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(0, "utf8")).target_directory)')"
printf '%s/%s/release/buzz-acp-node-launcher.exe\n' "$target_dir" "$target"
}
# write_windows_node_launcher <dest-exe> <launcher-exe> <entrypoint> [node-engine]
# Stages the compiled launcher shim at <dest-exe> and writes the sibling
# `<name>.shim.json` manifest the launcher reads at spawn time. Mirrors
# write_node_wrapper's contract: a relative <entrypoint> resolves against
# the launcher's directory at run time. Idempotent — the copy is skipped
# when the staged launcher is already identical, so a re-stage never
# rewrites an .exe a running agent may hold open.
write_windows_node_launcher() {
local dest_exe="$1"
local launcher_exe="$2"
local entrypoint="$3"
local node_engine="${4:->=22}"
local required_node_major
required_node_major="$(acp_required_node_major "$node_engine")"
if [[ ! -f "$launcher_exe" ]]; then
echo "ACP node launcher shim not found: $launcher_exe" >&2
return 1
fi
mkdir -p "$(dirname "$dest_exe")"
if ! cmp -s "$launcher_exe" "$dest_exe"; then
cp -f "$launcher_exe" "$dest_exe"
fi
chmod +x "$dest_exe"
ACP_SHIM_ENTRYPOINT="$entrypoint" \
ACP_SHIM_NODE_ENGINE="$node_engine" \
ACP_SHIM_REQUIRED_NODE_MAJOR="$required_node_major" \
node -e '
const fs = require("node:fs");
fs.writeFileSync(process.argv[1], `${JSON.stringify({
entrypoint: process.env.ACP_SHIM_ENTRYPOINT,
nodeEngine: process.env.ACP_SHIM_NODE_ENGINE,
requiredNodeMajor: Number(process.env.ACP_SHIM_REQUIRED_NODE_MAJOR),
}, null, 2)}\n`);
' "${dest_exe%.exe}.shim.json"
}

View File

@@ -14,8 +14,10 @@ Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple]
Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can
bundle them as application resources: vendored npm package trees under
resources/acp/node and executable wrappers under resources/acp/bin. The
optional target triple defaults to the Rust host target.
resources/acp/node and executable wrappers under resources/acp/bin (bash
shims on Unix targets; the compiled buzz-acp-node-launcher plus
<binary>.shim.json manifests on Windows targets). The optional target triple
defaults to the Rust host target.
Note: resources/acp/bin holds a single target at a time, so staging must stay
tied to the build target.
@@ -41,6 +43,12 @@ fi
cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)"
cache_root="$(dirname "$(dirname "$cache_bin_dir")")"
# Windows targets stage the compiled launcher shim instead of a bash
# wrapper. Resolved lazily at first use so a target with no locked tools
# stays a no-op stage; ensure-acp-tools.sh above already built the launcher
# for any target that has them, so resolution hits a warm target dir.
launcher_exe=""
resource_root="$app_root/src-tauri/resources/acp"
resource_bin_dir="$resource_root/bin"
resource_node_dir="$resource_root/node"
@@ -101,7 +109,14 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat
echo "Failed to stage npm ACP tool: $package@$version" >&2
exit 1
fi
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
if acp_target_is_windows "$target"; then
if [[ -z "$launcher_exe" ]]; then
launcher_exe="$(acp_node_launcher_exe "$target")"
fi
write_windows_node_launcher "$resource_bin_dir/$(acp_staged_binary_name "$binary" "$target")" "$launcher_exe" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
else
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
fi
node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")")
# Record the vendored native harness CLI (relative to the acp resource
# root) for the auth-probe manifest. Fail loudly if the lock names one
@@ -115,7 +130,12 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat
exit 1
fi
chmod +x "$cli_abspath"
harness_cli_entries+=("$id"$'\t'"$(basename "$native_executable")"$'\t'"$cli_relpath")
# The manifest keys CLIs by the bare probe name the app resolves
# ("claude", "codex"), so the Windows vendored executables drop their
# .exe suffix here.
cli_name="$(basename "$native_executable")"
cli_name="${cli_name%.exe}"
harness_cli_entries+=("$id"$'\t'"$cli_name"$'\t'"$cli_relpath")
fi
# Ad-hoc sign every Mach-O in the staged package, not just the main CLIs:
# the codex native package also vendors executables like rg and zsh, and

View File

@@ -13,6 +13,7 @@ const SUPPORTED_TARGETS = [
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
];
// The Codex ACP executable stays `codex-acp`, but bundled installs must come
@@ -89,6 +90,18 @@ const NPM_TARGET_CONFIG = {
openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex",
},
},
"x86_64-pc-windows-msvc": {
npmOs: "win32",
npmCpu: "x64",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-win32-x64",
openaiCodex: "@openai/codex-win32-x64",
},
nativeExecutables: {
claudeAgentSdk: "claude.exe",
openaiCodex: "vendor/x86_64-pc-windows-msvc/bin/codex.exe",
},
},
};
// Tarball URLs in the lock are informational — ensure-acp-tools.sh installs