diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b5d378d..735fd85c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -856,6 +856,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 cbe9fb1ea6..909f7e91dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,6 +132,12 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh + # Stage the pinned ACP bridge tools into src-tauri/resources/acp before + # `tauri build` bundles resources — without this the app ships an empty + # resources/acp and silently falls back to unpinned user installs. + - name: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh aarch64-apple-darwin + # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. - name: Resolve mesh-llm rev id: mesh_rev @@ -341,6 +347,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" + - name: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" + - name: Build unsigned Tauri app run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json env: @@ -588,6 +597,9 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh + - name: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh x86_64-unknown-linux-gnu + - name: Generate release config run: cd desktop && node scripts/build-release-config.mjs env: @@ -745,6 +757,13 @@ 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" + - name: Build Windows NSIS installer (unsigned) shell: bash run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json diff --git a/.gitignore b/.gitignore index ed2d1a9413..f23cb358ba 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,13 @@ node_modules/ # Root npm lockfiles are accidental; desktop uses pnpm in /desktop. /package-lock.json +# Bundled ACP bridge tools (regenerated by desktop/scripts/prepare-acp-tools-resource.sh) +desktop/src-tauri/resources/acp/bin/* +!desktop/src-tauri/resources/acp/bin/.gitkeep +desktop/src-tauri/resources/acp/node/ +desktop/src-tauri/resources/acp/node-runtime.json +desktop/src-tauri/resources/acp/harness-clis.json + # sqlx offline query data (generated, not portable) .sqlx/ diff --git a/Cargo.lock b/Cargo.lock index 6ba6f6d5d0..95ace47120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,6 +747,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 17267207a7..fef04b5025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,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/Justfile b/Justfile index 1059b6d96a..fe3b0ea45f 100644 --- a/Justfile +++ b/Justfile @@ -121,6 +121,10 @@ desktop-test: desktop-typecheck: cd {{desktop_dir}} && pnpm typecheck +# Query the latest npm releases of the bundled ACP bridge tools and update desktop/acp-tools.lock.json +bump-acp-tools *ARGS: + node desktop/scripts/update-acp-tools-lock.mjs {{ARGS}} + # Build desktop frontend assets desktop-build: cd {{desktop_dir}} && pnpm build @@ -206,6 +210,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" + ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} @@ -327,6 +332,11 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + # Stage the pinned ACP bridge tools and point the app at the staged dir so + # dev builds resolve the same bundled bridges as packaged builds. + ./desktop/scripts/prepare-acp-tools-resource.sh + export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin" + echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}" ./target/debug/buzz-relay & RELAY_PID=$! sleep 1 @@ -355,6 +365,11 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + # Stage the pinned ACP bridge tools and point the app at the staged dir so + # staging builds resolve the same bundled bridges as packaged builds. + ./desktop/scripts/prepare-acp-tools-resource.sh + export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin" + echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}" FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/crates/buzz-acp-node-launcher/Cargo.toml b/crates/buzz-acp-node-launcher/Cargo.toml new file mode 100644 index 0000000000..f6c00e08a9 --- /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 0000000000..e77adf0cde --- /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 0000000000..e8bd17f585 --- /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/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 16b89fce7e..45f943509a 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -63,8 +63,12 @@ pub(crate) enum AcpAvailabilityStatus { /// ACP adapter binary missing; underlying CLI may be present. AdapterMissing, /// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required. + /// Retired on current desktops; kept so payloads from older app versions + /// still parse. AdapterOutdated, /// CLI binary missing; ACP adapter may be present. + /// Retired on current desktops (the bundled bridges vendor their own + /// CLI); kept so payloads from older app versions still parse. CliMissing, /// Neither adapter nor CLI found. NotInstalled, diff --git a/desktop/acp-tools.lock.json b/desktop/acp-tools.lock.json new file mode 100644 index 0000000000..d9430ca33d --- /dev/null +++ b/desktop/acp-tools.lock.json @@ -0,0 +1,243 @@ +{ + "tools": [ + { + "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": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "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-darwin-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-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": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "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-linux-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-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-apple-darwin", + "npmOs": "darwin", + "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-darwin-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==", + "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", + "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-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "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-linux-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", + "nativeExecutable": "claude" + }, + { + "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": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "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-darwin-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-darwin-arm64", + "nativeIntegrity": "sha512-dABeDK+ATqMG54MGBd3VjpKfh5EOoqx9PKVQB2QYDaEXx3F6CdUCXue5QIMfr4OxziUj8pUcLAQyd+KFqiTUFw==", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz", + "nativeExecutable": "vendor/aarch64-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": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "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-linux-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-linux-arm64", + "nativeIntegrity": "sha512-451o15+XtaXCCb35t/KCyyPqXHnTPxPxtdqEYOnE3e4sH5AfnI/uVJwfdjOksMG6vRLy6R+fLvSDOMguRFLmQw==", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-arm64.tgz", + "nativeExecutable": "vendor/aarch64-unknown-linux-musl/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-apple-darwin", + "npmOs": "darwin", + "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-darwin-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-darwin-x64", + "nativeIntegrity": "sha512-K2g3Q3tNxzFhV0SuzO6HcsYK7EQrp/o4HyeReyhkwVrwwUPoYwyIbB0IRjHIiDzRhbKriDccid2iyF5aPqdTcg==", + "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", + "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-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "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-linux-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-linux-x64", + "nativeIntegrity": "sha512-HNGVI+BulrOaC/0IzBvd6EL62j7LrlbFKibrhw6hZjjCjAeUYzRB2jB4qDzXN1NfqDi6Xrvniof3kwbwab24lg==", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-x64.tgz", + "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" + } + ] +} diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index cf2ea09cf2..946aa771df 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -120,7 +120,10 @@ const overrides = new Map([ // record_provider param + applies persona_field_with_record_fallback. +5 lines. // global-agent-config: spawn_agent_child loads global config and merges as // lowest env layer (+8 lines). Queued to split. - ["src-tauri/src/managed_agents/runtime.rs", 2216], + // acp-dead-machinery retirement: codex adapter-availability spawn stamp + + // the availability_drift half of needs_restart deleted (the bundled bridge + // can't drift out-of-band); ratcheted 2216 -> 2079 to bank the deletions. + ["src-tauri/src/managed_agents/runtime.rs", 2079], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], @@ -151,19 +154,16 @@ const overrides = new Map([ // +18: CliConfigInvalid requirement surface for config-parse probe classification — // new Requirement variant + updated cli_login_requirements + 3 new probe-layer tests. // Load-bearing UX fix (bad config → clear diagnostic, not "run codex login"). - // codex-acp-package-swap: AdapterOutdated version-probe in cli_login_requirements - // (+22 lines). Load-bearing — blocks login gate for deprecated 0.16.x adapter. - // code-reviewer fix-round: codex readiness gate tests — 2 new tests for - // outdated-adapter and garbage-version-output paths through the codex id gate - // (+140 lines: make_codex_runtime helper, PATH_MUTEX serializer, 2 test fns). - // Load-bearing test coverage; queued to split with the file generally. // +1: pub(crate) mod cli_probe declaration for doctor auth probe access. // +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and // make_codex_runtime stubs (new KnownAcpRuntime fields). // Git Bash readiness is intentionally colocated with buzz-agent's other // setup-mode requirements. The Windows-only requirement and serialization // test add eight lines; split remains queued with the existing file debt. - ["src-tauri/src/managed_agents/readiness.rs", 1762], + // bundle-acps: codex version-gate retirement removes the AdapterOutdated + // probe from cli_login_requirements and its gate tests, both obsolete now + // that the bridges ship bundled; ratcheting 1762 -> 1597 to bank the headroom. + ["src-tauri/src/managed_agents/readiness.rs", 1597], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -196,9 +196,16 @@ const overrides = new Map([ // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). // Git Bash Doctor discovery adds the raw Tauri response and its camelCase // mapper. This is the existing API boundary; split remains queued. - ["src/shared/api/tauri.ts", 1304], + // bundle-acps: RawNodeRuntimeCheck type + fromRawNodeRuntimeCheck mapper + + // checkAcpNodeRuntime wrapper for the bundled-bridge Node.js doctor check + // (+35 lines on rebase union with Git Bash Doctor discovery). Queued to + // split with the rest of this file. + // bundled-adapter-doctor-copy: adapter_bundled field on + // RawAcpRuntimeCatalogEntry + mapper passthrough (+3 lines). + // acp-dead-machinery retirement: node_required wire field deleted; + // ratcheted 1342 -> 1340. + ["src/shared/api/tauri.ts", 1340], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). - // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. // agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with @@ -206,7 +213,17 @@ const overrides = new Map([ // mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling. // Git Bash prerequisite payload adds four fields to the shared Tauri API // contract. This is the canonical type location; split remains queued. - ["src/shared/api/types.ts", 1038], + // bundle-acps: NodeRuntimeCheck + NodeRuntimeRequirement types for the + // bundled-bridge Node.js doctor check (+23 lines on rebase union with + // main's Git Bash prerequisite type growth); "adapter_outdated" + // availability retired with the codex version gate (-1 line). + // bundled-adapter-doctor-copy: adapterBundled field on + // AcpRuntimeCatalogEntry (+2 lines). + // bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on + // AcpAvailabilityStatus explaining why the state no longer exists. + // acp-dead-machinery retirement: nodeRequired field deleted; + // ratcheted 1066 -> 1064. + ["src/shared/api/types.ts", 1064], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -240,14 +257,6 @@ const overrides = new Map([ // agent-config-propagation: the agent_command_override decision family // (divergent / create-time / update-time / apply) moved to // discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom. - // codex-acp-package-swap: probe_codex_acp_major_version (+24 lines) + - // AdapterOutdated version-gate in discover_acp_runtimes (+22 lines). Both - // load-bearing — required to detect the deprecated 0.16.x adapter and - // prevent silent relay breakage after the spawn-contract change. - // codex-acp-package-swap follow-up: tempfile-based bounded stdout read - // (+18 lines), codex_adapter_availability/is_outdated helpers (+16 lines), - // cross-platform probe contract. All load-bearing — required for correct - // probe behaviour on Windows and descendant-process edge cases. // doctor-install-reliability: refreshable login_shell_path cache, // find_nvm_default_bin + parse_semver_tag helpers, auth probe cache + // probe_auth_status/cached_auth_status, runtime_needs_npm, probe_args_for, @@ -261,11 +270,31 @@ const overrides = new Map([ // + updated adapter_availability_cached() signature (Option return, cold=None) // prevents false restart badge on newly restarted agents. Correctness fix; // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) - ["src-tauri/src/managed_agents/discovery.rs", 1245], + // bundle-acps: bundled ACP bridge check at the top of the resolution sweep, + // then the codex version-gate retirement (probe_codex_acp_major_version, + // codex_adapter_availability/is_outdated, AdapterOutdated arm) made + // obsolete by pinned bundling; ratcheted down to bank the deletions + // (main's codex-install-auto-restart cache-warm growth stays). + // bundled-adapter-doctor-copy: adapter_bundled computed in the discovery + // sweep so the Doctor UI can hide the bundle path (+4 lines). + // claude-code-acp-fallback-retirement: the legacy command moved from the + // resolution sweep to identity-only aliases; +5 comment lines documenting + // the commands/aliases split that move makes load-bearing. + // bundled-cli-probes: resolve_probe_binary (bundled-CLI-first probe + // resolution) + classify_runtime/underlying_cli doc rewrites for the + // CliMissing retirement (+8 lines). + // acp-dead-machinery retirement: adapter-availability cache + + // availability_drift, runtime_needs_npm/is_npm_global_install, and the + // node_required computation deleted; ratcheted 1147 -> 1046. + ["src-tauri/src/managed_agents/discovery.rs", 1046], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1029], + // bundle-acps: version-gate retirement deletes the probe/availability test + // sections; ratcheting 1029 -> 825 (under the 1000 default; kept as a ratchet). + // bundled-cli-probes: CliMissing test reworked to assert Available when the + // adapter resolves without an underlying CLI (+2 comment lines). + ["src-tauri/src/managed_agents/discovery/tests.rs", 827], // identity-import-keyring: the identity resolution state machine's behavioral // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, // adoption / read-back-corruption / marker-failure arms, recovery-mode @@ -395,7 +424,15 @@ const overrides = new Map([ // Git Bash Doctor discovery exposes a narrow async Tauri command at the // existing discovery boundary. The ten-line addition preserves the platform // neutral frontend contract; split remains queued. - ["src-tauri/src/commands/agent_discovery.rs", 1357], + // bundle-acps: node runtime check wiring for the bundled-bridge Doctor + // requirement (+9 lines on rebase union with Git Bash Doctor discovery). + // bundle-acps: adapter_verification_step post-install gate + its test + // quartet (+120 lines, partly offset by the version-gate retirement's + // deletions in this file). Queued to split. + // acp-dead-machinery retirement: npm EACCES preflight (resolve_npm_prefix, + // npm_preflight_check, npm_eacces_hint) + its Phase-2 branches and test + // groups deleted; ratcheted 1410 -> 1021 to bank the deletions. + ["src-tauri/src/commands/agent_discovery.rs", 1021], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh new file mode 100755 index 0000000000..8477acdc8d --- /dev/null +++ b/desktop/scripts/ensure-acp-tools.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +app_root="$(cd "$script_dir/.." && pwd)" +lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" + +# shellcheck source=lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + +usage() { + cat <<'USAGE' +Usage: desktop/scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] + +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. 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) + ACP_TOOLS_CACHE_DIR cache dir override +USAGE +} + +default_cache_root() { + if [[ -n "${XDG_CACHE_HOME:-}" ]]; then + printf '%s/buzz-dev/acp-tools\n' "$XDG_CACHE_HOME" + return + fi + case "$(uname -s)" in + Darwin) printf '%s/Library/Caches/buzz-dev/acp-tools\n' "$HOME" ;; + *) printf '%s/.cache/buzz-dev/acp-tools\n' "$HOME" ;; + esac +} + +target="" +print_bin_dir=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:-}" + [[ -n "$target" ]] || { echo "--target requires a value" >&2; exit 1; } + shift 2 + ;; + --print-bin-dir) + print_bin_dir=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" ]]; then + target="$(rustc -vV | sed -n 's|host: ||p')" +fi +if [[ -z "$target" ]]; then + echo "Could not determine rust host target. Pass --target explicitly." >&2 + exit 1 +fi + +cache_root="${ACP_TOOLS_CACHE_DIR:-$(default_cache_root)}" +bin_dir="$cache_root/bin/$target" + +if [[ ! -f "$lock_file" ]]; then + echo "ACP tools lockfile not found: $lock_file" >&2 + exit 1 +fi + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required tool missing: $1" >&2 + exit 1 + fi +} + +require_tool node +require_tool npm + +lock_entries="$(node - "$lock_file" "$target" <<'NODE' +const fs = require("node:fs"); +const [lockFile, target] = process.argv.slice(2); +const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); +const entries = (data.tools ?? []).filter((tool) => tool.target === target); +function requireString(entry, field) { + if (typeof entry[field] !== "string" || entry[field].trim() === "") { + throw new Error(`Invalid ACP tool lock entry for ${entry.id ?? "(unknown)"}: missing ${field}`); + } +} +for (const entry of entries) { + if (entry.source !== "npm") { + throw new Error(`Invalid ACP tool lock entry for ${entry.id}: unsupported source ${entry.source}`); + } + for (const field of [ + "id", + "binary", + "target", + "package", + "version", + "integrity", + "tarball", + "npmOs", + "npmCpu", + "dependencyPackage", + "dependencyVersion", + "dependencyIntegrity", + "dependencyTarball", + "nativePackage", + "nativePackageName", + "nativeVersion", + "nativeIntegrity", + "nativeTarball", + "nativeExecutable", + ]) { + requireString(entry, field); + } +} +process.stdout.write(JSON.stringify(entries)); +NODE +)" + +entry_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).length))' "$lock_entries")" +mkdir -p "$bin_dir" +if [[ "$entry_count" == "0" ]]; then + find "$bin_dir" -type f -delete + # stderr so the notice shows up in release build logs even when stdout is + # reserved for --print-bin-dir consumers (prepare-acp-tools-resource.sh). + echo "No ACP tools locked for target $target." >&2 + if [[ "$print_bin_dir" == "1" ]]; then + printf '%s\n' "$bin_dir" + fi + 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" + local version="$3" + local integrity="$4" + local dependency_package="$5" + local dependency_version="$6" + local dependency_integrity="$7" + local native_package="$8" + local native_package_name="$9" + local native_version="${10}" + local native_integrity="${11}" + local native_executable="${12}" + local claude_code_version="${13}" + + node - "$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" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const [ + installDir, + packageName, + expectedVersion, + expectedIntegrity, + dependencyPackageName, + expectedDependencyVersion, + expectedDependencyIntegrity, + nativePackageName, + expectedNativePackageName, + expectedNativeVersion, + expectedNativeIntegrity, + nativeExecutable, + expectedClaudeCodeVersion, +] = process.argv.slice(2); + +function packagePath(name, ...segments) { + return path.join(installDir, "node_modules", ...name.split("/"), ...segments); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +function packageLockEntry(lock, packageName) { + const suffix = `node_modules/${packageName}`; + const match = Object.entries(lock.packages ?? {}).find(([key]) => key === suffix || key.endsWith(`/${suffix}`)); + if (!match) { + throw new Error(`package-lock entry not found for ${packageName}`); + } + return match[1]; +} + +const packageJson = readJson(packagePath(packageName, "package.json")); +assertEqual(packageJson.name, packageName, `${packageName} name`); +assertEqual(packageJson.version, expectedVersion, `${packageName} version`); + +const lock = readJson(path.join(installDir, "package-lock.json")); +assertEqual(packageLockEntry(lock, packageName).integrity, expectedIntegrity, `${packageName} integrity`); + +const dependencyPackageJson = readJson(packagePath(dependencyPackageName, "package.json")); +assertEqual( + dependencyPackageJson.name, + dependencyPackageName, + `${dependencyPackageName} name`, +); +assertEqual( + dependencyPackageJson.version, + expectedDependencyVersion, + `${dependencyPackageName} version`, +); +if (expectedClaudeCodeVersion && expectedClaudeCodeVersion !== "null") { + assertEqual( + dependencyPackageJson.claudeCodeVersion, + expectedClaudeCodeVersion, + `${dependencyPackageName} claudeCodeVersion`, + ); +} +assertEqual( + packageLockEntry(lock, dependencyPackageName).integrity, + expectedDependencyIntegrity, + `${dependencyPackageName} integrity`, +); + +const nativePackageJson = readJson(packagePath(nativePackageName, "package.json")); +assertEqual( + nativePackageJson.name, + expectedNativePackageName, + `${nativePackageName} package name`, +); +assertEqual( + nativePackageJson.version, + expectedNativeVersion, + `${nativePackageName} version`, +); +fs.accessSync(packagePath(nativePackageName, nativeExecutable), fs.constants.X_OK); +assertEqual( + packageLockEntry(lock, nativePackageName).integrity, + expectedNativeIntegrity, + `${nativePackageName} integrity`, +); +NODE +} + +node -e ' +const entries = JSON.parse(process.argv[1]); +for (const entry of entries) { + console.log([ + entry.id, + entry.binary, + entry.package, + entry.version, + entry.integrity, + entry.tarball, + entry.npmOs, + entry.npmCpu, + entry.npmLibc ?? "", + entry.nodeEngine ?? ">=22", + entry.dependencyPackage, + entry.dependencyVersion, + entry.dependencyIntegrity, + entry.dependencyTarball, + entry.nativePackage, + entry.nativePackageName, + entry.nativeVersion, + entry.nativeIntegrity, + entry.nativeTarball, + entry.nativeExecutable, + entry.claudeCodeVersion ?? "", + ].join("\x1f")); +} +' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball npm_os npm_cpu npm_libc node_engine dependency_package dependency_version dependency_integrity dependency_tarball native_package native_package_name native_version native_integrity native_tarball native_executable claude_code_version; do + [[ -n "$id" ]] || continue + + tool_dir="$cache_root/$target/$id/$version" + install_dir="$tool_dir/npm" + 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/$(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. + stamp="$staged_bin.stamp" + if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" && -x "$native_binary" ]]; then + # 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 + + echo "Installing ACP tool $id $version from npm for $target..." >&2 + rm -rf "$install_dir" + mkdir -p "$install_dir" "$bin_dir" + npm_args=( + install + --prefix "$install_dir" + --omit=dev + --include=optional + --ignore-scripts + --no-audit + --no-fund + --os "$npm_os" + --cpu "$npm_cpu" + ) + if [[ -n "$npm_libc" ]]; then + npm_args+=(--libc "$npm_libc") + fi + npm_args+=("$package@$version") + 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" + 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" + printf 'STAMP_VERSION=%q\n' "$version" + printf 'STAMP_INTEGRITY=%q\n' "$integrity" + printf 'STAMP_TARBALL=%q\n' "$tarball" + printf 'STAMP_NPM_OS=%q\n' "$npm_os" + printf 'STAMP_NPM_CPU=%q\n' "$npm_cpu" + printf 'STAMP_NPM_LIBC=%q\n' "$npm_libc" + printf 'STAMP_NODE_ENGINE=%q\n' "$node_engine" + printf 'STAMP_DEPENDENCY_PACKAGE=%q\n' "$dependency_package" + printf 'STAMP_DEPENDENCY_VERSION=%q\n' "$dependency_version" + printf 'STAMP_DEPENDENCY_INTEGRITY=%q\n' "$dependency_integrity" + printf 'STAMP_DEPENDENCY_TARBALL=%q\n' "$dependency_tarball" + printf 'STAMP_CLAUDE_CODE_VERSION=%q\n' "$claude_code_version" + printf 'STAMP_NATIVE_PACKAGE=%q\n' "$native_package" + printf 'STAMP_NATIVE_PACKAGE_NAME=%q\n' "$native_package_name" + printf 'STAMP_NATIVE_VERSION=%q\n' "$native_version" + printf 'STAMP_NATIVE_INTEGRITY=%q\n' "$native_integrity" + printf 'STAMP_NATIVE_TARBALL=%q\n' "$native_tarball" + printf 'STAMP_NATIVE_EXECUTABLE=%q\n' "$native_executable" + printf 'STAMP_BINARY=%q\n' "$binary" + } > "$stamp" +done + +# bin_dir is prepended to the agent spawn PATH and the desktop's command +# resolution sweep, so binaries (and stamps) for tools no longer in the lock +# must be pruned, not just left behind. +locked_binaries="$(node -e ' +const entries = JSON.parse(process.argv[1]); +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")" + # 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 + +if [[ "$print_bin_dir" == "1" ]]; then + printf '%s\n' "$bin_dir" +fi diff --git a/desktop/scripts/lib/acp-node-wrapper.sh b/desktop/scripts/lib/acp-node-wrapper.sh new file mode 100644 index 0000000000..8689d22a18 --- /dev/null +++ b/desktop/scripts/lib/acp-node-wrapper.sh @@ -0,0 +1,148 @@ +# Shared Node wrapper generation for ACP bridge tools staged from npm. +# Sourced by ensure-acp-tools.sh and prepare-acp-tools-resource.sh so the +# wrapper staged into the dev cache and the wrapper bundled into app +# 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 +# the wrapper shim below and the node-runtime.json manifest consumed by +# the app's Node.js runtime doctor check, so the version the wrapper +# enforces at spawn time and the version the doctor reports at setup +# time cannot disagree. +acp_required_node_major() { + local node_engine="$1" + local major + major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" + if [[ -z "$major" ]]; then + major=22 + fi + printf '%s\n' "$major" +} + +# write_node_wrapper [node-engine] +# Writes an executable bash shim at that verifies a Node.js +# runtime satisfying (default ">=22") is on PATH, then +# execs node on . An absolute is embedded +# verbatim; a relative one is resolved against the wrapper's directory +# at run time. + +write_node_wrapper() { + local wrapper="$1" + local entrypoint="$2" + local node_engine="${3:->=22}" + local required_node_major + required_node_major="$(acp_required_node_major "$node_engine")" + + mkdir -p "$(dirname "$wrapper")" + { + printf '#!/usr/bin/env bash\n' + printf 'set -euo pipefail\n' + printf 'if ! command -v node >/dev/null 2>&1; then\n' + printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" + printf ' exit 127\n' + printf 'fi\n' + printf 'required_node_major=%q\n' "$required_node_major" + printf 'node_major="$(node -p '\''process.versions.node.split(".")[0]'\'' 2>/dev/null || true)"\n' + printf 'if [[ -z "$node_major" || "$node_major" -lt "$required_node_major" ]]; then\n' + printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" + printf ' exit 1\n' + printf 'fi\n' + if [[ "$entrypoint" == /* ]]; then + printf 'entrypoint=%q\n' "$entrypoint" + else + printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint" + fi + printf 'exec node "$entrypoint" "$@"\n' + } > "$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 new file mode 100755 index 0000000000..0b8ea5e14d --- /dev/null +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +app_root="$(cd "$script_dir/.." && pwd)" +lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" + +# shellcheck source=lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + +usage() { + cat <<'USAGE' +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 (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. +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +target="${1:-}" +ensure_args=() +if [[ -n "$target" ]]; then + ensure_args+=(--target "$target") +else + target="$(rustc -vV | sed -n 's|host: ||p')" +fi +if [[ -z "$target" ]]; then + echo "Could not determine rust host target." >&2 + exit 1 +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" +mkdir -p "$resource_bin_dir" + +# Keep .gitkeep but refresh any staged tools from the lock. +find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete +rm -rf "$resource_node_dir" +mkdir -p "$resource_node_dir" + +# Manifest for the app's Node.js runtime doctor check, staged next to the +# bin dir so the app can resolve it as the bin dir's parent. Removed up +# front so locks with no npm-sourced tools ship no manifest and the doctor +# check stays silent. +node_runtime_manifest="$resource_root/node-runtime.json" +rm -f "$node_runtime_manifest" +node_runtime_entries=() + +# Manifest of the native harness CLIs vendored inside the bundled bridges +# (e.g. `claude` inside the claude-agent-sdk native package, `codex` inside +# @openai/codex). The app resolves auth probes against these pinned binaries +# instead of user installs. Kept OUT of resources/acp/bin on purpose: that +# dir is the highest-priority segment of the agent-spawn PATH, and staging +# `claude`/`codex` there would shadow the user's CLIs inside every session. +harness_cli_manifest="$resource_root/harness-clis.json" +rm -f "$harness_cli_manifest" +harness_cli_entries=() + +# Ad-hoc signing failure is a warning, not a hard stop: an unsignable Mach-O +# fragment that never executes should not sink the stage, and release builds +# re-sign everything with the real identity anyway. But it must be visible — +# a silently unsigned binary surfaces much later as Gatekeeper killing a +# subprocess mid-session, which is undiagnosable from build output. +codesign_if_darwin() { + local file="$1" + local output + if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then + if ! output="$(codesign --force --sign - "$file" 2>&1)"; then + echo "Warning: ad-hoc codesign failed for $file — Gatekeeper may kill it at spawn time:" >&2 + echo "$output" >&2 + fi + fi +} + +while IFS=$'\t' read -r id binary package version node_engine native_package native_executable; do + [[ -n "$id" ]] || continue + install_dir="$cache_root/$target/$id/$version/npm" + entrypoint="$install_dir/node_modules/$package/dist/index.js" + if [[ ! -f "$entrypoint" ]]; then + echo "Locked npm ACP tool missing from cache: $package@$version" >&2 + exit 1 + fi + resource_package_dir="$resource_node_dir/$id" + mkdir -p "$resource_package_dir" + cp -R "$install_dir/." "$resource_package_dir/" + resource_entrypoint="$resource_package_dir/node_modules/$package/dist/index.js" + if [[ ! -f "$resource_entrypoint" ]]; then + echo "Failed to stage npm ACP tool: $package@$version" >&2 + exit 1 + fi + 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 + # that is not in the staged tree — a silent miss would quietly send auth + # probes back to unpinned user installs. + if [[ -n "$native_package" && -n "$native_executable" ]]; then + cli_relpath="node/$id/node_modules/$native_package/$native_executable" + cli_abspath="$resource_root/$cli_relpath" + if [[ ! -f "$cli_abspath" ]]; then + echo "Locked native harness CLI missing from staged tree: $cli_relpath" >&2 + exit 1 + fi + chmod +x "$cli_abspath" + # 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 + # unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux + # staging skips the file(1) scan. + if [[ "$(uname -s)" == "Darwin" ]]; then + while IFS= read -r -d '' candidate; do + if file -b "$candidate" | grep -q "Mach-O"; then + codesign_if_darwin "$candidate" + fi + done < <(find "$resource_package_dir" -type f -print0) + fi +done < <(node - "$lock_file" "$target" <<'NODE' +const fs = require("node:fs"); +const [lockFile, target] = process.argv.slice(2); +const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); +for (const entry of data.tools ?? []) { + if (entry.target !== target || typeof entry.binary !== "string") continue; + if (entry.source !== "npm") { + throw new Error(`Unsupported ACP tool source: ${entry.source}`); + } + console.log([ + entry.id, + entry.binary, + entry.package, + entry.version, + entry.nodeEngine ?? ">=22", + entry.nativePackage ?? "", + entry.nativeExecutable ?? "", + ].join("\t")); +} +NODE +) + +# One manifest entry per npm-sourced bridge, each carrying its own required +# Node major, so bridges with different engine ranges surface distinct +# requirements in the doctor check. +if ((${#node_runtime_entries[@]} > 0)); then + node -e ' +const fs = require("node:fs"); +const [manifestFile, ...entries] = process.argv.slice(1); +const tools = entries.map((line) => { + const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t"); + return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) }; +}); +fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`); +' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"} + echo "Wrote ACP Node runtime manifest: $node_runtime_manifest" +fi + +# One manifest entry per vendored native harness CLI, keyed by the bare CLI +# name the app's auth probes use (`claude`, `codex`). Paths are relative to +# the acp resource root (the bin dir's parent). +if ((${#harness_cli_entries[@]} > 0)); then + node -e ' +const fs = require("node:fs"); +const [manifestFile, ...entries] = process.argv.slice(1); +const clis = entries.map((line) => { + const [id, cli, path] = line.split("\t"); + return { id, cli, path }; +}); +fs.writeFileSync(manifestFile, `${JSON.stringify({ clis }, null, 2)}\n`); +' "$harness_cli_manifest" ${harness_cli_entries[@]+"${harness_cli_entries[@]}"} + echo "Wrote ACP harness CLI manifest: $harness_cli_manifest" +fi + +echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs new file mode 100755 index 0000000000..53403896b4 --- /dev/null +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -0,0 +1,456 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; + +const appRoot = path.resolve(import.meta.dirname, ".."); +const defaultLockFile = path.join(appRoot, "acp-tools.lock.json"); + +const SUPPORTED_TARGETS = [ + "aarch64-apple-darwin", + "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 +// from the maintained Agent Client Protocol package rather than the stale +// Zed package. +const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp"; + +const TOOL_SPECS = [ + { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + dependencyPackage: "@anthropic-ai/claude-agent-sdk", + nativePackageKey: "claudeAgentSdk", + includeClaudeCodeVersion: true, + }, + { + id: "codex-acp", + binary: "codex-acp", + package: CODEX_ACP_PACKAGE, + dependencyPackage: "@openai/codex", + nativePackageKey: "openaiCodex", + }, +]; + +const NPM_TARGET_CONFIG = { + "aarch64-apple-darwin": { + npmOs: "darwin", + npmCpu: "arm64", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-arm64", + openaiCodex: "@openai/codex-darwin-arm64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/aarch64-apple-darwin/bin/codex", + }, + }, + "x86_64-apple-darwin": { + npmOs: "darwin", + npmCpu: "x64", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-x64", + openaiCodex: "@openai/codex-darwin-x64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/x86_64-apple-darwin/bin/codex", + }, + }, + "aarch64-unknown-linux-gnu": { + npmOs: "linux", + npmCpu: "arm64", + npmLibc: "glibc", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-arm64", + openaiCodex: "@openai/codex-linux-arm64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/aarch64-unknown-linux-musl/bin/codex", + }, + }, + "x86_64-unknown-linux-gnu": { + npmOs: "linux", + npmCpu: "x64", + npmLibc: "glibc", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-x64", + openaiCodex: "@openai/codex-linux-x64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + 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 +// via the ambient npm registry and validates the registry-agnostic sha512 +// integrity. Normalize them to the public registry so regenerating against an +// internal mirror (e.g. Artifactory) neither leaks internal hosts into the +// committed lock nor churns every URL in the diff. +const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; + +const npmViewCache = new Map(); +const execFileAsync = promisify(execFile); + +function usage() { + console.log(`Usage: desktop/scripts/update-acp-tools-lock.mjs [--target ]... [--lock-file ] + +Queries npm for the latest release of each supported ACP bridge tool and +writes acp-tools.lock.json. Fails loudly when a package or one of its +per-target native dependencies cannot be resolved — never silently pins an +older version. + +A --target run regenerates only the selected targets; the existing lock's +entries for every other target are preserved verbatim, so a partial bump +never drops another target's pins. + +Tarball URLs are normalized to ${PUBLIC_NPM_REGISTRY} regardless of the +registry that resolved the metadata, so regeneration is deterministic across +environments and internal mirror hosts never land in the committed lock. + +Supported targets: + ${SUPPORTED_TARGETS.join("\n ")} + +Environment: + npm registry config used to resolve packages + ACP_TOOLS_LOCK_FILE lockfile path override +`); +} + +function parseArgs(argv) { + const targets = []; + let lockFile = process.env.ACP_TOOLS_LOCK_FILE ?? defaultLockFile; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } + if (arg === "--target") { + const value = argv[++i]; + if (!value) throw new Error("--target requires a value"); + targets.push(value); + continue; + } + if (arg === "--lock-file") { + const value = argv[++i]; + if (!value) throw new Error("--lock-file requires a value"); + lockFile = path.resolve(value); + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + const selectedTargets = targets.length ? targets : SUPPORTED_TARGETS; + for (const target of selectedTargets) { + if (!SUPPORTED_TARGETS.includes(target)) { + throw new Error(`Unsupported target '${target}'`); + } + } + return { targets: selectedTargets, lockFile }; +} + +async function npmView(spec, fields) { + const cacheKey = `${spec}\0${fields.join("\0")}`; + if (!npmViewCache.has(cacheKey)) { + npmViewCache.set( + cacheKey, + execFileAsync("npm", ["view", spec, ...fields, "--json"], { + maxBuffer: 10 * 1024 * 1024, + }).then(({ stdout }) => { + try { + return JSON.parse(stdout); + } catch (error) { + throw new Error( + `npm view ${spec} returned invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }), + ); + } + return npmViewCache.get(cacheKey); +} + +function requireString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`Missing ${label}`); + } + return value; +} + +// Rebuild the tarball URL on PUBLIC_NPM_REGISTRY, keeping the registry's +// `/-/.tgz` path (the basename is not always derivable +// from name@version — @openai/codex native tarballs carry a platform suffix). +function normalizeTarballUrl(tarball, packageName, label) { + const separator = "/-/"; + const separatorIndex = tarball.lastIndexOf(separator); + if (separatorIndex === -1) { + throw new Error(`Cannot normalize ${label} tarball URL: ${tarball}`); + } + const basename = tarball.slice(separatorIndex + separator.length); + return `${PUBLIC_NPM_REGISTRY}/${packageName}/-/${basename}`; +} + +function packageDist(metadata, label) { + const dist = metadata?.dist; + return { + tarball: normalizeTarballUrl( + requireString(dist?.tarball, `${label} dist.tarball`), + requireString(metadata?.name, `${label} name`), + label, + ), + integrity: requireString(dist?.integrity, `${label} dist.integrity`), + }; +} + +function compareSemver(left, right) { + const leftCore = left.split("-", 1)[0].split(".").map(Number); + const rightCore = right.split("-", 1)[0].split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if ((leftCore[i] ?? 0) !== (rightCore[i] ?? 0)) { + return (leftCore[i] ?? 0) - (rightCore[i] ?? 0); + } + } + // A release outranks any prerelease of the same core version. + return (left.includes("-") ? 0 : 1) - (right.includes("-") ? 0 : 1); +} + +// npm view returns a single object when a spec matches one version, but an +// array of per-version objects when a range matches several. Pick the highest +// matching version so a ranged dependency still pins the newest release. +function pickLatestMatch(metadata, label) { + if (!Array.isArray(metadata)) return metadata; + if (metadata.length === 0) { + throw new Error(`No versions match ${label}`); + } + return metadata.reduce((best, candidate) => + compareSemver( + requireString(candidate?.version, `${label} version`), + requireString(best?.version, `${label} version`), + ) > 0 + ? candidate + : best, + ); +} + +function parseNpmAliasSpec(spec, fallbackPackage) { + if (!spec.startsWith("npm:")) { + return { packageName: fallbackPackage, version: spec }; + } + const aliased = spec.slice("npm:".length); + const versionSeparator = aliased.lastIndexOf("@"); + if (versionSeparator <= 0) { + throw new Error(`Unsupported npm alias spec: ${spec}`); + } + return { + packageName: aliased.slice(0, versionSeparator), + version: aliased.slice(versionSeparator + 1), + }; +} + +async function lockToolForTarget(tool, target) { + const npmTarget = NPM_TARGET_CONFIG[target]; + if (!npmTarget) { + throw new Error(`No npm target mapping for ${target}`); + } + + const packageName = tool.package; + const packageMetadata = await npmView(`${packageName}@latest`, [ + "name", + "version", + "dist", + "dependencies", + "engines", + "bin", + ]); + + if (packageMetadata.name !== packageName) { + throw new Error( + `npm package ${packageName} resolved to ${packageMetadata.name}`, + ); + } + + const version = requireString( + packageMetadata.version, + `${packageName} version`, + ); + const packageInfo = packageDist(packageMetadata, `${packageName}@${version}`); + const entry = { + id: tool.id, + binary: tool.binary, + source: "npm", + package: packageName, + version, + integrity: packageInfo.integrity, + tarball: packageInfo.tarball, + target, + npmOs: npmTarget.npmOs, + npmCpu: npmTarget.npmCpu, + ...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}), + nodeEngine: packageMetadata.engines?.node ?? ">=22", + }; + + const dependencyRange = requireString( + packageMetadata.dependencies?.[tool.dependencyPackage], + `${packageName} dependency ${tool.dependencyPackage}`, + ); + const dependencyMetadata = pickLatestMatch( + await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [ + "name", + "version", + "dist", + "optionalDependencies", + "claudeCodeVersion", + ]), + `${tool.dependencyPackage}@${dependencyRange}`, + ); + const dependencyVersion = requireString( + dependencyMetadata.version, + `${tool.dependencyPackage}@${dependencyRange} version`, + ); + const dependencyInfo = packageDist( + dependencyMetadata, + `${tool.dependencyPackage}@${dependencyVersion}`, + ); + entry.dependencyPackage = tool.dependencyPackage; + entry.dependencyVersion = dependencyVersion; + entry.dependencyIntegrity = dependencyInfo.integrity; + entry.dependencyTarball = dependencyInfo.tarball; + if (tool.includeClaudeCodeVersion) { + entry.claudeCodeVersion = dependencyMetadata.claudeCodeVersion ?? null; + } + + const nativePackage = requireString( + npmTarget.nativePackages?.[tool.nativePackageKey], + `${target} native package for ${tool.nativePackageKey}`, + ); + const nativeExecutable = requireString( + npmTarget.nativeExecutables?.[tool.nativePackageKey], + `${target} native executable for ${tool.nativePackageKey}`, + ); + const nativeSpec = requireString( + dependencyMetadata.optionalDependencies?.[nativePackage], + `${tool.dependencyPackage}@${dependencyVersion} optional dependency ${nativePackage}`, + ); + const nativeAlias = parseNpmAliasSpec(nativeSpec, nativePackage); + const nativeMetadata = await npmView( + `${nativeAlias.packageName}@${nativeAlias.version}`, + ["name", "version", "dist"], + ); + const nativeVersion = requireString( + nativeMetadata.version, + `${nativeAlias.packageName}@${nativeAlias.version} version`, + ); + if (nativeVersion !== nativeAlias.version) { + throw new Error( + `${nativeAlias.packageName}@${nativeAlias.version} resolved to ${nativeVersion}`, + ); + } + const nativeInfo = packageDist( + nativeMetadata, + `${nativeAlias.packageName}@${nativeVersion}`, + ); + + return { + ...entry, + nativePackage, + nativePackageName: nativeMetadata.name ?? nativePackage, + nativeVersion, + nativeIntegrity: nativeInfo.integrity, + nativeTarball: nativeInfo.tarball, + nativeExecutable, + }; +} + +// Entries preserved from the existing lock when --target selects a subset. +// A missing lock preserves nothing (there is nothing to drop); an unreadable +// or malformed one aborts the run rather than clobbering pins we cannot see. +async function preservedLockEntries(lockFile, selectedTargets) { + let raw; + try { + raw = await readFile(lockFile, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw new Error( + `Cannot read existing lock ${lockFile} to preserve unselected targets: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `Existing lock ${lockFile} is not valid JSON — refusing to overwrite it with a partial --target run: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!Array.isArray(parsed?.tools)) { + throw new Error( + `Existing lock ${lockFile} has no tools array — refusing to overwrite it with a partial --target run`, + ); + } + return parsed.tools.filter((entry) => !selectedTargets.has(entry?.target)); +} + +async function main() { + const { targets, lockFile } = parseArgs(process.argv.slice(2)); + const selectedTargets = new Set(targets); + const fullRun = SUPPORTED_TARGETS.every((target) => + selectedTargets.has(target), + ); + const preserved = fullRun + ? [] + : await preservedLockEntries(lockFile, selectedTargets); + const tools = []; + for (const tool of TOOL_SPECS) { + for (const target of targets) { + tools.push(await lockToolForTarget(tool, target)); + } + } + if (preserved.length) { + console.log( + `Preserved ${preserved.length} existing lock entr${ + preserved.length === 1 ? "y" : "ies" + } for unselected targets`, + ); + } + tools.push(...preserved); + tools.sort((left, right) => + `${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`), + ); + await mkdir(path.dirname(lockFile), { recursive: true }); + await writeFile(lockFile, `${JSON.stringify({ tools }, null, 2)}\n`); + console.log(`Updated ${path.relative(process.cwd(), lockFile)}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2824f40271..0e19e72f16 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -66,7 +66,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" -tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] } +tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "process"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } bytes = "1" diff --git a/desktop/src-tauri/resources/acp/bin/.gitkeep b/desktop/src-tauri/resources/acp/bin/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 3ddd226ad0..be52b07329 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,9 +4,9 @@ use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, - ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, + command_availability, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, + InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo, + DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, @@ -19,45 +19,83 @@ fn active_installs() -> &'static std::sync::Mutex( - runtime_id: &str, adapter_path: Option<&std::path::Path>, adapter_install_commands: &'c [&'c str], ) -> Option> { match adapter_path { - // Adapter present and current — no install needed. - Some(_) if runtime_id != "codex" => None, - Some(path) if !crate::managed_agents::codex_adapter_is_outdated(path) => None, - // Codex adapter is outdated: uninstall the old package first so npm - // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. - Some(_) => Some(vec![ - "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", - ]), + // Adapter present — no install needed. + Some(_) => None, // Adapter missing: use the catalog's install commands directly. None => Some(adapter_install_commands.to_vec()), } } +/// Post-install verification that the runtime's ACP adapter actually resolves. +/// +/// `install_acp_runtime_blocking` runs its install phases and then re-resolves +/// the adapter through `resolve`. For the bundled bridges (claude, codex) the +/// install plan is empty, so without this gate a broken bundle would run zero +/// steps and report success — discovery would immediately classify the runtime +/// as not installed again, an install-succeeds/still-broken loop. +/// +/// Returns `None` when there is nothing to verify (`commands` is empty) or any +/// adapter command resolves. Otherwise returns a failed synthetic "verify" +/// step whose hint points at reinstalling Buzz when the adapter is bundled +/// (`bundled`, see [`runtime_adapter_is_bundled`]) or at the install step +/// output otherwise. +fn adapter_verification_step( + commands: &[&str], + label: &str, + bundled: bool, + resolve: impl Fn(&str) -> Option, +) -> Option { + if commands.is_empty() || commands.iter().any(|cmd| resolve(cmd).is_some()) { + return None; + } + let hint = if bundled { + format!( + "The {label} ACP adapter ships with the Buzz desktop app but could not be found in \ + this installation. Reinstall Buzz to restore the bundled adapter." + ) + } else { + format!( + "The {label} ACP adapter still could not be found after the install steps completed. \ + Check the step output above." + ) + }; + Some(InstallStepResult { + step: "verify".to_string(), + command: format!("resolve {}", commands.join(" | ")), + success: false, + stdout: String::new(), + stderr: format!("no {label} ACP adapter binary found on the resolution path"), + exit_code: None, + hint: Some(hint), + }) +} + +/// A runtime's adapter ships with the Buzz desktop app when its catalog entry +/// carries no install commands at all — neither CLI nor adapter. Goose has a +/// curl CLI installer (and its CLI *is* its adapter), so a failed goose verify +/// must not claim the adapter is bundled and point at reinstalling Buzz. +fn runtime_adapter_is_bundled(runtime: &crate::managed_agents::KnownAcpRuntime) -> bool { + runtime.cli_install_commands.is_empty() && runtime.adapter_install_commands.is_empty() +} + #[tauri::command] pub async fn discover_acp_providers() -> Result, String> { tokio::task::spawn_blocking(|| { @@ -69,6 +107,15 @@ pub async fn discover_acp_providers() -> Result, Str .map_err(|e| format!("spawn_blocking failed: {e}")) } +/// Doctor check for the bundled ACP bridges' Node.js runtime requirement. +/// `None` when the app bundles no npm-sourced bridges — the Doctor panel +/// hides the section entirely. +#[tauri::command] +pub async fn check_acp_node_runtime( +) -> Option { + crate::managed_agents::node_runtime::run_node_runtime_check().await +} + #[tauri::command] pub async fn install_acp_runtime( runtime_id: String, @@ -145,11 +192,6 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result Result std::process::Command { let shell = if std::path::Path::new("/bin/zsh").exists() { "/bin/zsh" @@ -735,230 +776,6 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize { index } -// ── npm EACCES preflight ────────────────────────────────────────────────────── - -/// Guidance text for the EACCES / unwritable-prefix case. -fn npm_eacces_guidance(command: &str) -> String { - format!( - "npm's global install directory isn't writable by your user.\n\ -\n\ -Fix (no sudo):\n\ - 1. Run: npm config set prefix ~/.npm-global\n\ - 2. Add to ~/.zprofile: export PATH=\"$HOME/.npm-global/bin:$PATH\"\n\ - 3. Restart Buzz, then click Install again.\n\ -\n\ -Or install manually, then click Refresh:\n\ - sudo {command}" - ) -} - -/// Guidance text shown when npm / Node.js is not found in the login-shell PATH. -const NPM_MISSING_HINT: &str = "Node.js / npm was not found. Install Node.js \ -(https://nodejs.org or your version manager), restart Buzz, then click Install again.\n\ -If npm works in your terminal, make sure your Node version manager is initialized in \ -~/.zprofile (not only ~/.zshrc) — Buzz resolves tools via non-interactive login shells."; - -/// Result of probing `npm prefix -g` in the hermit-stripped login shell. -#[cfg(unix)] -enum NpmPrefix { - /// npm responded with a parseable prefix path. - Found(std::path::PathBuf), - /// npm was not found, the spawn failed, the command returned a non-zero - /// exit, or the output could not be parsed. - Unavailable, - /// The probe exceeded the 30-second deadline (e.g. a version-manager init - /// that blocks on `/dev/tty`). The install should proceed so the stderr - /// classifier remains the backstop. - TimedOut, -} - -/// Spawn the same login shell used by `run_install_command` and run -/// `npm prefix -g` to discover where npm would install global packages. -#[cfg(unix)] -fn resolve_npm_prefix() -> NpmPrefix { - let mut cmd = install_shell_command("npm prefix -g"); - let mut child = match cmd - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(_) => return NpmPrefix::Unavailable, - }; - - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - let stderr_thread = std::thread::spawn(move || { - // Drain stderr so the child doesn't block on a full pipe. - if let Some(mut pipe) = stderr_pipe { - let _ = std::io::copy(&mut pipe, &mut std::io::sink()); - } - }); - - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let status = child.wait(); - let _ = tx.send(status); - }); - - // 30-second timeout — plenty for `npm prefix -g`; intentionally shorter - // than the 5-minute install budget in `run_install_command`. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - let raw_bytes: Option> = loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - // Timed out: send SIGTERM, clean up threads, signal the caller to - // fall through to the install path rather than abort. - unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - eprintln!( - "buzz: npm prefix probe timed out after 30s; \ - proceeding to install (stderr classifier is the backstop)" - ); - return NpmPrefix::TimedOut; - } - match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { - Ok(Ok(status)) => { - let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let _ = stderr_thread.join(); - break if status.success() { Some(stdout) } else { None }; - } - Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - break None; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - } - }; - - let bytes = match raw_bytes { - Some(b) => b, - None => return NpmPrefix::Unavailable, - }; - let raw = String::from_utf8_lossy(&bytes).into_owned(); - // Version managers can print banner lines before the real prefix — take the - // last non-empty line to skip any preamble. - let prefix = match raw.lines().rfind(|l| !l.trim().is_empty()) { - Some(l) => l.trim().to_string(), - None => return NpmPrefix::Unavailable, - }; - if prefix.is_empty() { - return NpmPrefix::Unavailable; - } - NpmPrefix::Found(std::path::PathBuf::from(prefix)) -} - -/// Check write access to a file-system path using the POSIX `access(2)` syscall. -#[cfg(unix)] -fn unix_is_writable(path: &std::path::Path) -> bool { - use std::os::unix::ffi::OsStrExt; - let bytes = path.as_os_str().as_bytes(); - let Ok(c_path) = std::ffi::CString::new(bytes) else { - return false; - }; - // SAFETY: `access` is a pure read-only syscall; we pass a valid NUL-terminated - // path and a standard flag constant. This mirrors the existing `setsid`/`kill` - // usage in this file. - unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 } -} - -/// Returns true when the directory where npm would write global packages is -/// writable by the current process user. -/// -/// On non-unix platforms always returns `true` — the EACCES preflight is a -/// no-op there; the stderr classifier still applies. -fn npm_install_target_is_writable(prefix: &std::path::Path) -> bool { - #[cfg(unix)] - { - // Probe the most specific candidate that exists; fall back up the tree. - for candidate in &[ - prefix.join("lib/node_modules"), - prefix.join("lib"), - prefix.to_path_buf(), - ] { - if candidate.exists() { - return unix_is_writable(candidate); - } - } - // Nothing exists — npm couldn't create it either. - unix_is_writable(prefix) - } - #[cfg(not(unix))] - { - let _ = prefix; - true - } -} - -/// Inspect `stderr` for known npm EACCES patterns and return actionable -/// guidance if matched, or `None` when the error is unrelated. -fn npm_eacces_hint(stderr: &str, command: &str) -> Option { - if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") { - Some(npm_eacces_guidance(command)) - } else { - None - } -} - -/// Run the npm preflight before executing an npm global install command. -/// Returns `Some(failed InstallStepResult)` to abort, or `None` to proceed. -fn npm_preflight_check(step: &str, command: &str) -> Option { - #[cfg(unix)] - { - match resolve_npm_prefix() { - NpmPrefix::Unavailable => Some(InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: String::new(), - exit_code: None, - hint: Some(NPM_MISSING_HINT.to_string()), - }), - NpmPrefix::Found(prefix) if !npm_install_target_is_writable(&prefix) => { - Some(InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!( - "npm global prefix '{}' is not writable by the current user.", - prefix.display() - ), - exit_code: None, - hint: Some(npm_eacces_guidance(command)), - }) - } - // `Found` + writable, or `TimedOut` — proceed; let the install run and - // the stderr classifier serve as the backstop. - _ => None, - } - } - #[cfg(not(unix))] - { - let _ = (step, command); - None - } -} - -// ── end npm preflight ───────────────────────────────────────────────────────── - #[tauri::command] pub async fn discover_managed_agent_prereqs( input: DiscoverManagedAgentPrereqsRequest, @@ -1011,219 +828,42 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result> = OnceLock::new(); + +/// Resolve and register the bundled ACP tools bin dir for the app lifetime: +/// the dev env override wins, then the Tauri resource dir for packaged apps. +/// Called once during app setup, before anything can resolve agent commands. +pub fn register_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) { + let resolved = bundled_acp_tools_dir_from_parts( + std::env::var_os(ACP_TOOLS_DIR_ENV).as_deref(), + app_handle + .path() + .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) + .ok() + .as_deref(), + ); + let _ = BUNDLED_ACP_TOOLS_DIR.set(resolved); +} + +/// The registered bundled ACP tools bin dir, if the app ships one. `None` +/// until [`register_bundled_acp_tools_dir`] runs (e.g. in unit tests), so +/// every consumer degrades to the pre-bundling resolution order. +pub(crate) fn bundled_acp_tools_dir() -> Option { + BUNDLED_ACP_TOOLS_DIR.get().cloned().flatten() +} + +/// Resolve `command` inside the bundled tools dir. Bare command names only — +/// a path-like command (absolute or multi-component) names a specific binary +/// the user picked and must never be redirected into the bundle. +pub(in crate::managed_agents) fn command_in_bundled_dir(command: &str) -> Option { + command_in_dir(&bundled_acp_tools_dir()?, command) +} + +/// Whether `path` points inside the bundled tools dir — i.e. the resolved +/// adapter is the pinned bridge Buzz ships rather than a user install. False +/// until [`register_bundled_acp_tools_dir`] runs or when no tools are bundled. +pub(in crate::managed_agents) fn path_is_in_bundled_dir(path: &Path) -> bool { + path_is_in_dir(bundled_acp_tools_dir().as_deref(), path) +} + +fn path_is_in_dir(dir: Option<&Path>, path: &Path) -> bool { + dir.is_some_and(|dir| path.starts_with(dir)) +} + +/// Path of the Node runtime manifest staged by +/// `desktop/scripts/prepare-acp-tools-resource.sh`: it lives next to the +/// tools bin dir (`acp/node-runtime.json` beside `acp/bin`), so it resolves +/// for both the bundled resource dir and a `BUZZ_ACP_TOOLS_DIR` dev override. +pub(in crate::managed_agents) fn node_runtime_manifest_path(bin_dir: &Path) -> Option { + bin_dir + .parent() + .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) +} + +/// On-disk shape of `resources/acp/harness-clis.json`. +#[derive(serde::Deserialize)] +struct HarnessCliManifest { + #[serde(default)] + clis: Vec, +} + +#[derive(serde::Deserialize)] +struct HarnessCliEntry { + cli: String, + path: String, +} + +/// Resolve the pinned native harness CLI (`claude`, `codex`) vendored inside +/// a bundled bridge, via the staged `harness-clis.json` manifest. This is the +/// same binary the bridge itself runs, so auth probes against it can never +/// drift from what agent sessions see. Bare command names only, mirroring +/// [`command_in_bundled_dir`]. Deliberately separate from the bin dir: these +/// CLIs must never join the agent-spawn PATH, where they would shadow the +/// user's (possibly newer) installs inside every session. +pub(in crate::managed_agents) fn bundled_harness_cli(cli: &str) -> Option { + let bin_dir = bundled_acp_tools_dir()?; + bundled_harness_cli_in_root(bin_dir.parent()?, cli) +} + +fn bundled_harness_cli_in_root(acp_root: &Path, cli: &str) -> Option { + if command_looks_like_path(cli) { + return None; + } + let manifest = std::fs::read_to_string(acp_root.join(HARNESS_CLI_MANIFEST_FILE)).ok()?; + let manifest: HarnessCliManifest = serde_json::from_str(&manifest).ok()?; + let entry = manifest.clis.into_iter().find(|entry| entry.cli == cli)?; + let relative = PathBuf::from(entry.path); + // Manifest paths are acp-root-relative by contract; anything absolute or + // escaping the root is malformed and must not resolve. + let escapes = relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if escapes { + return None; + } + let candidate = acp_root.join(relative); + is_executable_file(&candidate).then_some(candidate) +} + +fn command_in_dir(dir: &Path, command: &str) -> Option { + if command_looks_like_path(command) { + return None; + } + let candidate = dir.join(executable_basename(command)); + is_executable_file(&candidate).then_some(candidate) +} + +fn bundled_acp_tools_dir_from_parts( + env_override: Option<&OsStr>, + resource_dir: Option<&Path>, +) -> Option { + if let Some(value) = env_override { + if !value.is_empty() { + return Some(PathBuf::from(value)); + } + } + resource_dir.map(Path::to_path_buf) +} + +#[cfg(test)] +mod tests { + use super::{ + bundled_acp_tools_dir_from_parts, bundled_harness_cli_in_root, command_in_dir, + path_is_in_dir, + }; + use std::ffi::OsStr; + use std::path::Path; + + #[test] + fn path_is_in_dir_matches_whole_components_of_the_bundled_dir() { + let dir = Some(Path::new("/bundle/resources/acp/bin")); + assert!(path_is_in_dir( + dir, + Path::new("/bundle/resources/acp/bin/claude-agent-acp"), + )); + assert!(!path_is_in_dir( + dir, + Path::new("/usr/local/bin/claude-agent-acp"), + )); + // Component-wise prefix, not a string prefix. + assert!(!path_is_in_dir( + dir, + Path::new("/bundle/resources/acp/bin-old/claude-agent-acp"), + )); + assert!(!path_is_in_dir( + None, + Path::new("/bundle/resources/acp/bin/codex-acp") + )); + } + + #[test] + fn env_override_wins_over_resource_dir() { + assert_eq!( + bundled_acp_tools_dir_from_parts( + Some(OsStr::new("/dev/acp/bin")), + Some(Path::new("/bundle/resources/acp/bin")), + ) + .as_deref(), + Some(Path::new("/dev/acp/bin")), + ); + } + + #[test] + fn empty_env_override_falls_back_to_resource_dir() { + assert_eq!( + bundled_acp_tools_dir_from_parts( + Some(OsStr::new("")), + Some(Path::new("/bundle/resources/acp/bin")), + ) + .as_deref(), + Some(Path::new("/bundle/resources/acp/bin")), + ); + } + + #[test] + fn missing_inputs_resolve_to_none() { + assert!(bundled_acp_tools_dir_from_parts(None, None).is_none()); + } + + #[test] + fn node_runtime_manifest_sits_beside_bin_dir() { + assert_eq!( + super::node_runtime_manifest_path(Path::new("/bundle/resources/acp/bin")).as_deref(), + Some(Path::new("/bundle/resources/acp/node-runtime.json")), + ); + assert!(super::node_runtime_manifest_path(Path::new("/")).is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_finds_executable_by_bare_name() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("claude-agent-acp"); + fs::write(&tool, "#!/bin/sh\n").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("chmod tool"); + + assert_eq!( + command_in_dir(temp.path(), "claude-agent-acp").as_deref(), + Some(tool.as_path()), + ); + assert!(command_in_dir(temp.path(), "codex-acp").is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_rejects_path_like_commands() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("codex-acp"); + fs::write(&tool, "#!/bin/sh\n").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("chmod tool"); + + // An absolute path joined onto the bundled dir would *replace* it + // (Path::join semantics) — path-like commands must pass through to + // the regular resolution order untouched. + assert!(command_in_dir(temp.path(), tool.to_str().expect("utf8")).is_none()); + assert!(command_in_dir(temp.path(), "custom/codex-acp").is_none()); + } + + #[cfg(unix)] + #[test] + fn bundled_harness_cli_resolves_manifest_relative_path() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let vendored = temp.path().join("node/claude-acp/node_modules/sdk-native"); + fs::create_dir_all(&vendored).expect("vendored dir"); + let cli = vendored.join("claude"); + fs::write(&cli, "#!/bin/sh\n").expect("write cli"); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod cli"); + fs::write( + temp.path().join("harness-clis.json"), + r#"{"clis":[{"id":"claude-acp","cli":"claude","path":"node/claude-acp/node_modules/sdk-native/claude"}]}"#, + ) + .expect("write manifest"); + + assert_eq!( + bundled_harness_cli_in_root(temp.path(), "claude").as_deref(), + Some(cli.as_path()), + ); + assert!( + bundled_harness_cli_in_root(temp.path(), "codex").is_none(), + "a CLI absent from the manifest must not resolve" + ); + } + + #[test] + fn bundled_harness_cli_without_manifest_is_none() { + let temp = tempfile::tempdir().expect("temp dir"); + assert!(bundled_harness_cli_in_root(temp.path(), "claude").is_none()); + } + + #[cfg(unix)] + #[test] + fn bundled_harness_cli_rejects_escaping_paths_and_path_like_names() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let outside = temp.path().join("outside"); + fs::write(&outside, "#!/bin/sh\n").expect("write outside"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).expect("chmod outside"); + let root = temp.path().join("acp"); + fs::create_dir_all(&root).expect("acp root"); + fs::write( + root.join("harness-clis.json"), + format!( + r#"{{"clis":[{{"id":"a","cli":"escape","path":"../outside"}},{{"id":"b","cli":"absolute","path":"{}"}}]}}"#, + outside.display() + ), + ) + .expect("write manifest"); + + assert!( + bundled_harness_cli_in_root(&root, "escape").is_none(), + "a ..-escaping manifest path must not resolve" + ); + assert!( + bundled_harness_cli_in_root(&root, "absolute").is_none(), + "an absolute manifest path must not resolve" + ); + // Path-like probe names bypass the manifest entirely — they name a + // specific binary and must fall through to regular resolution. + assert!(bundled_harness_cli_in_root(&root, "some/claude").is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_skips_non_executable_files() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("claude-agent-acp"); + fs::write(&tool, "not executable").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o644)).expect("chmod tool"); + + assert!(command_in_dir(temp.path(), "claude-agent-acp").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2c4821a4c5..ffec03be14 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,7 +10,10 @@ use crate::managed_agents::{ pub(crate) struct KnownAcpRuntime { pub id: &'static str, pub label: &'static str, + /// Adapter binaries swept for resolution; the first is the default command. pub commands: &'static [&'static str], + /// Identity-only names (stored records, overrides) — recognized by + /// [`known_acp_runtime`], never resolved or spawned. pub aliases: &'static [&'static str], pub avatar_url: &'static str, /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI. @@ -18,7 +21,10 @@ pub(crate) struct KnownAcpRuntime { pub mcp_command: Option<&'static str>, /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. pub mcp_hooks: bool, - /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). + /// CLI binary whose presence distinguishes `AdapterMissing` from + /// `NotInstalled` when the adapter is absent. `None` for the bundled + /// bridges (claude, codex): they ship their own vendored CLI, so the + /// user's install neither gates availability nor serves auth probes. pub underlying_cli: Option<&'static str>, /// Shell commands to install the runtime CLI itself (run sequentially). pub cli_install_commands: &'static [&'static str], @@ -128,17 +134,19 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ KnownAcpRuntime { id: "claude", label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], + commands: &["claude-agent-acp"], + // The retired `claude-code-acp` adapter stays recognized for stored + // records from older installs, but is never resolved or spawned. + aliases: &["claude-code-acp", "claude-code", "claudecode"], avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Install the Claude Code CLI via the official install script.", - adapter_install_hint: "Install the Claude Code ACP adapter via npm.", + cli_install_hint: "", + adapter_install_hint: "The Claude Code ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -152,7 +160,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), + login_hint: Some("Run the Claude CLI to complete authentication (install it first if needed)."), auth_probe_args: Some(&["claude", "auth", "status"]), }, KnownAcpRuntime { @@ -163,12 +171,12 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Install the Codex CLI via the official install script.", - adapter_install_hint: "Install the Codex ACP adapter via npm.", + cli_install_hint: "", + adapter_install_hint: "The Codex ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -182,7 +190,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), + login_hint: Some("Run `codex login` to authenticate (install the Codex CLI first if needed)."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, @@ -227,12 +235,12 @@ fn workspace_root_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } -fn command_looks_like_path(command: &str) -> bool { +pub(in crate::managed_agents) fn command_looks_like_path(command: &str) -> bool { let path = Path::new(command); path.is_absolute() || path.components().count() > 1 } -fn executable_basename(command: &str) -> String { +pub(in crate::managed_agents) fn executable_basename(command: &str) -> String { let suffix = std::env::consts::EXE_SUFFIX; if suffix.is_empty() || command.ends_with(suffix) { command.to_string() @@ -428,7 +436,7 @@ fn command_search_dirs() -> Vec { unique } -fn is_executable_file(path: &Path) -> bool { +pub(in crate::managed_agents) fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; @@ -498,76 +506,16 @@ pub fn resolve_command(command: &str) -> Option { pub fn clear_resolve_cache() { let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner()); guard.clear(); - // Also invalidate the adapter-availability cache so a freshly-installed - // adapter is reflected the next time the summary builder checks the badge. - clear_adapter_availability_cache(); -} - -// ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── -// -// `build_managed_agent_summary` needs to compare the spawn-time adapter -// availability against the *current* availability without triggering a live -// `probe_codex_acp_major_version` subprocess on every poll cycle. This cache -// stores the last availability status of the codex-acp binary at its resolved -// path. It is warmed by `discover_acp_runtimes` (which already probes), so -// the badge path reads warm data, and is invalidated by `clear_resolve_cache` -// (called on every Doctor install and every `discover_acp_providers` call). - -fn adapter_availability_cache() -> &'static std::sync::Mutex> { - use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(None)) -} - -fn clear_adapter_availability_cache() { - if let Ok(mut guard) = adapter_availability_cache().lock() { - *guard = None; - } -} - -/// Cache the current codex-acp adapter availability status. -/// -/// Called by `discover_acp_runtimes` after it probes the codex adapter so the -/// badge path has a warm value without re-probing. -pub(crate) fn cache_adapter_availability(status: AcpAvailabilityStatus) { - if let Ok(mut guard) = adapter_availability_cache().lock() { - *guard = Some(status); - } } -/// Return the most recently cached codex-acp adapter availability, or -/// `None` if no discovery has run yet. -/// -/// This is a **read from cache only** — it never spawns a subprocess. The -/// value is populated by `discover_acp_runtimes` and invalidated by -/// `clear_resolve_cache`. When the cache is cold, returning `None` defers -/// the drift check until discovery has produced a real value, preventing -/// a fabricated `AdapterMissing` stamp from triggering a false restart badge -/// on a newly restarted process. -pub(crate) fn adapter_availability_cached() -> Option { - adapter_availability_cache() - .lock() - .ok() - .and_then(|g| g.clone()) -} - -/// Pure predicate: does the stamped adapter availability differ from the -/// current cached availability? -/// -/// Returns `false` whenever either side is `None` (unknown) — "no data" is -/// not evidence of drift. This is extracted for unit testing without global -/// state and used by `build_managed_agent_summary`. -pub(crate) fn availability_drift( - stamped: Option<&AcpAvailabilityStatus>, - current: Option, -) -> bool { - match (stamped, current) { - (Some(s), Some(c)) => *s != c, - _ => false, +fn resolve_command_uncached(command: &str) -> Option { + // Bundled ACP bridge tools (see `managed_agents::acp_tools`) win over + // every other source, so the pinned bridges shipped with the app are + // preferred to user-installed copies. + if let Some(path) = super::acp_tools::command_in_bundled_dir(command) { + return Some(path); } -} -fn resolve_command_uncached(command: &str) -> Option { if let Some(path) = resolve_workspace_command(command) { return Some(path); } @@ -808,22 +756,14 @@ pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) } -/// Returns true when the runtime has at least one adapter install step that -/// is an npm global install. Used to determine whether Node.js is required. -fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool { - runtime - .adapter_install_commands - .iter() - .any(|cmd| is_npm_global_install(cmd)) -} - -/// Returns `true` when `cmd` is an `npm install -g` invocation. -/// -/// Used by Doctor to determine whether Node.js is required before running an -/// install step, and by the npm EACCES preflight in the install command path. -pub(crate) fn is_npm_global_install(cmd: &str) -> bool { - let t = cmd.trim_start(); - t.starts_with("npm install -g ") || t.starts_with("npm i -g ") +/// Resolve the binary for a CLI auth probe (`claude`, `codex`): the pinned +/// CLI vendored inside the bundled bridge wins — it is the exact binary agent +/// sessions run and reads the same credential store — falling back to the +/// user's install for builds without staged bundle resources. Not routed +/// through `resolve_command`: probe binaries must stay out of the resolve +/// cache and off the agent-spawn PATH. +pub(crate) fn resolve_probe_binary(cli: &str) -> Option { + super::acp_tools::bundled_harness_cli(cli).or_else(|| resolve_command(cli)) } /// Run a CLI auth probe with a 10-second process-level timeout. @@ -943,25 +883,21 @@ pub fn missing_command_message(command: &str, role: &str) -> String { ) } +/// A resolving adapter is available, full stop — whether the runtime is +/// usable beyond that is an auth question (`auth_status`), not an install +/// question. The retired `CliMissing` state gated availability on a user +/// CLI install the bundled bridges no longer need. pub(crate) fn classify_runtime( adapter_result: Option<(&str, PathBuf)>, underlying_cli: Option<&str>, underlying_cli_found: bool, ) -> (AcpAvailabilityStatus, Option, Option) { if let Some((cmd, path)) = adapter_result { - if underlying_cli.is_some() && !underlying_cli_found { - ( - AcpAvailabilityStatus::CliMissing, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) - } else { - ( - AcpAvailabilityStatus::Available, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) - } + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) } else if underlying_cli.is_some() && underlying_cli_found { (AcpAvailabilityStatus::AdapterMissing, None, None) } else { @@ -969,114 +905,6 @@ pub(crate) fn classify_runtime( } } -/// Probe the major version of a `codex-acp` binary by running `--version`. -/// -/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs -/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. -/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does -/// not recognise `--version` and exits non-zero. -/// -/// Returns the major version on success, `None` on any failure (non-zero exit, -/// unparseable output, timeout, or missing binary). -/// -/// The probe is bounded by a 5-second deadline. The child is polled with -/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and -/// killed if it does not exit in time. -/// -/// Stdout is redirected to a temporary file rather than a pipe, so forked -/// descendants cannot hold EOF open. Reads from a regular file return EOF at its -/// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_major_version(binary_path: &Path) -> Option { - probe_codex_acp_major_version_with_path( - binary_path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} -pub(crate) fn probe_codex_acp_major_version_with_path( - binary_path: &Path, - augmented_path: Option<&str>, -) -> Option { - use std::io::{Read as _, Seek as _, SeekFrom}; - use std::time::{Duration, Instant}; - const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); - - // A regular file returns EOF at its current size even when a descendant - // inherits its descriptor, bounding the post-exit read cross-platform. - let mut tmp = tempfile::tempfile().ok()?; - - let mut command = Command::new(binary_path); - command.arg("--version"); - if let Some(path) = augmented_path { - command.env("PATH", path); - } - let mut child = command - .stdout(tmp.try_clone().ok()?) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - - // Poll until the deadline rather than blocking on stdout EOF. - let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; - let exit_status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - }; - - if !exit_status.success() { - return None; - } - - // Read at most 4 KiB from the regular file without blocking. - tmp.seek(SeekFrom::Start(0)).ok()?; - let mut buf = Vec::with_capacity(128); - let _ = (&mut tmp as &mut dyn std::io::Read) - .take(4096) - .read_to_end(&mut buf); - - let stdout = String::from_utf8_lossy(&buf); - // Output format: " .." - let version_str = stdout.split_whitespace().last()?; - let major_str = version_str.split('.').next()?; - major_str.parse::().ok() -} - -/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] -/// or [`AcpAvailabilityStatus::AdapterOutdated`]. -/// -/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. The 1.x adapter -/// (`@agentclientprotocol/codex-acp`) prints its version and exits 0; major ≥ 1 -/// yields `Available`. -/// -/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and -/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. -pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_major_version(path) { - Some(major) if major >= 1 => AcpAvailabilityStatus::Available, - _ => AcpAvailabilityStatus::AdapterOutdated, - } -} - -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed. Thin wrapper around [`codex_adapter_availability`]. -pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { - codex_adapter_availability(path) == AcpAvailabilityStatus::AdapterOutdated -} - /// Intermediate struct built before the (potentially slow) auth probe phase. struct PartialEntry { runtime: &'static KnownAcpRuntime, @@ -1097,28 +925,12 @@ pub fn discover_acp_runtimes() -> Vec { .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (mut availability, command, binary_path) = + let adapter_bundled = adapter_result + .as_ref() + .is_some_and(|(_, path)| super::acp_tools::path_is_in_bundled_dir(path)); + let (availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. - if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && command.as_deref() == Some("codex-acp") - { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); - } - } - - // Warm the adapter-availability cache for the badge fallback. - // The cache is scoped to the codex runtime; other runtimes leave it - // unchanged. Invalidated by `clear_resolve_cache`. - if runtime.id == "codex" { - cache_adapter_availability(availability.clone()); - } - let underlying_cli_path = runtime .underlying_cli .and_then(find_command) @@ -1136,9 +948,7 @@ pub fn discover_acp_runtimes() -> Vec { let adapter_hint = runtime.adapter_install_hint; let install_hint = match availability { AcpAvailabilityStatus::Available => cli_hint.to_string(), - AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), - AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { format!("{cli_hint} {adapter_hint}") @@ -1150,14 +960,6 @@ pub fn discover_acp_runtimes() -> Vec { } }; - // node_required: an npm adapter step is pending AND node/npm are absent. - let node_required = matches!( - availability, - AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled - ) && runtime_needs_npm(runtime) - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); - PartialEntry { runtime, entry: AcpRuntimeCatalogEntry { @@ -1167,13 +969,13 @@ pub fn discover_acp_runtimes() -> Vec { availability, command, binary_path, + adapter_bundled, default_args, mcp_command: runtime.mcp_command.map(str::to_string), install_hint, install_instructions_url: runtime.install_instructions_url.to_string(), can_auto_install, underlying_cli_path, - node_required, // Filled in by the probe phase below. auth_status: AuthStatus::Unknown, login_hint: None, @@ -1192,8 +994,8 @@ pub fn discover_acp_runtimes() -> Vec { return None; } let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = resolve_command(probe_args[0])?; + // Probe the bundled CLI when the app ships one, else the user's. + let binary_path = resolve_probe_binary(probe_args[0])?; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); let handle = std::thread::spawn(move || { diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..6ce9a2361b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -11,8 +11,8 @@ use super::{effective_agent_command, known_acp_runtime}; /// inherits. /// /// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude` -/// runtime resolves to `claude-agent-acp`, but a client with only the -/// `claude-code-acp` adapter installed sends that command instead. Both map to +/// runtime resolves to `claude-agent-acp`, but a record created by an older +/// Buzz may echo the legacy `claude-code-acp` command instead. Both map to /// the same `claude` runtime, so neither is a real divergence — string equality /// would wrongly bake a pin. An unknown/custom command (no matching runtime) /// only inherits when it exactly equals the persona command. @@ -116,7 +116,7 @@ pub fn apply_agent_command_update( /// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a /// runtime command in UI that exposes a runtime selector. This is a real pin /// and is preserved when it differs from the command inheritance would spawn, -/// including installed aliases such as `claude-code-acp`. +/// including legacy adapter names such as `claude-code-acp`. /// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime /// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback /// default. This is NOT a pin — baking it would freeze the agent on the fallback diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index d1c1cc18d8..ceac22fb81 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,13 +2,11 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, find_via_login_shell, + apply_agent_command_update, classify_runtime, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + parse_semver_tag, record_agent_command, refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -178,13 +176,15 @@ fn classifies_not_installed_when_no_underlying_cli() { } #[test] -fn classifies_cli_missing_when_adapter_found_but_cli_absent() { +fn classifies_available_when_adapter_found_even_without_underlying_cli() { + // The retired CliMissing gate must not come back: a resolving adapter is + // available regardless of whether an underlying CLI is on the user PATH. let (status, cmd, path) = classify_runtime( Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), Some("codex"), false, ); - assert_eq!(status, AcpAvailabilityStatus::CliMissing); + assert_eq!(status, AcpAvailabilityStatus::Available); assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } @@ -365,10 +365,10 @@ fn divergent_override_none_when_picked_matches_persona_runtime() { #[test] fn divergent_override_none_for_alternate_command_of_same_runtime() { - // A client with only `claude-code-acp` installed sends that command for - // a `claude` persona whose primary command is `claude-agent-acp`. Both - // map to the `claude` runtime, so it inherits — string equality would - // wrongly bake a pin (CRITICAL-3). + // A record created by an older Buzz echoes the legacy `claude-code-acp` + // command for a `claude` persona whose primary command is + // `claude-agent-acp`. Both map to the `claude` runtime, so it inherits — + // string equality would wrongly bake a pin (CRITICAL-3). let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")), @@ -447,9 +447,9 @@ fn create_time_override_none_when_persona_runtime_installed() { #[test] fn create_time_override_preserves_selected_runtime_alias() { // A `claude` persona inherits the primary command `claude-agent-acp`, - // but discovery may select an installed alias such as `claude-code-acp`. - // When UI marks that create-time selection as explicit, preserve the - // alias so the first spawn uses a command known to be installed. + // but the UI may send a legacy adapter name such as `claude-code-acp` + // (e.g. copied forward from an older record). When UI marks that + // create-time selection as explicit, preserve it verbatim. let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( create_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true), @@ -510,7 +510,7 @@ fn update_time_override_preserves_exact_persona_command_when_overriding() { #[test] fn update_time_override_preserves_alias_pin_when_overriding() { - // A `claude` persona with an installed `claude-code-acp` alias: picking + // A `claude` persona with the legacy `claude-code-acp` name: picking // it as a Custom pin is a deliberate divergence from the primary // command and must be preserved when overriding. let personas = vec![persona_with_runtime("p1", Some("claude"))]; @@ -606,208 +606,6 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } -// ── probe_codex_acp_major_version ───────────────────────────────────────────── - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_parses_1x_output() { - use std::os::unix::fs::PermissionsExt; - - // Simulate `@agentclientprotocol/codex-acp 1.1.2` output (1.x adapter) - let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let major = probe_codex_acp_major_version(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!(major, Some(1), "1.x adapter must return major version 1"); -} - -mod codex_version; - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { - use std::os::unix::fs::PermissionsExt; - - // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero - let dir = std::env::temp_dir().join(format!("buzz-probe-0x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let major = probe_codex_acp_major_version(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - major, None, - "old 0.16.x adapter (non-zero exit) must return None" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_missing_binary() { - let path = std::path::Path::new("/nonexistent/path/codex-acp-does-not-exist"); - let major = probe_codex_acp_major_version(path); - assert_eq!(major, None, "missing binary must return None"); -} - -// ── codex_adapter_availability / codex_adapter_is_outdated ─────────────────── -// -// Outcome-level classification: verify helpers map probe results to the correct -// AcpAvailabilityStatus and boolean without duplicating version-gate logic. - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_available_for_1x_binary() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let status = codex_adapter_availability(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - status, - AcpAvailabilityStatus::Available, - "1.x adapter must classify as Available" - ); -} - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_0x_binary() { - use std::os::unix::fs::PermissionsExt; - - // Simulate old 0.16.x: `--version` exits non-zero (unrecognised flag) - let dir = std::env::temp_dir().join(format!("buzz-avail-0x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let status = codex_adapter_availability(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - status, - AcpAvailabilityStatus::AdapterOutdated, - "0.x adapter (non-zero exit) must classify as AdapterOutdated" - ); -} - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_missing_binary() { - let path = std::path::Path::new("/nonexistent/codex-acp-probe-test"); - assert_eq!( - codex_adapter_availability(path), - AcpAvailabilityStatus::AdapterOutdated, - "missing binary must classify as AdapterOutdated" - ); - // Thin wrapper consistency - assert!( - codex_adapter_is_outdated(path), - "missing binary must be classified as outdated via thin wrapper" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { - use std::os::unix::fs::PermissionsExt; - use std::time::Instant; - - // Simulate a process that writes version to stdout then blocks forever. - // The probe reads stdout only after the child exits, so it will time out. - // `exec sleep 300` replaces the shell so killing the child reaps `sleep` too. - let dir = std::env::temp_dir().join(format!("buzz-probe-hung-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\nprintf '@agentclientprotocol/codex-acp 1.1.2\\n'\nexec sleep 300\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); - let elapsed = start.elapsed(); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - major, None, - "hung binary must return None (timeout kills child)" - ); - // The timeout is 5 s; give a 10 s margin for parallel pre-push suites. - assert!( - elapsed.as_secs() < 15, - "probe must complete within timeout bound; elapsed: {elapsed:?}" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open() { - use std::os::unix::fs::PermissionsExt; - use std::time::Instant; - - // Simulate a process that forks a background child which inherits stdout - // and stays alive, while the parent writes version and exits 0. - // - // The probe writes the child's stdout to a temp file, then reads from the - // file after the parent process exits. Because the file has reached EOF - // (the parent closed its write end), read_to_end() returns immediately - // without waiting for the descendant to close its inherited fd. - // - // `(exec sleep 60 &)` forks a subshell that execs `sleep 60`; the subshell - // inherits the parent's stdout fd and keeps it open. - let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n(exec sleep 60 &)\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); - let elapsed = start.elapsed(); - let _ = std::fs::remove_dir_all(dir); - - // Must return within ~1 s: non-blocking read, no waiting for descendant. - // Give a 9 s margin for parallel pre-push suites. - assert!( - elapsed.as_secs() < 10, - "probe must not block on descendant pipe; elapsed: {elapsed:?}" - ); - assert_eq!( - major, - Some(1), - "1.x version must be parsed even when descendant holds pipe open" - ); -} - // ── parse_semver_tag ────────────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs deleted file mode 100644 index 5886a43990..0000000000 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::super::probe_codex_acp_major_version_with_path; - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter() { - use std::fs; - use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().expect("temp dir"); - let script_dir = temp.path().join("script-bin"); - let interpreter_dir = temp.path().join("interpreter-bin"); - let empty_path_dir = temp.path().join("empty-bin"); - fs::create_dir_all(&script_dir).expect("script dir"); - fs::create_dir_all(&interpreter_dir).expect("interpreter dir"); - fs::create_dir_all(&empty_path_dir).expect("empty path dir"); - - let interpreter_path = interpreter_dir.join("node"); - fs::write( - &interpreter_path, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n", - ) - .expect("write interpreter"); - fs::set_permissions(&interpreter_path, fs::Permissions::from_mode(0o755)) - .expect("chmod interpreter"); - - let shim_path = script_dir.join("codex-acp"); - fs::write(&shim_path, "#!/usr/bin/env node\n").expect("write shim"); - fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); - - let scrubbed_path = std::env::join_paths([empty_path_dir.as_path()]) - .expect("join scrubbed PATH") - .to_string_lossy() - .into_owned(); - assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&scrubbed_path)), - None, - "with a scrubbed PATH, /usr/bin/env should not find node" - ); - - let augmented_path = std::env::join_paths([interpreter_dir.as_path()]) - .expect("join augmented PATH") - .to_string_lossy() - .into_owned(); - assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&augmented_path)), - Some(1), - "the injected augmented PATH should allow /usr/bin/env to find node" - ); -} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c8037f463c..47caf33f84 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod acp_tools; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; @@ -12,6 +13,7 @@ mod env_vars; mod git_bash; pub(crate) mod global_config; mod nest; +pub(crate) mod node_runtime; mod persona_avatars; mod persona_card; pub(crate) mod persona_events; diff --git a/desktop/src-tauri/src/managed_agents/node_runtime.rs b/desktop/src-tauri/src/managed_agents/node_runtime.rs new file mode 100644 index 0000000000..59bf776536 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/node_runtime.rs @@ -0,0 +1,484 @@ +//! Doctor check for the bundled ACP bridges' Node.js runtime requirement. +//! +//! The bundled bridges are shell shims that `exec node` (see +//! `desktop/scripts/lib/acp-node-wrapper.sh`); without a suitable Node.js on +//! the spawn PATH the first agent session dies with a bare exit 127. This +//! check surfaces the requirement in the Doctor panel ahead of time. The +//! manifest (`resources/acp/node-runtime.json`) is written at staging time by +//! `desktop/scripts/prepare-acp-tools-resource.sh`, one entry per npm-sourced +//! bridge, each carrying its own required Node major. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +const NODE_RUNTIME_FIX_URL: &str = "https://nodejs.org/en/download"; +const NODE_PROBE_TIMEOUT: Duration = Duration::from_secs(10); + +/// On-disk shape of `resources/acp/node-runtime.json`. Each tool carries its +/// own required Node major so bridges with different engine ranges are +/// checked independently. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeManifest { + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeTool { + binary: String, + #[serde(default)] + node_engine: Option, + required_node_major: u32, +} + +impl NodeRuntimeTool { + fn requirement_label(&self) -> String { + self.node_engine + .clone() + .unwrap_or_else(|| format!(">={}", self.required_node_major)) + } +} + +/// Wire type for the Doctor panel (snake_case JSON like the rest of the +/// commands surface). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NodeRuntimeCheck { + pub status: NodeRuntimeCheckStatus, + pub message: String, + pub manifest_path: String, + pub node_path: Option, + pub node_version: Option, + pub requirements: Vec, + pub fix_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeRuntimeCheckStatus { + Pass, + Warn, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NodeRuntimeRequirement { + pub binary: String, + pub requirement: String, + pub verdict: NodeRequirementVerdict, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeRequirementVerdict { + Satisfied, + Unmet, + Unknown, +} + +enum NodeRuntimeManifestState { + /// No manifest next to the bundled tools dir: no npm-sourced bridges are + /// bundled, so the check stays silent. + Missing, + Invalid { + path: PathBuf, + error: String, + }, + Loaded { + path: PathBuf, + manifest: NodeRuntimeManifest, + }, +} + +fn load_node_runtime_manifest(bundled_bin_dir: Option<&Path>) -> NodeRuntimeManifestState { + let Some(path) = bundled_bin_dir.and_then(super::acp_tools::node_runtime_manifest_path) else { + return NodeRuntimeManifestState::Missing; + }; + let contents = match std::fs::read(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return NodeRuntimeManifestState::Missing; + } + Err(error) => { + return NodeRuntimeManifestState::Invalid { + path, + error: format!("failed to read manifest: {error}"), + }; + } + }; + match serde_json::from_slice::(&contents) { + Ok(manifest) => NodeRuntimeManifestState::Loaded { path, manifest }, + Err(error) => NodeRuntimeManifestState::Invalid { + path, + error: format!("failed to parse manifest JSON: {error}"), + }, + } +} + +/// Surface the bundled ACP bridges' Node.js runtime requirement in the Doctor +/// panel instead of letting the first session spawn die with a bare exit 127. +/// Returns `None` when no npm-sourced bridges are bundled; an unreadable +/// manifest warns instead of silently hiding a packaging break. +pub(crate) async fn run_node_runtime_check() -> Option { + let bundled_dir = super::acp_tools::bundled_acp_tools_dir(); + let (manifest_path, manifest) = match load_node_runtime_manifest(bundled_dir.as_deref()) { + NodeRuntimeManifestState::Missing => return None, + NodeRuntimeManifestState::Invalid { path, error } => { + return Some(NodeRuntimeCheck { + status: NodeRuntimeCheckStatus::Warn, + message: format!( + "Bundled ACP bridge Node.js manifest is unreadable; bridge runtime \ + requirements cannot be verified ({error})" + ), + manifest_path: path.display().to_string(), + node_path: None, + node_version: None, + requirements: Vec::new(), + fix_url: NODE_RUNTIME_FIX_URL.to_string(), + }); + } + NodeRuntimeManifestState::Loaded { path, manifest } => (path, manifest), + }; + if manifest.tools.is_empty() { + return None; + } + + // Resolve node from the same augmented PATH the agent spawn and the CLI + // auth probes use, so this check cannot disagree with what the bundled + // wrapper shims will find at spawn time. When no augmented PATH can be + // built, spawned children inherit the process PATH — mirror that too. + let path_value = super::readiness::cli_probe::augmented_path() + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + let node_path = resolve_node_from_path_value(&path_value); + let node_version = match node_path.as_deref() { + Some(path) => query_node_version(path).await, + None => None, + }; + + Some(build_node_runtime_check( + &manifest_path, + &manifest.tools, + node_path, + node_version, + )) +} + +fn resolve_node_from_path_value(path_value: &str) -> Option { + let file_name = super::discovery::executable_basename("node"); + std::env::split_paths(path_value) + .map(|dir| dir.join(&file_name)) + .find(|candidate| super::discovery::is_executable_file(candidate)) + .map(|path| path.display().to_string()) +} + +async fn query_node_version(node_path: &str) -> Option { + let mut command = tokio::process::Command::new(node_path); + command + .args(["-p", "process.versions.node"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = tokio::time::timeout(NODE_PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(String::from) +} + +fn parse_node_major(version: &str) -> Option { + version + .trim() + .trim_start_matches('v') + .split('.') + .next()? + .parse() + .ok() +} + +fn node_requirement_summary<'a>(tools: impl IntoIterator) -> String { + tools + .into_iter() + .map(|tool| format!("{} needs Node.js {}", tool.binary, tool.requirement_label())) + .collect::>() + .join(", ") +} + +fn build_node_runtime_check( + manifest_path: &Path, + tools: &[NodeRuntimeTool], + node_path: Option, + node_version: Option, +) -> NodeRuntimeCheck { + let node_major = node_version.as_deref().and_then(parse_node_major); + let unmet: Vec<&NodeRuntimeTool> = match node_major { + Some(major) => tools + .iter() + .filter(|tool| major < tool.required_node_major) + .collect(), + None => Vec::new(), + }; + + let (status, message) = if node_path.is_none() { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Node.js was not found on PATH; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if node_major.is_none() { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Could not determine the Node.js version; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if unmet.is_empty() { + ( + NodeRuntimeCheckStatus::Pass, + format!( + "Node.js {} satisfies the bundled ACP bridge requirements", + node_version.as_deref().unwrap_or("unknown") + ), + ) + } else { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Node.js {} is too old for bundled ACP bridges: {}", + node_version.as_deref().unwrap_or("unknown"), + node_requirement_summary(unmet.iter().copied()) + ), + ) + }; + + let requirements = tools + .iter() + .map(|tool| NodeRuntimeRequirement { + binary: tool.binary.clone(), + requirement: tool.requirement_label(), + verdict: match node_major { + Some(major) if major >= tool.required_node_major => { + NodeRequirementVerdict::Satisfied + } + Some(_) => NodeRequirementVerdict::Unmet, + None => NodeRequirementVerdict::Unknown, + }, + }) + .collect(); + + NodeRuntimeCheck { + status, + message, + manifest_path: manifest_path.display().to_string(), + node_path, + node_version, + requirements, + fix_url: NODE_RUNTIME_FIX_URL.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + build_node_runtime_check, load_node_runtime_manifest, parse_node_major, + NodeRequirementVerdict, NodeRuntimeCheckStatus, NodeRuntimeManifestState, NodeRuntimeTool, + }; + use std::path::Path; + + fn tool(binary: &str, node_engine: Option<&str>, required_node_major: u32) -> NodeRuntimeTool { + NodeRuntimeTool { + binary: binary.to_string(), + node_engine: node_engine.map(str::to_string), + required_node_major, + } + } + + #[test] + fn parse_node_major_handles_plain_and_v_prefixed_versions() { + assert_eq!(parse_node_major("22.11.0"), Some(22)); + assert_eq!(parse_node_major("v20.1.2"), Some(20)); + assert_eq!(parse_node_major(" 18.0.0 \n"), Some(18)); + assert_eq!(parse_node_major("not-a-version"), None); + assert_eq!(parse_node_major(""), None); + } + + #[test] + fn requirement_label_falls_back_to_required_major() { + assert_eq!(tool("codex-acp", None, 22).requirement_label(), ">=22"); + assert_eq!( + tool("claude-agent-acp", Some(">=18"), 18).requirement_label(), + ">=18" + ); + } + + #[test] + fn node_missing_warns_with_unknown_verdicts() { + let tools = vec![tool("claude-agent-acp", Some(">=18"), 18)]; + let check = + build_node_runtime_check(Path::new("/acp/node-runtime.json"), &tools, None, None); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("not found on PATH"), + "{}", + check.message + ); + assert!(check + .message + .contains("claude-agent-acp needs Node.js >=18")); + assert_eq!(check.requirements.len(), 1); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Unknown + ); + } + + #[test] + fn unparseable_version_warns() { + let tools = vec![tool("codex-acp", None, 20)]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("garbage".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("Could not determine"), + "{}", + check.message + ); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Unknown + ); + } + + #[test] + fn old_node_warns_listing_only_unmet_tools() { + let tools = vec![ + tool("claude-agent-acp", Some(">=18"), 18), + tool("codex-acp", Some(">=22"), 22), + ]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("20.10.0".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("codex-acp needs Node.js >=22"), + "{}", + check.message + ); + assert!( + !check.message.contains("claude-agent-acp"), + "satisfied tools must not appear in the warn summary: {}", + check.message + ); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Satisfied + ); + assert_eq!(check.requirements[1].verdict, NodeRequirementVerdict::Unmet); + } + + #[test] + fn new_enough_node_passes() { + let tools = vec![ + tool("claude-agent-acp", Some(">=18"), 18), + tool("codex-acp", Some(">=22"), 22), + ]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("22.11.0".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Pass); + assert!(check + .requirements + .iter() + .all(|req| req.verdict == NodeRequirementVerdict::Satisfied),); + } + + #[test] + fn manifest_missing_when_no_bin_dir_or_file() { + assert!(matches!( + load_node_runtime_manifest(None), + NodeRuntimeManifestState::Missing + )); + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + assert!(matches!( + load_node_runtime_manifest(Some(&bin_dir)), + NodeRuntimeManifestState::Missing + )); + } + + #[test] + fn manifest_invalid_when_unparseable() { + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + std::fs::write(temp.path().join("node-runtime.json"), "not json").expect("write manifest"); + let NodeRuntimeManifestState::Invalid { error, .. } = + load_node_runtime_manifest(Some(&bin_dir)) + else { + panic!("expected Invalid state"); + }; + assert!(error.contains("parse"), "{error}"); + } + + #[test] + fn manifest_loads_the_staging_script_shape() { + // Mirrors the exact JSON prepare-acp-tools-resource.sh writes. + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + std::fs::write( + temp.path().join("node-runtime.json"), + r#"{ + "tools": [ + { + "id": "claude-agent-acp", + "binary": "claude-agent-acp", + "nodeEngine": ">=18.0.0", + "requiredNodeMajor": 18 + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "nodeEngine": ">=22", + "requiredNodeMajor": 22 + } + ] +} +"#, + ) + .expect("write manifest"); + let NodeRuntimeManifestState::Loaded { manifest, .. } = + load_node_runtime_manifest(Some(&bin_dir)) + else { + panic!("expected Loaded state"); + }; + assert_eq!(manifest.tools.len(), 2); + assert_eq!(manifest.tools[0].binary, "claude-agent-acp"); + assert_eq!(manifest.tools[0].requirement_label(), ">=18.0.0"); + assert_eq!(manifest.tools[1].required_node_major, 22); + } +} diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 1ef3eae010..473861ba6a 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -135,7 +135,6 @@ pub fn finish_spawn( log_path: std::path::PathBuf, spawn_config_hash: u64, setup_mode: bool, - adapter_availability: Option, agent_name: &str, ) -> super::ManagedAgentProcess { let job = create_job_for_child(child.id()); @@ -150,7 +149,6 @@ pub fn finish_spawn( log_path, spawn_config_hash, setup_mode, - adapter_availability, job, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 8349cff014..3dd959fab7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -39,7 +39,6 @@ //! UI display only. use std::collections::BTreeMap; -use std::path::Path; use serde::{Deserialize, Serialize}; @@ -47,8 +46,7 @@ use crate::managed_agents::{ agent_env::baked_build_env, config_bridge::read_goose_file_config, discovery::{ - classify_runtime, codex_adapter_availability, find_command, known_acp_runtime, - resolve_command, KnownAcpRuntime, + classify_runtime, find_command, known_acp_runtime, resolve_probe_binary, KnownAcpRuntime, }, env_vars::merged_user_env, global_config::GlobalAgentConfig, @@ -504,38 +502,26 @@ fn cli_login_requirements( .iter() .find_map(|cmd| find_command(cmd).map(|path| (*cmd, path))); - // Check whether the underlying CLI itself (e.g. "claude", "codex") is on PATH. + // Check whether the underlying CLI is on PATH — only set for runtimes + // whose CLI is a separate install (not the bundled claude/codex bridges, + // which vendor their own); it distinguishes AdapterMissing from + // NotInstalled below. let underlying_cli_found = runtime .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (availability, cmd, adapter_path) = + let (availability, _cmd, _adapter_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: if the adapter resolved as Available, probe the version. - // An adapter with major version < 1 is the deprecated package and must be - // treated as outdated (blocks login probe — the agent can't reach the relay). - // Guard on `cmd == "codex-acp"` to match the discovery path and avoid - // probing when the runtime resolves via an alias command. - let availability = if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && cmd.as_deref() == Some("codex-acp") - { - adapter_path - .as_deref() - .map(|path_str| codex_adapter_availability(Path::new(path_str))) - .unwrap_or(availability) - } else { - availability - }; - match availability { AcpAvailabilityStatus::Available => { - // Both adapter and CLI are present — probe login status. - // Resolve via the full login-shell PATH so the probe works in a - // packaged macOS DMG where the GUI PATH lacks npm/homebrew. - let Some(binary_path) = resolve_command(probe_args[0]) else { + // Adapter present — probe login status against the bundled CLI + // when the app ships one (the same pinned binary agent sessions + // run), else the user's install resolved via the full login-shell + // PATH so the probe works in a packaged macOS DMG where the GUI + // PATH lacks npm/homebrew. + let Some(binary_path) = resolve_probe_binary(probe_args[0]) else { // Unexpectedly not resolvable (race or PATH edge case). return vec![Requirement::CliLogin { probe_args: probe_args.iter().map(|s| s.to_string()).collect(), @@ -1059,10 +1045,11 @@ mod tests { } #[test] - fn cli_login_requirements_cli_missing_emits_cli_missing() { + fn cli_login_requirements_probe_runs_even_without_underlying_cli() { // Adapter present (use the running test binary as a portable stand-in), - // underlying CLI absent. - // → CliMissing state → no probe run → CliLogin{CliMissing}. + // underlying CLI absent. The retired CliMissing gate would have skipped + // the probe; now the adapter alone means Available, the probe runs + // (here: exit 0 → logged in) and no requirement is emitted. let exe = present_binary_str(); let rt = make_cli_runtime( static_commands(vec![exe]), // adapter found via absolute path @@ -1070,19 +1057,9 @@ mod tests { ); let reqs = cli_login_requirements(&[exe, "--list"], "install the CLI", &rt); assert!( - !reqs.is_empty(), - "CLI missing must produce a CliLogin requirement" + reqs.is_empty(), + "adapter present must probe login regardless of the user CLI; got {reqs:?}" ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::CliMissing, - "adapter present, CLI absent → CliMissing" - ); - } } #[test] @@ -1131,152 +1108,6 @@ mod tests { } } - // ── codex readiness version gate ─────────────────────────────────────── - - /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. - /// `adapter_commands` are the exact strings passed to `find_command` — use - /// `&["codex-acp"]` when the binary is on PATH, or `&[]` - /// when resolving via absolute path. `underlying_cli` is a portable - /// stand-in so the adapter is not misclassified as `CliMissing`. - fn make_codex_runtime( - adapter_commands: &'static [&'static str], - underlying_cli: Option<&'static str>, - ) -> KnownAcpRuntime { - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: adapter_commands, - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli, - cli_install_commands: &[], - adapter_install_commands: &[], - install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - config_file_path: None, - config_file_format: None, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: None, - auth_probe_args: None, - } - } - - /// Build a temp dir containing a `codex-acp` script with the given body, - /// prepend it to PATH, and clear the resolve cache. Returns the temp dir - /// and the original PATH string for restoration. - #[cfg(unix)] - fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("create temp dir"); - let bin = dir.path().join("codex-acp"); - std::fs::write(&bin, script_body).expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod script"); - - let original_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{}:{}", dir.path().display(), original_path); - std::env::set_var("PATH", &new_path); - crate::managed_agents::clear_resolve_cache(); - - (dir, original_path) - } - - /// Restore PATH and clear the resolve cache after a PATH-mutating test. - #[cfg(unix)] - fn restore_path(original: &str) { - std::env::set_var("PATH", original); - crate::managed_agents::clear_resolve_cache(); - } - - /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, - /// login probe skipped. - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); - let exe = present_binary_str(); - // underlying_cli = running test binary (always present, never probed) - let rt = make_codex_runtime(&["codex-acp"], Some(exe)); - let reqs = cli_login_requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "outdated codex adapter must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "0.x codex adapter must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - - /// Codex readiness: adapter exits 0 but output is not a parseable version - /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); - let exe = present_binary_str(); - let rt = make_codex_runtime(&["codex-acp"], Some(exe)); - let reqs = cli_login_requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "garbage version output must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "unparseable version output must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - // ── custom/unknown command ───────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 8e2f866b7e..703f0d00f3 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -2,7 +2,8 @@ use std::path::Path; use crate::managed_agents::runtime::build_augmented_path; -/// Build the augmented PATH for CLI probes, including nvm's default Node.js +/// Build the augmented PATH for CLI probes, including the bundled ACP bridge +/// tools dir (pinned bridges shipped with the app) and nvm's default Node.js /// bin directory so `#!/usr/bin/env node` shims (e.g. codex-acp) resolve. pub(crate) fn augmented_path() -> Option { let home = dirs::home_dir(); @@ -10,6 +11,7 @@ pub(crate) fn augmented_path() -> Option { .as_deref() .and_then(crate::managed_agents::find_nvm_default_bin); build_augmented_path( + crate::managed_agents::acp_tools::bundled_acp_tools_dir(), home, std::env::current_exe() .ok() diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 44856c206a..9d7663c0df 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1321,29 +1321,18 @@ pub fn build_managed_agent_summary( // at launch; recompute from current disk state and flag drift. Only a // tracked live process can drift — stopped agents spawn fresh, and // adopted (runtime_pid-only) processes have no stamped hash to compare. - // - // Additionally, for runtimes with an adapter version gate (codex only), - // check whether the cached adapter availability has drifted from the value - // stamped at spawn. This catches out-of-band adapter changes (manual - // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The - // cache is read-only here — no subprocess is spawned. let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| { use tauri::Manager; let state = app.state::(); let global_for_hash = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let hash_drift = runtime.spawn_config_hash + runtime.spawn_config_hash != crate::managed_agents::spawn_hash::spawn_config_hash( record, personas, &crate::relay::relay_ws_url_with_override(&state), &global_for_hash, - ); - let availability_drift = super::availability_drift( - runtime.adapter_availability.as_ref(), - super::adapter_availability_cached(), - ); - hash_drift || availability_drift + ) }); // Resolve the effective harness the same way, then derive args/mcp from it, @@ -1539,6 +1528,7 @@ pub fn spawn_agent_child( }; // Augment PATH for DMG launches so child processes can find: + // - bundled ACP bridge tools (pinned versions shipped with the app) // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) @@ -1547,6 +1537,7 @@ pub fn spawn_agent_child( .as_deref() .and_then(super::find_nvm_default_bin); let augmented_path = build_augmented_path( + super::acp_tools::bundled_acp_tools_dir(), dirs::home_dir(), std::env::current_exe() .ok() @@ -1881,20 +1872,6 @@ pub fn spawn_agent_child( let spawn_config_hash = super::spawn_hash::spawn_config_hash(record, &personas, &effective_relay_url, &global); - // Stamp the adapter availability for runtimes with a version gate (codex - // only). The summary builder compares this against the current cached value - // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). - // Non-codex runtimes get `None` — nothing changes for them. - // When the cache is cold (e.g. Doctor just installed and cleared the cache), - // `adapter_availability_cached()` returns `None`, so the stamp is `None` and - // the drift check is skipped until discovery warms the cache — preventing a - // false restart badge immediately after auto-restart. - let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { - super::adapter_availability_cached() - } else { - None - }; - let _ = super::write_agent_pid_file(app, &record.pubkey, child.id()); // Windows: assign the harness to a Job Object so its whole tree dies with @@ -1905,7 +1882,6 @@ pub fn spawn_agent_child( log_path, spawn_config_hash, spawned_setup_mode, - spawned_adapter_availability, &record.name, )); #[cfg(not(windows))] @@ -1914,7 +1890,6 @@ pub fn spawn_agent_child( log_path, spawn_config_hash, setup_mode: spawned_setup_mode, - adapter_availability: spawned_adapter_availability, }) } diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index af2d2e96da..4db6e62dbd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -5,10 +5,12 @@ use std::path::PathBuf; /// Assemble the augmented `PATH` for a launched managed-agent child process. /// /// Concatenates, in priority order: -/// 1. `/.local/bin` — bundled CLI symlink -/// 2. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) -/// 3. exe parent dir — DMG sidecars under `Contents/MacOS/` -/// 4. user's login-shell `PATH` — runtimes like node/python from other managers +/// 1. `bundled_acp_bin` — bundled ACP bridge tools dir, so pinned bridges +/// shipped with the app win over user-installed copies +/// 2. `/.local/bin` — bundled CLI symlink +/// 3. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) +/// 4. exe parent dir — DMG sidecars under `Contents/MacOS/` +/// 5. user's login-shell `PATH` — runtimes like node/python from other managers /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -17,12 +19,16 @@ use std::path::PathBuf; /// guards against, which left managed agents unable to find `buzz`. Returns /// `None` only when no entries exist. pub(in crate::managed_agents) fn build_augmented_path( + bundled_acp_bin: Option, home: Option, exe_parent: Option, shell_path: Option, nvm_bin: Option, ) -> Option { let mut parts: Vec = Vec::new(); + if let Some(bundled) = bundled_acp_bin { + parts.push(bundled); + } if let Some(home) = home { parts.push(home.join(".local").join("bin")); } @@ -35,11 +41,37 @@ pub(in crate::managed_agents) fn build_augmented_path( if let Some(shell_path) = shell_path { parts.extend(std::env::split_paths(&shell_path)); } - if parts.is_empty() { + join_paths_best_effort(parts) +} + +/// Join PATH entries, degrading to a best-effort join when a single entry +/// embeds the platform separator (legal in macOS paths): `join_paths` rejects +/// the whole list for one such entry, which would collapse the entire +/// augmented `PATH` to `None` and hand child processes a bare GUI PATH. Drop +/// the un-joinable entries (logging each) instead of erasing every search +/// path. Returns `None` only when no joinable entries exist. +fn join_paths_best_effort(mut paths: Vec) -> Option { + if paths.is_empty() { return None; } // join_paths uses the platform separator (':' on Unix, ';' on Windows). - std::env::join_paths(parts) + if let Ok(joined) = std::env::join_paths(&paths) { + return Some(joined.to_string_lossy().into_owned()); + } + paths.retain(|path| { + let joinable = std::env::join_paths(std::iter::once(path)).is_ok(); + if !joinable { + eprintln!( + "buzz-desktop: dropping un-joinable PATH entry: {}", + path.display() + ); + } + joinable + }); + if paths.is_empty() { + return None; + } + std::env::join_paths(paths) .ok() .map(|s| s.to_string_lossy().into_owned()) } @@ -57,6 +89,7 @@ mod tests { // it and the whole augmented PATH collapses to None (managed agents then // lose `buzz`). let result = build_augmented_path( + None, Some(PathBuf::from("/home/agent")), Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()), @@ -73,20 +106,46 @@ mod tests { #[test] fn none_when_no_inputs() { - assert_eq!(build_augmented_path(None, None, None, None), None); + assert_eq!(build_augmented_path(None, None, None, None, None), None); } #[cfg(unix)] #[test] fn shell_path_only() { - let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string()), None); + let result = + build_augmented_path(None, None, None, Some("/usr/bin:/bin".to_string()), None); assert_eq!(result.as_deref(), Some("/usr/bin:/bin")); } + #[cfg(unix)] + #[test] + fn bundled_acp_bin_is_highest_priority_segment() { + let result = build_augmented_path( + Some(PathBuf::from( + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin", + )), + Some(PathBuf::from("/home/user")), + Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), + Some("/usr/bin:/bin".to_string()), + Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")), + ); + assert_eq!( + result.as_deref(), + Some( + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin:\ +/home/user/.local/bin:\ +/home/user/.nvm/versions/node/v20.0.0/bin:\ +/Applications/Buzz.app/Contents/MacOS:\ +/usr/bin:/bin" + ), + ); + } + #[cfg(unix)] #[test] fn nvm_bin_inserted_after_local_bin_before_exe_parent() { let result = build_augmented_path( + None, Some(PathBuf::from("/home/user")), Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), Some("/usr/bin:/bin".to_string()), @@ -107,6 +166,7 @@ mod tests { #[test] fn nvm_bin_none_does_not_add_segment() { let result = build_augmented_path( + None, Some(PathBuf::from("/home/user")), Some(PathBuf::from("/usr/local/bin")), None, @@ -117,4 +177,38 @@ mod tests { Some("/home/user/.local/bin:/usr/local/bin"), ); } + + #[cfg(unix)] + #[test] + fn unjoinable_entry_is_dropped_instead_of_emptying_path() { + // A dir embedding the separator (legal in macOS paths) can't be joined + // into PATH; it must be dropped, not collapse the whole augmented PATH + // to None. + let result = build_augmented_path( + Some(PathBuf::from("/weird:dir/bin")), + None, + None, + Some("/shell/bin:/user/bin".to_string()), + None, + ); + let path = result.expect("PATH should survive an un-joinable entry"); + let paths: Vec<_> = std::env::split_paths(&path).collect(); + assert_eq!( + paths, + vec![PathBuf::from("/shell/bin"), PathBuf::from("/user/bin")] + ); + } + + #[cfg(unix)] + #[test] + fn none_when_all_entries_unjoinable() { + let result = build_augmented_path( + Some(PathBuf::from("/weird:dir/bin")), + None, + None, + None, + None, + ); + assert_eq!(result, None); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 9c1c5cc677..ea3cb11e02 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -431,12 +431,6 @@ pub struct ManagedAgentProcess { /// `install_acp_runtime` to target only stuck agents for auto-restart, /// excluding healthy in-pool agents. pub setup_mode: bool, - /// Adapter availability status stamped at spawn time for runtimes with a - /// version gate (currently codex only; `None` for all others). Runtime-only - /// — never persisted. The summary builder compares this against the current - /// cached availability and sets `needs_restart` on drift, catching out-of- - /// band adapter changes that Phase-1 auto-restart doesn't cover. - pub adapter_availability: Option, /// Win32 Job Object owning the harness + its entire process tree. Closing /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole /// tree — the Windows mirror of the Unix process-group teardown. `None` @@ -525,14 +519,14 @@ pub struct ManagedAgentLogResponse { pub log_path: String, } +/// The retired `CliMissing` variant (adapter present, user CLI absent) is +/// gone: the bundled bridges vendor their own CLI, so a resolving adapter is +/// available regardless of user installs — sign-in state is `AuthStatus`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpAvailabilityStatus { Available, AdapterMissing, - /// Adapter binary is present but is from the deprecated package (< 1.0). Reinstall required. - AdapterOutdated, - CliMissing, NotInstalled, } @@ -566,6 +560,10 @@ pub struct AcpRuntimeCatalogEntry { pub availability: AcpAvailabilityStatus, pub command: Option, pub binary_path: Option, + /// true when `binary_path` is the pinned bridge bundled with the app + /// rather than a user install. The Doctor UI says "Bundled with Buzz" + /// instead of rendering the resource-dir path. + pub adapter_bundled: bool, pub default_args: Vec, pub mcp_command: Option, pub install_hint: String, @@ -573,9 +571,6 @@ pub struct AcpRuntimeCatalogEntry { /// true when at least one automated install step is available pub can_auto_install: bool, pub underlying_cli_path: Option, - /// true when an npm adapter step is pending but Node.js / npm is absent. - /// The UI hides the Install button and shows a Node.js install callout. - pub node_required: bool, /// Login/authentication status for CLI-based runtimes. pub auth_status: AuthStatus, /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 25b0ad9b8f..ccb11ec123 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -59,6 +59,7 @@ "binaries/git-credential-nostr", "binaries/buzz" ], + "resources": ["resources/acp"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 93edb30d12..cd2bd81567 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -10,6 +10,7 @@ import { channelsQueryKey } from "@/features/channels/hooks"; import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { + checkAcpNodeRuntime, createManagedAgent, deleteManagedAgent, discoverAcpRuntimes, @@ -91,6 +92,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; +export const nodeRuntimeCheckQueryKey = ["node-runtime-check"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const; @@ -148,6 +150,15 @@ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { }); } +export function useNodeRuntimeCheckQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: nodeRuntimeCheckQueryKey, + queryFn: checkAcpNodeRuntime, + staleTime: 60_000, + }); +} + export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { const query = useAcpRuntimesQuery(options); const available = React.useMemo( diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index e6cedca297..17c47ff2cd 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -552,11 +552,7 @@ export function AgentDefinitionDialog({

{selectedRuntime.availability === "adapter_missing" ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` - : selectedRuntime.availability === "adapter_outdated" - ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` - : `${selectedRuntime.label} is not installed.`}{" "} + : `${selectedRuntime.label} is not installed.`}{" "} Visit Settings > Doctor to set it up.

) : null; diff --git a/desktop/src/features/agents/ui/personaDialogPickers.test.mjs b/desktop/src/features/agents/ui/personaDialogPickers.test.mjs index 5cc49a9896..de81a57264 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogPickers.test.mjs @@ -97,7 +97,7 @@ test("getDefaultPersonaRuntime falls back to goose when buzz-agent is unavailabl test("getDefaultPersonaRuntime returns first available when neither buzz-agent nor goose is available", () => { const runtimes = [ makeRuntime("buzz-agent", "adapter_missing"), - makeRuntime("goose", "cli_missing"), + makeRuntime("goose", "not_installed"), makeRuntime("claude"), ]; const result = getDefaultPersonaRuntime(runtimes); @@ -111,7 +111,7 @@ test("getDefaultPersonaRuntime returns null for an empty list", () => { test("getDefaultPersonaRuntime returns null when no runtime is available", () => { const runtimes = [ makeRuntime("buzz-agent", "not_installed"), - makeRuntime("goose", "cli_missing"), + makeRuntime("goose", "adapter_missing"), ]; assert.equal(getDefaultPersonaRuntime(runtimes), null); }); @@ -168,11 +168,7 @@ test("getPersonaModelOptions for buzz-agent with no provider returns default mod // is non-null (so the UI surfaces the reason) for each unavailability reason. test("formatModelDiscoveryErrorStatus returns a non-null status for runtime unavailable errors", () => { - for (const availability of [ - "adapter_missing", - "cli_missing", - "not_installed", - ]) { + for (const availability of ["adapter_missing", "not_installed"]) { const status = formatModelDiscoveryErrorStatus( new Error(`Runtime not available: ${availability}`), "anthropic", diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index f7d53213a7..fb9d0baacc 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -386,13 +386,9 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { const suffix = runtime.availability === "adapter_missing" ? " (adapter missing)" - : runtime.availability === "adapter_outdated" - ? " (adapter outdated)" - : runtime.availability === "cli_missing" - ? " (CLI missing)" - : runtime.availability === "not_installed" - ? " (not installed)" - : ""; + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } @@ -402,14 +398,10 @@ function runtimeAvailabilitySortRank( switch (availability) { case "available": return 0; - case "cli_missing": - return 1; case "not_installed": - return 2; + return 1; case "adapter_missing": - return 3; - case "adapter_outdated": - return 3; + return 2; } } diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 603643a6bf..ede6aa2501 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -353,42 +353,6 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } - if (runtime.availability === "adapter_outdated") { - return ( - <> -

- ACP adapter detected but outdated — reinstall required. -

-

- This updates the machine-global{" "} - codex-acp{" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - ); - } - - if (runtime.availability === "cli_missing") { - return ( - <> -

- ACP adapter detected; CLI missing. -

-

- {runtime.installHint} -

- - ); - } - return ( <>

diff --git a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs index 66b11b6be8..33eb195ddb 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs +++ b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs @@ -19,7 +19,6 @@ function makeRuntime(overrides = {}) { installInstructionsUrl: "https://example.com", canAutoInstall: false, underlyingCliPath: null, - nodeRequired: false, loginHint: null, ...overrides, }; diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs index cf21b6f6a8..2fea5986c5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -108,7 +108,7 @@ test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime ch input: updateInput({ runtime: "claude" }), managedAgent: agent(), previousPersona: persona({ runtime: "goose" }), - runtimes: [runtime({ availability: "cli_missing", command: null })], + runtimes: [runtime({ availability: "not_installed", command: null })], }), "Claude Code is not available. Install it before saving this linked agent.", ); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b588fdc5ac..7e78409b77 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -14,9 +14,14 @@ import { useAcpRuntimesQuery, useInstallAcpRuntimeMutation, useGitBashPrerequisiteQuery, + useNodeRuntimeCheckQuery, } from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; -import type { AcpRuntimeCatalogEntry, AuthStatus } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + AuthStatus, + NodeRuntimeCheck, +} from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -33,10 +38,6 @@ function StatusIcon({ return ; case "adapter_missing": return ; - case "adapter_outdated": - return ; - case "cli_missing": - return ; case "not_installed": return ; } @@ -82,7 +83,7 @@ function InstallActions({ onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { - const showInstall = runtime.canAutoInstall && !runtime.nodeRequired; + const showInstall = runtime.canAutoInstall; return (

@@ -116,48 +117,6 @@ function InstallActions({ ); } -/** - * Node.js callout when required, or the install actions when it is not. - * Used for both `adapter_missing` and `not_installed` availability states. - * The `cli_missing` branch is intentionally excluded — its install path does - * not involve npm, so no Node.js gate applies. - */ -function NodeRequiredOrInstall({ - hasError, - isInstalling, - onInstall, - runtime, -}: { - hasError: boolean; - isInstalling: boolean; - onInstall: () => void; - runtime: AcpRuntimeCatalogEntry; -}) { - if (runtime.nodeRequired) { - return ( -

- Node.js is required to install this adapter.{" "} - - , then click Re-run. -

- ); - } - return ( - - ); -} - function RuntimeRow({ installError, installSuccess, @@ -177,9 +136,7 @@ function RuntimeRow({ "flex min-h-16 items-start gap-3 px-4 py-3 text-sm", runtime.availability === "available" ? "bg-background/60" - : runtime.availability === "adapter_missing" || - runtime.availability === "adapter_outdated" || - runtime.availability === "cli_missing" + : runtime.availability === "adapter_missing" ? "bg-amber-500/5" : "bg-muted/20", )} @@ -204,8 +161,9 @@ function RuntimeRow({ runtime.binaryPath ? ( <>

- Available via{" "} - {describeResolvedCommand(runtime.command, runtime.binaryPath)}. + {runtime.adapterBundled + ? "Bundled with Buzz." + : `Available via ${describeResolvedCommand(runtime.command, runtime.binaryPath)}.`}

{runtime.defaultArgs.length > 0 ? (

@@ -215,19 +173,12 @@ function RuntimeRow({

) : null} - {runtime.underlyingCliPath && - runtime.underlyingCliPath !== runtime.binaryPath ? ( -
-

- CLI:{" "} - {runtime.underlyingCliPath} -

-

- ACP adapter:{" "} - {runtime.binaryPath} -

-
- ) : ( + {/* The bundled bridge's resource-dir path is noise — the + "Bundled with Buzz." line above covers it. The + user-CLI path row is retired with the cli_missing gate: the + bundled bridges vendor their own CLI, so no runtime reports a + separate CLI path anymore. */} + {runtime.adapterBundled ? null : ( <>

{runtime.binaryPath} @@ -271,57 +222,6 @@ function RuntimeRow({

{runtime.installHint}

- - - ) : runtime.availability === "adapter_outdated" ? ( - <> -

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but it is from the deprecated package. Reinstall to enable relay - connectivity. -

-

- This updates the machine-global{" "} - - codex-acp - {" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - - ) : runtime.availability === "cli_missing" ? ( - <> -

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but the {runtime.label} CLI is not installed. -

-

- {runtime.installHint} -

{runtime.installHint}

- +
+ {check.status === "pass" ? ( + + ) : ( + + )} +
+ +
+

Node.js runtime

+

+ {check.message}. +

+ {check.nodePath ? ( +

+ {check.nodePath} +

+ ) : null} + {check.requirements.length > 0 ? ( +
    + {check.requirements.map((req) => ( +
  • + + {req.binary} + {" "} + requires Node.js {req.requirement} —{" "} + {req.verdict === "satisfied" + ? "satisfied" + : req.verdict === "unmet" + ? "unmet" + : "unknown"} +
  • + ))} +
+ ) : null} + {check.status === "warn" ? ( + + ) : null} +
+
+ ); +} + export function DoctorSettingsPanel() { const runtimesQuery = useAcpRuntimesQuery(); const gitBashQuery = useGitBashPrerequisiteQuery(); const runtimes = runtimesQuery.data ?? []; - const isRefreshing = runtimesQuery.isFetching; + const nodeRuntimeQuery = useNodeRuntimeCheckQuery(); + const isRefreshing = runtimesQuery.isFetching || nodeRuntimeQuery.isFetching; const installMutation = useInstallAcpRuntimeMutation(); const [installResults, setInstallResults] = React.useState< Record @@ -471,7 +439,7 @@ export function DoctorSettingsPanel() {
) : null}
-

Agent CLIs and ACP runtimes

+

AI agents

- Installation status of supported agent CLIs and their ACP - runtimes. + Whether each supported agent is installed and signed in.

{runtimesQuery.isLoading ? (
- Looking for ACP runtimes... + Checking for installed agents…
) : runtimes.length > 0 ? ( runtimes.map((runtime) => ( @@ -530,10 +498,14 @@ export function DoctorSettingsPanel() { )) ) : (
- No known ACP runtimes found. + No supported AI agents found on this computer.
)} + {nodeRuntimeQuery.data ? ( + + ) : null} + {runtimesQuery.error instanceof Error ? (

{runtimesQuery.error.message} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 1779e974fc..8ef0a54a8e 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -41,6 +41,8 @@ import type { CommandAvailability, InstallRuntimeResult, GitBashPrerequisite, + NodeRuntimeCheck, + NodeRuntimeRequirementVerdict, OpenDmInput, RuntimeConfigSurface, } from "@/shared/api/types"; @@ -223,18 +225,33 @@ export type RawAcpRuntimeCatalogEntry = { availability: AcpAvailabilityStatus; command: string | null; binary_path: string | null; + /** Always sent by the backend; optional so e2e fixtures can omit it. */ + adapter_bundled?: boolean; default_args: string[]; mcp_command: string | null; install_hint: string; install_instructions_url: string; can_auto_install: boolean; underlying_cli_path: string | null; - node_required: boolean; /** Tagged union with snake_case status values — same shape as `AuthStatus`. */ auth_status: AuthStatus; login_hint?: string; }; +export type RawNodeRuntimeCheck = { + status: "pass" | "warn"; + message: string; + manifest_path: string; + node_path: string | null; + node_version: string | null; + requirements: { + binary: string; + requirement: string; + verdict: NodeRuntimeRequirementVerdict; + }[]; + fix_url: string; +}; + export type RawInstallStepResult = { step: string; command: string; @@ -903,18 +920,30 @@ function fromRawAcpRuntimeCatalogEntry( availability: entry.availability, command: entry.command, binaryPath: entry.binary_path, + adapterBundled: entry.adapter_bundled ?? false, defaultArgs: entry.default_args, mcpCommand: entry.mcp_command, installHint: entry.install_hint, installInstructionsUrl: entry.install_instructions_url, canAutoInstall: entry.can_auto_install, underlyingCliPath: entry.underlying_cli_path, - nodeRequired: entry.node_required, authStatus: entry.auth_status, loginHint: entry.login_hint ?? null, }; } +function fromRawNodeRuntimeCheck(raw: RawNodeRuntimeCheck): NodeRuntimeCheck { + return { + status: raw.status, + message: raw.message, + manifestPath: raw.manifest_path, + nodePath: raw.node_path, + nodeVersion: raw.node_version, + requirements: raw.requirements, + fixUrl: raw.fix_url, + }; +} + function fromRawInstallRuntimeResult( raw: RawInstallRuntimeResult, ): InstallRuntimeResult { @@ -1092,6 +1121,13 @@ export async function discoverAcpRuntimes(): Promise { ).map(fromRawAcpRuntimeCatalogEntry); } +export async function checkAcpNodeRuntime(): Promise { + const raw = await invokeTauri( + "check_acp_node_runtime", + ); + return raw ? fromRawNodeRuntimeCheck(raw) : null; +} + export async function installAcpRuntime( runtimeId: string, ): Promise { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3e3a9c69d8..e009f19f04 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -548,11 +548,14 @@ export type GitBashPrerequisite = { installHint: string; }; +/** + * The retired "cli_missing" state (adapter present, user CLI absent) is gone: + * the bundled bridges vendor their own CLI, so a resolving adapter is + * available regardless of user installs — sign-in state lives in AuthStatus. + */ export type AcpAvailabilityStatus = | "available" | "adapter_missing" - | "adapter_outdated" - | "cli_missing" | "not_installed"; /** Authentication/login status for a CLI-based ACP runtime. */ @@ -570,14 +573,14 @@ export type AcpRuntimeCatalogEntry = { availability: AcpAvailabilityStatus; command: string | null; binaryPath: string | null; + /** True when `binaryPath` is the bridge bundled with the Buzz app rather than a user install. */ + adapterBundled: boolean; defaultArgs: string[]; mcpCommand: string | null; installHint: string; installInstructionsUrl: string; canAutoInstall: boolean; underlyingCliPath: string | null; - /** True when an npm adapter step is pending but Node.js / npm is absent. */ - nodeRequired: boolean; /** Login/auth status for CLI-based runtimes. */ authStatus: AuthStatus; /** Hint for completing authentication; null when not applicable or already logged in. */ @@ -591,6 +594,29 @@ export type AcpRuntime = AcpRuntimeCatalogEntry & { binaryPath: string; }; +export type NodeRuntimeRequirementVerdict = "satisfied" | "unmet" | "unknown"; + +export type NodeRuntimeRequirement = { + binary: string; + requirement: string; + verdict: NodeRuntimeRequirementVerdict; +}; + +/** + * Doctor check for the bundled ACP bridges' Node.js runtime requirement + * (`check_acp_node_runtime`). Null when the app bundles no npm-sourced + * bridges — the Doctor panel hides the section entirely. + */ +export type NodeRuntimeCheck = { + status: "pass" | "warn"; + message: string; + manifestPath: string; + nodePath: string | null; + nodeVersion: string | null; + requirements: NodeRuntimeRequirement[]; + fixUrl: string; +}; + export type InstallStepResult = { step: string; command: string; diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs index f616d78298..c38e95aacc 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -79,10 +79,11 @@ test("extractConfigNudge parses cli_login requirement", () => { assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); }); -test("extractConfigNudge parses cli_login with adapter_outdated availability", () => { - // Backend emits availability: "adapter_outdated" when codex-acp is old (0.16.x). - // isConfigNudgeRequirement must accept this literal — regression test for the - // validator omission that caused it to be silently rejected. +test("extractConfigNudge rejects retired adapter_outdated availability", () => { + // The codex version gate was retired when the ACP bridges started shipping + // bundled with the app — "adapter_outdated" is no longer a valid + // availability. Stale nudge JSON emitted by an older app version must not + // parse into a card the current UI has no rendering for. const payload = { agent_name: "Codex", agent_pubkey: CODEX_PUBKEY, @@ -95,10 +96,34 @@ test("extractConfigNudge parses cli_login with adapter_outdated availability", ( }, ], }; - assert.deepEqual( + assert.equal( extractConfigNudge(withSentinel("prose", payload)), - payload, - "adapter_outdated availability must be accepted by the validator", + null, + "retired adapter_outdated availability must be rejected by the validator", + ); +}); + +test("extractConfigNudge rejects retired cli_missing availability", () => { + // The cli_missing gate was retired when auth probes moved to the CLIs + // vendored inside the bundled bridges — a user CLI install no longer gates + // availability. Stale nudge JSON emitted by an older app version must not + // parse into a card the current UI has no rendering for. + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["claude", "auth", "status"], + setup_copy: "install the Claude CLI", + availability: "cli_missing", + }, + ], + }; + assert.equal( + extractConfigNudge(withSentinel("prose", payload)), + null, + "retired cli_missing availability must be rejected by the validator", ); }); diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 7893236c28..5ceb8314d6 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,9 +32,7 @@ export type ConfigNudgeRequirement = * Determines which message and CTA the nudge card shows: * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing - * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required - * - "cli_missing" → ACP adapter installed but CLI missing - * - "not_installed" → neither adapter nor CLI found + * - "not_installed" → no adapter found */ availability: AcpAvailabilityStatus; } @@ -140,10 +138,11 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { Array.isArray(r.probe_args) && r.probe_args.every((a) => typeof a === "string") && typeof r.setup_copy === "string" && + // Retired literals ("adapter_outdated", "cli_missing") emitted by + // older app versions are rejected here so stale nudge JSON cannot + // render a card the current UI has no branch for. (r.availability === "available" || r.availability === "adapter_missing" || - r.availability === "adapter_outdated" || - r.availability === "cli_missing" || r.availability === "not_installed") ); case "git_bash": diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index 4be3e63980..b7ebd6ff35 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -103,12 +103,8 @@ function cliLoginMessage( switch (req.availability) { case "not_installed": return `${harness} isn't installed`; - case "cli_missing": - return `${harness} CLI is missing`; case "adapter_missing": return `${harness} ACP adapter isn't installed`; - case "adapter_outdated": - return `${harness} ACP adapter is outdated — reinstall required`; case "available": // Tooling is present but authentication is needed — fall back to // the backend-supplied copy which has the exact login command. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 41054deab2..6a4a316821 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -43,6 +43,7 @@ import { import type { RawAcpRuntimeCatalogEntry, RawInstallRuntimeResult, + RawNodeRuntimeCheck, } from "@/shared/api/tauri"; type TestIdentity = { @@ -109,6 +110,9 @@ type E2eConfig = { mock?: { acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; activePersonaIds?: string[]; + /** Bundled-bridge Node.js runtime doctor check; null (default) hides the + * Doctor panel section, matching an app with no npm-sourced bridges. */ + nodeRuntimeCheck?: RawNodeRuntimeCheck | null; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. * Call N returns results[N]; when exhausted the last entry repeats. @@ -6329,7 +6333,6 @@ async function handleDiscoverAcpRuntimes( install_instructions_url: "https://block.github.io/goose/", can_auto_install: true, underlying_cli_path: null, - node_required: false, auth_status: { status: "not_applicable" }, login_hint: undefined, }, @@ -6347,7 +6350,6 @@ async function handleDiscoverAcpRuntimes( "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", can_auto_install: true, underlying_cli_path: "/usr/local/bin/claude", - node_required: false, auth_status: { status: "unknown" }, login_hint: undefined, }, @@ -6365,7 +6367,6 @@ async function handleDiscoverAcpRuntimes( install_instructions_url: "https://github.com/openai/codex", can_auto_install: false, underlying_cli_path: null, - node_required: false, auth_status: { status: "unknown" }, login_hint: undefined, }, @@ -6382,7 +6383,6 @@ async function handleDiscoverAcpRuntimes( install_instructions_url: "https://github.com/block/buzz", can_auto_install: false, underlying_cli_path: null, - node_required: false, auth_status: { status: "not_applicable" }, login_hint: undefined, }, @@ -8792,6 +8792,8 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); + case "check_acp_node_runtime": + return activeConfig?.mock?.nodeRuntimeCheck ?? null; case "install_acp_runtime": return handleInstallAcpRuntime( payload as { runtimeId?: string }, diff --git a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts index 9bd2732175..e0ab3ffe43 100644 --- a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts @@ -257,44 +257,7 @@ test.describe("doctor CTA nudge card screenshots", () => { }); }); - /** - * 04 — cli_missing state: ACP adapter present but underlying CLI absent. - * Shows "claude CLI is missing" copy. - */ - test("04-cli-login-cli-missing-state", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT_PUBKEY, - name: AGENT_NAME, - status: "stopped" as const, - channelNames: ["general"], - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - - const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ - { - surface: "cli_login", - probe_args: ["claude"], - setup_copy: "install the Claude CLI", - availability: "cli_missing", - }, - ]); - - await injectNudgeAndNavigate(page, content); - - const card = page.locator("[data-config-nudge]").last(); - await expect(card).toBeVisible({ timeout: 10_000 }); - await expect(card.getByText(/CLI is missing/)).toBeVisible(); - - await card.scrollIntoViewIfNeeded(); - await settleAnimations(page); - - await card.screenshot({ - path: `${SHOTS}/04-cli-login-cli-missing-state.png`, - }); - }); + // The former 04-cli-login-cli-missing-state test retired with the + // cli_missing gate: the validator now rejects that availability literal, + // so no card renders for it (see configNudge.test.mjs). }); diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index a1aed04900..1c462e53ea 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -25,7 +25,6 @@ const GOOSE_AVAILABLE = { install_instructions_url: "https://block.github.io/goose/", can_auto_install: false, underlying_cli_path: null, - node_required: false, auth_status: { status: "not_applicable" }, }; @@ -43,13 +42,15 @@ const BUZZ_AGENT_AVAILABLE = { install_instructions_url: "https://github.com/block/buzz", can_auto_install: false, underlying_cli_path: null, - node_required: false, auth_status: { status: "not_applicable" }, }; /** * Claude available and logged in — used as a neutral entry when claude is not * the runtime under test, and as the base for the auth states being tested. + * No `underlying_cli_path` and no auto-install: the bundled bridge vendors + * its own CLI, so the backend reports neither for claude since the + * cli_missing gate was retired. */ const CLAUDE_AVAILABLE_LOGGED_IN = { id: "claude", @@ -63,15 +64,14 @@ const CLAUDE_AVAILABLE_LOGGED_IN = { install_hint: "", install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - can_auto_install: true, - underlying_cli_path: "/usr/local/bin/claude", - node_required: false, + can_auto_install: false, + underlying_cli_path: null, auth_status: { status: "logged_in" }, }; /** - * Codex not-installed base — tweak `availability`, `auth_status`, and - * `node_required` in each test as needed. + * Codex not-installed base — tweak `availability` and `auth_status` in each + * test as needed. */ const CODEX_NOT_INSTALLED = { id: "codex", @@ -86,7 +86,6 @@ const CODEX_NOT_INSTALLED = { install_instructions_url: "https://github.com/zed-industries/codex-acp", can_auto_install: true, underlying_cli_path: null, - node_required: false, auth_status: { status: "unknown" }, }; @@ -150,7 +149,6 @@ test.describe("Doctor panel state screenshots", () => { availability: "available", command: "codex-acp", binary_path: "/usr/local/bin/codex-acp", - underlying_cli_path: "/usr/local/bin/codex", auth_status: { status: "logged_out" }, login_hint: "Run `codex login` to authenticate.", }, @@ -204,43 +202,12 @@ test.describe("Doctor panel state screenshots", () => { await row.screenshot({ path: `${SHOTS}/03-auth-config-error.png` }); }); - /** - * 04 — adapter_missing runtime with node_required: true: the amber "Node.js - * is required…" callout replaces the Install button so the user cannot - * inadvertently trigger a doomed npm install. + /* + * 04-node-required retired with the per-row `node_required` install gate: + * no runtime carries npm adapter install commands anymore, so the amber + * "Node.js is required…" callout has no trigger. The Node.js requirement + * for the bundled bridges is covered by 07-node-runtime-warn below. */ - test("04-node-required", async ({ page }) => { - await installMockBridge(page, { - acpRuntimesCatalog: [ - GOOSE_AVAILABLE, - CLAUDE_AVAILABLE_LOGGED_IN, - { - ...CODEX_NOT_INSTALLED, - availability: "adapter_missing", - underlying_cli_path: "/usr/local/bin/codex", - node_required: true, - install_hint: - "Install the Codex ACP adapter: npm install -g @zed-industries/codex-acp", - }, - BUZZ_AGENT_AVAILABLE, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); - - const row = page.getByTestId("doctor-runtime-codex"); - await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Node.js is required"); - // Exact-name match so "Install Node.js" (inside the callout) is not counted. - await expect( - row.getByRole("button", { name: "Install", exact: true }), - ).toHaveCount(0); - - await row.scrollIntoViewIfNeeded(); - await waitForAnimations(page); - await row.screenshot({ path: `${SHOTS}/04-node-required.png` }); - }); /** * 05 — a failed install renders a "Retry" button; clicking Retry succeeds. @@ -259,7 +226,6 @@ test.describe("Doctor panel state screenshots", () => { { ...CODEX_NOT_INSTALLED, can_auto_install: true, - node_required: false, }, BUZZ_AGENT_AVAILABLE, ], @@ -328,4 +294,159 @@ test.describe("Doctor panel state screenshots", () => { await waitForAnimations(page); await row.screenshot({ path: `${SHOTS}/05-retry-success.png` }); }); + + /** + * 06 — bundled-bridge Node.js runtime check passing: green section listing + * each bundled bridge's requirement as satisfied, no fix link. + */ + test("06-node-runtime-pass", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + nodeRuntimeCheck: { + status: "pass", + message: + "Node.js 22.11.0 satisfies the bundled ACP bridge requirements", + manifest_path: + "/Applications/Buzz.app/Contents/Resources/resources/acp/node-runtime.json", + node_path: "/opt/homebrew/bin/node", + node_version: "22.11.0", + requirements: [ + { + binary: "claude-agent-acp", + requirement: ">=18.0.0", + verdict: "satisfied", + }, + { binary: "codex-acp", requirement: ">=22", verdict: "satisfied" }, + ], + fix_url: "https://nodejs.org/en/download", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const section = page.getByTestId("doctor-node-runtime"); + await expect(section).toBeVisible({ timeout: 10_000 }); + await expect(section).toContainText("Node.js runtime"); + await expect(section).toContainText( + "Node.js 22.11.0 satisfies the bundled ACP bridge requirements", + ); + await expect(section).toContainText("claude-agent-acp"); + await expect(section).toContainText("codex-acp"); + await expect( + section.getByRole("button", { name: "Install Node.js" }), + ).toHaveCount(0); + + await section.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await section.screenshot({ path: `${SHOTS}/06-node-runtime-pass.png` }); + }); + + /** + * 07 — bundled-bridge Node.js runtime check warning (node too old): amber + * section with the unmet requirement and an "Install Node.js" fix link. + */ + test("07-node-runtime-warn", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + nodeRuntimeCheck: { + status: "warn", + message: + "Node.js 20.10.0 is too old for bundled ACP bridges: codex-acp needs Node.js >=22", + manifest_path: + "/Applications/Buzz.app/Contents/Resources/resources/acp/node-runtime.json", + node_path: "/usr/local/bin/node", + node_version: "20.10.0", + requirements: [ + { + binary: "claude-agent-acp", + requirement: ">=18.0.0", + verdict: "satisfied", + }, + { binary: "codex-acp", requirement: ">=22", verdict: "unmet" }, + ], + fix_url: "https://nodejs.org/en/download", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const section = page.getByTestId("doctor-node-runtime"); + await expect(section).toBeVisible({ timeout: 10_000 }); + await expect(section).toContainText("too old for bundled ACP bridges"); + await expect(section).toContainText("unmet"); + await expect( + section.getByRole("button", { name: "Install Node.js" }), + ).toBeVisible(); + + await section.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await section.screenshot({ path: `${SHOTS}/07-node-runtime-warn.png` }); + }); + + /** + * 08 — no bundled bridges (nodeRuntimeCheck omitted → null): the Doctor + * panel renders no Node.js runtime section at all. + */ + test("08-node-runtime-hidden-when-not-bundled", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [GOOSE_AVAILABLE, BUZZ_AGENT_AVAILABLE], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + await expect(page.getByTestId("doctor-runtime-goose")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("doctor-node-runtime")).toHaveCount(0); + }); + + /** + * 09 — available runtime whose adapter is the bridge bundled with the app: + * the row says "Bundled with Buzz" instead of rendering the + * resource-dir path, and no CLI path renders — the bundled bridge vendors + * its own CLI, so the user-CLI row retired with the cli_missing gate. + */ + test("09-bundled-adapter", async ({ page }) => { + const bundledPath = + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin/claude-agent-acp"; + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + { + ...CLAUDE_AVAILABLE_LOGGED_IN, + binary_path: bundledPath, + adapter_bundled: true, + }, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const row = page.getByTestId("doctor-runtime-claude"); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row).toContainText("Bundled with Buzz."); + await expect(row).not.toContainText("CLI:"); + await expect(row).not.toContainText(bundledPath); + await expect(row).not.toContainText("installed on PATH"); + + await row.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/09-bundled-adapter.png` }); + }); });