From 64eae442e34ff924a0db2d2cc0eb9d9fae0f5064 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 16:19:27 +1000 Subject: [PATCH] feat(desktop): bundle the ACP bridge tools on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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 .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 .exe next to .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 [.exe][.stamp] and .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 .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 Signed-off-by: Matt Toohey --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 3 + Cargo.lock | 10 + Cargo.toml | 1 + crates/buzz-acp-node-launcher/Cargo.toml | 21 ++ crates/buzz-acp-node-launcher/src/main.rs | 278 ++++++++++++++++++ .../buzz-acp-node-launcher/tests/launcher.rs | 115 ++++++++ desktop/acp-tools.lock.json | 47 +++ desktop/scripts/ensure-acp-tools.sh | 39 ++- desktop/scripts/lib/acp-node-wrapper.sh | 87 ++++++ desktop/scripts/prepare-acp-tools-resource.sh | 28 +- desktop/scripts/update-acp-tools-lock.mjs | 13 + 12 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-acp-node-launcher/Cargo.toml create mode 100644 crates/buzz-acp-node-launcher/src/main.rs create mode 100644 crates/buzz-acp-node-launcher/tests/launcher.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1906a7cf..de9ada44f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -859,6 +859,12 @@ jobs: # 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 + - name: Test (buzz-acp-node-launcher) + # The compiled launcher shim staged as the bundled ACP bridges' + # .exe on Windows; its spawn path (Job Object, exit-code + # proxying) only gates if tested ON Windows. The end-to-end tests use + # the node preinstalled on windows-latest runners. + run: cargo test -p buzz-acp-node-launcher --target $env:TARGET # 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). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ccb9b77a..909f7e91d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -757,6 +757,9 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + # Also builds the buzz-acp-node-launcher shim for the target with the + # toolchain installed above (Windows targets stage it as the bridges' + # .exe). - name: Stage bundled ACP tools shell: bash run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" diff --git a/Cargo.lock b/Cargo.lock index 9876dfe33..72ab1becf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-acp-node-launcher" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "buzz-admin" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3499285f9..c470e2f12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/buzz-search", "crates/buzz-audit", "crates/buzz-acp", + "crates/buzz-acp-node-launcher", "crates/buzz-agent", "crates/sprig", "crates/buzz-test-client", diff --git a/crates/buzz-acp-node-launcher/Cargo.toml b/crates/buzz-acp-node-launcher/Cargo.toml new file mode 100644 index 000000000..f6c00e08a --- /dev/null +++ b/crates/buzz-acp-node-launcher/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "buzz-acp-node-launcher" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Compiled launcher shim for the bundled ACP bridge tools on Windows" + +[[bin]] +name = "buzz-acp-node-launcher" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/buzz-acp-node-launcher/src/main.rs b/crates/buzz-acp-node-launcher/src/main.rs new file mode 100644 index 000000000..e77adf0cd --- /dev/null +++ b/crates/buzz-acp-node-launcher/src/main.rs @@ -0,0 +1,278 @@ +#![cfg_attr(not(windows), forbid(unsafe_code))] +//! Compiled launcher shim for the bundled ACP bridge tools. +//! +//! Unix targets stage the bundled bridges (`claude-agent-acp`, `codex-acp`) +//! as bash wrapper shims (`desktop/scripts/lib/acp-node-wrapper.sh`), which +//! Windows cannot execute — and the desktop's command resolution only looks +//! for `.exe` there anyway. Windows targets stage this launcher as +//! `.exe` next to a `.shim.json` manifest instead. The +//! launcher reproduces the wrapper contract exactly: verify a Node.js +//! runtime satisfying the locked engine range is on PATH, then run node on +//! the vendored entrypoint, forwarding arguments, stdio, and the exit code. +//! +//! On Windows the node child is additionally assigned to a Job Object with +//! `KILL_ON_JOB_CLOSE`, so terminating the launcher (as the buzz-acp +//! harness's `kill_on_drop` does when a session ends) takes the node process +//! tree with it instead of orphaning it. On Unix the launcher execs node, +//! matching the bash shim; it exists there only so the crate builds and +//! tests on every workspace platform. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// On-disk shape of the `.shim.json` manifest written by +/// `desktop/scripts/lib/acp-node-wrapper.sh` next to the staged launcher. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ShimManifest { + /// JS entrypoint to run; a relative path resolves against the launcher's + /// own directory, mirroring the bash wrapper. + entrypoint: String, + /// The lock's Node engine range (e.g. ">=22"), used verbatim in error + /// messages so they match the bash wrapper's. + node_engine: String, + /// Minimum Node.js major version enforced before spawning. + required_node_major: u32, +} + +/// The manifest sits next to the launcher under the same staged binary name: +/// `claude-agent-acp.exe` reads `claude-agent-acp.shim.json`. +fn shim_manifest_path(exe: &Path) -> PathBuf { + exe.with_extension("shim.json") +} + +fn resolve_entrypoint(exe_dir: &Path, entrypoint: &str) -> PathBuf { + let entrypoint = Path::new(entrypoint); + if entrypoint.is_absolute() { + entrypoint.to_path_buf() + } else { + exe_dir.join(entrypoint) + } +} + +fn parse_node_major(version: &str) -> Option { + version.trim().split('.').next()?.parse().ok() +} + +/// `Ok(None)` means node ran but produced no parsable version; `Err` is a +/// spawn failure (`NotFound` when node is not on PATH at all). +fn node_major_version() -> std::io::Result> { + let output = Command::new("node") + .args(["-p", "process.versions.node"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output()?; + if !output.status.success() { + return Ok(None); + } + Ok(parse_node_major(&String::from_utf8_lossy(&output.stdout))) +} + +fn fail(message: std::fmt::Arguments<'_>, code: i32) -> ! { + eprintln!("{message}"); + std::process::exit(code); +} + +/// Run node on the entrypoint, forwarding stdio and the exit code. Diverges: +/// on Unix this execs (the launcher *becomes* node, like the bash shim); on +/// Windows it waits on a Job-Object-managed child. +fn run_node(name: &str, entrypoint: &Path, args: std::env::ArgsOs) -> ! { + let mut command = Command::new("node"); + command.arg(entrypoint).args(args.skip(1)); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = command.exec(); + fail(format_args!("{name}: failed to exec node: {error}"), 1); + } + + #[cfg(windows)] + { + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => fail(format_args!("{name}: failed to spawn node: {error}"), 1), + }; + // Held for the launcher's lifetime: the OS closes the handle at + // process exit, which is exactly when KILL_ON_JOB_CLOSE should fire. + // After a normal wait() the child is already gone and the close is a + // no-op; on TerminateProcess it reaps the whole node tree. + let _job = job::KillOnCloseJob::assign(&child); + match child.wait() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(error) => fail(format_args!("{name}: failed to wait on node: {error}"), 1), + } + } +} + +fn main() { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(error) => fail( + format_args!("acp-node-launcher: cannot determine own path: {error}"), + 1, + ), + }; + let name = exe + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "acp-node-launcher".to_string()); + + let manifest_path = shim_manifest_path(&exe); + let manifest_raw = match std::fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(error) => fail( + format_args!( + "{name}: cannot read shim manifest {}: {error}", + manifest_path.display() + ), + 1, + ), + }; + let manifest: ShimManifest = match serde_json::from_str(&manifest_raw) { + Ok(manifest) => manifest, + Err(error) => fail( + format_args!( + "{name}: invalid shim manifest {}: {error}", + manifest_path.display() + ), + 1, + ), + }; + + let exe_dir = exe.parent().unwrap_or_else(|| Path::new(".")); + let entrypoint = resolve_entrypoint(exe_dir, &manifest.entrypoint); + if !entrypoint.is_file() { + fail( + format_args!( + "{name}: bundled entrypoint missing: {}", + entrypoint.display() + ), + 1, + ); + } + + // Same message and exit codes as the bash wrapper: 127 when node is not + // on PATH at all, 1 when it is too old (or unidentifiable). + match node_major_version() { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => fail( + format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine), + 127, + ), + Ok(Some(major)) if major >= manifest.required_node_major => {} + Ok(_) | Err(_) => fail( + format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine), + 1, + ), + } + + run_node(&name, &entrypoint, std::env::args_os()); +} + +#[cfg(windows)] +mod job { + //! Job Object holding the node child, mirroring buzz-dev-mcp's + //! `KillGroup` (crates/buzz-dev-mcp/src/shell.rs): `KILL_ON_JOB_CLOSE` + //! kills every process still in the job when the last handle closes. + + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + pub struct KillOnCloseJob { + _job: HANDLE, + } + + impl KillOnCloseJob { + /// Best-effort: a creation or assignment failure leaves the child + /// unmanaged (the outer harness Job Object still reaps it at app + /// shutdown) rather than failing the launch. + pub fn assign(child: &std::process::Child) -> Self { + // SAFETY: each call is a documented Win32 FFI call with arguments + // that satisfy its contract — a null SECURITY_ATTRIBUTES/name for + // an anonymous job, a zeroed #[repr(C)] info struct sized by + // size_of, and the live process handle owned by `child`. A null + // job HANDLE on failure makes the later calls harmless no-ops. + let job = unsafe { + let job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if !job.is_null() { + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + std::ptr::addr_of!(info).cast(), + std::mem::size_of::() as u32, + ); + AssignProcessToJobObject(job, child.as_raw_handle() as HANDLE); + } + job + }; + Self { _job: job } + } + } +} + +#[cfg(test)] +mod tests { + use super::{parse_node_major, resolve_entrypoint, shim_manifest_path, ShimManifest}; + use std::path::Path; + + #[test] + fn manifest_parses_the_staging_script_shape() { + let manifest: ShimManifest = serde_json::from_str( + r#"{ + "entrypoint": "../node/claude-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js", + "nodeEngine": ">=22", + "requiredNodeMajor": 22 + }"#, + ) + .expect("parse manifest"); + assert!(manifest.entrypoint.ends_with("dist/index.js")); + assert_eq!(manifest.node_engine, ">=22"); + assert_eq!(manifest.required_node_major, 22); + } + + #[test] + fn manifest_rejects_missing_fields() { + assert!(serde_json::from_str::(r#"{"entrypoint": "index.js"}"#).is_err()); + } + + #[test] + fn shim_manifest_sits_next_to_the_launcher_under_the_staged_name() { + assert_eq!( + shim_manifest_path(Path::new("/acp/bin/claude-agent-acp.exe")), + Path::new("/acp/bin/claude-agent-acp.shim.json"), + ); + // Unix-style staging without an extension gains the suffix whole. + assert_eq!( + shim_manifest_path(Path::new("/acp/bin/codex-acp")), + Path::new("/acp/bin/codex-acp.shim.json"), + ); + } + + #[test] + fn relative_entrypoints_resolve_against_the_launcher_dir() { + assert_eq!( + resolve_entrypoint(Path::new("/acp/bin"), "../node/x/dist/index.js"), + Path::new("/acp/bin/../node/x/dist/index.js"), + ); + assert_eq!( + resolve_entrypoint(Path::new("/acp/bin"), "/abs/dist/index.js"), + Path::new("/abs/dist/index.js"), + ); + } + + #[test] + fn node_major_parses_plain_and_noisy_versions() { + assert_eq!(parse_node_major("22.14.0\n"), Some(22)); + assert_eq!(parse_node_major("24"), Some(24)); + assert_eq!(parse_node_major(""), None); + assert_eq!(parse_node_major("not-a-version"), None); + } +} diff --git a/crates/buzz-acp-node-launcher/tests/launcher.rs b/crates/buzz-acp-node-launcher/tests/launcher.rs new file mode 100644 index 000000000..e8bd17f58 --- /dev/null +++ b/crates/buzz-acp-node-launcher/tests/launcher.rs @@ -0,0 +1,115 @@ +//! End-to-end tests for the launcher shim: copy the built binary under a +//! bridge name next to a `.shim.json` manifest — exactly how the +//! staging scripts lay it out — and run it against the real node on PATH. +//! Tests that need node skip (loudly) when it is absent, so the suite still +//! passes on stripped-down environments. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn node_available() -> bool { + Command::new("node") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Stage the launcher under `name` in `dir`, as the staging scripts do. +fn stage_launcher(dir: &Path, name: &str) -> PathBuf { + let staged = dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + fs::copy(env!("CARGO_BIN_EXE_buzz-acp-node-launcher"), &staged).expect("stage launcher"); + staged +} + +fn write_shim_manifest(dir: &Path, name: &str, entrypoint: &str, required_node_major: u32) { + fs::write( + dir.join(format!("{name}.shim.json")), + format!( + r#"{{"entrypoint":{},"nodeEngine":">={required_node_major}","requiredNodeMajor":{required_node_major}}}"#, + serde_json::to_string(entrypoint).expect("encode entrypoint"), + ), + ) + .expect("write shim manifest"); +} + +#[test] +fn runs_entrypoint_forwarding_args_stdio_and_exit_code() { + if !node_available() { + eprintln!("skipping: node not on PATH"); + return; + } + let temp = tempfile::tempdir().expect("temp dir"); + fs::write( + temp.path().join("entry.js"), + "console.log(['ok', ...process.argv.slice(2)].join(' '));\nprocess.exit(7);\n", + ) + .expect("write entrypoint"); + // Relative entrypoint: must resolve against the launcher's directory. + write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 0); + let staged = stage_launcher(temp.path(), "claude-agent-acp"); + + let output = Command::new(&staged) + .args(["--flag", "value"]) + .output() + .expect("run staged launcher"); + + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "ok --flag value" + ); + assert_eq!(output.status.code(), Some(7)); +} + +#[test] +fn missing_manifest_fails_loudly() { + let temp = tempfile::tempdir().expect("temp dir"); + let staged = stage_launcher(temp.path(), "codex-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("shim manifest"), + "stderr should name the missing manifest: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn missing_entrypoint_fails_loudly() { + let temp = tempfile::tempdir().expect("temp dir"); + write_shim_manifest(temp.path(), "codex-acp", "no-such/dist/index.js", 0); + let staged = stage_launcher(temp.path(), "codex-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("entrypoint missing"), + "stderr should name the missing entrypoint: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn node_major_below_requirement_is_rejected() { + if !node_available() { + eprintln!("skipping: node not on PATH"); + return; + } + let temp = tempfile::tempdir().expect("temp dir"); + fs::write(temp.path().join("entry.js"), "process.exit(0);\n").expect("write entrypoint"); + write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 999); + let staged = stage_launcher(temp.path(), "claude-agent-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires Node.js >=999 on PATH."), + "stderr should carry the wrapper-shim message: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/desktop/acp-tools.lock.json b/desktop/acp-tools.lock.json index 6ef4947bf..d9430ca33 100644 --- a/desktop/acp-tools.lock.json +++ b/desktop/acp-tools.lock.json @@ -73,6 +73,30 @@ "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", "nativeExecutable": "claude" }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "x86_64-pc-windows-msvc", + "npmOs": "win32", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-win32-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-win32-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg==", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.205.tgz", + "nativeExecutable": "claude.exe" + }, { "id": "claude-acp", "binary": "claude-agent-acp", @@ -168,6 +192,29 @@ "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "x86_64-pc-windows-msvc", + "npmOs": "win32", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-win32-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-win32-x64", + "nativeIntegrity": "sha512-qv2HOp6v/nVP31p5I5GxYyL0wa79PMzim1+W9CKSV0UldjFV9AMbualA8PeXcYhbvvh9Y1UASXxwjuQdlyfAvw==", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-win32-x64.tgz", + "nativeExecutable": "vendor/x86_64-pc-windows-msvc/bin/codex.exe" + }, { "id": "codex-acp", "binary": "codex-acp", diff --git a/desktop/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh index da1b91c15..8477acdc8 100755 --- a/desktop/scripts/ensure-acp-tools.sh +++ b/desktop/scripts/ensure-acp-tools.sh @@ -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 .exe next +to a .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: + # [.exe][.stamp] and the Windows launcher's .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 diff --git a/desktop/scripts/lib/acp-node-wrapper.sh b/desktop/scripts/lib/acp-node-wrapper.sh index 7d99839ee..8689d22a1 100644 --- a/desktop/scripts/lib/acp-node-wrapper.sh +++ b/desktop/scripts/lib/acp-node-wrapper.sh @@ -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 `.exe` next +# to a `.shim.json` manifest (write_windows_node_launcher) — Windows +# cannot execute bash shims, and the desktop's command resolution only looks +# for `.exe`. Both shims enforce the same lock-derived Node engine +# requirement. +# +# acp_target_is_windows +# Whether the Rust target triple names a Windows target. +acp_target_is_windows() { + [[ "$1" == *-windows-* ]] +} + +# acp_staged_binary_name +# The filename a tool's shim is staged under for : the lock's bare +# binary name on Unix, `.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 # 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 +# Prints the path to the compiled Windows launcher shim for , +# 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 [node-engine] +# Stages the compiled launcher shim at and writes the sibling +# `.shim.json` manifest the launcher reads at spawn time. Mirrors +# write_node_wrapper's contract: a relative 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" +} diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh index 4bc67126f..0b8ea5e14 100755 --- a/desktop/scripts/prepare-acp-tools-resource.sh +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -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 +.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 diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs index f80091c21..53403896b 100755 --- a/desktop/scripts/update-acp-tools-lock.mjs +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -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