From 672b3379d5b4aeb73b8e11d4eecce382b743f15a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 3 Aug 2026 17:19:42 +1000 Subject: [PATCH 1/6] feat(staged): add inert managed Node.js runtime installer module Step 1 of the app-managed Node runtime port from Berd: land the managed Node install machinery with no call sites yet. - node-runtime.lock.json pins Node v24.11.0 for all 4 supported target triples with official SHASUMS256.txt checksums; regenerated by scripts/update-node-runtime-lock.mjs (`just bump-node-runtime`), and the committed pins were verified by regenerating them from Artifactory's nodejs mirror - src-tauri/src/managed_node.rs streams the pinned tarball download (90 MB cap, incremental SHA-256), validates archive entries against absolute and `..` paths before unpacking, extracts to a temp dir, and atomically swaps into ~/.staged/packages/node/// with .old rollback; the readiness probe requires `bin/node --version` to equal the pin and bin/npm to be present - installs and prunes take a cross-process flock on ~/.staged/packages/.lock on top of the in-process tokio mutex, since ~/.staged is shared by concurrently running Staged instances - prune_superseded_node_runtimes is a standalone fn never called from install, so shims execing a superseded runtime keep working until a fully successful reconcile epilogue prunes - new `no-block-npm-registry` cargo feature switches the download base from Block's Artifactory nodejs mirror to nodejs.org - packages_dir() helper in paths.rs roots the ~/.staged/packages tree Gates: just check-all passes (cargo fmt, clippy -D warnings, svelte typecheck, 537 Rust tests including 15 new managed_node tests covering lock parsing, archive-entry validation, the readiness probe, the file lock, and the standalone prune, frontend tests); the managed_node suite also passes under --features no-block-npm-registry. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/justfile | 4 + apps/staged/node-runtime.lock.json | 21 + .../scripts/update-node-runtime-lock.mjs | 148 +++ apps/staged/src-tauri/Cargo.lock | 2 + apps/staged/src-tauri/Cargo.toml | 9 + apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/managed_node.rs | 1055 +++++++++++++++++ apps/staged/src-tauri/src/paths.rs | 9 + 8 files changed, 1249 insertions(+) create mode 100644 apps/staged/node-runtime.lock.json create mode 100755 apps/staged/scripts/update-node-runtime-lock.mjs create mode 100644 apps/staged/src-tauri/src/managed_node.rs diff --git a/apps/staged/justfile b/apps/staged/justfile index 8e1f5bce3..5cc53272e 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -141,6 +141,10 @@ release version: bump-acp-tools *ARGS: node scripts/update-acp-tools-lock.mjs {{ ARGS }} +# Fetch official Node.js release checksums and update node-runtime.lock.json (e.g. `just bump-node-runtime v24.12.0`) +bump-node-runtime *ARGS: + node scripts/update-node-runtime-lock.mjs {{ ARGS }} + # ============================================================================ # Code Quality # ============================================================================ diff --git a/apps/staged/node-runtime.lock.json b/apps/staged/node-runtime.lock.json new file mode 100644 index 000000000..b4e593d8f --- /dev/null +++ b/apps/staged/node-runtime.lock.json @@ -0,0 +1,21 @@ +{ + "version": "v24.11.0", + "artifacts": { + "aarch64-apple-darwin": { + "filename": "node-v24.11.0-darwin-arm64.tar.gz", + "sha256": "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201" + }, + "aarch64-unknown-linux-gnu": { + "filename": "node-v24.11.0-linux-arm64.tar.gz", + "sha256": "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d" + }, + "x86_64-apple-darwin": { + "filename": "node-v24.11.0-darwin-x64.tar.gz", + "sha256": "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1" + }, + "x86_64-unknown-linux-gnu": { + "filename": "node-v24.11.0-linux-x64.tar.gz", + "sha256": "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd" + } + } +} diff --git a/apps/staged/scripts/update-node-runtime-lock.mjs b/apps/staged/scripts/update-node-runtime-lock.mjs new file mode 100755 index 000000000..044c6a9db --- /dev/null +++ b/apps/staged/scripts/update-node-runtime-lock.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const appRoot = path.resolve(import.meta.dirname, ".."); +const defaultLockFile = path.join(appRoot, "node-runtime.lock.json"); + +// Block's Artifactory `nodejs` repo is a read-through mirror of +// https://nodejs.org/dist with an identical path shape, so SHASUMS256.txt is +// the official Node.js checksum file either way. +const DEFAULT_BASE_URL = + "https://global.block-artifacts.com/artifactory/nodejs"; + +// The rust target triples Staged builds for (macOS release targets plus +// Linux local builds), mapped to the platform component of Node's release +// tarball names. +const TARGET_PLATFORMS = { + "aarch64-apple-darwin": "darwin-arm64", + "aarch64-unknown-linux-gnu": "linux-arm64", + "x86_64-apple-darwin": "darwin-x64", + "x86_64-unknown-linux-gnu": "linux-x64", +}; + +function usage() { + console.log(`Usage: scripts/update-node-runtime-lock.mjs [version] [--lock-file ] [--base-url ] + +Fetches the official SHASUMS256.txt for a Node.js release and rewrites +node-runtime.lock.json with the tarball pins for all supported targets. +Without a version argument, the currently locked version is re-resolved +(a checksum refresh). + +Supported targets: + ${Object.keys(TARGET_PLATFORMS).join("\n ")} + +Environment: + NODE_RUNTIME_LOCK_FILE lockfile path override +`); +} + +function normalizeVersion(value) { + const version = value.startsWith("v") ? value : `v${value}`; + if (!/^v\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid Node.js version: ${value}`); + } + return version; +} + +function parseArgs(argv) { + let version = null; + let lockFile = process.env.NODE_RUNTIME_LOCK_FILE ?? defaultLockFile; + let baseUrl = DEFAULT_BASE_URL; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } + if (arg === "--lock-file") { + const value = argv[++i]; + if (!value) throw new Error("--lock-file requires a value"); + lockFile = path.resolve(value); + continue; + } + if (arg === "--base-url") { + const value = argv[++i]; + if (!value) throw new Error("--base-url requires a value"); + baseUrl = value.replace(/\/+$/, ""); + continue; + } + if (arg.startsWith("-")) { + throw new Error(`Unknown argument: ${arg}`); + } + if (version !== null) { + throw new Error(`Unexpected extra argument: ${arg}`); + } + version = normalizeVersion(arg); + } + return { version, lockFile, baseUrl }; +} + +async function currentLockedVersion(lockFile) { + let raw; + try { + raw = await readFile(lockFile, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + return normalizeVersion(JSON.parse(raw).version); +} + +async function fetchShasums(baseUrl, version) { + const url = `${baseUrl}/${version}/SHASUMS256.txt`; + const response = await fetch(url, { + headers: { "User-Agent": "staged-node-runtime-lock" }, + }); + if (!response.ok) { + throw new Error( + `Fetch failed: ${url} (${response.status} ${response.statusText})`, + ); + } + const shasums = new Map(); + for (const line of (await response.text()).split("\n")) { + const match = line.match(/^([0-9a-f]{64})\s+(\S+)$/); + if (match) shasums.set(match[2], match[1]); + } + if (shasums.size === 0) { + throw new Error(`No checksums parsed from ${url}`); + } + return shasums; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const version = args.version ?? (await currentLockedVersion(args.lockFile)); + if (!version) { + throw new Error( + "No version given and no existing lockfile to refresh; pass a version (e.g. v24.11.0)", + ); + } + + const shasums = await fetchShasums(args.baseUrl, version); + const artifacts = {}; + for (const [target, platform] of Object.entries(TARGET_PLATFORMS)) { + const filename = `node-${version}-${platform}.tar.gz`; + const sha256 = shasums.get(filename); + if (!sha256) { + throw new Error( + `SHASUMS256.txt for ${version} has no entry for ${filename}`, + ); + } + artifacts[target] = { filename, sha256 }; + } + + await writeFile( + args.lockFile, + `${JSON.stringify({ version, artifacts }, null, 2)}\n`, + ); + console.log( + `Updated ${path.relative(process.cwd(), args.lockFile)} to Node.js ${version}`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c1024ff2e..48ee386de 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "builderbot-actions", "dirs", "doctor", + "flate2", "git-diff", "hex", "include_dir", @@ -36,6 +37,7 @@ dependencies = [ "sha2 0.11.0", "strip-ansi-escapes", "subtle", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 54c1125f1..e68ea5ac6 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -81,6 +81,15 @@ resvg = "0.47" usvg = "0.47" tiny-skia = "0.12" +# Managed Node.js runtime: tarball extraction for runtime installs +tar = "0.4" +flate2 = "1" + +[features] +# no-block-npm-registry: downloads the managed Node.js runtime from upstream +# nodejs.org instead of Block's Artifactory mirror. +no-block-npm-registry = [] + # Debug binaries archived — uncomment when needed # [[bin]] # name = "debug_diff" diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 6ed34a8a9..6648329cb 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod doctor; pub mod git; pub mod github_commands; pub mod image_commands; +pub mod managed_node; pub mod migrations; pub mod note_commands; pub mod paths; diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs new file mode 100644 index 000000000..0fb55a7f9 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -0,0 +1,1055 @@ +//! Staged-managed Node.js runtime. +//! +//! Downloads the Node.js version pinned in `node-runtime.lock.json` (app +//! root, embedded at compile time; refresh with `just bump-node-runtime`), +//! verifies it against the lock's SHA-256, and atomically installs it under +//! `~/.staged/packages/node///`. The tarball comes from +//! Block's Artifactory `nodejs` repo — a read-through mirror of +//! `https://nodejs.org/dist` — so the lock's hash pin is the trust root, not +//! the mirror; `no-block-npm-registry` builds fetch from nodejs.org directly. +//! +//! `~/.staged/packages` is shared by every running Staged instance (several +//! worktree `just dev` processes routinely run alongside the installed app), +//! so installs and prunes hold a cross-process advisory `flock` on +//! `/.lock` in addition to the in-process serialization mutex. + +use std::collections::BTreeMap; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +const NODE_RUNTIME_LOCK_JSON: &str = include_str!("../../node-runtime.lock.json"); + +const BLOCK_NODE_DIST_BASE_URL: &str = "https://global.block-artifacts.com/artifactory/nodejs"; +const UPSTREAM_NODE_DIST_BASE_URL: &str = "https://nodejs.org/dist"; + +const PACKAGES_LOCK_FILENAME: &str = ".lock"; + +/// Hard cap on the compressed tarball; the largest pinned artifact today is +/// ~49 MB, so anything near this is a wrong or corrupted download. +const MAX_ARCHIVE_BYTES: u64 = 90 * 1024 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// Chunk-level download progress would log far too often; report at this +/// granularity instead. +const PROGRESS_LOG_STEP_BYTES: u64 = 10 * 1024 * 1024; + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct NodeRuntimeLock { + /// Pinned Node.js version, `v`-prefixed (`v24.11.0`) — the exact string + /// `node --version` prints. + pub version: String, + /// Rust target triple → release tarball pin. + pub artifacts: BTreeMap, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct NodeRuntimeArtifact { + pub filename: String, + pub sha256: String, +} + +impl NodeRuntimeArtifact { + /// Node's platform string (`darwin-arm64`, …), derived from the tarball + /// name so the lock stays the single source of truth. + fn platform<'a>(&'a self, version: &str) -> Option<&'a str> { + self.filename + .strip_prefix(format!("node-{version}-").as_str())? + .strip_suffix(".tar.gz") + } +} + +pub fn node_runtime_lock() -> &'static NodeRuntimeLock { + static LOCK: OnceLock = OnceLock::new(); + LOCK.get_or_init(|| { + serde_json::from_str(NODE_RUNTIME_LOCK_JSON) + .expect("embedded node-runtime.lock.json must parse") + }) +} + +pub(crate) fn current_target_triple() -> Option<&'static str> { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Some("aarch64-apple-darwin") + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + Some("x86_64-apple-darwin") + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + Some("aarch64-unknown-linux-gnu") + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + Some("x86_64-unknown-linux-gnu") + } else { + None + } +} + +fn node_dist_base_url() -> &'static str { + if cfg!(feature = "no-block-npm-registry") { + UPSTREAM_NODE_DIST_BASE_URL + } else { + BLOCK_NODE_DIST_BASE_URL + } +} + +#[derive(Debug)] +pub enum ManagedNodeError { + UnsupportedTarget { + os: &'static str, + arch: &'static str, + }, + DataDir(String), + LockMissingTarget(String), + InvalidLockFilename(String), + Network(String), + HttpStatus(u16), + ArchiveTooLarge { + limit_bytes: u64, + }, + Sha256Mismatch { + expected: String, + actual: String, + }, + UnsafeArchiveEntry(String), + IncompleteRuntime(String), + Io(String), +} + +impl std::fmt::Display for ManagedNodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedTarget { os, arch } => { + write!(f, "Staged does not provide a managed Node.js runtime for {os}-{arch}") + } + Self::DataDir(message) => { + write!(f, "failed to resolve the managed Node.js runtime directory: {message}") + } + Self::LockMissingTarget(target) => { + write!(f, "node-runtime.lock.json has no artifact for target {target}") + } + Self::InvalidLockFilename(filename) => write!( + f, + "node-runtime.lock.json artifact '{filename}' is not a node--.tar.gz tarball" + ), + Self::Network(message) => write!(f, "Node.js runtime download failed: {message}"), + Self::HttpStatus(status) => write!(f, "Node.js runtime download failed: HTTP {status}"), + Self::ArchiveTooLarge { limit_bytes } => { + write!(f, "Node.js runtime archive exceeds the {limit_bytes}-byte limit") + } + Self::Sha256Mismatch { expected, actual } => write!( + f, + "Node.js runtime archive SHA-256 mismatch: expected {expected}, got {actual}" + ), + Self::UnsafeArchiveEntry(path) => { + write!(f, "Node.js runtime archive contains an unsafe entry path: {path}") + } + Self::IncompleteRuntime(message) => { + write!(f, "managed Node.js runtime install is incomplete: {message}") + } + Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ManagedNodeError {} + +/// `~/.staged/packages/node` — every managed runtime version lives under here. +pub fn managed_node_root() -> Option { + Some(node_root_under(&crate::paths::packages_dir()?)) +} + +pub fn managed_node_bin_dir() -> Option { + Some(pinned_install_dir(&managed_node_root()?)?.join("bin")) +} + +fn node_root_under(packages_root: &Path) -> PathBuf { + packages_root.join("node") +} + +/// Where the pinned runtime for the current target lives (or belongs) under +/// `node_root` — `//`. `None` when the embedded +/// lock has no artifact for this target. +pub fn pinned_install_dir(node_root: &Path) -> Option { + let lock = node_runtime_lock(); + let artifact = lock.artifacts.get(current_target_triple()?)?; + let platform = artifact.platform(&lock.version)?; + Some(install_dir(node_root, &lock.version, platform)) +} + +/// Whether the pinned runtime under `node_root` is installed and healthy for +/// the current target. `false` on unsupported targets. +pub async fn pinned_runtime_ready(node_root: &Path) -> bool { + match pinned_install_dir(node_root) { + Some(final_dir) => runtime_ready(&final_dir, &node_runtime_lock().version).await, + None => false, + } +} + +fn install_dir(node_root: &Path, version: &str, platform: &str) -> PathBuf { + node_root.join(version).join(platform) +} + +/// Make sure the pinned Node.js runtime is installed and healthy, downloading +/// and atomically swapping it into place when it is not. Safe to call +/// concurrently — including from other Staged processes sharing +/// `~/.staged/packages`: installs are serialized on an in-process mutex plus +/// a cross-process file lock, and readiness is re-checked after acquiring +/// them. +pub async fn ensure_managed_node_runtime() -> Result<(), ManagedNodeError> { + let packages_root = crate::paths::packages_dir() + .ok_or_else(|| ManagedNodeError::DataDir("home directory is unavailable".to_string()))?; + ensure_managed_node_runtime_at( + &packages_root, + node_dist_base_url(), + node_runtime_lock(), + MAX_ARCHIVE_BYTES, + ) + .await +} + +async fn ensure_managed_node_runtime_at( + packages_root: &Path, + base_url: &str, + lock: &NodeRuntimeLock, + max_archive_bytes: u64, +) -> Result<(), ManagedNodeError> { + let target = current_target_triple().ok_or(ManagedNodeError::UnsupportedTarget { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + })?; + let artifact = lock + .artifacts + .get(target) + .ok_or_else(|| ManagedNodeError::LockMissingTarget(target.to_string()))?; + let platform = artifact + .platform(&lock.version) + .ok_or_else(|| ManagedNodeError::InvalidLockFilename(artifact.filename.clone()))?; + + let node_root = node_root_under(packages_root); + let final_dir = install_dir(&node_root, &lock.version, platform); + if runtime_ready(&final_dir, &lock.version).await { + return Ok(()); + } + + let _guard = install_serialization_lock().lock().await; + let _packages_lock = lock_packages_dir(packages_root).await?; + if runtime_ready(&final_dir, &lock.version).await { + return Ok(()); + } + + let plan = InstallPlan { + node_root: &node_root, + version: &lock.version, + platform, + filename: &artifact.filename, + sha256: &artifact.sha256, + base_url, + max_archive_bytes, + }; + install_runtime(&plan).await?; + + if runtime_ready(&final_dir, &lock.version).await { + Ok(()) + } else { + Err(ManagedNodeError::IncompleteRuntime( + "installed runtime failed the readiness probe".to_string(), + )) + } +} + +fn install_serialization_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +/// Held cross-process advisory lock on `/.lock`; dropping it +/// (closing the descriptor) releases the lock, and a crashed holder's lock +/// dies with its process. +struct PackagesDirLock { + _file: std::fs::File, +} + +/// Take the exclusive cross-process lock serializing mutations of the shared +/// `~/.staged/packages` tree. The in-process [`install_serialization_lock`] +/// must already be held so at most one task per process parks a blocking +/// thread waiting here. Blocks until whichever other Staged process holds the +/// lock finishes. +async fn lock_packages_dir(packages_root: &Path) -> Result { + let packages_root = packages_root.to_path_buf(); + tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&packages_root) + .map_err(|error| ManagedNodeError::Io(format!("create packages dir: {error}")))?; + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(packages_root.join(PACKAGES_LOCK_FILENAME)) + .map_err(|error| ManagedNodeError::Io(format!("open packages lock file: {error}")))?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(ManagedNodeError::Io(format!( + "lock packages dir: {}", + std::io::Error::last_os_error() + ))); + } + Ok(PackagesDirLock { _file: file }) + }) + .await + .map_err(|error| ManagedNodeError::Io(format!("packages lock task failed: {error}")))? +} + +/// Fast-path readiness probe: `bin/npm` is present and the installed +/// `bin/node` runs and reports exactly the pinned version. +async fn runtime_ready(final_dir: &Path, version: &str) -> bool { + let bin = final_dir.join("bin"); + if !bin.join("npm").is_file() { + return false; + } + let node = bin.join("node"); + if !node.is_file() { + return false; + } + let output = tokio::process::Command::new(&node) + .arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + output + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim() == version) + .unwrap_or(false) +} + +struct InstallPlan<'a> { + node_root: &'a Path, + version: &'a str, + platform: &'a str, + filename: &'a str, + sha256: &'a str, + base_url: &'a str, + max_archive_bytes: u64, +} + +async fn install_runtime(plan: &InstallPlan<'_>) -> Result<(), ManagedNodeError> { + let final_dir = install_dir(plan.node_root, plan.version, plan.platform); + let temp_dir = plan + .node_root + .join(format!("{}.{}.tmp", plan.version, plan.platform)); + let archive_path = plan.node_root.join(format!("{}.download", plan.filename)); + + if temp_dir.exists() { + std::fs::remove_dir_all(&temp_dir) + .map_err(|error| ManagedNodeError::Io(format!("remove stale temp dir: {error}")))?; + } + if let Some(parent) = final_dir.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + ManagedNodeError::Io(format!("create runtime version dir: {error}")) + })?; + } + + let url = format!( + "{}/{}/{}", + plan.base_url.trim_end_matches('/'), + plan.version, + plan.filename + ); + download_archive(&url, &archive_path, plan.sha256, plan.max_archive_bytes).await?; + + log::info!("Extracting managed Node.js runtime"); + std::fs::create_dir_all(&temp_dir) + .map_err(|error| ManagedNodeError::Io(format!("create temp dir: {error}")))?; + let extract_result = { + let archive_path = archive_path.clone(); + let temp_dir = temp_dir.clone(); + tokio::task::spawn_blocking(move || extract_archive(&archive_path, &temp_dir)) + .await + .map_err(|error| ManagedNodeError::Io(format!("extract task failed: {error}")))? + }; + let _ = std::fs::remove_file(&archive_path); + extract_result?; + + // Node tarballs unpack into a single `node--` dir. + let extracted_dir = temp_dir.join(format!("node-{}-{}", plan.version, plan.platform)); + let source_dir = if extracted_dir.is_dir() { + extracted_dir + } else { + temp_dir.clone() + }; + verify_runtime_tree(&source_dir)?; + + log::info!("Installing managed Node.js runtime"); + let old_dir = final_dir.with_extension("old"); + if old_dir.exists() { + std::fs::remove_dir_all(&old_dir) + .map_err(|error| ManagedNodeError::Io(format!("remove stale old dir: {error}")))?; + } + if final_dir.exists() { + std::fs::rename(&final_dir, &old_dir) + .map_err(|error| ManagedNodeError::Io(format!("stage previous runtime: {error}")))?; + } + if let Err(error) = std::fs::rename(&source_dir, &final_dir) { + if old_dir.exists() { + let _ = std::fs::rename(&old_dir, &final_dir); + } + return Err(ManagedNodeError::Io(format!( + "install Node.js runtime: {error}" + ))); + } + let _ = std::fs::remove_dir_all(&old_dir); + let _ = std::fs::remove_dir_all(&temp_dir); + // Superseded version dirs are deliberately NOT pruned here: bridge shims + // exec the absolute path of the Node version they were installed against, + // so the old runtime must survive until every shim has been rewritten + // onto this one. The reconcile epilogue prunes via + // `prune_superseded_node_runtimes` once every bridge install succeeded. + Ok(()) +} + +/// Remove every managed runtime version under `/node` except the +/// embedded pin, along with stale temp dirs and orphaned downloads. Callers +/// must only prune once nothing execs a superseded version anymore — i.e. +/// after a reconcile in which every managed bridge reinstalled (and +/// re-shimmed) onto the pin. Serialized against in-flight installs — in this +/// process and in every other Staged process sharing the tree — so a +/// concurrent install's temp artifacts are never swept out from under it. +pub async fn prune_superseded_node_runtimes(packages_root: &Path) { + let _guard = install_serialization_lock().lock().await; + let _packages_lock = match lock_packages_dir(packages_root).await { + Ok(lock) => lock, + Err(error) => { + log::warn!("skipping managed Node.js prune: {error}"); + return; + } + }; + prune_superseded( + &node_root_under(packages_root), + &node_runtime_lock().version, + ); +} + +/// Everything under the node root that is not the kept version dir — +/// superseded version dirs, stale temp dirs, orphaned downloads — is garbage +/// once no shim points into it. Best-effort only; a locked file just logs and +/// is retried on the next successful reconcile. +fn prune_superseded(node_root: &Path, version: &str) { + let Ok(entries) = std::fs::read_dir(node_root) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy() == version { + continue; + } + let path = entry.path(); + let result = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + if let Err(error) = result { + log::warn!( + "failed to prune superseded managed Node.js entry {}: {error}", + path.display() + ); + } + } +} + +async fn download_archive( + url: &str, + dest: &Path, + expected_sha256: &str, + max_bytes: u64, +) -> Result<(), ManagedNodeError> { + let result = stream_archive(url, dest, expected_sha256, max_bytes).await; + if result.is_err() { + let _ = tokio::fs::remove_file(dest).await; + } + result +} + +async fn stream_archive( + url: &str, + dest: &Path, + expected_sha256: &str, + max_bytes: u64, +) -> Result<(), ManagedNodeError> { + let client = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .map_err(|error| ManagedNodeError::Network(format!("build download client: {error}")))?; + let mut response = client + .get(url) + .send() + .await + .map_err(|error| ManagedNodeError::Network(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(ManagedNodeError::HttpStatus(status.as_u16())); + } + let total_bytes = response.content_length(); + if let Some(total) = total_bytes { + if total > max_bytes { + return Err(ManagedNodeError::ArchiveTooLarge { + limit_bytes: max_bytes, + }); + } + } + + let mut file = tokio::fs::File::create(dest) + .await + .map_err(|error| ManagedNodeError::Io(format!("create archive file: {error}")))?; + let mut hasher = Sha256::new(); + let mut received_bytes = 0_u64; + let mut last_logged_step = 0_u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| ManagedNodeError::Network(error.to_string()))? + { + received_bytes += chunk.len() as u64; + if received_bytes > max_bytes { + return Err(ManagedNodeError::ArchiveTooLarge { + limit_bytes: max_bytes, + }); + } + hasher.update(&chunk); + file.write_all(&chunk) + .await + .map_err(|error| ManagedNodeError::Io(format!("write archive file: {error}")))?; + let step = received_bytes / PROGRESS_LOG_STEP_BYTES; + if step > last_logged_step { + last_logged_step = step; + let received_mb = received_bytes / (1024 * 1024); + match total_bytes { + Some(total) => log::info!( + "Downloading managed Node.js: {received_mb} MB of {} MB", + total.div_ceil(1024 * 1024) + ), + None => log::info!("Downloading managed Node.js: {received_mb} MB"), + } + } + } + file.flush() + .await + .map_err(|error| ManagedNodeError::Io(format!("flush archive file: {error}")))?; + drop(file); + + let actual = hex::encode(hasher.finalize()); + if !actual.eq_ignore_ascii_case(expected_sha256) { + return Err(ManagedNodeError::Sha256Mismatch { + expected: expected_sha256.to_string(), + actual, + }); + } + Ok(()) +} + +fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<(), ManagedNodeError> { + // Two passes over the (seekable) file: validate every entry path before a + // single byte is written, then unpack. + let file = std::fs::File::open(archive_path) + .map_err(|error| ManagedNodeError::Io(format!("open archive: {error}")))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + validate_archive_entries(&mut archive)?; + + let file = std::fs::File::open(archive_path) + .map_err(|error| ManagedNodeError::Io(format!("open archive for extraction: {error}")))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + archive + .unpack(dest_dir) + .map_err(|error| ManagedNodeError::Io(format!("extract archive: {error}"))) +} + +fn validate_archive_entries( + archive: &mut tar::Archive, +) -> Result<(), ManagedNodeError> { + let entries = archive + .entries() + .map_err(|error| ManagedNodeError::Io(format!("read archive entries: {error}")))?; + for entry in entries { + let entry = + entry.map_err(|error| ManagedNodeError::Io(format!("read archive entry: {error}")))?; + let path = entry + .path() + .map_err(|error| ManagedNodeError::Io(format!("read archive entry path: {error}")))?; + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(ManagedNodeError::UnsafeArchiveEntry( + path.to_string_lossy().into_owned(), + )); + } + } + Ok(()) +} + +fn verify_runtime_tree(dir: &Path) -> Result<(), ManagedNodeError> { + for binary in ["node", "npm"] { + if !dir.join("bin").join(binary).is_file() { + return Err(ManagedNodeError::IncompleteRuntime(format!( + "archive is missing bin/{binary}" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const TEST_VERSION: &str = "v9.9.9"; + const TEST_PLATFORM: &str = "testos-testarch"; + + fn target() -> &'static str { + current_target_triple().expect("tests only run on supported targets") + } + + fn test_lock(sha256: &str) -> NodeRuntimeLock { + let mut artifacts = BTreeMap::new(); + artifacts.insert( + target().to_string(), + NodeRuntimeArtifact { + filename: format!("node-{TEST_VERSION}-{TEST_PLATFORM}.tar.gz"), + sha256: sha256.to_string(), + }, + ); + NodeRuntimeLock { + version: TEST_VERSION.to_string(), + artifacts, + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + fn node_script(version: &str) -> String { + format!("#!/bin/sh\necho {version}\n") + } + + fn gzip(tar_bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(tar_bytes).unwrap(); + encoder.finish().unwrap() + } + + fn append_file(builder: &mut tar::Builder>, path: &str, contents: &str, mode: u32) { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + builder + .append_data(&mut header, path, contents.as_bytes()) + .unwrap(); + } + + /// A minimal but shape-faithful Node release tarball: executable + /// `bin/node` stub plus the `bin/npm` symlink into `lib/node_modules`. + fn node_tarball(version: &str) -> Vec { + let prefix = format!("node-{version}-{TEST_PLATFORM}"); + let mut builder = tar::Builder::new(Vec::new()); + append_file( + &mut builder, + &format!("{prefix}/bin/node"), + &node_script(version), + 0o755, + ); + append_file( + &mut builder, + &format!("{prefix}/lib/node_modules/npm/bin/npm-cli.js"), + "#!/usr/bin/env node\n", + 0o755, + ); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + builder + .append_link( + &mut header, + format!("{prefix}/bin/npm"), + "../lib/node_modules/npm/bin/npm-cli.js", + ) + .unwrap(); + gzip(&builder.into_inner().unwrap()) + } + + /// `tar::Builder` refuses to author unsafe paths, so write the name field + /// into the raw header bytes. + fn raw_entry_tar(name: &str) -> Vec { + let mut header = tar::Header::new_gnu(); + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + header.set_size(4); + header.set_mode(0o644); + header.set_cksum(); + let mut builder = tar::Builder::new(Vec::new()); + builder.append(&header, &b"evil"[..]).unwrap(); + builder.into_inner().unwrap() + } + + /// One-shot HTTP server; without a Content-Length header the body is + /// delimited by connection close, which exercises the streaming size cap. + async fn serve_once(body: Vec, with_content_length: bool) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 1024]; + let _ = socket.read(&mut request).await; + let head = if with_content_length { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + } else { + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_string() + }; + socket.write_all(head.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + let _ = socket.shutdown().await; + }); + format!("http://{addr}") + } + + fn test_node_root(packages_root: &Path) -> PathBuf { + node_root_under(packages_root) + } + + fn write_ready_runtime(node_root: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + let bin = install_dir(node_root, version, TEST_PLATFORM).join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let node = bin.join("node"); + std::fs::write(&node, node_script(version)).unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bin.join("npm"), "").unwrap(); + } + + #[test] + fn embedded_lock_pins_every_supported_target() { + let lock = node_runtime_lock(); + assert!(lock.version.starts_with('v'), "version: {}", lock.version); + for target in [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + ] { + let artifact = lock + .artifacts + .get(target) + .unwrap_or_else(|| panic!("lock is missing {target}")); + assert_eq!(artifact.sha256.len(), 64, "{target}"); + assert!( + artifact.sha256.chars().all(|c| c.is_ascii_hexdigit()), + "{target}" + ); + assert!(artifact.platform(&lock.version).is_some(), "{target}"); + } + } + + #[test] + fn artifact_platform_derives_from_filename() { + let artifact = NodeRuntimeArtifact { + filename: "node-v24.11.0-darwin-arm64.tar.gz".to_string(), + sha256: String::new(), + }; + assert_eq!(artifact.platform("v24.11.0"), Some("darwin-arm64")); + assert_eq!(artifact.platform("v24.12.0"), None); + } + + #[test] + fn base_url_follows_registry_feature() { + if cfg!(feature = "no-block-npm-registry") { + assert_eq!(node_dist_base_url(), UPSTREAM_NODE_DIST_BASE_URL); + } else { + assert_eq!(node_dist_base_url(), BLOCK_NODE_DIST_BASE_URL); + } + } + + #[test] + fn archive_validation_rejects_traversal_and_absolute_paths() { + for name in ["../evil.sh", "/abs/evil.sh"] { + let mut archive = tar::Archive::new(std::io::Cursor::new(raw_entry_tar(name))); + let error = validate_archive_entries(&mut archive).unwrap_err(); + assert!( + matches!(error, ManagedNodeError::UnsafeArchiveEntry(_)), + "{name}: {error}" + ); + } + } + + #[tokio::test] + async fn packages_lock_excludes_other_holders_until_dropped() { + let packages_dir = tempfile::tempdir().unwrap(); + let guard = lock_packages_dir(packages_dir.path()).await.unwrap(); + + // A second open file description — the same shape another Staged + // process's flock takes — must not get the lock while the guard lives. + let contender = std::fs::OpenOptions::new() + .write(true) + .open(packages_dir.path().join(PACKAGES_LOCK_FILENAME)) + .unwrap(); + assert_eq!( + unsafe { libc::flock(contender.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK) + ); + + drop(guard); + assert_eq!( + unsafe { libc::flock(contender.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + } + + #[tokio::test] + async fn install_keeps_superseded_versions_until_reconcile_prunes() { + let packages_dir = tempfile::tempdir().unwrap(); + let packages_root = packages_dir.path(); + let node_root = test_node_root(packages_root); + // Leftovers from a superseded install and a crashed one. + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + std::fs::create_dir_all(node_root.join(format!("{TEST_VERSION}.{TEST_PLATFORM}.tmp"))) + .unwrap(); + std::fs::write(node_root.join("node-v9.9.8-old.tar.gz.download"), b"stale").unwrap(); + + let archive = node_tarball(TEST_VERSION); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + ensure_managed_node_runtime_at(packages_root, &base_url, &lock, MAX_ARCHIVE_BYTES) + .await + .unwrap(); + + let bin = install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).join("bin"); + assert!(bin.join("node").is_file()); + assert!(bin.join("npm").is_file()); + // The install cleans up its own temp dir but leaves the superseded + // version (and the other install's orphaned download) alone: shims + // written against v9.9.8 must keep working until the reconcile + // epilogue confirms every bridge migrated and prunes. + assert!(node_root.join("v9.9.8").exists()); + assert!(!node_root + .join(format!("{TEST_VERSION}.{TEST_PLATFORM}.tmp")) + .exists()); + assert!(node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + + prune_superseded(&node_root, TEST_VERSION); + assert!(bin.join("node").is_file()); + assert!(!node_root.join("v9.9.8").exists()); + assert!(!node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + } + + #[tokio::test] + async fn fast_path_skips_download_when_runtime_matches_pin() { + let packages_dir = tempfile::tempdir().unwrap(); + write_ready_runtime(&test_node_root(packages_dir.path()), TEST_VERSION); + + // An unroutable base URL: any download attempt fails the test. + ensure_managed_node_runtime_at( + packages_dir.path(), + "http://127.0.0.1:1", + &test_lock(&"0".repeat(64)), + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn sha_mismatch_fails_and_preserves_previous_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let node_root = test_node_root(packages_dir.path()); + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + + let lock = test_lock(&sha256_hex(b"something else entirely")); + let base_url = serve_once(node_tarball(TEST_VERSION), true).await; + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::Sha256Mismatch { .. }), + "{error}" + ); + assert!(!install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).exists()); + // The failed download is cleaned up and the previous install is only + // pruned by a later fully-successful reconcile. + assert!(install_dir(&node_root, "v9.9.8", TEST_PLATFORM).exists()); + let downloads = std::fs::read_dir(&node_root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".download")) + .count(); + assert_eq!(downloads, 0); + } + + #[tokio::test] + async fn download_size_cap_aborts_stream() { + let packages_dir = tempfile::tempdir().unwrap(); + let body = vec![0_u8; 4096]; + let lock = test_lock(&sha256_hex(&body)); + let base_url = serve_once(body, false).await; + + let error = ensure_managed_node_runtime_at(packages_dir.path(), &base_url, &lock, 1024) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::ArchiveTooLarge { .. }), + "{error}" + ); + let leftovers = std::fs::read_dir(test_node_root(packages_dir.path())) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".download")) + .count(); + assert_eq!(leftovers, 0); + } + + #[tokio::test] + async fn traversal_entry_fails_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let archive = gzip(&raw_entry_tar("../evil.sh")); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::UnsafeArchiveEntry(_)), + "{error}" + ); + let node_root = test_node_root(packages_dir.path()); + assert!(!install_dir(&node_root, TEST_VERSION, TEST_PLATFORM).exists()); + // `../evil.sh` would have escaped the temp dir into the node root. + assert!(!node_root.join("evil.sh").exists()); + } + + #[tokio::test] + async fn archive_missing_npm_fails_install() { + let packages_dir = tempfile::tempdir().unwrap(); + let prefix = format!("node-{TEST_VERSION}-{TEST_PLATFORM}"); + let mut builder = tar::Builder::new(Vec::new()); + append_file( + &mut builder, + &format!("{prefix}/bin/node"), + &node_script(TEST_VERSION), + 0o755, + ); + let archive = gzip(&builder.into_inner().unwrap()); + let lock = test_lock(&sha256_hex(&archive)); + let base_url = serve_once(archive, true).await; + + let error = ensure_managed_node_runtime_at( + packages_dir.path(), + &base_url, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::IncompleteRuntime(_)), + "{error}" + ); + assert!(!install_dir( + &test_node_root(packages_dir.path()), + TEST_VERSION, + TEST_PLATFORM + ) + .exists()); + } + + #[test] + fn pinned_install_dir_follows_the_embedded_lock() { + let lock = node_runtime_lock(); + let artifact = &lock.artifacts[target()]; + let platform = artifact.platform(&lock.version).unwrap(); + assert_eq!( + pinned_install_dir(Path::new("/data/packages/node")), + Some( + Path::new("/data/packages/node") + .join(&lock.version) + .join(platform) + ) + ); + } + + #[tokio::test] + async fn pinned_runtime_ready_probes_the_embedded_pin() { + let node_root_dir = tempfile::tempdir().unwrap(); + let node_root = node_root_dir.path(); + assert!(!pinned_runtime_ready(node_root).await); + + // A runtime matching the real embedded pin at the pinned install dir. + use std::os::unix::fs::PermissionsExt; + let bin = pinned_install_dir(node_root).unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let node = bin.join("node"); + std::fs::write(&node, node_script(&node_runtime_lock().version)).unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bin.join("npm"), "").unwrap(); + assert!(pinned_runtime_ready(node_root).await); + } + + #[tokio::test] + async fn prune_superseded_node_runtimes_keeps_only_the_embedded_pin() { + let packages_dir = tempfile::tempdir().unwrap(); + let node_root = test_node_root(packages_dir.path()); + let pinned_bin = pinned_install_dir(&node_root).unwrap().join("bin"); + std::fs::create_dir_all(&pinned_bin).unwrap(); + std::fs::create_dir_all(install_dir(&node_root, "v9.9.8", TEST_PLATFORM)).unwrap(); + std::fs::write(node_root.join("node-v9.9.8-old.tar.gz.download"), b"stale").unwrap(); + + prune_superseded_node_runtimes(packages_dir.path()).await; + + assert!(pinned_bin.exists()); + assert!(!node_root.join("v9.9.8").exists()); + assert!(!node_root.join("node-v9.9.8-old.tar.gz.download").exists()); + // The prune stays inside /node: the cross-process lock file + // it holds lives beside the node root and must survive. + assert!(packages_dir.path().join(PACKAGES_LOCK_FILENAME).exists()); + } + + #[tokio::test] + async fn readiness_probe_requires_exact_pinned_version_and_npm() { + let node_root_dir = tempfile::tempdir().unwrap(); + let node_root = node_root_dir.path(); + let final_dir = install_dir(node_root, TEST_VERSION, TEST_PLATFORM); + assert!(!runtime_ready(&final_dir, TEST_VERSION).await); + + write_ready_runtime(node_root, TEST_VERSION); + assert!(runtime_ready(&final_dir, TEST_VERSION).await); + assert!(!runtime_ready(&final_dir, "v9.9.8").await); + + // A runtime whose npm is gone is damaged even if node still runs. + std::fs::remove_file(final_dir.join("bin").join("npm")).unwrap(); + assert!(!runtime_ready(&final_dir, TEST_VERSION).await); + } +} diff --git a/apps/staged/src-tauri/src/paths.rs b/apps/staged/src-tauri/src/paths.rs index 32be34448..efb27f340 100644 --- a/apps/staged/src-tauri/src/paths.rs +++ b/apps/staged/src-tauri/src/paths.rs @@ -21,6 +21,15 @@ pub fn clone_path_for(github_repo: &str) -> Option { repos_dir().map(|d| d.join(github_repo)) } +/// Root for app-managed runtime packages (the managed Node.js runtime and, +/// eventually, npm-installed ACP bridges): `~/.staged/packages/` +/// +/// Shared by every running Staged instance; mutations must hold the +/// cross-process lock (see `managed_node`). +pub fn packages_dir() -> Option { + data_dir().map(|d| d.join("packages")) +} + /// Root directory for workspace-scoped local data: `~/.staged/workspaces/` pub fn workspaces_dir() -> Option { data_dir().map(|d| d.join("workspaces")) From 5b4347f3cb0e5fe0dbf5690ac460dd4ea84aba29 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 3 Aug 2026 17:41:59 +1000 Subject: [PATCH 2/6] feat(staged): route doctor npm installs into a private prefix on the managed runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the app-managed Node runtime port from Berd: doctor fixes stop touching the host global npm prefix, npm traffic routes through Block's Artifactory square-npm registry, and the node-runtime doctor check reports the managed runtime instead of host Node. - new src-tauri/src/managed_acp_tools.rs (first half — the floating installer and startup reconciler land in step 3): packages-tree path helpers (npm-prefix, bin, tools/, state.json), managed_npm_env() setting upper+lowercase NPM_CONFIG_PREFIX/NPM_CONFIG_CACHE plus COREPACK_HOME (sanitize_shell_env already strips these keys from captured shell snapshots, so the pairs are authoritative), managed_prepend_dirs() (dev override -> bridge shims -> npm-prefix/bin -> managed node bin), the managed_tools_enabled() predicate, the STAGED_ACP_TOOLS_DIR reader (single owner of the env var now: set -> management disabled, override dir prepended first), and the square-npm registry URL gated behind the no-block-npm-registry feature - doctor.rs overlays the managed npm env on the doctor env snapshot, passes the square-npm registry in both run and fix options, ensures the managed runtime before executing any npm-backed fix or update command, and routes the node-runtime check's Fix button natively to ensure_managed_node_runtime instead of a shell command - the node-runtime check now reports the managed runtime: Pass when the pinned install answers the readiness probe, Warn + native reinstall fix when it is damaged or missing while Staged-installed npm tools need it, silent otherwise; the bundled-manifest/host-Node probe is gone (acp_tools' manifest helper itself goes with step 4's bundle flip) - acp_tools.rs folds managed_prepend_dirs() into apply_bundled_tools_env so session spawns and doctor checks/fixes share one PATH shape — bundled bridges keep winning until the step-3 resolution flip, but the managed node bin dir on PATH already retires the bundled wrappers' host-Node dependency once the runtime is installed; GOOSE_SEARCH_PATHS carries the same dir list - managed_node.rs: explicit truncate(false) on the flock file open (clippy 1.96 suspicious_open_options), and the packages-lock test now retries the post-drop acquisition with a 5s deadline — a child forked by a concurrently-running test can hold a dup of the just-closed lock fd until its exec closes it, which made the single non-blocking attempt flake under the full suite Effect: copilot/amp doctor fixes land in ~/.staged/packages/npm-prefix on the managed runtime and resolve identically at check time and at session spawn time; the host npm global prefix is no longer written. Gates: just check-all passes (cargo fmt, clippy -D warnings, svelte typecheck, 543 Rust tests, 481 frontend tests); clippy and the full Rust suite also pass under --features no-block-npm-registry. Pending manual verification for a later session: run a copilot or amp doctor fix and confirm the install lands in the private prefix, resolves in a doctor re-check and in a session spawn, and leaves the host `npm root -g` untouched. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/Cargo.toml | 4 +- apps/staged/src-tauri/src/acp_tools.rs | 174 +++-- apps/staged/src-tauri/src/doctor.rs | 601 +++++++----------- apps/staged/src-tauri/src/lib.rs | 1 + .../staged/src-tauri/src/managed_acp_tools.rs | 321 ++++++++++ apps/staged/src-tauri/src/managed_node.rs | 23 +- 6 files changed, 682 insertions(+), 442 deletions(-) create mode 100644 apps/staged/src-tauri/src/managed_acp_tools.rs diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index e68ea5ac6..971d1f1a3 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -87,7 +87,9 @@ flate2 = "1" [features] # no-block-npm-registry: downloads the managed Node.js runtime from upstream -# nodejs.org instead of Block's Artifactory mirror. +# nodejs.org instead of Block's Artifactory mirror, and lets npm-backed +# doctor checks and fixes use npm's default public registry instead of +# Artifactory's square-npm proxy. no-block-npm-registry = [] # Debug binaries archived — uncomment when needed diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index 35e05496e..935ec842c 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -6,17 +6,21 @@ //! bin directory at runtime and shapes captured shell-env snapshots so the //! bundled bridges win over user-installed copies while everything else on //! the user's PATH (including installed harness CLIs and their auth state) -//! stays discoverable. +//! stays discoverable. The Staged-managed install dirs (`managed_acp_tools`: +//! bridge shims, the private npm prefix's bin, the managed Node runtime's +//! bin) are folded into the same shaping, so sessions and doctor share one +//! PATH layout. -use std::ffi::OsStr; use std::path::{Path, PathBuf}; use tauri::path::BaseDirectory; use tauri::Manager; /// Dev-mode override exported by `just dev`, pointing at the freshly staged -/// `src-tauri/resources/acp/bin` in the working tree. -pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_ACP_TOOLS_DIR"; +/// `src-tauri/resources/acp/bin` in the working tree. The env var also +/// disables managed bridge installs (see `managed_acp_tools`). +pub use crate::managed_acp_tools::ACP_TOOLS_DIR_ENV; + /// Bundled resource path, relative to the Tauri resource dir (mirrors the /// `resources/acp` entry in `tauri.conf.json`). const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; @@ -30,7 +34,7 @@ const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; /// env override wins, then the Tauri resource dir for packaged apps. pub fn resolve_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) -> Option { bundled_acp_tools_dir_from_parts( - std::env::var_os(ACP_TOOLS_DIR_ENV).as_deref(), + crate::managed_acp_tools::dev_tools_override_dir(), app_handle .path() .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) @@ -50,45 +54,76 @@ pub fn node_runtime_manifest_path(bin_dir: &Path) -> Option { } fn bundled_acp_tools_dir_from_parts( - env_override: Option<&OsStr>, + env_override: Option, 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) + env_override.or_else(|| resource_dir.map(Path::to_path_buf)) } -/// Shape a captured shell-env snapshot so the bundled ACP bridges win: +/// Shape a captured shell-env snapshot so the bundled ACP bridges — and the +/// Staged-managed npm install locations — win: /// -/// - Prepend `bundled_dir` to the snapshot's PATH, keeping the rest of the -/// imported shell PATH intact so user-installed CLIs (and their auth -/// state) remain discoverable. +/// - Prepend the tool search dirs (see [`tool_search_dirs`]) to the +/// snapshot's PATH, keeping the rest of the imported shell PATH intact so +/// user-installed CLIs (and their auth state) remain discoverable. /// - Pin `GOOSE_SEARCH_PATHS` *after* the shell-env import so same-named /// values from the user's shell cannot override the bundled tools. The /// value is a JSON array to match Goose's config env parsing, scoped to -/// the managed dir plus any explicit pre-existing Goose search dirs — +/// the same search dirs plus any explicit pre-existing Goose search dirs — /// never the whole shell PATH. +/// +/// Sessions and doctor checks/fixes both shape their snapshots here, so an +/// agent installed by a doctor fix into the private npm prefix resolves +/// identically at check time and at spawn time. pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, bundled_dir: &Path) { - prepend_dir_to_path(vars, bundled_dir); - apply_goose_search_paths(vars, bundled_dir); + let dirs = tool_search_dirs(bundled_dir); + prepend_dirs_to_path(vars, &dirs); + apply_goose_search_paths(vars, &dirs); } -fn prepend_dir_to_path(vars: &mut Vec<(String, String)>, dir: &Path) { +/// Every dir agent binaries resolve from, in precedence order: the bundled +/// (or `STAGED_ACP_TOOLS_DIR`) bridge dir, then the managed dirs — bridge +/// shims, the private npm prefix's bin, and the managed Node runtime's bin, +/// which also lets the bundled bridge wrappers find `node` without a host +/// install once the managed runtime exists. +fn tool_search_dirs(bundled_dir: &Path) -> Vec { + tool_search_dirs_from_parts( + bundled_dir, + crate::managed_acp_tools::managed_prepend_dirs(), + ) +} + +fn tool_search_dirs_from_parts(bundled_dir: &Path, managed_dirs: Vec) -> Vec { + // The dev override dir appears both as `bundled_dir` and as the first + // managed prepend when the env var is set; keep the first occurrence. + let mut dirs = vec![bundled_dir.to_path_buf()]; + for dir in managed_dirs { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + +fn prepend_dirs_to_path(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { match vars.iter_mut().find(|(key, _)| key == "PATH") { Some((_, value)) => { - let mut paths = vec![dir.to_path_buf()]; - paths.extend(std::env::split_paths(value).filter(|path| path != dir)); + let mut paths = dirs.to_vec(); + paths.extend(std::env::split_paths(value).filter(|path| !dirs.contains(path))); *value = crate::shell_env::join_paths_best_effort(paths); } - None => vars.push(("PATH".to_string(), dir.to_string_lossy().to_string())), + None => vars.push(( + "PATH".to_string(), + crate::shell_env::join_paths_best_effort(dirs.to_vec()), + )), } } -fn apply_goose_search_paths(vars: &mut Vec<(String, String)>, dir: &Path) { - let mut search_paths = vec![dir.to_string_lossy().to_string()]; +fn apply_goose_search_paths(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { + let mut search_paths: Vec = dirs + .iter() + .map(|dir| dir.to_string_lossy().into_owned()) + .collect(); if let Some((_, existing)) = vars.iter().find(|(key, _)| key == GOOSE_SEARCH_PATHS_ENV) { match parse_goose_search_paths(existing) { Ok(paths) => search_paths.extend(paths), @@ -116,19 +151,25 @@ fn parse_goose_search_paths(value: &str) -> Result, serde_json::Erro #[cfg(test)] mod tests { - use super::{apply_bundled_tools_env, bundled_acp_tools_dir_from_parts}; - use std::ffi::OsStr; + use super::{ + apply_goose_search_paths, bundled_acp_tools_dir_from_parts, prepend_dirs_to_path, + tool_search_dirs_from_parts, + }; use std::path::{Path, PathBuf}; fn var<'a>(vars: &'a [(String, String)], key: &str) -> Option<&'a str> { vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) } + fn dirs(paths: &[&str]) -> Vec { + paths.iter().map(PathBuf::from).collect() + } + #[test] fn env_override_wins_over_resource_dir() { assert_eq!( bundled_acp_tools_dir_from_parts( - Some(OsStr::new("/dev/acp/bin")), + Some(PathBuf::from("/dev/acp/bin")), Some(Path::new("/bundle/resources/acp/bin")), ) .as_deref(), @@ -137,13 +178,10 @@ mod tests { } #[test] - fn empty_env_override_falls_back_to_resource_dir() { + fn missing_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(), + bundled_acp_tools_dir_from_parts(None, Some(Path::new("/bundle/resources/acp/bin"))) + .as_deref(), Some(Path::new("/bundle/resources/acp/bin")), ); } @@ -163,50 +201,76 @@ mod tests { } #[test] - fn bundled_dir_is_prepended_before_shell_path() { + fn tool_search_dirs_start_with_bundled_then_managed() { + assert_eq!( + tool_search_dirs_from_parts( + Path::new("/bundle/acp/bin"), + dirs(&["/data/packages/bin", "/data/packages/npm-prefix/bin"]), + ), + dirs(&[ + "/bundle/acp/bin", + "/data/packages/bin", + "/data/packages/npm-prefix/bin", + ]) + ); + } + + #[test] + fn tool_search_dirs_dedupe_the_dev_override() { + // With STAGED_ACP_TOOLS_DIR set, the override dir arrives both as the + // bundled dir and as the first managed prepend; it must appear once. + assert_eq!( + tool_search_dirs_from_parts( + Path::new("/dev/acp/bin"), + dirs(&["/dev/acp/bin", "/data/packages/npm-prefix/bin"]), + ), + dirs(&["/dev/acp/bin", "/data/packages/npm-prefix/bin"]) + ); + } + + #[test] + fn tool_dirs_are_prepended_before_shell_path() { let mut vars = vec![ ("PATH".to_string(), "/shell/bin:/user/bin".to_string()), ("LANG".to_string(), "en_US.UTF-8".to_string()), ]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin", "/packages/npm-prefix/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); assert_eq!( paths, - vec![ - PathBuf::from("/acp/bin"), - PathBuf::from("/shell/bin"), - PathBuf::from("/user/bin"), - ] + dirs(&[ + "/acp/bin", + "/packages/npm-prefix/bin", + "/shell/bin", + "/user/bin", + ]) ); // Non-PATH variables are untouched. assert_eq!(var(&vars, "LANG"), Some("en_US.UTF-8")); } #[test] - fn bundled_dir_is_not_duplicated_in_path() { + fn tool_dirs_are_not_duplicated_in_path() { let mut vars = vec![("PATH".to_string(), "/shell/bin:/acp/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); - assert_eq!( - paths, - vec![PathBuf::from("/acp/bin"), PathBuf::from("/shell/bin")] - ); + assert_eq!(paths, dirs(&["/acp/bin", "/shell/bin"])); } #[test] #[cfg(unix)] - fn unjoinable_bundled_dir_keeps_shell_path_instead_of_emptying_it() { + fn unjoinable_tool_dir_keeps_shell_path_instead_of_emptying_it() { // A dir embedding the separator (legal in macOS paths) can't be joined // into PATH; it must be dropped, not erase every shell search path. let mut vars = vec![("PATH".to_string(), "/shell/bin:/user/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/weird:dir/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/weird:dir/bin"])); let path = var(&vars, "PATH").expect("PATH should be set"); let paths: Vec<_> = std::env::split_paths(path).collect(); @@ -216,10 +280,10 @@ mod tests { } #[test] - fn missing_path_gets_bundled_dir_only() { + fn missing_path_gets_tool_dirs_only() { let mut vars = Vec::new(); - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + prepend_dirs_to_path(&mut vars, &dirs(&["/acp/bin"])); assert_eq!(var(&vars, "PATH"), Some("/acp/bin")); } @@ -228,11 +292,11 @@ mod tests { fn goose_search_paths_is_set_as_json_array() { let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin", "/packages/npm-prefix/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); - assert_eq!(paths, vec!["/acp/bin"]); + assert_eq!(paths, vec!["/acp/bin", "/packages/npm-prefix/bin"]); } #[test] @@ -245,7 +309,7 @@ mod tests { ), ]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); @@ -261,7 +325,7 @@ mod tests { "/not/a/json/array".to_string(), )]; - apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + apply_goose_search_paths(&mut vars, &dirs(&["/acp/bin"])); let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index a4620ff75..14c87ce36 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,8 +1,6 @@ //! Tauri command wrappers for the doctor health-check system. use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; pub use doctor::types::{AuthStatus, InstallSource}; pub use doctor::{ @@ -14,7 +12,9 @@ pub use doctor::{ /// `apply_bundled_tools_env` so checks resolve binaries from the same PATH /// the agent spawn path uses — a bridge Staged bundles must never be /// reported missing (or prompt an install) just because the user has no -/// global copy. +/// global copy. The managed npm env is overlaid on top, so checks probe npm +/// state (`npm prefix -g`, version lookups) with the same private-prefix view +/// the fixes install into — a check never contradicts the fix that just ran. async fn doctor_env_vars(bundled_dir: Option<&Path>) -> Vec<(String, String)> { let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), @@ -23,6 +23,10 @@ async fn doctor_env_vars(bundled_dir: Option<&Path>) -> Vec<(String, String)> { if let Some(dir) = bundled_dir { crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dir); } + crate::managed_acp_tools::apply_managed_npm_env( + &mut env_vars, + &crate::managed_acp_tools::managed_npm_env(), + ); env_vars } @@ -34,9 +38,11 @@ fn run_checks_options( RunChecksOptions { check_freshness, offline: false, - // Use the default public registries — Staged installs these agents - // from public npm/brew/crates.io, not an internal mirror. - npm_registry: None, + // npm-backed checks and fixes route through Block's Artifactory + // square-npm proxy (registry.npmjs.org is blocked on managed + // devices); `no-block-npm-registry` builds fall back to npm's + // default public registry. + npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), env: None, // Doctor labels binaries resolved from this dir as bundled (install // source + readout flag) and suppresses registry update fixes for @@ -53,7 +59,7 @@ fn execute_fix_options( ) -> ExecuteFixOptions { ExecuteFixOptions { command_override, - npm_registry: None, + npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), env: None, } .with_env_snapshot(env_vars) @@ -83,7 +89,7 @@ pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport } /// Run the doctor crate's checks plus Staged-local ones (currently the -/// bundled ACP Node.js runtime check) over one shared env snapshot. Bundled +/// managed Node.js runtime check) over one shared env snapshot. Bundled /// readouts are labeled by the doctor crate itself via /// `RunChecksOptions::bundled_tools_dir`. async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) -> DoctorReport { @@ -95,7 +101,7 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) env_vars.clone(), bundled_dir.clone(), )), - run_node_runtime_check(&env_vars, bundled_dir.as_deref()), + run_node_runtime_check(), ); if let Some(check) = node_runtime { report.checks.push(check); @@ -106,19 +112,41 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) /// Run a fix for a doctor check, identified by check ID and fix type. /// /// The actual shell command is looked up from the static check definitions — -/// the caller never sends a raw command string. +/// the caller never sends a raw command string. The node-runtime fix is +/// native — (re)install the pinned managed runtime — not a shell command; +/// npm-backed fixes install the managed runtime first, since they run npm +/// from it into the private prefix (the existing "Running…" spinner covers +/// the one-time download). #[tauri::command] pub async fn run_doctor_fix( app_handle: tauri::AppHandle, check_id: String, fix_type: FixType, ) -> Result<(), String> { + if check_id == NODE_RUNTIME_CHECK_ID { + return ensure_managed_node_runtime_for_fix().await; + } let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; + if doctor::agents::lookup_fix_command(&check_id, &fix_type) + .as_deref() + .is_some_and(crate::managed_acp_tools::is_npm_backed_command) + { + ensure_managed_node_runtime_for_fix().await?; + } doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) .await } +/// Install (or repair) the managed Node.js runtime ahead of a fix that needs +/// it. Progress goes to the log — doctor fixes have no streamed-output +/// channel, only a button spinner. +async fn ensure_managed_node_runtime_for_fix() -> Result<(), String> { + crate::managed_node::ensure_managed_node_runtime() + .await + .map_err(|error| error.to_string()) +} + /// Run a source-aware update for a single readout (main CLI or ACP bridge). /// /// Unlike [`run_doctor_fix`], update commands (`UpdateMain`/`UpdateBridge`) are @@ -148,6 +176,11 @@ pub async fn run_doctor_update( that does not match the backend-derived update command." )); } + // npm-backed updates run the managed npm into the private prefix, so the + // managed runtime must exist before the command does. + if crate::managed_acp_tools::is_npm_backed_command(&expected) { + ensure_managed_node_runtime_for_fix().await?; + } // Run the backend-derived `expected`, not the frontend-supplied `command`. // They are equal past the guard above, but executing `expected` makes the // command that runs provably the one the backend derived — no dependence on @@ -192,242 +225,130 @@ async fn expected_update_command( } // ============================================================================= -// Bundled ACP Node.js runtime check +// Managed Node.js runtime check // ============================================================================= const NODE_RUNTIME_CHECK_ID: &str = "node-runtime"; const NODE_RUNTIME_CHECK_LABEL: &str = "Node.js Runtime"; -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`, written by -/// `scripts/prepare-acp-tools-resource.sh` while staging npm-sourced ACP -/// bridges. Each tool carries its own required Node major so bridges with -/// different engine ranges are checked independently. -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct NodeRuntimeManifest { - #[serde(default)] - tools: Vec, -} -#[derive(Debug, serde::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)) - } -} - -enum NodeRuntimeManifestState { - /// No manifest next to the bundled tools dir: no npm-sourced bridges are - /// bundled, so the check stays silent. +/// Disk states of the Staged-managed Node.js runtime the check reports on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ManagedNodeRuntimeState { + /// The pinned version is installed and answers the readiness probe. + Ready, + /// The pinned install dir exists but the probe fails — a crashed install + /// or damaged tree that a reinstall repairs. + Broken, + /// The pinned version is not on disk (fresh profile, or a pin bump left + /// only a superseded version behind). 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(crate::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}"), - }; - } +/// Report the state of the Staged-managed Node.js runtime that npm-installed +/// agent tools run on, with a native fix that (re)installs the pinned +/// version (`run_doctor_fix` routes this check id to +/// `ensure_managed_node_runtime`). Silent when there is nothing to report: +/// an unsupported target, or a runtime that was never installed and no +/// Staged-installed npm tools that would need it. +async fn run_node_runtime_check() -> Option { + let node_root = crate::managed_node::managed_node_root()?; + let install_dir = crate::managed_node::pinned_install_dir(&node_root)?; + let state = if crate::managed_node::pinned_runtime_ready(&node_root).await { + ManagedNodeRuntimeState::Ready + } else if install_dir.exists() { + ManagedNodeRuntimeState::Broken + } else { + ManagedNodeRuntimeState::Missing }; - 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}"), - }, - } + // Both install families depend on the runtime: the private-prefix npm + // tools (copilot, amp-acp) and the managed bridge shims, whose embedded + // node paths break silently without it. + let mut npm_tools: Vec = [ + crate::managed_acp_tools::npm_prefix_bin_dir(), + crate::managed_acp_tools::managed_shim_bin_dir(), + ] + .into_iter() + .flatten() + .flat_map(|dir| installed_npm_tool_names(&dir)) + .collect(); + npm_tools.sort(); + npm_tools.dedup(); + build_node_runtime_check(state, &install_dir, &npm_tools) } -/// Surface the bundled ACP bridges' Node.js runtime requirement at setup -/// time instead of letting the first session spawn die with a bare exit 127 -/// (the bundled bridges are bash shims that exec node). Returns `None` when -/// no npm-sourced bridges are bundled; an unreadable manifest warns instead -/// of silently hiding a packaging break. -async fn run_node_runtime_check( - env_vars: &[(String, String)], - bundled_bin_dir: Option<&Path>, -) -> Option { - let (manifest_path, manifest) = match load_node_runtime_manifest(bundled_bin_dir) { - NodeRuntimeManifestState::Missing => return None, - NodeRuntimeManifestState::Invalid { path, error } => { - return Some(node_runtime_doctor_check( - CheckStatus::Warn, - "Bundled ACP bridge Node.js manifest is unreadable; bridge runtime requirements cannot be verified".to_string(), - Some(path.display().to_string()), - Some(format!("error: {error}")), - )); - } - NodeRuntimeManifestState::Loaded { path, manifest } => (path, manifest), - }; - if manifest.tools.is_empty() { - return None; - } - - // Resolve node from the same PATH shape every other doctor check and the - // agent spawn path use, so this check cannot disagree with what the - // bundled wrapper shims will find at spawn time. - let path_value = env_vars - .iter() - .find(|(key, _)| key == "PATH") - .map(|(_, value)| value.as_str()) - .unwrap_or_default(); - let node_path = doctor::resolve::resolve_executable_from_path("node", path_value) - .map(|path| path.to_string_lossy().to_string()); - let node_version = match node_path.as_deref() { - Some(path) => query_node_version(path).await, - None => None, +/// Names of the bin shims npm wrote into the Staged-private prefix — the +/// tools that need the managed runtime to run at all. +fn installed_npm_tool_names(bin_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return Vec::new(); }; - - Some(build_node_runtime_check( - &manifest_path, - &manifest.tools, - node_path, - node_version, - )) -} - -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(", ") + entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| !name.starts_with('.')) + .collect() } fn build_node_runtime_check( - manifest_path: &Path, - tools: &[NodeRuntimeTool], - node_path: Option, - node_version: Option, -) -> DoctorCheck { - 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() { - ( - CheckStatus::Warn, - format!( - "Node.js was not found on PATH; bundled ACP bridges require it ({})", - node_requirement_summary(tools) - ), - ) - } else if node_major.is_none() { - ( - CheckStatus::Warn, - format!( - "Could not determine the Node.js version; bundled ACP bridges require it ({})", - node_requirement_summary(tools) - ), - ) - } else if unmet.is_empty() { - ( + state: ManagedNodeRuntimeState, + install_dir: &Path, + npm_tools: &[String], +) -> Option { + let version = &crate::managed_node::node_runtime_lock().version; + let (status, message) = match state { + ManagedNodeRuntimeState::Ready => ( CheckStatus::Pass, - format!( - "Node.js {} satisfies the bundled ACP bridge requirements", - node_version.as_deref().unwrap_or("unknown") - ), - ) - } else { - ( + format!("Staged-managed Node.js {version} is installed"), + ), + ManagedNodeRuntimeState::Broken => ( + CheckStatus::Warn, + format!("Staged-managed Node.js {version} is damaged; run the fix to reinstall it"), + ), + ManagedNodeRuntimeState::Missing if npm_tools.is_empty() => return None, + ManagedNodeRuntimeState::Missing => ( CheckStatus::Warn, format!( - "Node.js {} is too old for bundled ACP bridges: {}", - node_version.as_deref().unwrap_or("unknown"), - node_requirement_summary(unmet.iter().copied()) + "Staged-managed Node.js {version} is not installed; Staged-installed agent tools require it" ), - ) + ), }; + let state_label = match state { + ManagedNodeRuntimeState::Ready => "ready", + ManagedNodeRuntimeState::Broken => "broken", + ManagedNodeRuntimeState::Missing => "missing", + }; let mut detail = vec![ - "checked: bundled ACP bridge Node.js runtime requirement".to_string(), - format!("manifest: {}", manifest_path.display()), - format!( - "node: {}", - node_path.as_deref().unwrap_or("not found on PATH") - ), - format!("version: {}", node_version.as_deref().unwrap_or("unknown")), - "requirements:".to_string(), + "checked: Staged-managed Node.js runtime".to_string(), + format!("pinned version: {version}"), + format!("install dir: {}", install_dir.display()), + format!("state: {state_label}"), ]; - for tool in tools { - let verdict = match node_major { - Some(major) if major >= tool.required_node_major => "satisfied", - Some(_) => "unmet", - None => "unknown", - }; - detail.push(format!( - "- {}: requires Node.js {} [{verdict}]", - tool.binary, - tool.requirement_label() - )); + if npm_tools.is_empty() { + detail.push("Staged-installed npm tools: none".to_string()); + } else { + detail.push("Staged-installed npm tools:".to_string()); + detail.extend(npm_tools.iter().map(|name| format!("- {name}"))); } - node_runtime_doctor_check(status, message, node_path, Some(detail.join("\n"))) + let node_path = (state == ManagedNodeRuntimeState::Ready) + .then(|| install_dir.join("bin").join("node").display().to_string()); + // Native fix: `run_doctor_fix` routes this check id to + // `ensure_managed_node_runtime`. The command string is what the fix + // confirmation dialog displays, not a shell command. + let fix = (status != CheckStatus::Pass).then(|| { + ( + FixType::Command, + format!("download and install Node.js {version} into ~/.staged/packages"), + ) + }); + Some(node_runtime_doctor_check( + status, + message, + node_path, + Some(detail.join("\n")), + fix, + )) } fn node_runtime_doctor_check( @@ -435,16 +356,17 @@ fn node_runtime_doctor_check( message: String, path: Option, raw_output: Option, + fix: Option<(FixType, String)>, ) -> DoctorCheck { + let (fix_type, fix_command) = fix.map(|(t, c)| (Some(t), Some(c))).unwrap_or((None, None)); DoctorCheck { id: NODE_RUNTIME_CHECK_ID.to_string(), label: NODE_RUNTIME_CHECK_LABEL.to_string(), status, message, - // Only rendered by the frontend for non-pass statuses. - fix_url: Some(NODE_RUNTIME_FIX_URL.to_string()), - fix_command: None, - fix_type: None, + fix_url: None, + fix_command, + fix_type, path, bridge_path: None, raw_output, @@ -463,194 +385,94 @@ fn node_runtime_doctor_check( mod tests { use super::*; - fn node_tool(binary: &str, engine: &str, major: u32) -> NodeRuntimeTool { - NodeRuntimeTool { - binary: binary.to_string(), - node_engine: Some(engine.to_string()), - required_node_major: major, - } + fn pinned_version() -> String { + crate::managed_node::node_runtime_lock().version.clone() } #[test] - fn node_runtime_check_passes_when_all_bridges_are_satisfied() { - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - + fn ready_runtime_passes_without_a_fix() { let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - Some("22.17.0".to_string()), - ); + ManagedNodeRuntimeState::Ready, + Path::new("/data/packages/node/v9.9.9/plat"), + &["copilot".to_string()], + ) + .expect("ready runtime is reported"); assert_eq!(check.status, CheckStatus::Pass); assert_eq!( check.message, - "Node.js 22.17.0 satisfies the bundled ACP bridge requirements" - ); - assert_eq!(check.path.as_deref(), Some("/usr/local/bin/node")); - let output = check.raw_output.as_deref().expect("raw output"); - assert!(output.contains("manifest: /resources/acp/node-runtime.json")); - assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [satisfied]")); - assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); - } - - #[test] - fn node_runtime_check_warns_only_for_bridges_with_unmet_majors() { - // Bridges may require different Node majors; a Node 21 runtime - // satisfies codex (>=20) but not claude (>=22). - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - - let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - Some("21.7.3".to_string()), + format!("Staged-managed Node.js {} is installed", pinned_version()) ); - - assert_eq!(check.status, CheckStatus::Warn); assert_eq!( - check.message, - "Node.js 21.7.3 is too old for bundled ACP bridges: claude-agent-acp needs Node.js >=22" + check.path.as_deref(), + Some("/data/packages/node/v9.9.9/plat/bin/node") ); - assert!(!check.message.contains("codex-acp")); + assert!(check.fix_type.is_none()); + assert!(check.fix_command.is_none()); + assert!(check.fix_url.is_none()); let output = check.raw_output.as_deref().expect("raw output"); - assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [unmet]")); - assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); + assert!(output.contains("state: ready")); + assert!(output.contains("- copilot")); } #[test] - fn node_runtime_check_warns_when_node_is_missing() { - let tools = [ - node_tool("claude-agent-acp", ">=22", 22), - node_tool("codex-acp", ">=20", 20), - ]; - + fn damaged_runtime_warns_with_a_native_reinstall_fix() { let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - None, - None, - ); + ManagedNodeRuntimeState::Broken, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + ) + .expect("damaged runtime is reported"); assert_eq!(check.status, CheckStatus::Warn); - assert_eq!( - check.message, - "Node.js was not found on PATH; bundled ACP bridges require it (claude-agent-acp needs Node.js >=22, codex-acp needs Node.js >=20)" - ); + assert!(check.message.contains("is damaged")); assert!(check.path.is_none()); - assert_eq!( - check.fix_url.as_deref(), - Some("https://nodejs.org/en/download") - ); + assert_eq!(check.fix_type, Some(FixType::Command)); + let fix_command = check.fix_command.as_deref().expect("fix command"); + assert!(fix_command.contains(&pinned_version())); + assert!(fix_command.contains("~/.staged/packages")); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("state: broken")); + assert!(output.contains("Staged-installed npm tools: none")); } #[test] - fn node_runtime_check_warns_when_version_is_unknown() { - let tools = [node_tool("claude-agent-acp", ">=22", 22)]; - + fn missing_runtime_warns_only_when_installed_tools_need_it() { + // Tools installed into the private prefix need the runtime: warn with + // the reinstall fix. let check = build_node_runtime_check( - Path::new("/resources/acp/node-runtime.json"), - &tools, - Some("/usr/local/bin/node".to_string()), - None, - ); - + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &["amp-acp".to_string(), "copilot".to_string()], + ) + .expect("needed-but-missing runtime is reported"); assert_eq!(check.status, CheckStatus::Warn); - assert!(check - .message - .starts_with("Could not determine the Node.js version")); - assert!(check - .message - .contains("claude-agent-acp needs Node.js >=22")); - } - - #[test] - fn node_runtime_manifest_loads_from_bundled_dir_parent() { - let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - std::fs::write( - dir.path().join("node-runtime.json"), - r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=22","requiredNodeMajor":22},{"id":"codex-acp","binary":"codex-acp","nodeEngine":">=20","requiredNodeMajor":20}]}"#, + assert!(check.message.contains("is not installed")); + assert_eq!(check.fix_type, Some(FixType::Command)); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("- amp-acp")); + assert!(output.contains("- copilot")); + + // Nothing installed that needs it: stay silent. + assert!(build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], ) - .unwrap(); - - let NodeRuntimeManifestState::Loaded { path, manifest } = - load_node_runtime_manifest(Some(&bin_dir)) - else { - panic!("expected manifest to load"); - }; - - assert_eq!(path, dir.path().join("node-runtime.json")); - assert_eq!(manifest.tools.len(), 2); - assert_eq!(manifest.tools[0].binary, "claude-agent-acp"); - assert_eq!(manifest.tools[0].required_node_major, 22); - assert_eq!(manifest.tools[1].required_node_major, 20); + .is_none()); } #[test] - fn node_runtime_manifest_missing_or_invalid() { - assert!(matches!( - load_node_runtime_manifest(None), - NodeRuntimeManifestState::Missing - )); - - let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - assert!(matches!( - load_node_runtime_manifest(Some(&bin_dir)), - NodeRuntimeManifestState::Missing - )); - - std::fs::write(dir.path().join("node-runtime.json"), "not json").unwrap(); - assert!(matches!( - load_node_runtime_manifest(Some(&bin_dir)), - NodeRuntimeManifestState::Invalid { .. } - )); - } - - #[tokio::test] - async fn node_runtime_check_runs_end_to_end_from_manifest() { + fn installed_npm_tool_names_lists_visible_entries_only() { let dir = tempfile::tempdir().unwrap(); - let bin_dir = dir.path().join("bin"); - std::fs::create_dir(&bin_dir).unwrap(); - std::fs::write( - dir.path().join("node-runtime.json"), - r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=0","requiredNodeMajor":0}]}"#, - ) - .unwrap(); - let env_vars = vec![("PATH".to_string(), std::env::var("PATH").unwrap())]; - - let check = run_node_runtime_check(&env_vars, Some(&bin_dir)) - .await - .expect("check emitted for npm-bundled bridges"); - - assert_eq!(check.id, "node-runtime"); - // With a zero required major any resolvable Node passes; on a host - // without Node the check still surfaces as a warning instead of - // vanishing. - if check.path.is_some() { - assert_eq!(check.status, CheckStatus::Pass); - } else { - assert_eq!(check.status, CheckStatus::Warn); - } + std::fs::write(dir.path().join("copilot"), "").unwrap(); + std::fs::write(dir.path().join(".copilot.tmp"), "").unwrap(); - assert!(run_node_runtime_check(&env_vars, None).await.is_none()); - } + let names = installed_npm_tool_names(dir.path()); + assert_eq!(names, vec!["copilot".to_string()]); - #[test] - fn parse_node_major_handles_plain_and_prefixed_versions() { - assert_eq!(parse_node_major("22.17.0"), Some(22)); - assert_eq!(parse_node_major("v20.11.1\n"), Some(20)); - assert_eq!(parse_node_major("not-a-version"), None); - assert_eq!(parse_node_major(""), None); + // An absent dir reads as no tools, not an error. + assert!(installed_npm_tool_names(&dir.path().join("absent")).is_empty()); } /// Bundled-readout labeling lives in the doctor crate now; Staged's job is @@ -664,4 +486,19 @@ mod tests { let opts = run_checks_options(false, Vec::new(), None); assert!(opts.bundled_tools_dir.is_none()); } + + /// Checks and fixes must agree on the registry: both option builders take + /// it from the same `managed_acp_tools::npm_registry()` gate. + #[test] + fn doctor_options_route_npm_through_the_managed_registry() { + let expected = crate::managed_acp_tools::npm_registry().map(str::to_string); + assert_eq!( + run_checks_options(false, Vec::new(), None).npm_registry, + expected + ); + assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected); + if !cfg!(feature = "no-block-npm-registry") { + assert!(expected.is_some()); + } + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 6648329cb..d28ae30d4 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod doctor; pub mod git; pub mod github_commands; pub mod image_commands; +pub mod managed_acp_tools; pub mod managed_node; pub mod migrations; pub mod note_commands; diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs new file mode 100644 index 000000000..414f6bf87 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -0,0 +1,321 @@ +//! Staged-managed ACP tool install locations and npm environment. +//! +//! Staged owns both sides of every npm-backed agent install: the managed Node +//! runtime (`managed_node`) supplies `node`/`npm`, and everything npm writes +//! lands in Staged-private directories under `~/.staged/packages` instead of +//! the host's global prefix. This module holds the path layout, the npm env +//! pairs that steer installs into the private prefix, and the PATH prepends +//! that make the results resolvable; the floating bridge installer and the +//! startup reconciler that build on these land next. +//! +//! Layout under `~/.staged/packages` (shared by every running Staged +//! instance — see the cross-process locking notes in `managed_node`): +//! +//! - `npm-prefix/` — the private npm global prefix the doctor crate's +//! runtime `npm install -g` fixes (copilot, amp-acp) are steered into by +//! the env pairs in [`managed_npm_env`]. +//! - `node///` — managed Node runtimes (`managed_node`). +//! - `tools//` — per-bridge npm `--prefix` trees for the managed ACP +//! bridges (claude-acp, codex-acp). +//! - `bin/` — Staged-written shims for managed bridges. +//! - `state.json` — installed bridge versions + last reconcile outcome. +//! +//! `STAGED_ACP_TOOLS_DIR` stays honored as a dev/bridge-developer override: +//! when set, bridge management is disabled (no managed shim dir) so the +//! override dir is the one source of bridge binaries. + +use std::path::{Path, PathBuf}; + +use crate::managed_node; + +/// Dev/bridge-developer override (exported by `just dev`): a directory of +/// bridge binaries that replaces managed bridge resolution. +pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_ACP_TOOLS_DIR"; + +/// Block's internal Artifactory npm registry. Direct access to +/// `registry.npmjs.org` is blocked by Cloudflare WARP on managed devices, so +/// npm-backed agent installs must route through this proxy. The doctor crate +/// exposes an optional `npm_registry` param but bakes in no registry of its +/// own, so Staged supplies this URL at every fix/run call site. +pub const BLOCK_NPM_REGISTRY_URL: &str = + "https://global.block-artifacts.com/artifactory/api/npm/square-npm/"; + +/// The npm registry every Staged-run npm command routes through, or `None` +/// (npm's default public registry) for `no-block-npm-registry` builds. +pub fn npm_registry() -> Option<&'static str> { + if cfg!(feature = "no-block-npm-registry") { + None + } else { + Some(BLOCK_NPM_REGISTRY_URL) + } +} + +/// The `STAGED_ACP_TOOLS_DIR` override dir, when set and non-empty. +pub fn dev_tools_override_dir() -> Option { + std::env::var_os(ACP_TOOLS_DIR_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn dev_tools_override_active() -> bool { + dev_tools_override_dir().is_some() +} + +/// Whether this build manages ACP bridge installs (and therefore exposes the +/// managed shim dir): the `STAGED_ACP_TOOLS_DIR` dev override supplies +/// bridges from its own dir instead, and an unsupported target has no +/// managed runtime to install onto. +pub fn managed_tools_enabled() -> bool { + managed_tools_enabled_from_parts( + dev_tools_override_active(), + managed_node::current_target_triple().is_some(), + ) +} + +fn managed_tools_enabled_from_parts(override_active: bool, supported_target: bool) -> bool { + !override_active && supported_target +} + +/// The Staged-private npm global prefix, `~/.staged/packages/npm-prefix`. +pub fn npm_prefix_dir() -> Option { + crate::paths::packages_dir().map(|dir| dir.join("npm-prefix")) +} + +/// Where npm writes global bin shims for the private prefix. +pub fn npm_prefix_bin_dir() -> Option { + npm_prefix_dir().map(|dir| dir.join("bin")) +} + +/// `~/.staged/packages/bin` — the Staged-written shims for managed bridges. +/// `None` when this build does not manage bridges (see +/// [`managed_tools_enabled`]), so stale managed shims cannot resolve while +/// the `STAGED_ACP_TOOLS_DIR` dev override is active. +pub fn managed_shim_bin_dir() -> Option { + if !managed_tools_enabled() { + return None; + } + crate::paths::packages_dir().map(|root| shim_bin_dir(&root)) +} + +fn shim_bin_dir(packages_root: &Path) -> PathBuf { + packages_root.join("bin") +} + +/// `/tools` — every managed bridge's npm `--prefix` tree lives +/// under here. +pub fn tools_root(packages_root: &Path) -> PathBuf { + packages_root.join("tools") +} + +/// `/tools/` — the npm `--prefix` a managed bridge installs +/// into. Floating upgrades reuse the same prefix, so the entrypoint path a +/// shim points at is version-independent. +pub fn tool_install_dir(packages_root: &Path, id: &str) -> PathBuf { + tools_root(packages_root).join(id) +} + +/// `/state.json` — installed bridge versions + the last reconcile +/// outcome. +pub fn state_path(packages_root: &Path) -> PathBuf { + packages_root.join("state.json") +} + +/// Directories to prepend (in order) wherever agent binaries must resolve: +/// the `STAGED_ACP_TOOLS_DIR` dev override when active (it replaces the +/// managed shim dir), then the managed bridge shims, the private prefix's +/// bin shims, and the managed Node runtime's bin dir — the latter is what +/// makes npm's `#!/usr/bin/env node` shims (and, until the bundle flip, the +/// bundled bridge wrappers) run without host Node, and what resolves `npm` +/// itself for install fixes. +pub fn managed_prepend_dirs() -> Vec { + managed_prepend_dirs_from_parts( + dev_tools_override_dir(), + managed_shim_bin_dir(), + npm_prefix_bin_dir(), + managed_node::managed_node_bin_dir(), + ) +} + +fn managed_prepend_dirs_from_parts( + override_bin: Option, + shim_bin: Option, + npm_prefix_bin: Option, + node_bin: Option, +) -> Vec { + override_bin + .into_iter() + .chain(shim_bin) + .chain(npm_prefix_bin) + .chain(node_bin) + .collect() +} + +/// Env pairs steering every npm invocation Staged spawns into the private +/// prefix. Both spellings are set: npm canonically reads the lowercase +/// `npm_config_*` form, but tooling conventionally exports the uppercase one. +/// `sanitize_shell_env` already strips user-shell values for these keys from +/// captured snapshots, so these pairs are authoritative, not a race. +pub fn managed_npm_env() -> Vec<(String, String)> { + npm_prefix_dir() + .map(|prefix| managed_npm_env_at(&prefix)) + .unwrap_or_default() +} + +pub fn managed_npm_env_at(prefix: &Path) -> Vec<(String, String)> { + let prefix_value = prefix.to_string_lossy().into_owned(); + let cache_value = prefix.join("cache").to_string_lossy().into_owned(); + let corepack_value = prefix.join("corepack").to_string_lossy().into_owned(); + vec![ + ("NPM_CONFIG_PREFIX".to_string(), prefix_value.clone()), + ("npm_config_prefix".to_string(), prefix_value), + ("NPM_CONFIG_CACHE".to_string(), cache_value.clone()), + ("npm_config_cache".to_string(), cache_value), + ("COREPACK_HOME".to_string(), corepack_value), + ] +} + +/// Overlay the managed npm env onto an environment snapshot, replacing any +/// same-named entries so a stray inherited value can never win. +pub fn apply_managed_npm_env(vars: &mut Vec<(String, String)>, overrides: &[(String, String)]) { + for (key, value) in overrides { + match vars.iter_mut().find(|(existing, _)| existing == key) { + Some(entry) => entry.1 = value.clone(), + None => vars.push((key.clone(), value.clone())), + } + } +} + +/// Whether a doctor fix command runs through npm — and therefore needs the +/// managed Node runtime installed first. Mirrors the doctor crate's (private) +/// npm-command predicate so the two stay in agreement about which commands +/// get registry/env treatment. +pub fn is_npm_backed_command(command: &str) -> bool { + command.starts_with("npm ") || command.contains("npm install") || command.contains("npm view") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_registry_follows_registry_feature() { + if cfg!(feature = "no-block-npm-registry") { + assert_eq!(npm_registry(), None); + } else { + assert_eq!(npm_registry(), Some(BLOCK_NPM_REGISTRY_URL)); + } + } + + #[test] + fn path_helpers_lay_out_the_packages_tree() { + let root = Path::new("/home/.staged/packages"); + assert_eq!(shim_bin_dir(root), root.join("bin")); + assert_eq!(tools_root(root), root.join("tools")); + assert_eq!( + tool_install_dir(root, "claude-acp"), + root.join("tools").join("claude-acp") + ); + assert_eq!(state_path(root), root.join("state.json")); + } + + #[test] + fn managed_tools_require_no_override_and_a_supported_target() { + assert!(managed_tools_enabled_from_parts(false, true)); + assert!(!managed_tools_enabled_from_parts(true, true)); + assert!(!managed_tools_enabled_from_parts(false, false)); + } + + #[test] + fn managed_npm_env_points_every_pair_into_the_prefix() { + let env = managed_npm_env_at(Path::new("/data/packages/npm-prefix")); + let expect = |key: &str, value: &str| { + assert_eq!( + env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()), + Some(value), + "{key}" + ); + }; + expect("NPM_CONFIG_PREFIX", "/data/packages/npm-prefix"); + expect("npm_config_prefix", "/data/packages/npm-prefix"); + expect("NPM_CONFIG_CACHE", "/data/packages/npm-prefix/cache"); + expect("npm_config_cache", "/data/packages/npm-prefix/cache"); + expect("COREPACK_HOME", "/data/packages/npm-prefix/corepack"); + assert_eq!(env.len(), 5); + } + + #[test] + fn apply_managed_npm_env_replaces_and_inserts() { + let mut vars = vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("NPM_CONFIG_PREFIX".to_string(), "/stray/prefix".to_string()), + ]; + + apply_managed_npm_env( + &mut vars, + &managed_npm_env_at(Path::new("/data/npm-prefix")), + ); + + assert_eq!(vars.len(), 6); + assert_eq!(vars[0], ("PATH".to_string(), "/usr/bin".to_string())); + assert_eq!( + vars[1], + ( + "NPM_CONFIG_PREFIX".to_string(), + "/data/npm-prefix".to_string() + ) + ); + assert!(vars + .iter() + .any(|(k, v)| k == "COREPACK_HOME" && v == "/data/npm-prefix/corepack")); + } + + #[test] + fn managed_prepend_dirs_orders_shims_then_prefix_then_node() { + assert_eq!( + managed_prepend_dirs_from_parts( + None, + Some(PathBuf::from("/data/packages/bin")), + Some(PathBuf::from("/data/packages/npm-prefix/bin")), + Some(PathBuf::from("/data/packages/node/v1/plat/bin")), + ), + vec![ + PathBuf::from("/data/packages/bin"), + PathBuf::from("/data/packages/npm-prefix/bin"), + PathBuf::from("/data/packages/node/v1/plat/bin"), + ] + ); + // The dev override replaces the managed shim dir and resolves first; + // the prefix bin still resolves already-installed shims (host node + // may run them). + assert_eq!( + managed_prepend_dirs_from_parts( + Some(PathBuf::from("/dev/acp/bin")), + None, + Some(PathBuf::from("/data/packages/npm-prefix/bin")), + None + ), + vec![ + PathBuf::from("/dev/acp/bin"), + PathBuf::from("/data/packages/npm-prefix/bin"), + ] + ); + } + + #[test] + fn npm_backed_commands_are_detected() { + for command in [ + "npm install -g @github/copilot", + "npm install -g amp-acp@latest --registry=https://example.test/npm/", + "sh -c 'npm install -g @agentclientprotocol/claude-agent-acp'", + ] { + assert!(is_npm_backed_command(command), "{command}"); + } + for command in [ + "curl -fsSL https://cursor.com/install | bash", + "brew install --cask codex", + "claude /login", + ] { + assert!(!is_npm_backed_command(command), "{command}"); + } + } +} diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs index 0fb55a7f9..cf7978bef 100644 --- a/apps/staged/src-tauri/src/managed_node.rs +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -284,6 +284,10 @@ async fn lock_packages_dir(packages_root: &Path) -> Result Date: Mon, 3 Aug 2026 18:07:47 +1000 Subject: [PATCH 3/6] feat(staged): float managed ACP bridge installs behind a startup reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the app-managed Node runtime port from Berd: the claude/codex ACP bridges become floating npm installs under ~/.staged/packages, installed and upgraded on every launch by a startup reconciler, with the bundled resources demoted to a search-path fallback until step 4 flips the bundle off. - managed_acp_tools.rs (second half): the MANAGED_TOOLS table (claude-acp and codex-acp, both vendoring their agent's full CLI) and install_managed_tool — ensure the managed runtime, run a floating `npm install @latest --prefix tools/` on the managed npm (15-min timeout, --ignore-scripts, square-npm registry unless no-block-npm-registry), floor-check the dist/index.js entrypoint in place of the old lock's integrity pins, write a #!/bin/sh shim execing the managed node by absolute path (temp + chmod + atomic rename), and record version + node_version in state.json; installs run in place so a failed offline upgrade keeps the prior version working, and every mutation of the shared packages tree holds the cross-process flock (managed_node's lock, now pub(crate)) on top of the in-process tool-install mutex, taken only after the runtime ensure releases the same flock since nesting it deadlocks - acp_tools_reconciler.rs, spawned from lib.rs setup: installs/upgrades every managed tool, then finish_reconcile prunes stale ids/shims/tool dirs, records the outcome in state.json, and — only when every install succeeded — prunes superseded Node runtimes, since a failed bridge's un-rewritten shim may still exec the old runtime; the acp-tools-reconciled {ok, providerIds} event goes out via emit_to_all (WebSocket fanout included) on success and failure alike, and failures retry next launch - acp_tools.rs resolution precedence flips to STAGED_ACP_TOOLS_DIR -> managed shim dir -> bundled resource dir; the new AcpToolsDirs carries the primary (registered with acp_client's find_command, labeled bundled by doctor so no manual-update nag) plus the resource dir as the trailing spawned-PATH fallback through session spawns and doctor env shaping - doctor.rs routes fixes and updates for ai-agent-claude/ai-agent-codex through install_managed_tool instead of the crate's `npm install -g`, falling back to the regular commands whenever management is off; DoctorCheckRow's readout label becomes "Managed by Staged" - frontend: lib/listeners/acpToolsListener.ts (mounted in App.svelte) re-runs provider discovery (force, past the 30-minute SWR cache) and any loaded doctor report on the reconcile event, so a fresh profile's agent picker unsticks without a manual refresh or restart - new no-managed-acp-tools cargo feature compiles the managed set empty and hides the shim dir from PATH prepends, so a restricted build cannot pick up shims another build left in the shared ~/.staged tree Gates: just check-all passes (cargo fmt, clippy -D warnings, svelte typecheck, 557 Rust tests including 14 new managed_acp_tools tests covering the tool table, shim writing, state round-trips, the fake-npm install flow, and the reconcile epilogue's success-gated prune, 481 frontend tests); clippy and the full Rust suite also pass under --features no-block-npm-registry,no-managed-acp-tools. Pending manual verification for a later session (needs a scratch HOME and network control): first launch installs both bridges and the picker unsticks without reopening; kill-mid-download recovers on relaunch; offline launch keeps a prior install working; STAGED_ACP_TOOLS_DIR pointed at a local claude-agent-acp checkout still wins. One accepted transitional window until step 4: in-process resolution (find_command) consults the shim dir, so on a brand-new profile the bridges surface in the picker only once the first reconcile lands — the bundled resource fallback covers spawned-env PATH resolution, not the OnceLock dir. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/Cargo.toml | 7 + apps/staged/src-tauri/src/acp_tools.rs | 211 ++-- .../src-tauri/src/acp_tools_reconciler.rs | 81 ++ apps/staged/src-tauri/src/doctor.rs | 111 +- apps/staged/src-tauri/src/lib.rs | 17 +- .../staged/src-tauri/src/managed_acp_tools.rs | 946 +++++++++++++++++- apps/staged/src-tauri/src/managed_node.rs | 17 +- apps/staged/src-tauri/src/session_runner.rs | 17 +- apps/staged/src/App.svelte | 6 + .../lib/features/doctor/DoctorCheckRow.svelte | 7 +- .../src/lib/listeners/acpToolsListener.ts | 35 + 11 files changed, 1329 insertions(+), 126 deletions(-) create mode 100644 apps/staged/src-tauri/src/acp_tools_reconciler.rs create mode 100644 apps/staged/src/lib/listeners/acpToolsListener.ts diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 971d1f1a3..364427766 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -92,6 +92,13 @@ flate2 = "1" # Artifactory's square-npm proxy. no-block-npm-registry = [] +# no-managed-acp-tools: compiles the managed ACP bridge set empty for +# restricted builds — the startup reconciler installs nothing, doctor fixes +# fall back to the doctor crate's regular commands, and the managed shim dir +# is hidden from PATH prepends so shims another build left in the shared +# ~/.staged/packages tree cannot resolve. +no-managed-acp-tools = [] + # Debug binaries archived — uncomment when needed # [[bin]] # name = "debug_diff" diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index 935ec842c..774814f90 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -1,15 +1,17 @@ -//! Bundled ACP bridge tool resolution and spawn-environment shaping. +//! ACP bridge tool resolution and spawn-environment shaping. //! -//! Staged ships pinned ACP bridge CLIs (`claude-agent-acp`, `codex-acp`) as -//! application resources (see `acp-tools.lock.json` and -//! `scripts/prepare-acp-tools-resource.sh`). This module resolves the staged -//! bin directory at runtime and shapes captured shell-env snapshots so the -//! bundled bridges win over user-installed copies while everything else on -//! the user's PATH (including installed harness CLIs and their auth state) -//! stays discoverable. The Staged-managed install dirs (`managed_acp_tools`: -//! bridge shims, the private npm prefix's bin, the managed Node runtime's -//! bin) are folded into the same shaping, so sessions and doctor share one -//! PATH layout. +//! The claude/codex ACP bridges resolve, in precedence order, from the +//! `STAGED_ACP_TOOLS_DIR` dev override, the Staged-managed bridge shims the +//! startup reconciler installs (`managed_acp_tools`), and — until the step-4 +//! bundle flip — the pinned bridges Staged still ships as application +//! resources (see `acp-tools.lock.json` and +//! `scripts/prepare-acp-tools-resource.sh`). This module resolves those +//! directories at runtime and shapes captured shell-env snapshots so they +//! win over user-installed copies while everything else on the user's PATH +//! (including installed harness CLIs and their auth state) stays +//! discoverable. The other Staged-managed install dirs (the private npm +//! prefix's bin, the managed Node runtime's bin) are folded into the same +//! shaping, so sessions and doctor share one PATH layout. use std::path::{Path, PathBuf}; @@ -30,16 +32,35 @@ const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; /// Goose reads extra binary search dirs from this env var as a JSON array. const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; -/// Resolve the directory holding the bundled ACP bridge executables: the dev -/// env override wins, then the Tauri resource dir for packaged apps. -pub fn resolve_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) -> Option { - bundled_acp_tools_dir_from_parts( +/// The resolved ACP bridge directories for this build and environment. +#[derive(Clone, Debug, Default)] +pub struct AcpToolsDirs { + /// The highest-precedence bridge dir: the `STAGED_ACP_TOOLS_DIR` dev + /// override, else the managed shim dir (when this build manages + /// bridges), else the bundled resource dir. Registered with + /// `acp_client::set_bundled_tools_dir` and labeled `Bundled` by doctor — + /// Staged owns updates for whatever resolves here, so the user is never + /// nagged to update it manually. + pub primary: Option, + /// The bundled resource dir when it is not already the primary: it stays + /// on the spawned-env search path as the last-resort fallback until the + /// step-4 bundle flip, so the bundled bridges keep working (for doctor + /// checks and agent subprocesses) on profiles the reconciler has not + /// populated yet. + pub resource_fallback: Option, +} + +/// Resolve the ACP bridge directories: dev env override → managed shim dir → +/// bundled resource dir, with the resource dir kept as a trailing search-dir +/// fallback while it is not the primary. +pub fn resolve_acp_tools_dirs(app_handle: &tauri::AppHandle) -> AcpToolsDirs { + acp_tools_dirs_from_parts( crate::managed_acp_tools::dev_tools_override_dir(), + crate::managed_acp_tools::managed_shim_bin_dir(), app_handle .path() .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) - .ok() - .as_deref(), + .ok(), ) } @@ -53,21 +74,29 @@ pub fn node_runtime_manifest_path(bin_dir: &Path) -> Option { .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) } -fn bundled_acp_tools_dir_from_parts( +fn acp_tools_dirs_from_parts( env_override: Option, - resource_dir: Option<&Path>, -) -> Option { - env_override.or_else(|| resource_dir.map(Path::to_path_buf)) + managed_shim_dir: Option, + resource_dir: Option, +) -> AcpToolsDirs { + let primary = env_override + .or(managed_shim_dir) + .or_else(|| resource_dir.clone()); + let resource_fallback = resource_dir.filter(|dir| primary.as_deref() != Some(dir)); + AcpToolsDirs { + primary, + resource_fallback, + } } -/// Shape a captured shell-env snapshot so the bundled ACP bridges — and the -/// Staged-managed npm install locations — win: +/// Shape a captured shell-env snapshot so the managed/bundled ACP bridges — +/// and the Staged-managed npm install locations — win: /// /// - Prepend the tool search dirs (see [`tool_search_dirs`]) to the /// snapshot's PATH, keeping the rest of the imported shell PATH intact so /// user-installed CLIs (and their auth state) remain discoverable. /// - Pin `GOOSE_SEARCH_PATHS` *after* the shell-env import so same-named -/// values from the user's shell cannot override the bundled tools. The +/// values from the user's shell cannot override the managed tools. The /// value is a JSON array to match Goose's config env parsing, scoped to /// the same search dirs plus any explicit pre-existing Goose search dirs — /// never the whole shell PATH. @@ -75,34 +104,38 @@ fn bundled_acp_tools_dir_from_parts( /// Sessions and doctor checks/fixes both shape their snapshots here, so an /// agent installed by a doctor fix into the private npm prefix resolves /// identically at check time and at spawn time. -pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, bundled_dir: &Path) { - let dirs = tool_search_dirs(bundled_dir); - prepend_dirs_to_path(vars, &dirs); - apply_goose_search_paths(vars, &dirs); +pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, dirs: &AcpToolsDirs) { + let search_dirs = tool_search_dirs(dirs); + if search_dirs.is_empty() { + return; + } + prepend_dirs_to_path(vars, &search_dirs); + apply_goose_search_paths(vars, &search_dirs); } -/// Every dir agent binaries resolve from, in precedence order: the bundled -/// (or `STAGED_ACP_TOOLS_DIR`) bridge dir, then the managed dirs — bridge -/// shims, the private npm prefix's bin, and the managed Node runtime's bin, +/// Every dir agent binaries resolve from, in precedence order: the primary +/// bridge dir (dev override or managed shims), then the remaining managed +/// dirs — the private npm prefix's bin and the managed Node runtime's bin, /// which also lets the bundled bridge wrappers find `node` without a host -/// install once the managed runtime exists. -fn tool_search_dirs(bundled_dir: &Path) -> Vec { - tool_search_dirs_from_parts( - bundled_dir, - crate::managed_acp_tools::managed_prepend_dirs(), - ) +/// install once the managed runtime exists — and the bundled resource dir +/// last, as the fallback for bridges the reconciler has not installed yet. +fn tool_search_dirs(dirs: &AcpToolsDirs) -> Vec { + tool_search_dirs_from_parts(dirs, crate::managed_acp_tools::managed_prepend_dirs()) } -fn tool_search_dirs_from_parts(bundled_dir: &Path, managed_dirs: Vec) -> Vec { - // The dev override dir appears both as `bundled_dir` and as the first - // managed prepend when the env var is set; keep the first occurrence. - let mut dirs = vec![bundled_dir.to_path_buf()]; - for dir in managed_dirs { - if !dirs.contains(&dir) { - dirs.push(dir); +fn tool_search_dirs_from_parts(dirs: &AcpToolsDirs, managed_dirs: Vec) -> Vec { + // The primary dir also appears as the first managed prepend (the dev + // override or the shim dir); keep the first occurrence of each dir. + let mut search_dirs: Vec = dirs.primary.clone().into_iter().collect(); + for dir in managed_dirs + .into_iter() + .chain(dirs.resource_fallback.clone()) + { + if !search_dirs.contains(&dir) { + search_dirs.push(dir); } } - dirs + search_dirs } fn prepend_dirs_to_path(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { @@ -152,8 +185,8 @@ fn parse_goose_search_paths(value: &str) -> Result, serde_json::Erro #[cfg(test)] mod tests { use super::{ - apply_goose_search_paths, bundled_acp_tools_dir_from_parts, prepend_dirs_to_path, - tool_search_dirs_from_parts, + acp_tools_dirs_from_parts, apply_goose_search_paths, prepend_dirs_to_path, + tool_search_dirs_from_parts, AcpToolsDirs, }; use std::path::{Path, PathBuf}; @@ -166,29 +199,55 @@ mod tests { } #[test] - fn env_override_wins_over_resource_dir() { + fn env_override_wins_over_shim_and_resource_dirs() { + let resolved = acp_tools_dirs_from_parts( + Some(PathBuf::from("/dev/acp/bin")), + Some(PathBuf::from("/data/packages/bin")), + Some(PathBuf::from("/bundle/resources/acp/bin")), + ); + assert_eq!(resolved.primary.as_deref(), Some(Path::new("/dev/acp/bin"))); assert_eq!( - bundled_acp_tools_dir_from_parts( - Some(PathBuf::from("/dev/acp/bin")), - Some(Path::new("/bundle/resources/acp/bin")), - ) - .as_deref(), - Some(Path::new("/dev/acp/bin")), + resolved.resource_fallback.as_deref(), + Some(Path::new("/bundle/resources/acp/bin")), + ); + } + + #[test] + fn managed_shim_dir_wins_over_resource_dir() { + let resolved = acp_tools_dirs_from_parts( + None, + Some(PathBuf::from("/data/packages/bin")), + Some(PathBuf::from("/bundle/resources/acp/bin")), + ); + assert_eq!( + resolved.primary.as_deref(), + Some(Path::new("/data/packages/bin")), + ); + assert_eq!( + resolved.resource_fallback.as_deref(), + Some(Path::new("/bundle/resources/acp/bin")), ); } #[test] - fn missing_env_override_falls_back_to_resource_dir() { + fn resource_dir_as_primary_is_not_repeated_as_fallback() { + // No override and no managed shims (e.g. no-managed-acp-tools + // builds): the resource dir is the primary and must not double as + // the fallback. + let resolved = + acp_tools_dirs_from_parts(None, None, Some(PathBuf::from("/bundle/resources/acp/bin"))); assert_eq!( - bundled_acp_tools_dir_from_parts(None, Some(Path::new("/bundle/resources/acp/bin"))) - .as_deref(), + resolved.primary.as_deref(), Some(Path::new("/bundle/resources/acp/bin")), ); + assert!(resolved.resource_fallback.is_none()); } #[test] fn missing_inputs_resolve_to_none() { - assert!(bundled_acp_tools_dir_from_parts(None, None).is_none()); + let resolved = acp_tools_dirs_from_parts(None, None, None); + assert!(resolved.primary.is_none()); + assert!(resolved.resource_fallback.is_none()); } #[test] @@ -200,17 +259,24 @@ mod tests { assert!(super::node_runtime_manifest_path(Path::new("/")).is_none()); } + fn tools_dirs(primary: &str, resource_fallback: Option<&str>) -> AcpToolsDirs { + AcpToolsDirs { + primary: Some(PathBuf::from(primary)), + resource_fallback: resource_fallback.map(PathBuf::from), + } + } + #[test] - fn tool_search_dirs_start_with_bundled_then_managed() { + fn tool_search_dirs_order_shims_then_managed_then_resource_fallback() { assert_eq!( tool_search_dirs_from_parts( - Path::new("/bundle/acp/bin"), + &tools_dirs("/data/packages/bin", Some("/bundle/acp/bin")), dirs(&["/data/packages/bin", "/data/packages/npm-prefix/bin"]), ), dirs(&[ - "/bundle/acp/bin", "/data/packages/bin", "/data/packages/npm-prefix/bin", + "/bundle/acp/bin", ]) ); } @@ -218,13 +284,30 @@ mod tests { #[test] fn tool_search_dirs_dedupe_the_dev_override() { // With STAGED_ACP_TOOLS_DIR set, the override dir arrives both as the - // bundled dir and as the first managed prepend; it must appear once. + // primary and as the first managed prepend; it must appear once. assert_eq!( tool_search_dirs_from_parts( - Path::new("/dev/acp/bin"), + &tools_dirs("/dev/acp/bin", Some("/bundle/acp/bin")), dirs(&["/dev/acp/bin", "/data/packages/npm-prefix/bin"]), ), - dirs(&["/dev/acp/bin", "/data/packages/npm-prefix/bin"]) + dirs(&[ + "/dev/acp/bin", + "/data/packages/npm-prefix/bin", + "/bundle/acp/bin", + ]) + ); + } + + #[test] + fn tool_search_dirs_handle_a_resource_only_resolution() { + // Bundled-resource primary (nothing managed): no duplicate, managed + // prefix dirs still searched. + assert_eq!( + tool_search_dirs_from_parts( + &tools_dirs("/bundle/acp/bin", None), + dirs(&["/data/packages/npm-prefix/bin"]), + ), + dirs(&["/bundle/acp/bin", "/data/packages/npm-prefix/bin"]) ); } diff --git a/apps/staged/src-tauri/src/acp_tools_reconciler.rs b/apps/staged/src-tauri/src/acp_tools_reconciler.rs new file mode 100644 index 000000000..25fd41ebf --- /dev/null +++ b/apps/staged/src-tauri/src/acp_tools_reconciler.rs @@ -0,0 +1,81 @@ +//! Startup reconciler for the Staged-managed ACP bridges. +//! +//! Spawned from app setup: installs or upgrades every managed bridge +//! ([`crate::managed_acp_tools::MANAGED_TOOLS`]) to the latest published +//! version on launch, so a new bridge release ships to users the next time +//! Staged starts. Each install runs a floating `npm install @latest` +//! onto the Staged-managed Node runtime in `~/.staged/packages`. Failures are +//! logged, recorded in `state.json`, and retried on the next launch; a +//! previously installed version keeps working in the meantime, so an offline +//! launch never removes a working bridge. Superseded managed Node runtimes +//! are pruned only in the epilogue of a fully-successful run — every bridge +//! shim execs its Node by absolute versioned path, so an old runtime must +//! outlive the last shim that references it. +//! +//! Silent when there is nothing to manage: the `STAGED_ACP_TOOLS_DIR` dev +//! override is active, the `no-managed-acp-tools` build feature is set, or +//! the target is unsupported. +//! +//! Completion is broadcast to the renderer as [`ACP_TOOLS_RECONCILED_EVENT`]: +//! on a fresh profile the frontend caches its doctor report and provider +//! discovery (bridges missing) long before the reconciler finishes +//! downloading Node and installing the bridges, and nothing re-probes on its +//! own — without a signal the agent picker keeps reporting missing bridges +//! that are already installed until the user manually refreshes or restarts. + +use tauri::AppHandle; + +use crate::managed_acp_tools; + +/// Emitted once per launch after the reconciler finishes, successful or not — +/// a partial failure still installs the other bridge, so the renderer should +/// re-probe either way. Mirrored in `src/lib/listeners/acpToolsListener.ts`. +pub const ACP_TOOLS_RECONCILED_EVENT: &str = "acp-tools-reconciled"; + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AcpToolsReconciledPayload { + ok: bool, + /// Managed tool ids (`claude-acp`, `codex-acp`). + provider_ids: Vec<&'static str>, +} + +pub fn spawn_startup_reconcile(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + reconcile(app).await; + }); +} + +async fn reconcile(app: AppHandle) { + let tools = managed_acp_tools::managed_tools(); + if tools.is_empty() { + return; + } + + let mut errors = Vec::new(); + for tool in &tools { + let log_prefix = format!("[acp-tools reconcile {}]", tool.id); + let on_line = |line: &str| log::info!("{log_prefix} {line}"); + match managed_acp_tools::install_managed_tool(tool.id, &on_line).await { + Ok(()) => log::info!("{log_prefix} {} is up to date", tool.package), + Err(error) => { + log::warn!("{log_prefix} install failed (will retry next launch): {error}"); + errors.push(format!("{}: {error}", tool.id)); + } + } + } + let ok = errors.is_empty(); + managed_acp_tools::finish_reconcile(&tools, errors).await; + + // Through `emit_to_all` rather than a bare Tauri emit so web-mode + // browser clients get the refresh signal over the WebSocket fanout too. + crate::web_server::emit_to_all( + &app, + ACP_TOOLS_RECONCILED_EVENT, + AcpToolsReconciledPayload { + ok, + provider_ids: tools.iter().map(|tool| tool.id).collect(), + }, + ); +} diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 14c87ce36..f4646d7f5 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -10,19 +10,17 @@ pub use doctor::{ /// Environment snapshot for doctor checks and fixes. Shaped through /// `apply_bundled_tools_env` so checks resolve binaries from the same PATH -/// the agent spawn path uses — a bridge Staged bundles must never be -/// reported missing (or prompt an install) just because the user has no +/// the agent spawn path uses — a bridge Staged manages or bundles must never +/// be reported missing (or prompt an install) just because the user has no /// global copy. The managed npm env is overlaid on top, so checks probe npm /// state (`npm prefix -g`, version lookups) with the same private-prefix view /// the fixes install into — a check never contradicts the fix that just ran. -async fn doctor_env_vars(bundled_dir: Option<&Path>) -> Vec<(String, String)> { +async fn doctor_env_vars(dirs: &crate::acp_tools::AcpToolsDirs) -> Vec<(String, String)> { let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), ) .await; - if let Some(dir) = bundled_dir { - crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dir); - } + crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dirs); crate::managed_acp_tools::apply_managed_npm_env( &mut env_vars, &crate::managed_acp_tools::managed_npm_env(), @@ -46,8 +44,9 @@ fn run_checks_options( env: None, // Doctor labels binaries resolved from this dir as bundled (install // source + readout flag) and suppresses registry update fixes for - // them — versions are pinned by acp-tools.lock.json and ship with - // Staged updates. + // them — Staged owns their updates, whether they are managed shims + // the startup reconciler floats to @latest or bridges pinned by + // acp-tools.lock.json shipping with Staged updates. bundled_tools_dir: bundled_dir, } .with_env_snapshot(env_vars) @@ -93,13 +92,13 @@ pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport /// readouts are labeled by the doctor crate itself via /// `RunChecksOptions::bundled_tools_dir`. async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) -> DoctorReport { - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; + let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(app_handle); + let env_vars = doctor_env_vars(&acp_tools_dirs).await; let (mut report, node_runtime) = tokio::join!( doctor::run_checks_with_options(run_checks_options( check_freshness, env_vars.clone(), - bundled_dir.clone(), + acp_tools_dirs.primary.clone(), )), run_node_runtime_check(), ); @@ -112,11 +111,14 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) /// Run a fix for a doctor check, identified by check ID and fix type. /// /// The actual shell command is looked up from the static check definitions — -/// the caller never sends a raw command string. The node-runtime fix is -/// native — (re)install the pinned managed runtime — not a shell command; -/// npm-backed fixes install the managed runtime first, since they run npm -/// from it into the private prefix (the existing "Running…" spinner covers -/// the one-time download). +/// the caller never sends a raw command string. Two families of fixes are +/// native rather than shell commands: the node-runtime fix (re)installs the +/// pinned managed runtime, and install fixes for the managed ACP bridges run +/// the floating managed installer so the bridge lands in +/// `~/.staged/packages/tools` with an absolute-path shim instead of the +/// crate's `npm install -g`. Remaining npm-backed fixes install the managed +/// runtime first, since they run npm from it into the private prefix (the +/// existing "Running…" spinner covers the one-time download). #[tauri::command] pub async fn run_doctor_fix( app_handle: tauri::AppHandle, @@ -126,8 +128,13 @@ pub async fn run_doctor_fix( if check_id == NODE_RUNTIME_CHECK_ID { return ensure_managed_node_runtime_for_fix().await; } - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; + if matches!(fix_type, FixType::Command | FixType::Bridge) { + if let Some(tool_id) = managed_tool_for_check(&check_id) { + return install_managed_tool_logged(tool_id, &check_id).await; + } + } + let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); + let env_vars = doctor_env_vars(&acp_tools_dirs).await; if doctor::agents::lookup_fix_command(&check_id, &fix_type) .as_deref() .is_some_and(crate::managed_acp_tools::is_npm_backed_command) @@ -138,6 +145,31 @@ pub async fn run_doctor_fix( .await } +/// The managed ACP bridge behind a doctor check id, when this build manages +/// it. `None` routes the check to the doctor crate's regular fix commands — +/// which is also the correct fallback whenever bridge management is off (dev +/// override, `no-managed-acp-tools`, unsupported target). +fn managed_tool_for_check(check_id: &str) -> Option<&'static str> { + let tool_id = match check_id { + "ai-agent-claude" => "claude-acp", + "ai-agent-codex" => "codex-acp", + _ => return None, + }; + crate::managed_acp_tools::managed_tool(tool_id).map(|tool| tool.id) +} + +/// Install (or float-upgrade) a managed bridge for a doctor fix/update. +/// Progress goes to the log — doctor fixes have no streamed-output channel, +/// only a button spinner. +async fn install_managed_tool_logged(tool_id: &str, check_id: &str) -> Result<(), String> { + let log_prefix = format!("[doctor fix {check_id}]"); + crate::managed_acp_tools::install_managed_tool(tool_id, &|line| { + log::info!("{log_prefix} {line}"); + }) + .await + .map_err(|error| error.to_string()) +} + /// Install (or repair) the managed Node.js runtime ahead of a fix that needs /// it. Progress goes to the log — doctor fixes have no streamed-output /// channel, only a button spinner. @@ -166,10 +198,26 @@ pub async fn run_doctor_update( fix_type: FixType, command: String, ) -> Result<(), String> { - let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); - let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; - let expected = - expected_update_command(&check_id, &fix_type, env_vars.clone(), bundled_dir).await?; + // Updates for the managed ACP bridges are the floating installer itself + // (`@latest` onto the managed runtime) — no shell command runs, so + // the frontend-supplied command needs no validation here. Readouts + // resolved from the managed shim dir derive no update command at all + // (they are labeled bundled), so this arm only fires for a bridge copy + // that resolved elsewhere (e.g. the resource fallback on a profile the + // reconciler has not populated yet) — and the managed install is the + // correct upgrade for that state too. + if let Some(tool_id) = managed_tool_for_check(&check_id) { + return install_managed_tool_logged(tool_id, &check_id).await; + } + let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); + let env_vars = doctor_env_vars(&acp_tools_dirs).await; + let expected = expected_update_command( + &check_id, + &fix_type, + env_vars.clone(), + acp_tools_dirs.primary.clone(), + ) + .await?; if expected != command { return Err(format!( "Update command mismatch for {check_id}: refusing to run a command \ @@ -487,6 +535,25 @@ mod tests { assert!(opts.bundled_tools_dir.is_none()); } + /// Fixes and updates for the two bridge checks route to the managed + /// installer exactly when this build manages bridges; every other check + /// keeps the doctor crate's regular fix commands. + #[test] + fn managed_bridge_checks_route_to_the_managed_installer() { + let managed = crate::managed_acp_tools::managed_tools_enabled(); + assert_eq!( + managed_tool_for_check("ai-agent-claude"), + managed.then_some("claude-acp") + ); + assert_eq!( + managed_tool_for_check("ai-agent-codex"), + managed.then_some("codex-acp") + ); + assert_eq!(managed_tool_for_check("ai-agent-copilot"), None); + assert_eq!(managed_tool_for_check("ai-agent-amp"), None); + assert_eq!(managed_tool_for_check(NODE_RUNTIME_CHECK_ID), None); + } + /// Checks and fixes must agree on the registry: both option builders take /// it from the same `managed_acp_tools::npm_registry()` gate. #[test] diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d28ae30d4..87f58991f 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) mod acp_config; pub mod acp_tools; +pub mod acp_tools_reconciler; pub mod actions; pub mod agent; pub mod background_sync; @@ -1873,13 +1874,23 @@ pub fn run() { ) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { - // Register the bundled ACP bridge tools dir before any command + // Register the primary ACP bridge tools dir before any command // runs so binary resolution (session spawn, provider discovery) - // prefers the pinned bridges Staged ships as resources. - if let Some(dir) = acp_tools::resolve_bundled_acp_tools_dir(app.handle()) { + // prefers the managed bridge shims (or the STAGED_ACP_TOOLS_DIR + // dev override) over user-installed copies, falling back to the + // pinned bridges Staged still ships as resources. The shim dir + // path is stable even before the first reconcile writes shims — + // find_command probes per call and falls through to PATH until + // then. + if let Some(dir) = acp_tools::resolve_acp_tools_dirs(app.handle()).primary { acp_client::set_bundled_tools_dir(dir); } + // Install/upgrade the managed ACP bridges in the background; the + // renderer refreshes doctor + provider discovery on the + // completion event. + acp_tools_reconciler::spawn_startup_reconcile(app.handle()); + let updater_pubkey_present = app .config() .plugins diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index 414f6bf87..a414eef49 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -1,19 +1,28 @@ -//! Staged-managed ACP tool install locations and npm environment. +//! Staged-managed ACP tool installs. //! //! Staged owns both sides of every npm-backed agent install: the managed Node //! runtime (`managed_node`) supplies `node`/`npm`, and everything npm writes //! lands in Staged-private directories under `~/.staged/packages` instead of -//! the host's global prefix. This module holds the path layout, the npm env -//! pairs that steer installs into the private prefix, and the PATH prepends -//! that make the results resolvable; the floating bridge installer and the -//! startup reconciler that build on these land next. +//! the host's global prefix. Two install families live here: +//! +//! - **Private npm prefix** (`npm-prefix/`): the doctor crate's runtime +//! `npm install -g` fixes (copilot, amp-acp) are steered here by the env +//! pairs in [`managed_npm_env`]. +//! - **Managed bridges** (`tools//` + `bin/`): the claude/codex ACP +//! bridges in [`MANAGED_TOOLS`]. [`install_managed_tool`] installs — or +//! upgrades — each to the latest published version with a floating +//! `npm install @latest --prefix` on the managed runtime, writes an +//! absolute-path shim into `bin/` (no host `node` on PATH required), and +//! records the installed version in `state.json`. The startup reconciler +//! (`acp_tools_reconciler`) runs this for every managed bridge on launch, +//! so a new bridge release ships to users the next time Staged starts. //! //! Layout under `~/.staged/packages` (shared by every running Staged -//! instance — see the cross-process locking notes in `managed_node`): +//! instance — see the cross-process locking notes in `managed_node`; every +//! mutation of the bridge trees below holds that flock on top of this +//! module's in-process tool-install mutex): //! -//! - `npm-prefix/` — the private npm global prefix the doctor crate's -//! runtime `npm install -g` fixes (copilot, amp-acp) are steered into by -//! the env pairs in [`managed_npm_env`]. +//! - `npm-prefix/` — the private npm global prefix. //! - `node///` — managed Node runtimes (`managed_node`). //! - `tools//` — per-bridge npm `--prefix` trees for the managed ACP //! bridges (claude-acp, codex-acp). @@ -21,10 +30,19 @@ //! - `state.json` — installed bridge versions + last reconcile outcome. //! //! `STAGED_ACP_TOOLS_DIR` stays honored as a dev/bridge-developer override: -//! when set, bridge management is disabled (no managed shim dir) so the -//! override dir is the one source of bridge binaries. +//! when set, bridge management is disabled (no managed tools, no shim dir, +//! no installs) so the override dir is the one source of bridge binaries. +//! The `no-managed-acp-tools` build feature compiles the managed bridge set +//! to empty for restricted builds — nothing installs, and the shim dir is +//! hidden from PATH prepends so shims another build left in the shared tree +//! cannot resolve. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::io::AsyncBufReadExt; use crate::managed_node; @@ -63,17 +81,23 @@ fn dev_tools_override_active() -> bool { /// Whether this build manages ACP bridge installs (and therefore exposes the /// managed shim dir): the `STAGED_ACP_TOOLS_DIR` dev override supplies -/// bridges from its own dir instead, and an unsupported target has no -/// managed runtime to install onto. +/// bridges from its own dir instead, the `no-managed-acp-tools` feature +/// compiles management out for restricted builds, and an unsupported target +/// has no managed runtime to install onto. pub fn managed_tools_enabled() -> bool { managed_tools_enabled_from_parts( dev_tools_override_active(), + cfg!(feature = "no-managed-acp-tools"), managed_node::current_target_triple().is_some(), ) } -fn managed_tools_enabled_from_parts(override_active: bool, supported_target: bool) -> bool { - !override_active && supported_target +fn managed_tools_enabled_from_parts( + override_active: bool, + managed_tools_disabled: bool, + supported_target: bool, +) -> bool { + !override_active && !managed_tools_disabled && supported_target } /// The Staged-private npm global prefix, `~/.staged/packages/npm-prefix`. @@ -193,6 +217,538 @@ pub fn is_npm_backed_command(command: &str) -> bool { command.starts_with("npm ") || command.contains("npm install") || command.contains("npm view") } +// ============================================================================= +// The managed bridge set — installed and upgraded from the npm registry +// ============================================================================= + +/// A Staged-managed ACP bridge: installed and upgraded from the npm registry +/// at runtime (see [`install_managed_tool`]) rather than pinned and bundled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ManagedTool { + /// The install id (`tools/` dir name, `state.json` key). + pub id: &'static str, + /// The bin name the shim is written under — the same command name the + /// bundled bridges resolve as, so shims shadow the bundle seamlessly. + pub binary: &'static str, + /// The npm package installed from the registry. + pub package: &'static str, +} + +/// The ACP bridges Staged installs and upgrades on every launch. Both vendor +/// their agent's full CLI (Claude Code, `codex`) inside the npm package, so +/// no separate main-CLI install is needed. +pub const MANAGED_TOOLS: &[ManagedTool] = &[ + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + }, + ManagedTool { + id: "codex-acp", + binary: "codex-acp", + package: "@agentclientprotocol/codex-acp", + }, +]; + +/// The managed bridges this build installs at runtime, or an empty list when +/// nothing is managed (see [`managed_tools_enabled`]). +pub fn managed_tools() -> Vec { + if !managed_tools_enabled() { + return Vec::new(); + } + MANAGED_TOOLS.to_vec() +} + +/// The managed bridge with this install id, when this build manages it. +pub fn managed_tool(id: &str) -> Option { + managed_tools().into_iter().find(|tool| tool.id == id) +} + +// ============================================================================= +// state.json — installed versions + last reconcile result +// ============================================================================= + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ManagedToolsState { + pub tools: BTreeMap, + pub last_reconcile: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledToolPin { + pub binary: String, + /// The version npm resolved for `@latest`, recorded for the + /// reconcile log and future doctor readouts. Empty when the installed + /// `package.json` could not be read. + pub version: String, + /// The managed Node.js runtime version the shim execs by absolute path. + /// After a Node pin bump this trails the embedded pin until the bridge + /// reinstalls and its shim is rewritten — which is why superseded + /// runtimes are pruned only after a fully-successful reconcile. + #[serde(default)] + pub node_version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconcileRecord { + pub at_ms: u64, + pub ok: bool, + #[serde(default)] + pub errors: Vec, +} + +/// Read `/state.json`; a missing or corrupt file is an empty state. +pub(crate) fn read_state(packages_root: &Path) -> ManagedToolsState { + std::fs::read_to_string(state_path(packages_root)) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + +fn write_state(packages_root: &Path, state: &ManagedToolsState) -> std::io::Result<()> { + std::fs::create_dir_all(packages_root)?; + let path = state_path(packages_root); + let temp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; + std::fs::write(&temp, format!("{json}\n"))?; + std::fs::rename(&temp, &path) +} + +// ============================================================================= +// install_managed_tool — floating npm install + shim + state +// ============================================================================= + +/// Floating bridge installs download ~70-95 MB of packages through the +/// registry; a hung npm must not wedge the install mutex forever. +const NPM_INSTALL_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +#[derive(Debug)] +pub enum ManagedToolError { + DataDir(String), + /// This id is not managed on this build/target; callers route before + /// installing, so surfacing one means the managed set changed under a + /// running operation. + NotManaged(String), + Node(managed_node::ManagedNodeError), + NpmInstall(String), + /// The install exited cleanly but produced no runnable bridge — a floor + /// check replacing the old lock's integrity validation. + Incomplete(String), + Io(String), +} + +impl std::fmt::Display for ManagedToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DataDir(message) => { + write!( + f, + "failed to resolve the managed ACP tools directory: {message}" + ) + } + Self::NotManaged(message) => { + write!(f, "not a Staged-managed ACP bridge: {message}") + } + Self::Node(error) => error.fmt(f), + Self::NpmInstall(message) => write!(f, "npm install failed: {message}"), + Self::Incomplete(message) => { + write!(f, "installed ACP bridge is incomplete: {message}") + } + Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ManagedToolError {} + +/// Receives every progress/output line of an install. Doctor fixes and the +/// startup reconciler feed these to the log with their own prefixes; there is +/// no streamed-output UI channel for installs. +pub type InstallLineFn<'a> = dyn Fn(&str) + Send + Sync + 'a; + +fn tool_install_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +/// Install (or upgrade) one managed bridge to the latest published version: +/// ensure the managed Node runtime, run the floating `npm install +/// @latest --prefix`, write the absolute-path shim, and record the +/// installed version in `state.json`. Safe to call concurrently — doctor +/// fixes and the startup reconciler serialize on one process-wide install +/// mutex, and mutations of the shared packages tree additionally hold the +/// cross-process flock (other Staged instances reconcile the same +/// `~/.staged/packages`). A failed install leaves any previously installed +/// version in place — including the Node runtime its shim execs, since +/// superseded runtimes are pruned only after a fully-successful reconcile — +/// so an offline launch never removes a working bridge. +pub async fn install_managed_tool( + id: &str, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let tool = managed_tool(id).ok_or_else(|| { + ManagedToolError::NotManaged(format!("'{id}' is not a Staged-managed ACP bridge")) + })?; + let packages_root = crate::paths::packages_dir() + .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; + let node_root = managed_node::managed_node_root() + .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; + let node_install_dir = managed_node::pinned_install_dir(&node_root).ok_or_else(|| { + ManagedToolError::NotManaged("no managed Node.js runtime pin for this target".to_string()) + })?; + + let _guard = tool_install_lock().lock().await; + managed_node::ensure_managed_node_runtime() + .await + .map_err(ManagedToolError::Node)?; + // The cross-process lock is taken only after the runtime ensure has + // released the same flock — nesting the two would deadlock (see + // `lock_packages_dir`). In the unlocked window another process can at + // most install or prune, and both leave the pinned runtime in place. + let _packages_lock = managed_node::lock_packages_dir(&packages_root) + .await + .map_err(ManagedToolError::Node)?; + install_npm_tool( + &packages_root, + &node_install_dir, + &managed_node::node_runtime_lock().version, + &tool, + npm_registry(), + on_line, + ) + .await +} + +/// The install body, path-parameterized so tests drive it with a fixture +/// `npm`. Caller holds the install mutex + packages flock and has ensured +/// the runtime. +async fn install_npm_tool( + packages_root: &Path, + node_install_dir: &Path, + node_version: &str, + tool: &ManagedTool, + registry: Option<&str>, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let install_dir = tool_install_dir(packages_root, tool.id); + // Install in place: a failed floating upgrade leaves the previous tree, + // shim, and state untouched, so the old bridge keeps working. + std::fs::create_dir_all(&install_dir) + .map_err(|error| ManagedToolError::Io(format!("create tool install dir: {error}")))?; + + on_line(&format!( + "Installing {}@latest into ~/.staged/packages", + tool.package + )); + run_floating_npm_install( + packages_root, + node_install_dir, + &install_dir, + tool, + registry, + on_line, + ) + .await?; + + let entrypoint = npm_entrypoint(&install_dir, tool.package); + if !entrypoint.is_file() { + return Err(ManagedToolError::Incomplete(format!( + "{}: bridge entrypoint {} is missing after install", + tool.package, + entrypoint.display() + ))); + } + let version = installed_version(&install_dir, tool.package).unwrap_or_default(); + + write_shim( + &shim_bin_dir(packages_root), + tool.binary, + &shim_contents(&node_binary(node_install_dir), &entrypoint), + ) + .map_err(|error| ManagedToolError::Io(format!("write bridge shim: {error}")))?; + + let mut state = read_state(packages_root); + state.tools.insert( + tool.id.to_string(), + InstalledToolPin { + binary: tool.binary.to_string(), + version: version.clone(), + node_version: node_version.to_string(), + }, + ); + write_state(packages_root, &state) + .map_err(|error| ManagedToolError::Io(format!("write state.json: {error}")))?; + on_line(&format!( + "{}@{} is ready", + tool.package, + if version.is_empty() { + "latest" + } else { + version.as_str() + } + )); + Ok(()) +} + +/// `/node_modules//dist/index.js` — the bridge +/// entrypoint convention both managed bridges follow. +fn npm_entrypoint(install_dir: &Path, package: &str) -> PathBuf { + package_dir(install_dir, package) + .join("dist") + .join("index.js") +} + +fn package_dir(install_dir: &Path, package: &str) -> PathBuf { + package + .split('/') + .fold(install_dir.join("node_modules"), |dir, part| dir.join(part)) +} + +fn node_binary(node_install_dir: &Path) -> PathBuf { + node_install_dir.join("bin").join("node") +} + +/// The version npm resolved for the just-installed package, from its +/// `package.json`. Best-effort: the state record is informational, so an +/// unreadable version does not fail the install. +fn installed_version(install_dir: &Path, package: &str) -> Option { + let json = + std::fs::read_to_string(package_dir(install_dir, package).join("package.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&json).ok()?; + value + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +async fn run_floating_npm_install( + packages_root: &Path, + node_install_dir: &Path, + install_dir: &Path, + tool: &ManagedTool, + registry: Option<&str>, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let node_bin_dir = node_install_dir.join("bin"); + let mut command = tokio::process::Command::new(node_bin_dir.join("npm")); + command + .arg("install") + .arg("--prefix") + .arg(install_dir) + .args([ + "--omit=dev", + "--include=optional", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ]); + if let Some(registry) = registry { + command.arg("--registry").arg(registry); + } + // `@latest` floats to the newest published version; npm on the managed + // runtime resolves the platform-native optional dependency for the + // running machine on its own, so no `--os`/`--cpu` pinning is needed. + command.arg(format!("{}@latest", tool.package)); + + // npm's own `#!/usr/bin/env node` shebang must resolve the managed node. + let mut paths = vec![node_bin_dir.clone()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + if let Ok(path_value) = std::env::join_paths(paths) { + command.env("PATH", path_value); + } + // Share the private prefix's download cache; `--prefix` on the command + // line outranks any inherited prefix config. + let cache = packages_root.join("npm-prefix").join("cache"); + command.env("NPM_CONFIG_CACHE", &cache); + command.env("npm_config_cache", &cache); + command + .current_dir(install_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let mut child = command + .spawn() + .map_err(|error| ManagedToolError::NpmInstall(format!("spawn managed npm: {error}")))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let forward_out = async { + if let Some(stream) = stdout { + let mut lines = tokio::io::BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(&line); + } + } + }; + let forward_err = async { + if let Some(stream) = stderr { + let mut lines = tokio::io::BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(&line); + } + } + }; + let wait = async { + match tokio::time::timeout(NPM_INSTALL_TIMEOUT, child.wait()).await { + Ok(result) => result + .map_err(|error| ManagedToolError::NpmInstall(format!("wait on npm: {error}"))), + Err(_) => { + let _ = child.kill().await; + Err(ManagedToolError::NpmInstall(format!( + "timed out after {} seconds", + NPM_INSTALL_TIMEOUT.as_secs() + ))) + } + } + }; + let (status, (), ()) = tokio::join!(wait, forward_out, forward_err); + let status = status?; + if status.success() { + Ok(()) + } else { + Err(ManagedToolError::NpmInstall(format!( + "npm install exited with {status}" + ))) + } +} + +/// Shim body for a managed bridge. Both paths are absolute, so the shim needs +/// no `node` on PATH and cannot hit the bundled wrapper's exit-127 mode. +fn shim_contents(node: &Path, entrypoint: &Path) -> String { + format!( + "#!/bin/sh\n# Written by Staged's managed ACP tools installer; do not edit.\nexec {} {} \"$@\"\n", + sh_quote(node), + sh_quote(entrypoint) + ) +} + +fn sh_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', r"'\''")) +} + +fn write_shim(bin_dir: &Path, binary: &str, contents: &str) -> std::io::Result<()> { + std::fs::create_dir_all(bin_dir)?; + let path = bin_dir.join(binary); + let temp = bin_dir.join(format!(".{binary}.tmp")); + std::fs::write(&temp, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o755))?; + } + std::fs::rename(&temp, &path) +} + +// ============================================================================= +// Reconcile epilogue — prune stale ids, record the outcome, gate the Node prune +// ============================================================================= + +/// Reconcile epilogue: drop installs for ids no longer in the managed set +/// (their shims, tool dirs, and state entries), record the run's outcome in +/// `state.json`, and — only when every managed bridge installed cleanly — +/// prune superseded managed Node runtimes. Takes the install mutex and the +/// cross-process flock so it cannot race an in-flight install in this or any +/// other Staged process. +pub(crate) async fn finish_reconcile(managed: &[ManagedTool], errors: Vec) { + let Some(packages_root) = crate::paths::packages_dir() else { + return; + }; + finish_reconcile_at(&packages_root, managed, errors).await; +} + +async fn finish_reconcile_at(packages_root: &Path, managed: &[ManagedTool], errors: Vec) { + let all_installed = errors.is_empty(); + { + let _guard = tool_install_lock().lock().await; + let _packages_lock = match managed_node::lock_packages_dir(packages_root).await { + Ok(lock) => lock, + Err(error) => { + log::warn!("skipping ACP tools reconcile epilogue: {error}"); + return; + } + }; + prune_stale_managed_tools(packages_root, managed); + record_reconcile(packages_root, errors); + } + // Success-gated Node prune: `errors` empty means every managed bridge + // reinstalled this run, so every shim now embeds the pinned runtime's + // path and superseded runtimes are unreferenced. On partial failure the + // old runtime is kept — the failed bridge's un-rewritten shim still + // resolves a real Node, so an offline launch never breaks a working + // bridge. Runs outside the scope above: the prune takes the same flock + // itself, and nesting would deadlock (see `lock_packages_dir`). + if all_installed { + managed_node::prune_superseded_node_runtimes(packages_root).await; + } +} + +pub(crate) fn prune_stale_managed_tools(packages_root: &Path, managed: &[ManagedTool]) { + let managed_ids: Vec<&str> = managed.iter().map(|tool| tool.id).collect(); + let managed_binaries: Vec<&str> = managed.iter().map(|tool| tool.binary).collect(); + + let mut state = read_state(packages_root); + let stale: Vec = state + .tools + .keys() + .filter(|id| !managed_ids.contains(&id.as_str())) + .cloned() + .collect(); + for id in &stale { + if let Some(pin) = state.tools.remove(id) { + let _ = std::fs::remove_file(shim_bin_dir(packages_root).join(&pin.binary)); + } + } + if !stale.is_empty() { + if let Err(error) = write_state(packages_root, &state) { + log::warn!("failed to write ACP tools state after prune: {error}"); + } + } + + // Tool dirs with no state entry (crashed installs) and shims for binaries + // no longer managed. `/bin` holds only Staged-written shims, so + // pruning by name is safe. + if let Ok(entries) = std::fs::read_dir(tools_root(packages_root)) { + for entry in entries.flatten() { + if !managed_ids.contains(&entry.file_name().to_string_lossy().as_ref()) { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + if let Ok(entries) = std::fs::read_dir(shim_bin_dir(packages_root)) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with('.') && !managed_binaries.contains(&name.as_str()) { + let _ = std::fs::remove_file(entry.path()); + } + } + } +} + +pub(crate) fn record_reconcile(packages_root: &Path, errors: Vec) { + let mut state = read_state(packages_root); + state.last_reconcile = Some(ReconcileRecord { + at_ms: now_ms(), + ok: errors.is_empty(), + errors, + }); + if let Err(error) = write_state(packages_root, &state) { + log::warn!("failed to record ACP tools reconcile result: {error}"); + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -219,10 +775,11 @@ mod tests { } #[test] - fn managed_tools_require_no_override_and_a_supported_target() { - assert!(managed_tools_enabled_from_parts(false, true)); - assert!(!managed_tools_enabled_from_parts(true, true)); - assert!(!managed_tools_enabled_from_parts(false, false)); + fn managed_tools_require_no_override_no_disable_and_a_supported_target() { + assert!(managed_tools_enabled_from_parts(false, false, true)); + assert!(!managed_tools_enabled_from_parts(true, false, true)); + assert!(!managed_tools_enabled_from_parts(false, true, true)); + assert!(!managed_tools_enabled_from_parts(false, false, false)); } #[test] @@ -318,4 +875,355 @@ mod tests { assert!(!is_npm_backed_command(command), "{command}"); } } + + #[test] + fn managed_tools_table_lists_the_two_bridges() { + let ids: Vec<&str> = MANAGED_TOOLS.iter().map(|tool| tool.id).collect(); + assert_eq!(ids, vec!["claude-acp", "codex-acp"]); + for tool in MANAGED_TOOLS { + assert!( + tool.package.starts_with("@agentclientprotocol/"), + "{}", + tool.package + ); + assert!(!tool.binary.is_empty(), "{}", tool.id); + } + } + + // -- fixtures ----------------------------------------------------------- + + fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + fn test_tool() -> ManagedTool { + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + } + } + + const TEST_NODE_VERSION: &str = "v9.9.9"; + + fn write_json(path: &Path, value: &serde_json::Value) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap(); + } + + /// A fixture install tree: the package's `package.json` (with the resolved + /// version) and its `dist/index.js` entrypoint. + fn write_fixture_install(install_dir: &Path, tool: &ManagedTool, version: &str) { + write_json( + &package_dir(install_dir, tool.package).join("package.json"), + &serde_json::json!({ "name": tool.package, "version": version }), + ); + let entrypoint = npm_entrypoint(install_dir, tool.package); + std::fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + std::fs::write(&entrypoint, "// bridge\n").unwrap(); + } + + // -- shims -------------------------------------------------------------- + + #[test] + fn shim_contents_execs_absolute_paths_and_quotes_spaces() { + let contents = shim_contents( + Path::new("/data/dir with spaces/packages/node/v1/plat/bin/node"), + Path::new("/data/dir with spaces/packages/tools/claude-acp/node_modules/@scope/claude-acp/dist/index.js"), + ); + assert!(contents.starts_with("#!/bin/sh\n")); + assert!(contents.ends_with( + "exec '/data/dir with spaces/packages/node/v1/plat/bin/node' '/data/dir with spaces/packages/tools/claude-acp/node_modules/@scope/claude-acp/dist/index.js' \"$@\"\n" + )); + } + + #[test] + fn write_shim_is_executable() { + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + write_shim(&bin_dir, "claude-agent-acp", "#!/bin/sh\nexec true\n").unwrap(); + let shim = bin_dir.join("claude-agent-acp"); + assert!(is_executable(&shim)); + assert_eq!( + std::fs::read_to_string(&shim).unwrap(), + "#!/bin/sh\nexec true\n" + ); + } + + // -- state -------------------------------------------------------------- + + #[test] + fn state_round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let mut state = ManagedToolsState::default(); + state.tools.insert( + "claude-acp".to_string(), + InstalledToolPin { + binary: "claude-agent-acp".to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }, + ); + state.last_reconcile = Some(ReconcileRecord { + at_ms: 42, + ok: false, + errors: vec!["codex-acp: boom".to_string()], + }); + write_state(dir.path(), &state).unwrap(); + assert_eq!(read_state(dir.path()), state); + + // Missing and corrupt files read as the empty state. + assert_eq!( + read_state(&dir.path().join("absent")), + ManagedToolsState::default() + ); + std::fs::write(state_path(dir.path()), "not json").unwrap(); + assert_eq!(read_state(dir.path()), ManagedToolsState::default()); + } + + // -- install flow (fake npm) -------------------------------------------- + + /// A fake managed-node install dir whose `npm` copies a pre-built fixture + /// tree into the `--prefix` dir, standing in for a real floating install. + fn write_fake_node_with_npm(node_install_dir: &Path, template: &Path, exit_code: i32) { + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), "#!/bin/sh\necho v9.9.9\n").unwrap(); + let npm = format!( + "#!/bin/sh\nprefix=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n prev=\"$arg\"\ndone\ncp -R '{}/.' \"$prefix/\"\necho \"added 3 packages\"\nexit {exit_code}\n", + template.display() + ); + std::fs::write(bin.join("npm"), npm).unwrap(); + use std::os::unix::fs::PermissionsExt; + for name in ["node", "npm"] { + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + } + + #[tokio::test] + async fn install_npm_tool_installs_shims_and_records_version() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fixture_install(&template, &tool, "1.2.3"); + write_fake_node_with_npm(&node_install_dir, &template, 0); + + let lines = std::sync::Mutex::new(Vec::new()); + let on_line = |line: &str| lines.lock().unwrap().push(line.to_string()); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &on_line, + ) + .await + .unwrap(); + + let shim = shim_bin_dir(&packages_root).join(tool.binary); + let entrypoint = npm_entrypoint(&tool_install_dir(&packages_root, tool.id), tool.package); + assert!(is_executable(&shim)); + assert_eq!( + std::fs::read_to_string(&shim).unwrap(), + shim_contents(&node_binary(&node_install_dir), &entrypoint) + ); + assert_eq!( + read_state(&packages_root).tools.get(tool.id), + Some(&InstalledToolPin { + binary: tool.binary.to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }) + ); + assert!(entrypoint.is_file()); + + let recorded = lines.lock().unwrap().clone(); + assert!(recorded + .iter() + .any(|line| line.contains("added 3 packages"))); + assert!(recorded.iter().any(|line| line.contains("1.2.3 is ready"))); + } + + #[tokio::test] + async fn failed_npm_install_writes_no_shim_and_no_state() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fixture_install(&template, &tool, "1.2.3"); + write_fake_node_with_npm(&node_install_dir, &template, 7); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!(!shim_bin_dir(&packages_root).join(tool.binary).exists()); + assert!(read_state(&packages_root).tools.is_empty()); + } + + #[tokio::test] + async fn install_without_entrypoint_fails_incomplete_before_shims() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + // A clean npm exit that produced no bridge entrypoint (empty template). + let template = dir.path().join("template"); + std::fs::create_dir_all(&template).unwrap(); + write_fake_node_with_npm(&node_install_dir, &template, 0); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::Incomplete(_)), "{error}"); + assert!(!shim_bin_dir(&packages_root).join(tool.binary).exists()); + assert!(read_state(&packages_root).tools.is_empty()); + } + + // -- reconcile epilogue -------------------------------------------------- + + /// Lay down a complete healthy install (tree + shim + state) for `tool`. + fn write_installed_tool(packages_root: &Path, node_install_dir: &Path, tool: &ManagedTool) { + let install_dir = tool_install_dir(packages_root, tool.id); + write_fixture_install(&install_dir, tool, "1.2.3"); + let entrypoint = npm_entrypoint(&install_dir, tool.package); + write_shim( + &shim_bin_dir(packages_root), + tool.binary, + &shim_contents(&node_binary(node_install_dir), &entrypoint), + ) + .unwrap(); + let mut state = read_state(packages_root); + state.tools.insert( + tool.id.to_string(), + InstalledToolPin { + binary: tool.binary.to_string(), + version: "1.2.3".to_string(), + node_version: TEST_NODE_VERSION.to_string(), + }, + ); + write_state(packages_root, &state).unwrap(); + } + + #[test] + fn prune_removes_installs_dropped_from_the_managed_set() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path(); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let kept = test_tool(); + let dropped = ManagedTool { + id: "codex-acp", + binary: "codex-acp", + package: "@agentclientprotocol/codex-acp", + }; + write_installed_tool(packages_root, &node_install_dir, &kept); + write_installed_tool(packages_root, &node_install_dir, &dropped); + // A crashed install with no state entry. + std::fs::create_dir_all(tools_root(packages_root).join("ghost-acp")).unwrap(); + + prune_stale_managed_tools(packages_root, std::slice::from_ref(&kept)); + + let state = read_state(packages_root); + assert!(state.tools.contains_key(kept.id)); + assert!(!state.tools.contains_key(dropped.id)); + assert!(shim_bin_dir(packages_root).join(kept.binary).exists()); + assert!(!shim_bin_dir(packages_root).join(dropped.binary).exists()); + assert!(tools_root(packages_root).join(kept.id).exists()); + assert!(!tools_root(packages_root).join(dropped.id).exists()); + assert!(!tools_root(packages_root).join("ghost-acp").exists()); + } + + #[test] + fn record_reconcile_stamps_the_state() { + let dir = tempfile::tempdir().unwrap(); + record_reconcile(dir.path(), vec!["codex-acp: boom".to_string()]); + let record = read_state(dir.path()).last_reconcile.unwrap(); + assert!(!record.ok); + assert_eq!(record.errors, vec!["codex-acp: boom".to_string()]); + assert!(record.at_ms > 0); + + record_reconcile(dir.path(), Vec::new()); + let record = read_state(dir.path()).last_reconcile.unwrap(); + assert!(record.ok); + assert!(record.errors.is_empty()); + } + + /// A packages root with the pinned Node runtime dir plus a superseded + /// version left over from before a Node pin bump. Returns + /// `(packages_root, pinned_dir, superseded_dir)`. + fn write_node_bump_leftovers(dir: &Path) -> (PathBuf, PathBuf, PathBuf) { + let packages_root = dir.join("packages"); + let node_root = packages_root.join("node"); + let pinned_dir = managed_node::pinned_install_dir(&node_root).unwrap(); + let superseded_dir = node_root.join("v0.0.1").join("plat"); + std::fs::create_dir_all(&pinned_dir).unwrap(); + std::fs::create_dir_all(&superseded_dir).unwrap(); + (packages_root, pinned_dir, superseded_dir) + } + + #[tokio::test] + async fn clean_reconcile_prunes_the_superseded_node_runtime() { + let dir = tempfile::tempdir().unwrap(); + let (packages_root, pinned_dir, superseded_dir) = write_node_bump_leftovers(dir.path()); + let tool = test_tool(); + write_installed_tool(&packages_root, &pinned_dir, &tool); + + finish_reconcile_at(&packages_root, std::slice::from_ref(&tool), Vec::new()).await; + + assert!(pinned_dir.exists()); + assert!(!superseded_dir.exists()); + assert!(read_state(&packages_root).last_reconcile.unwrap().ok); + } + + #[tokio::test] + async fn partial_failure_keeps_the_superseded_node_runtime() { + let dir = tempfile::tempdir().unwrap(); + let (packages_root, pinned_dir, superseded_dir) = write_node_bump_leftovers(dir.path()); + let tool = test_tool(); + // The failed bridge's shim was never rewritten: it still execs the + // superseded runtime, which must therefore survive the epilogue. + write_installed_tool(&packages_root, &superseded_dir, &tool); + + finish_reconcile_at( + &packages_root, + std::slice::from_ref(&tool), + vec![format!("{}: npm install failed", tool.id)], + ) + .await; + + assert!(pinned_dir.exists()); + assert!(superseded_dir.exists()); + let shim = std::fs::read_to_string(shim_bin_dir(&packages_root).join(tool.binary)).unwrap(); + assert!(shim.contains(&superseded_dir.to_string_lossy().into_owned())); + assert!(!read_state(&packages_root).last_reconcile.unwrap().ok); + } } diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs index cf7978bef..d1bde73c2 100644 --- a/apps/staged/src-tauri/src/managed_node.rs +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -267,16 +267,21 @@ fn install_serialization_lock() -> &'static tokio::sync::Mutex<()> { /// Held cross-process advisory lock on `/.lock`; dropping it /// (closing the descriptor) releases the lock, and a crashed holder's lock /// dies with its process. -struct PackagesDirLock { +pub(crate) struct PackagesDirLock { _file: std::fs::File, } /// Take the exclusive cross-process lock serializing mutations of the shared -/// `~/.staged/packages` tree. The in-process [`install_serialization_lock`] -/// must already be held so at most one task per process parks a blocking -/// thread waiting here. Blocks until whichever other Staged process holds the -/// lock finishes. -async fn lock_packages_dir(packages_root: &Path) -> Result { +/// `~/.staged/packages` tree. An in-process mutex (this module's +/// [`install_serialization_lock`], or `managed_acp_tools`' tool-install +/// mutex) must already be held so at most one task per process parks a +/// blocking thread waiting here. Blocks until whichever other Staged process +/// holds the lock finishes. Never acquire while already holding a +/// [`PackagesDirLock`]: flock ownership is per open file description, so the +/// second acquisition in the same process deadlocks against the first. +pub(crate) async fn lock_packages_dir( + packages_root: &Path, +) -> Result { let packages_root = packages_root.to_path_buf(); tokio::task::spawn_blocking(move || { std::fs::create_dir_all(&packages_root) diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index f2648ac5c..9108b687b 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -486,9 +486,10 @@ pub fn start_session( let cancel_token = registry.register(&config.session_id); - // Resolve the bundled ACP tools dir before the AppHandle moves into the - // session thread; the snapshots built there prepend it to the agent PATH. - let bundled_acp_tools_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); + // Resolve the ACP tools dirs before the AppHandle moves into the + // session thread; the snapshots built there prepend them to the agent + // PATH. + let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); // The agent protocol may use !Send futures, so we spin up a dedicated // thread with its own single-threaded Tokio runtime + LocalSet. @@ -521,23 +522,19 @@ pub fn start_session( // back to spawning `$SHELL -ils` and exec'ing the agent. // // Both snapshots are shaped through `apply_bundled_tools_env` - // after capture, so the bundled ACP bridges win over + // after capture, so the managed/bundled ACP bridges win over // user-installed copies (and over `GOOSE_SEARCH_PATHS` values // from the user's shell) for the agent and everything it spawns. let driver = if config.workspace_name.is_none() { let cache = shell_env_cache(); let mut home_snapshot = crate::shell_env::home_env_vars_with_extended_path(cache.as_ref()).await; - if let Some(dir) = bundled_acp_tools_dir.as_deref() { - crate::acp_tools::apply_bundled_tools_env(&mut home_snapshot, dir); - } + crate::acp_tools::apply_bundled_tools_env(&mut home_snapshot, &acp_tools_dirs); let driver = driver.with_interpreter_env_snapshot(home_snapshot); match cache.get(&config.working_dir).await { Ok(snapshot) => { let mut vars = snapshot.vars().to_vec(); - if let Some(dir) = bundled_acp_tools_dir.as_deref() { - crate::acp_tools::apply_bundled_tools_env(&mut vars, dir); - } + crate::acp_tools::apply_bundled_tools_env(&mut vars, &acp_tools_dirs); driver.with_env_snapshot(vars) } Err(e) => { diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 9f3c50da5..b916f4595 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -48,6 +48,7 @@ import { listenForSessionStatus } from './lib/listeners/sessionStatusListener'; import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener'; import { listenForPageLifecycle } from './lib/listeners/pageLifecycleListener'; + import { listenForAcpToolsReconciled } from './lib/listeners/acpToolsListener'; import { darkMode } from './lib/stores/isDark.svelte'; import * as prPollingService from './lib/services/prPollingService'; import { reposUiEnabled } from './lib/featureFlags'; @@ -68,6 +69,7 @@ let unlistenSessionStatus: UnlistenFn | undefined; let unlistenCacheInvalidation: UnlistenFn | undefined; let unlistenPageLifecycle: (() => void) | undefined; + let unlistenAcpToolsReconciled: UnlistenFn | undefined; let unregisterShortcuts: (() => void) | null = null; let stopUpdaterLoop: (() => void) | null = null; let storeIncompat = $state(null); @@ -299,6 +301,9 @@ unlistenSessionStatus = listenForSessionStatus(); unlistenCacheInvalidation = listenForCacheInvalidation(); unlistenPageLifecycle = listenForPageLifecycle(); + // Refresh provider discovery (and any loaded doctor report) once the + // backend finishes installing/upgrading the managed ACP bridges. + unlistenAcpToolsReconciled = listenForAcpToolsReconciled(); try { await initPreferences(); @@ -487,6 +492,7 @@ unlistenSessionStatus?.(); unlistenCacheInvalidation?.(); unlistenPageLifecycle?.(); + unlistenAcpToolsReconciled?.(); stopUpdaterLoop?.(); }); diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index da0735b9c..0d2d51134 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -167,9 +167,12 @@ {check.label} {check.message} + {#if check.path} {#if check.main?.bundled} - Bundled with Staged + Managed by Staged {:else} {check.path} {/if} @@ -177,7 +180,7 @@ {/if} {#if check.bridgePath} {#if check.bridge?.bundled} - Bundled with Staged + Managed by Staged {:else} {check.bridgePath} {/if} diff --git a/apps/staged/src/lib/listeners/acpToolsListener.ts b/apps/staged/src/lib/listeners/acpToolsListener.ts new file mode 100644 index 000000000..8789af906 --- /dev/null +++ b/apps/staged/src/lib/listeners/acpToolsListener.ts @@ -0,0 +1,35 @@ +/** + * Listener for the backend's ACP tools reconcile completion. + * + * On launch the backend installs/upgrades the Staged-managed ACP bridges + * (claude, codex) in the background (`acp_tools_reconciler.rs`). On a fresh + * profile, provider discovery and any doctor report are cached long before + * that finishes, and nothing re-probes on its own — without this signal the + * agent picker keeps reporting missing bridges that are already installed + * until a manual refresh or restart. The event also fires on partial + * failure: the bridges that did land should become selectable. + */ + +import { listenToEvent, type UnlistenFn } from '../transport'; +import { refreshProviders } from '../features/agents/agent.svelte'; +import { doctorState, runChecks } from '../features/doctor/doctor.svelte'; + +/** Mirrors `ACP_TOOLS_RECONCILED_EVENT` in `acp_tools_reconciler.rs`. */ +interface AcpToolsReconciledEvent { + /** False when at least one managed bridge install failed this launch. */ + ok: boolean; + /** Managed tool ids the reconciler handled (e.g. `claude-acp`). */ + providerIds: string[]; +} + +export function listenForAcpToolsReconciled(): UnlistenFn { + return listenToEvent('acp-tools-reconciled', () => { + // Force: discover_acp_providers sits behind a 30-minute SWR cache, and + // the pre-reconcile discovery it holds is exactly what is stale now. + void refreshProviders({ force: true }); + // Re-run doctor checks only when a report has been loaded — running them + // just paints doctorState for the settings panel, so there is nothing to + // refresh before the user first opens it. + if (doctorState.report) void runChecks(); + }); +} From eef9219be903b059f598790ac609601753435bfa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 10:49:58 +1000 Subject: [PATCH 4/6] feat(staged): flip off the bundled ACP bridges and delete the bundling machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 (folding in the step-5 cleanup sweep) of the app-managed Node runtime port from Berd: packaged and dev builds no longer ship or stage the pinned ACP bridges — the startup reconciler's floating managed installs (steps 1-3) are the only Staged-supplied source of the claude/codex bridges now. - delete the build-time bundling machinery: acp-tools.lock.json and its 50h-cooling-off updater (scripts/update-acp-tools-lock.mjs), the dev-cache installer (scripts/ensure-acp-tools.sh), the host-Node wrapper template (scripts/lib/acp-node-wrapper.sh), the resource stager with its codesign sweep (scripts/prepare-acp-tools-resource.sh — runtime-installed files carry no quarantine xattr, so Gatekeeper needs no ad-hoc signing), the resources/acp tree with its repo-root gitignore entries, and the daily pin-bump workflow (.github/workflows/staged-bump-acp-tools.yml) — floating @latest makes the reviewed bump-PR pipeline moot - tauri.conf.json stops bundling resources/acp; staged-release.yml drops its "Stage bundled ACP tools" step; justfile: install and build/release-build lose the ensure/prepare calls, bump-acp-tools is gone, and dev stops staging bridges and exporting STAGED_ACP_TOOLS_DIR — dev instances share the managed installs in ~/.staged/packages, and the env var remains a manual local-checkout override - acp_tools.rs sheds the resolution machinery: AcpToolsDirs and the bundled-resource fallback collapse into primary_tools_dir() (dev override -> managed shim dir), apply_bundled_tools_env becomes apply_managed_tools_env computing the prepend dirs itself, and node_runtime_manifest_path — the last bundled-manifest remnant — is deleted - the doctor commands and session spawn needed an AppHandle only to resolve the Tauri resource dir, so run_doctor, run_doctor_freshness, run_doctor_fix, and run_doctor_update drop the parameter and web_server dispatch follows - docs/comment sweep: README's agent-setup section describes the managed installs instead of a manual npm install of the renamed @zed-industries package, and bundle-era comments in managed_acp_tools.rs, doctor.rs, lib.rs, session_runner.rs, DoctorCheckRow.svelte, and commands.ts are reworded Release-notes pointer: the old dev cache at ~/Library/Caches/staged-dev/acp-tools is orphaned by this change and safe to delete; nothing reads or repopulates it anymore. Gates: just check-all passes (cargo fmt, clippy -D warnings, svelte typecheck, 551 Rust tests, 481 frontend tests); clippy and the full Rust suite also pass under --features no-block-npm-registry,no-managed-acp-tools. Local `just build` succeeds and the built Staged.app contains no acp resources (only resources/pikchr/grammar.md; 46 MB .app, 20 MB DMG). Still pending from the plan: the manual no-host-Node matrix (fresh first run, offline-after-install, upgrade from a bundled-era install whose resource dir disappears). Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .github/workflows/staged-bump-acp-tools.yml | 94 --- .github/workflows/staged-release.yml | 4 - .gitignore | 6 - apps/staged/README.md | 22 +- apps/staged/acp-tools.lock.json | 196 ------ apps/staged/justfile | 16 +- apps/staged/scripts/ensure-acp-tools.sh | 387 ----------- apps/staged/scripts/lib/acp-node-wrapper.sh | 61 -- .../scripts/prepare-acp-tools-resource.sh | 128 ---- apps/staged/scripts/update-acp-tools-lock.mjs | 602 ------------------ .../src-tauri/resources/acp/bin/.gitkeep | 0 apps/staged/src-tauri/src/acp_tools.rs | 277 ++------ apps/staged/src-tauri/src/doctor.rs | 57 +- apps/staged/src-tauri/src/lib.rs | 7 +- .../staged/src-tauri/src/managed_acp_tools.rs | 19 +- apps/staged/src-tauri/src/session_runner.rs | 13 +- apps/staged/src-tauri/src/web_server.rs | 9 +- apps/staged/src-tauri/tauri.conf.json | 3 +- apps/staged/src/lib/commands.ts | 4 +- .../lib/features/doctor/DoctorCheckRow.svelte | 5 +- 20 files changed, 121 insertions(+), 1789 deletions(-) delete mode 100644 .github/workflows/staged-bump-acp-tools.yml delete mode 100644 apps/staged/acp-tools.lock.json delete mode 100755 apps/staged/scripts/ensure-acp-tools.sh delete mode 100644 apps/staged/scripts/lib/acp-node-wrapper.sh delete mode 100755 apps/staged/scripts/prepare-acp-tools-resource.sh delete mode 100755 apps/staged/scripts/update-acp-tools-lock.mjs delete mode 100644 apps/staged/src-tauri/resources/acp/bin/.gitkeep diff --git a/.github/workflows/staged-bump-acp-tools.yml b/.github/workflows/staged-bump-acp-tools.yml deleted file mode 100644 index 642008dcf..000000000 --- a/.github/workflows/staged-bump-acp-tools.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Staged Bump ACP Tools - -on: - schedule: - # Daily at 06:30 UTC. - - cron: "30 6 * * *" - workflow_dispatch: - -# One run at a time so a late-finishing run cannot race a newer one. -concurrency: - group: staged-bump-acp-tools - -jobs: - bump: - name: Bump ACP tool pins - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - # Install hermit (manages node, just) - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1.1.5 - - # The branch is bot-owned and single-commit: every run recreates it from - # main, so it never diverges or accumulates history. - - name: Reset automation branch from main - run: git switch --force-create automation/update-acp-pins - - # The recipe pins the newest releases that have aged past the script's - # 50-hour cooling-off window (its default), not bare `latest`, so a - # release that is about to be yanked or superseded never lands here. - - name: Bump ACP tool pins - run: just -f apps/staged/justfile bump-acp-tools - - # The recipe touches only the lockfile; a clean diff means no upstream - # releases outside the cooling-off window and the run is done. - - name: Detect lockfile changes - id: bump - run: | - if git diff --quiet -- apps/staged/acp-tools.lock.json; then - echo "Lockfile unchanged; no upstream releases." - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - # PR CI does not consume the lockfile, so a broken pin would otherwise - # surface only at release time (aarch64-apple-darwin) or on a dev - # machine's next `just dev`. Install the new pins (with integrity - # validation) for every target in the lockfile before pushing them - # anywhere; npm's --os/--cpu flags let this Linux runner install the - # foreign-platform entries too. Targets are passed explicitly because - # the script's auto-detection needs rustc, which this job otherwise - # does not use. - - name: Smoke-check new pins for all lockfile targets - if: steps.bump.outputs.changed == 'true' - run: | - targets="$(node -e ' - const { tools } = require("./apps/staged/acp-tools.lock.json"); - process.stdout.write([...new Set(tools.map((tool) => tool.target))].sort().join("\n")); - ')" - while IFS= read -r target; do - apps/staged/scripts/ensure-acp-tools.sh --target "$target" - done <<< "$targets" - - - name: Commit and force-push - if: steps.bump.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(staged): bump bundled ACP tools" \ - -- apps/staged/acp-tools.lock.json - git push --force origin automation/update-acp-pins - - # Refresh-in-place: while a PR for this branch is open, the force-push - # above already updated it to show the fresh pins rebased on current - # main. Only create a PR when none is open. - - name: Create PR if none open - if: steps.bump.outputs.changed == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - open_prs="$(gh pr list --head automation/update-acp-pins --state open --json number --jq length)" - if [ "$open_prs" = "0" ]; then - gh pr create \ - --base main \ - --head automation/update-acp-pins \ - --title "chore(staged): bump bundled ACP tools" \ - --body "Automated daily bump of the ACP bridge tool pins in \`apps/staged/acp-tools.lock.json\` via \`just bump-acp-tools\`, which pins the newest releases that have aged past a 50-hour cooling-off window rather than bare \`latest\`. The new pins were smoke-checked in the workflow run by installing them for every lockfile target with \`scripts/ensure-acp-tools.sh\`. While this PR is open, each daily run force-pushes the freshest eligible pins to this branch." - else - echo "Open PR already exists for automation/update-acp-pins; it now shows the fresh pins." - fi diff --git a/.github/workflows/staged-release.yml b/.github/workflows/staged-release.yml index de6e821e2..cef2016bd 100644 --- a/.github/workflows/staged-release.yml +++ b/.github/workflows/staged-release.yml @@ -79,10 +79,6 @@ jobs: STAGED_UPDATER_ENDPOINT: https://github.com/${{ github.repository }}/releases/download/staged-latest/latest.json run: pnpm run tauri:release:config - - name: Stage bundled ACP tools - working-directory: apps/staged - run: ./scripts/prepare-acp-tools-resource.sh "$TAURI_TARGET" - - name: Build unsigned Tauri app working-directory: apps/staged env: diff --git a/.gitignore b/.gitignore index 89142c17b..af9ed7944 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,3 @@ target/ .DS_Store .hermit node_modules/ - -# Staged bundled ACP bridge tools (regenerated by apps/staged/scripts/prepare-acp-tools-resource.sh) -apps/staged/src-tauri/resources/acp/bin/* -!apps/staged/src-tauri/resources/acp/bin/.gitkeep -apps/staged/src-tauri/resources/acp/node/ -apps/staged/src-tauri/resources/acp/node-runtime.json diff --git a/apps/staged/README.md b/apps/staged/README.md index 87473c221..3ad41af5f 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -64,15 +64,19 @@ If you installed manually (not via the install script), copy `scripts/staged` to ### AI Agent Setup (ACP) -Staged discovers ACP providers by CLI command name. For Claude Code ACP: - -```bash -npm install -g @zed-industries/claude-agent-acp -``` - -This package installs the `claude-agent-acp` executable. - -After installing, open Staged and run **Settings -> Doctor** to verify the Claude check is detected as installed. +Staged manages the Claude Code and Codex ACP bridges itself: on every launch +it installs (or upgrades) them into `~/.staged/packages` on a Staged-managed +Node.js runtime, so no host Node or manual `npm install` is needed. The first +launch downloads them, which requires network access; after that they work +offline. Other agents (Goose, Pi, copilot, amp) are discovered by CLI command +name on your PATH, and **Settings -> Doctor** can install the npm-based ones +into the same Staged-private prefix. + +Open **Settings -> Doctor** to verify agent checks are detected as installed. + +To develop against a local bridge checkout, export `STAGED_ACP_TOOLS_DIR` +pointing at a directory with the bridge binaries — this disables the managed +installs and resolves bridges from that directory instead. ## Development diff --git a/apps/staged/acp-tools.lock.json b/apps/staged/acp-tools.lock.json deleted file mode 100644 index c96344337..000000000 --- a/apps/staged/acp-tools.lock.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "tools": [ - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "aarch64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "arm64", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "aarch64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "arm64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "x86_64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "x64", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "claude-acp", - "binary": "claude-agent-acp", - "source": "npm", - "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.61.0", - "integrity": "sha512-2L+arbrnyJLFZCg5rvjJC1KiPdD1pqMKBrbOF1tQ3D9ogM6I5BsqahZv7gDn9zu5XKBV9vRhsLjOqYiVtYB7OQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.61.0.tgz", - "target": "x86_64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "x64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.217", - "dependencyIntegrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", - "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", - "claudeCodeVersion": "2.1.217", - "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", - "nativeVersion": "0.3.217", - "nativeIntegrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz", - "nativeExecutable": "claude" - }, - { - "id": "codex-acp", - "binary": "codex-acp", - "source": "npm", - "package": "@agentclientprotocol/codex-acp", - "version": "1.1.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "aarch64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "arm64", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-darwin-arm64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-darwin-arm64", - "nativeIntegrity": "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-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.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "aarch64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "arm64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-linux-arm64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-linux-arm64", - "nativeIntegrity": "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-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.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "x86_64-apple-darwin", - "npmOs": "darwin", - "npmCpu": "x64", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-darwin-x64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-darwin-x64", - "nativeIntegrity": "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-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.7", - "integrity": "sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==", - "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.7.tgz", - "target": "x86_64-unknown-linux-gnu", - "npmOs": "linux", - "npmCpu": "x64", - "npmLibc": "glibc", - "nodeEngine": ">=22", - "dependencyPackage": "@openai/codex", - "dependencyVersion": "0.145.0", - "dependencyIntegrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", - "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", - "nativePackage": "@openai/codex-linux-x64", - "nativePackageName": "@openai/codex", - "nativeVersion": "0.145.0-linux-x64", - "nativeIntegrity": "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==", - "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-x64.tgz", - "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" - } - ] -} diff --git a/apps/staged/justfile b/apps/staged/justfile index 5cc53272e..10a662a30 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -9,7 +9,6 @@ install: pnpm install cd src-tauri && cargo fetch - ./scripts/ensure-acp-tools.sh # ============================================================================ # Development @@ -23,11 +22,10 @@ dev repo="": # Install JS deps if needed [[ -d node_modules ]] || pnpm install - # 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. - ./scripts/prepare-acp-tools-resource.sh - export STAGED_ACP_TOOLS_DIR="$(pwd)/src-tauri/resources/acp/bin" - echo "Using ACP tools dir: ${STAGED_ACP_TOOLS_DIR}" + # Dev instances share the managed ACP bridges and Node runtime in + # ~/.staged/packages with every other Staged instance. Export + # STAGED_ACP_TOOLS_DIR manually to point sessions at a local bridge + # checkout instead (it disables the managed installs). # Derive a stable port from the working directory so the same worktree # always gets the same port. This avoids changing TAURI_CONFIG between @@ -62,7 +60,6 @@ dev repo="": # Build the app for production build: - ./scripts/prepare-acp-tools-resource.sh pnpm run tauri:build # Generate the release-only Tauri config with updater settings @@ -71,7 +68,6 @@ release-config: # Build a signed/notarized release bundle once release config exists release-build target="aarch64-apple-darwin" *args: - ./scripts/prepare-acp-tools-resource.sh {{target}} pnpm exec tauri build --target {{target}} --config src-tauri/tauri.release.conf.json {{args}} # Create a release branch, bump staged versions, and open a release PR @@ -137,10 +133,6 @@ release version: echo "Pushed tag staged/v{{version}} — CI will build and publish the release." -# Update acp-tools.lock.json to the newest npm releases of the bundled ACP bridge tools that have aged past the 50h cooling-off window -bump-acp-tools *ARGS: - node scripts/update-acp-tools-lock.mjs {{ ARGS }} - # Fetch official Node.js release checksums and update node-runtime.lock.json (e.g. `just bump-node-runtime v24.12.0`) bump-node-runtime *ARGS: node scripts/update-node-runtime-lock.mjs {{ ARGS }} diff --git a/apps/staged/scripts/ensure-acp-tools.sh b/apps/staged/scripts/ensure-acp-tools.sh deleted file mode 100755 index 922ab567d..000000000 --- a/apps/staged/scripts/ensure-acp-tools.sh +++ /dev/null @@ -1,387 +0,0 @@ -#!/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=scripts/lib/acp-node-wrapper.sh -source "$script_dir/lib/acp-node-wrapper.sh" - -usage() { - cat <<'USAGE' -Usage: scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] - -Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared -Staged 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. - -Environment variables: - ACP_TOOLS_LOCK_FILE lockfile path (default: ./acp-tools.lock.json) - ACP_TOOLS_CACHE_DIR cache dir override - ACP_TOOLS_NPM_REGISTRY npm registry override (default: normal npm config - resolution, minus project .npmrc files — see below) -USAGE -} - -default_cache_root() { - if [[ -n "${XDG_CACHE_HOME:-}" ]]; then - printf '%s/staged-dev/acp-tools\n' "$XDG_CACHE_HOME" - return - fi - case "$(uname -s)" in - Darwin) printf '%s/Library/Caches/staged-dev/acp-tools\n' "$HOME" ;; - *) printf '%s/.cache/staged-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 - if [[ "$print_bin_dir" == "1" ]]; then - printf '%s\n' "$bin_dir" - else - echo "No ACP tools locked for target $target." - fi - exit 0 -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/$binary" - # 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 - 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 resolves the bridge's range for its vendored harness dependency at - # install time, so a plain install picks the newest in-range release — not - # the locked one whenever the lock's cooling-off window pinned older (or - # upstream published in-range after the lock landed). The override forces - # the locked resolution; without it validate_npm_install below rejects the - # install. Package names and semver versions cannot contain JSON - # metacharacters, so printf-templated JSON is safe. - printf '{ "name": "acp-tools-install", "private": true, "overrides": { "%s": "%s" } }\n' \ - "$dependency_package" "$dependency_version" > "$install_dir/package.json" - # The install runs with the (freshly wiped) install dir as cwd — npm - # silently ignores root overrides when --prefix is passed — and the - # package.json written above makes it the project root, so repo .npmrc - # files can't influence this install; only user/global config and the - # optional ACP_TOOLS_NPM_REGISTRY flag apply. There is no hard-coded - # registry.npmjs.org default on purpose: Block's Cloudflare gateway blocks - # that host outright on managed devices (dependency confusion policy), so a - # corporate-mirror ~/.npmrc must keep winning by default and that flag is - # the escape hatch for other environments. - npm_args=( - install - --omit=dev - --include=optional - --ignore-scripts - --no-audit - --no-fund - --os "$npm_os" - --cpu "$npm_cpu" - ) - if [[ -n "${ACP_TOOLS_NPM_REGISTRY:-}" ]]; then - npm_args+=(--registry "$ACP_TOOLS_NPM_REGISTRY") - fi - if [[ -n "$npm_libc" ]]; then - npm_args+=(--libc "$npm_libc") - fi - npm_args+=("$package@$version") - if ! (cd "$install_dir" && npm "${npm_args[@]}") >&2; then - echo "Failed to install $package@$version. If your configured registry is a corporate mirror that has not synced this version yet, retry once it catches up, or set ACP_TOOLS_NPM_REGISTRY to a registry that has it." >&2 - exit 1 - fi - - validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" - write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine" - { - 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 on the Goose search path, 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")" - if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then - rm -f -- "$staged_file" - fi -done - -if [[ "$print_bin_dir" == "1" ]]; then - printf '%s\n' "$bin_dir" -fi diff --git a/apps/staged/scripts/lib/acp-node-wrapper.sh b/apps/staged/scripts/lib/acp-node-wrapper.sh deleted file mode 100644 index 7d99839ee..000000000 --- a/apps/staged/scripts/lib/acp-node-wrapper.sh +++ /dev/null @@ -1,61 +0,0 @@ -# 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). -# -# 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" -} diff --git a/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh deleted file mode 100755 index 2250e4b88..000000000 --- a/apps/staged/scripts/prepare-acp-tools-resource.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/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=scripts/lib/acp-node-wrapper.sh -source "$script_dir/lib/acp-node-wrapper.sh" - -usage() { - cat <<'USAGE' -Usage: scripts/prepare-acp-tools-resource.sh [target-triple] - -Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can -bundle them as application resources: vendored npm package trees under -resources/acp/node and executable wrappers under resources/acp/bin. The -optional target triple defaults to the Rust host target. - -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")")" -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=() - -codesign_if_darwin() { - local file="$1" - if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then - codesign --force --sign - "$file" >/dev/null 2>&1 || true - fi -} - -while IFS=$'\t' read -r id binary package version node_engine; 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 - write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" - node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")") - # 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"].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 - -echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/apps/staged/scripts/update-acp-tools-lock.mjs b/apps/staged/scripts/update-acp-tools-lock.mjs deleted file mode 100755 index 88dca34bf..000000000 --- a/apps/staged/scripts/update-acp-tools-lock.mjs +++ /dev/null @@ -1,602 +0,0 @@ -#!/usr/bin/env node -import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -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", -]; - -// Releases must age past a cooling-off window before they are eligible to -// pin: broken or compromised releases are typically yanked or superseded -// within a day or two of publish, so waiting out the window keeps the daily -// bump from shipping them. When `latest` is still inside the window, the -// newest older stable release that has aged past it is pinned instead. -const DEFAULT_COOLING_OFF_HOURS = 50; - -// 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"; - -// `passthroughArgs` is the bridge's CLI-passthrough invocation that prints the -// vendored harness CLI's version. Doctor probes auth (and, for bundled -// installs, freshness) through these same passthrough subcommands -// (`claude-agent-acp --cli …`, `codex-acp cli …`), so a bridge release that -// drops or renames them must fail the smoke check here instead of silently -// breaking every doctor probe in the field. -// -// codex-acp uses `-V` because its entrypoint intercepts a literal `--version` -// anywhere in argv and prints the bridge's own version; only codex's clap -// short flag reaches the vendored binary. -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, - passthroughArgs: ["--cli", "--version"], - }, - { - id: "codex-acp", - binary: "codex-acp", - package: CODEX_ACP_PACKAGE, - dependencyPackage: "@openai/codex", - nativePackageKey: "openaiCodex", - passthroughArgs: ["cli", "-V"], - }, -]; - -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", - }, - }, -}; - -const npmViewCache = new Map(); -const execFileAsync = promisify(execFile); - -function usage() { - console.log(`Usage: scripts/update-acp-tools-lock.mjs [--target ]... [--lock-file ] [--skip-smoke] [--cooling-off-hours ] - -Queries npm for the newest release of each supported ACP bridge tool that has -aged past the cooling-off window (default ${DEFAULT_COOLING_OFF_HOURS} hours, 0 disables it) 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 than the cooling-off window calls for. - -Before writing the lock, each tool's CLI passthrough (the subcommand doctor's -auth/version probes rely on) is smoke-checked against the resolved release by -installing it into a temp prefix and running it on the current platform. ---skip-smoke bypasses this (e.g. on hosts that cannot execute the vendored -binaries). - -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; - let skipSmoke = false; - let coolingOffHours = DEFAULT_COOLING_OFF_HOURS; - 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; - } - if (arg === "--skip-smoke") { - skipSmoke = true; - continue; - } - if (arg === "--cooling-off-hours") { - const value = argv[++i]; - if (!value) throw new Error("--cooling-off-hours requires a value"); - coolingOffHours = Number(value); - if (!Number.isFinite(coolingOffHours) || coolingOffHours < 0) { - throw new Error("--cooling-off-hours must be a non-negative number"); - } - 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, skipSmoke, coolingOffHours }; -} - -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; -} - -function packageDist(metadata, label) { - const dist = metadata?.dist; - return { - tarball: requireString(dist?.tarball, `${label} dist.tarball`), - 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); -} - -// Publish timestamp of a version from a packument `time` map, or null when -// the registry does not report one (an unknown publish time never counts as -// aged — the cooling-off window cannot be silently waived). -function publishedAtMs(timeMap, version) { - const published = Date.parse(timeMap?.[version] ?? ""); - return Number.isFinite(published) ? published : null; -} - -function hasAged(timeMap, version, coolingOffHours, now) { - if (coolingOffHours <= 0) return true; - const published = publishedAtMs(timeMap, version); - return published !== null && now - published >= coolingOffHours * 3600_000; -} - -// Resolve the version to pin for a package: the `latest` dist-tag once it has -// aged past the cooling-off window, otherwise the newest older stable release -// that has. Never resolves past `latest`, so an upstream dist-tag rollback -// (e.g. after a bad release) is honored even when newer versions exist. -async function resolveLatestAgedVersion(packageName, coolingOffHours, now) { - const packument = await npmView(packageName, [ - "dist-tags", - "time", - "versions", - ]); - const latest = requireString( - packument["dist-tags"]?.latest, - `${packageName} dist-tags.latest`, - ); - const timeMap = packument.time ?? {}; - if (hasAged(timeMap, latest, coolingOffHours, now)) return latest; - // npm view returns a bare string instead of a one-element array when the - // package has a single version. - const versions = Array.isArray(packument.versions) - ? packument.versions - : [packument.versions]; - const candidates = versions.filter( - (version) => - typeof version === "string" && - !version.includes("-") && - compareSemver(version, latest) < 0 && - hasAged(timeMap, version, coolingOffHours, now), - ); - if (candidates.length === 0) { - throw new Error( - `${packageName} has no stable release that has aged past the ` + - `${coolingOffHours}h cooling-off window (latest ${latest} published ` + - `${timeMap[latest] ?? "at an unknown time"})`, - ); - } - const resolved = candidates.reduce((best, candidate) => - compareSemver(candidate, best) > 0 ? candidate : best, - ); - console.log( - `${packageName}: latest ${latest} (published ${timeMap[latest]}) is ` + - `inside the ${coolingOffHours}h cooling-off window; pinning ` + - `${resolved} instead.`, - ); - return resolved; -} - -const coolingOffLogDedup = new Set(); - -// 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 that has aged past the cooling-off window, so a ranged -// dependency still pins the newest eligible release. A bridge release that -// aged past the window had at least one in-range dependency version at -// publish time — necessarily just as aged — so an empty result means the -// range never matched anything, not an over-strict window. -function pickLatestAgedMatch(metadata, label, timeMap, coolingOffHours, now) { - const matches = Array.isArray(metadata) ? metadata : [metadata]; - if (matches.length === 0) { - throw new Error(`No versions match ${label}`); - } - const pickNewest = (candidates) => - candidates.reduce((best, candidate) => - compareSemver( - requireString(candidate?.version, `${label} version`), - requireString(best?.version, `${label} version`), - ) > 0 - ? candidate - : best, - ); - const aged = matches.filter((candidate) => - hasAged( - timeMap, - requireString(candidate?.version, `${label} version`), - coolingOffHours, - now, - ), - ); - if (aged.length === 0) { - throw new Error( - `All versions matching ${label} were published within the ` + - `${coolingOffHours}h cooling-off window`, - ); - } - const best = pickNewest(aged); - const newest = pickNewest(matches); - // Deduped because this runs once per target with identical inputs. - if (newest.version !== best.version && !coolingOffLogDedup.has(label)) { - coolingOffLogDedup.add(label); - console.log( - `${label}: newest match ${newest.version} (published ` + - `${timeMap?.[newest.version] ?? "at an unknown time"}) is inside ` + - `the ${coolingOffHours}h cooling-off window; pinning ` + - `${best.version} instead.`, - ); - } - return 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, - agedVersion, - coolingOffHours, - now, -) { - 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}@${agedVersion}`, [ - "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 = pickLatestAgedMatch( - await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [ - "name", - "version", - "dist", - "optionalDependencies", - "claudeCodeVersion", - ]), - `${tool.dependencyPackage}@${dependencyRange}`, - await npmView(tool.dependencyPackage, ["time"]), - coolingOffHours, - now, - ); - 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, - }; -} - -// Install the resolved release into a temp npm prefix and run the bridge's -// CLI passthrough on the current platform, requiring exit 0 and the locked -// vendored-harness version in the output. Guards the interface doctor's -// probes depend on: the passthrough flags are bridge behavior, not a -// documented stable contract, so a release that breaks them — or one whose -// entrypoint starts answering the probe with the bridge's own version -// instead of the vendored CLI's — must fail here before it gets pinned. -async function smokeCheckPassthrough(tool, locked) { - const invocation = `${tool.binary} ${tool.passthroughArgs.join(" ")}`; - const version = locked.version; - // The version doctor surfaces: the vendored harness CLI's, not the bridge's. - const harnessVersion = locked.claudeCodeVersion ?? locked.dependencyVersion; - const prefix = await mkdtemp(path.join(os.tmpdir(), "acp-tools-smoke-")); - try { - // npm resolves the bridge's dependency range at install time, so a plain - // install picks the newest in-range release — not the locked one whenever - // the cooling-off window pinned older. The override forces the locked - // resolution (ensure-acp-tools.sh installs the same way), so the check - // exercises the exact bridge + vendored-harness pair the lock ships. - // npm silently ignores root overrides when --prefix is passed, so the - // install must run with the prefix as its working directory instead; - // writing package.json first makes the prefix the project root, keeping - // repo config out of the install just as --prefix did. - await writeFile( - path.join(prefix, "package.json"), - `${JSON.stringify({ - name: "acp-tools-smoke", - private: true, - overrides: { [locked.dependencyPackage]: locked.dependencyVersion }, - })}\n`, - ); - await execFileAsync( - "npm", - [ - "install", - "--no-fund", - "--no-audit", - "--loglevel=error", - `${tool.package}@${version}`, - ], - { cwd: prefix, maxBuffer: 10 * 1024 * 1024, timeout: 10 * 60 * 1000 }, - ); - const binary = path.join(prefix, "node_modules", ".bin", tool.binary); - let output; - try { - const { stdout, stderr } = await execFileAsync( - binary, - tool.passthroughArgs, - { timeout: 60 * 1000 }, - ); - output = `${stdout}\n${stderr}`.trim(); - } catch (error) { - throw new Error( - `Passthrough smoke check failed for ${tool.package}@${version}: ` + - `\`${invocation}\` did not exit 0 — doctor's auth/version probes ` + - `would break on this release. ${error instanceof Error ? error.message : String(error)}`, - ); - } - if (!output.includes(harnessVersion)) { - throw new Error( - `Passthrough smoke check failed for ${tool.package}@${version}: ` + - `\`${invocation}\` did not print the vendored ` + - `${tool.dependencyPackage} version ${harnessVersion}: ` + - `${output || "(empty output)"}. If the output shows the bridge's ` + - `own version, its entrypoint is intercepting the probe before the ` + - `passthrough dispatch and doctor's freshness readout would be wrong.`, - ); - } - console.log(`Smoke-checked \`${invocation}\`: ${output.split("\n")[0]}`); - } finally { - await rm(prefix, { recursive: true, force: true }); - } -} - -async function main() { - const { targets, lockFile, skipSmoke, coolingOffHours } = parseArgs( - process.argv.slice(2), - ); - const now = Date.now(); - const tools = []; - for (const tool of TOOL_SPECS) { - const agedVersion = await resolveLatestAgedVersion( - tool.package, - coolingOffHours, - now, - ); - for (const target of targets) { - tools.push( - await lockToolForTarget(tool, target, agedVersion, coolingOffHours, now), - ); - } - } - tools.sort((left, right) => - `${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`), - ); - if (skipSmoke) { - console.log("Skipping passthrough smoke checks (--skip-smoke)"); - } else { - for (const tool of TOOL_SPECS) { - const locked = tools.find((entry) => entry.id === tool.id); - if (locked) { - await smokeCheckPassthrough(tool, locked); - } - } - } - 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/apps/staged/src-tauri/resources/acp/bin/.gitkeep b/apps/staged/src-tauri/resources/acp/bin/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index 774814f90..98f1a161c 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -1,141 +1,54 @@ //! ACP bridge tool resolution and spawn-environment shaping. //! -//! The claude/codex ACP bridges resolve, in precedence order, from the -//! `STAGED_ACP_TOOLS_DIR` dev override, the Staged-managed bridge shims the -//! startup reconciler installs (`managed_acp_tools`), and — until the step-4 -//! bundle flip — the pinned bridges Staged still ships as application -//! resources (see `acp-tools.lock.json` and -//! `scripts/prepare-acp-tools-resource.sh`). This module resolves those -//! directories at runtime and shapes captured shell-env snapshots so they -//! win over user-installed copies while everything else on the user's PATH +//! The claude/codex ACP bridges resolve from the `STAGED_ACP_TOOLS_DIR` dev +//! override or the Staged-managed bridge shims the startup reconciler +//! installs (`managed_acp_tools`). This module shapes captured shell-env +//! snapshots so those dirs — together with the other Staged-managed install +//! dirs (the private npm prefix's bin, the managed Node runtime's bin) — win +//! over user-installed copies while everything else on the user's PATH //! (including installed harness CLIs and their auth state) stays -//! discoverable. The other Staged-managed install dirs (the private npm -//! prefix's bin, the managed Node runtime's bin) are folded into the same -//! shaping, so sessions and doctor share one PATH layout. - -use std::path::{Path, PathBuf}; - -use tauri::path::BaseDirectory; -use tauri::Manager; - -/// Dev-mode override exported by `just dev`, pointing at the freshly staged -/// `src-tauri/resources/acp/bin` in the working tree. The env var also -/// disables managed bridge installs (see `managed_acp_tools`). -pub use crate::managed_acp_tools::ACP_TOOLS_DIR_ENV; - -/// Bundled resource path, relative to the Tauri resource dir (mirrors the -/// `resources/acp` entry in `tauri.conf.json`). -const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; -/// Node runtime manifest staged by `scripts/prepare-acp-tools-resource.sh` -/// next to the bundled bin dir. -const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; -/// Goose reads extra binary search dirs from this env var as a JSON array. -const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; - -/// The resolved ACP bridge directories for this build and environment. -#[derive(Clone, Debug, Default)] -pub struct AcpToolsDirs { - /// The highest-precedence bridge dir: the `STAGED_ACP_TOOLS_DIR` dev - /// override, else the managed shim dir (when this build manages - /// bridges), else the bundled resource dir. Registered with - /// `acp_client::set_bundled_tools_dir` and labeled `Bundled` by doctor — - /// Staged owns updates for whatever resolves here, so the user is never - /// nagged to update it manually. - pub primary: Option, - /// The bundled resource dir when it is not already the primary: it stays - /// on the spawned-env search path as the last-resort fallback until the - /// step-4 bundle flip, so the bundled bridges keep working (for doctor - /// checks and agent subprocesses) on profiles the reconciler has not - /// populated yet. - pub resource_fallback: Option, -} +//! discoverable. Sessions and doctor both shape their snapshots here, so an +//! agent installed by a doctor fix resolves identically at check time and at +//! spawn time. -/// Resolve the ACP bridge directories: dev env override → managed shim dir → -/// bundled resource dir, with the resource dir kept as a trailing search-dir -/// fallback while it is not the primary. -pub fn resolve_acp_tools_dirs(app_handle: &tauri::AppHandle) -> AcpToolsDirs { - acp_tools_dirs_from_parts( - crate::managed_acp_tools::dev_tools_override_dir(), - crate::managed_acp_tools::managed_shim_bin_dir(), - app_handle - .path() - .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) - .ok(), - ) -} +use std::path::PathBuf; -/// Path of the Node runtime manifest staged by -/// `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 `STAGED_ACP_TOOLS_DIR` dev override. -pub fn node_runtime_manifest_path(bin_dir: &Path) -> Option { - bin_dir - .parent() - .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) -} +/// Goose reads extra binary search dirs from this env var as a JSON array. +const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; -fn acp_tools_dirs_from_parts( - env_override: Option, - managed_shim_dir: Option, - resource_dir: Option, -) -> AcpToolsDirs { - let primary = env_override - .or(managed_shim_dir) - .or_else(|| resource_dir.clone()); - let resource_fallback = resource_dir.filter(|dir| primary.as_deref() != Some(dir)); - AcpToolsDirs { - primary, - resource_fallback, - } +/// The highest-precedence bridge dir: the `STAGED_ACP_TOOLS_DIR` dev +/// override, else the managed shim dir (when this build manages bridges). +/// Registered with `acp_client::set_bundled_tools_dir` and labeled `Bundled` +/// by doctor — Staged owns updates for whatever resolves here, so the user +/// is never nagged to update it manually. +pub fn primary_tools_dir() -> Option { + // The shim dir is already `None` while the dev override is active, so + // the override wins whenever both could apply. + crate::managed_acp_tools::dev_tools_override_dir() + .or_else(crate::managed_acp_tools::managed_shim_bin_dir) } -/// Shape a captured shell-env snapshot so the managed/bundled ACP bridges — -/// and the Staged-managed npm install locations — win: +/// Shape a captured shell-env snapshot so the Staged-managed install dirs +/// ([`crate::managed_acp_tools::managed_prepend_dirs`]) win: /// -/// - Prepend the tool search dirs (see [`tool_search_dirs`]) to the -/// snapshot's PATH, keeping the rest of the imported shell PATH intact so -/// user-installed CLIs (and their auth state) remain discoverable. +/// - Prepend them to the snapshot's PATH, keeping the rest of the imported +/// shell PATH intact so user-installed CLIs (and their auth state) remain +/// discoverable. /// - Pin `GOOSE_SEARCH_PATHS` *after* the shell-env import so same-named /// values from the user's shell cannot override the managed tools. The /// value is a JSON array to match Goose's config env parsing, scoped to /// the same search dirs plus any explicit pre-existing Goose search dirs — /// never the whole shell PATH. -/// -/// Sessions and doctor checks/fixes both shape their snapshots here, so an -/// agent installed by a doctor fix into the private npm prefix resolves -/// identically at check time and at spawn time. -pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, dirs: &AcpToolsDirs) { - let search_dirs = tool_search_dirs(dirs); - if search_dirs.is_empty() { - return; - } - prepend_dirs_to_path(vars, &search_dirs); - apply_goose_search_paths(vars, &search_dirs); +pub fn apply_managed_tools_env(vars: &mut Vec<(String, String)>) { + apply_tools_env_with_dirs(vars, &crate::managed_acp_tools::managed_prepend_dirs()); } -/// Every dir agent binaries resolve from, in precedence order: the primary -/// bridge dir (dev override or managed shims), then the remaining managed -/// dirs — the private npm prefix's bin and the managed Node runtime's bin, -/// which also lets the bundled bridge wrappers find `node` without a host -/// install once the managed runtime exists — and the bundled resource dir -/// last, as the fallback for bridges the reconciler has not installed yet. -fn tool_search_dirs(dirs: &AcpToolsDirs) -> Vec { - tool_search_dirs_from_parts(dirs, crate::managed_acp_tools::managed_prepend_dirs()) -} - -fn tool_search_dirs_from_parts(dirs: &AcpToolsDirs, managed_dirs: Vec) -> Vec { - // The primary dir also appears as the first managed prepend (the dev - // override or the shim dir); keep the first occurrence of each dir. - let mut search_dirs: Vec = dirs.primary.clone().into_iter().collect(); - for dir in managed_dirs - .into_iter() - .chain(dirs.resource_fallback.clone()) - { - if !search_dirs.contains(&dir) { - search_dirs.push(dir); - } +fn apply_tools_env_with_dirs(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { + if dirs.is_empty() { + return; } - search_dirs + prepend_dirs_to_path(vars, dirs); + apply_goose_search_paths(vars, dirs); } fn prepend_dirs_to_path(vars: &mut Vec<(String, String)>, dirs: &[PathBuf]) { @@ -184,11 +97,8 @@ fn parse_goose_search_paths(value: &str) -> Result, serde_json::Erro #[cfg(test)] mod tests { - use super::{ - acp_tools_dirs_from_parts, apply_goose_search_paths, prepend_dirs_to_path, - tool_search_dirs_from_parts, AcpToolsDirs, - }; - use std::path::{Path, PathBuf}; + use super::{apply_goose_search_paths, apply_tools_env_with_dirs, prepend_dirs_to_path}; + use std::path::PathBuf; fn var<'a>(vars: &'a [(String, String)], key: &str) -> Option<&'a str> { vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) @@ -199,115 +109,38 @@ mod tests { } #[test] - fn env_override_wins_over_shim_and_resource_dirs() { - let resolved = acp_tools_dirs_from_parts( - Some(PathBuf::from("/dev/acp/bin")), - Some(PathBuf::from("/data/packages/bin")), - Some(PathBuf::from("/bundle/resources/acp/bin")), - ); - assert_eq!(resolved.primary.as_deref(), Some(Path::new("/dev/acp/bin"))); - assert_eq!( - resolved.resource_fallback.as_deref(), - Some(Path::new("/bundle/resources/acp/bin")), - ); - } - - #[test] - fn managed_shim_dir_wins_over_resource_dir() { - let resolved = acp_tools_dirs_from_parts( - None, - Some(PathBuf::from("/data/packages/bin")), - Some(PathBuf::from("/bundle/resources/acp/bin")), - ); - assert_eq!( - resolved.primary.as_deref(), - Some(Path::new("/data/packages/bin")), - ); - assert_eq!( - resolved.resource_fallback.as_deref(), - Some(Path::new("/bundle/resources/acp/bin")), - ); - } - - #[test] - fn resource_dir_as_primary_is_not_repeated_as_fallback() { - // No override and no managed shims (e.g. no-managed-acp-tools - // builds): the resource dir is the primary and must not double as - // the fallback. - let resolved = - acp_tools_dirs_from_parts(None, None, Some(PathBuf::from("/bundle/resources/acp/bin"))); - assert_eq!( - resolved.primary.as_deref(), - Some(Path::new("/bundle/resources/acp/bin")), - ); - assert!(resolved.resource_fallback.is_none()); + fn no_managed_dirs_leaves_the_snapshot_untouched() { + // A build with nothing to manage (no override, no managed dirs) must + // not touch PATH or invent a GOOSE_SEARCH_PATHS. + let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; + apply_tools_env_with_dirs(&mut vars, &[]); + assert_eq!(vars, vec![("PATH".to_string(), "/shell/bin".to_string())]); } #[test] - fn missing_inputs_resolve_to_none() { - let resolved = acp_tools_dirs_from_parts(None, None, None); - assert!(resolved.primary.is_none()); - assert!(resolved.resource_fallback.is_none()); - } + fn managed_dirs_shape_path_and_goose_search_paths_together() { + let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; - #[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")), + apply_tools_env_with_dirs( + &mut vars, + &dirs(&["/data/packages/bin", "/data/packages/npm-prefix/bin"]), ); - assert!(super::node_runtime_manifest_path(Path::new("/")).is_none()); - } - fn tools_dirs(primary: &str, resource_fallback: Option<&str>) -> AcpToolsDirs { - AcpToolsDirs { - primary: Some(PathBuf::from(primary)), - resource_fallback: resource_fallback.map(PathBuf::from), - } - } - - #[test] - fn tool_search_dirs_order_shims_then_managed_then_resource_fallback() { + let path = var(&vars, "PATH").expect("PATH should be set"); + let paths: Vec<_> = std::env::split_paths(path).collect(); assert_eq!( - tool_search_dirs_from_parts( - &tools_dirs("/data/packages/bin", Some("/bundle/acp/bin")), - dirs(&["/data/packages/bin", "/data/packages/npm-prefix/bin"]), - ), + paths, dirs(&[ "/data/packages/bin", "/data/packages/npm-prefix/bin", - "/bundle/acp/bin", - ]) - ); - } - - #[test] - fn tool_search_dirs_dedupe_the_dev_override() { - // With STAGED_ACP_TOOLS_DIR set, the override dir arrives both as the - // primary and as the first managed prepend; it must appear once. - assert_eq!( - tool_search_dirs_from_parts( - &tools_dirs("/dev/acp/bin", Some("/bundle/acp/bin")), - dirs(&["/dev/acp/bin", "/data/packages/npm-prefix/bin"]), - ), - dirs(&[ - "/dev/acp/bin", - "/data/packages/npm-prefix/bin", - "/bundle/acp/bin", + "/shell/bin", ]) ); - } - - #[test] - fn tool_search_dirs_handle_a_resource_only_resolution() { - // Bundled-resource primary (nothing managed): no duplicate, managed - // prefix dirs still searched. + let goose = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); + let goose_paths: Vec = serde_json::from_str(goose).expect("valid JSON array"); assert_eq!( - tool_search_dirs_from_parts( - &tools_dirs("/bundle/acp/bin", None), - dirs(&["/data/packages/npm-prefix/bin"]), - ), - dirs(&["/bundle/acp/bin", "/data/packages/npm-prefix/bin"]) + goose_paths, + vec!["/data/packages/bin", "/data/packages/npm-prefix/bin"] ); } diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index f4646d7f5..6feae12ca 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -9,18 +9,18 @@ pub use doctor::{ }; /// Environment snapshot for doctor checks and fixes. Shaped through -/// `apply_bundled_tools_env` so checks resolve binaries from the same PATH -/// the agent spawn path uses — a bridge Staged manages or bundles must never -/// be reported missing (or prompt an install) just because the user has no +/// `apply_managed_tools_env` so checks resolve binaries from the same PATH +/// the agent spawn path uses — a bridge Staged manages must never be +/// reported missing (or prompt an install) just because the user has no /// global copy. The managed npm env is overlaid on top, so checks probe npm /// state (`npm prefix -g`, version lookups) with the same private-prefix view /// the fixes install into — a check never contradicts the fix that just ran. -async fn doctor_env_vars(dirs: &crate::acp_tools::AcpToolsDirs) -> Vec<(String, String)> { +async fn doctor_env_vars() -> Vec<(String, String)> { let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), ) .await; - crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dirs); + crate::acp_tools::apply_managed_tools_env(&mut env_vars); crate::managed_acp_tools::apply_managed_npm_env( &mut env_vars, &crate::managed_acp_tools::managed_npm_env(), @@ -44,9 +44,8 @@ fn run_checks_options( env: None, // Doctor labels binaries resolved from this dir as bundled (install // source + readout flag) and suppresses registry update fixes for - // them — Staged owns their updates, whether they are managed shims - // the startup reconciler floats to @latest or bridges pinned by - // acp-tools.lock.json shipping with Staged updates. + // them — the startup reconciler floats the managed shims to @latest, + // so Staged owns their updates. bundled_tools_dir: bundled_dir, } .with_env_snapshot(env_vars) @@ -71,8 +70,8 @@ fn execute_fix_options( /// The frontend calls this first for an instant paint, then follows up with /// [`run_doctor_freshness`] to fill in version/update information. #[tauri::command] -pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { - run_doctor_report(&app_handle, false).await +pub async fn run_doctor() -> DoctorReport { + run_doctor_report(false).await } /// Run all health checks with version freshness enabled. @@ -83,22 +82,21 @@ pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { /// `updateAvailable`, and the source-aware `updateCommand`/`updateFixType` on /// each readout. Hits the network, so it must never block first paint. #[tauri::command] -pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport { - run_doctor_report(&app_handle, true).await +pub async fn run_doctor_freshness() -> DoctorReport { + run_doctor_report(true).await } /// Run the doctor crate's checks plus Staged-local ones (currently the /// managed Node.js runtime check) over one shared env snapshot. Bundled /// readouts are labeled by the doctor crate itself via /// `RunChecksOptions::bundled_tools_dir`. -async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) -> DoctorReport { - let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(app_handle); - let env_vars = doctor_env_vars(&acp_tools_dirs).await; +async fn run_doctor_report(check_freshness: bool) -> DoctorReport { + let env_vars = doctor_env_vars().await; let (mut report, node_runtime) = tokio::join!( doctor::run_checks_with_options(run_checks_options( check_freshness, env_vars.clone(), - acp_tools_dirs.primary.clone(), + crate::acp_tools::primary_tools_dir(), )), run_node_runtime_check(), ); @@ -120,11 +118,7 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) /// runtime first, since they run npm from it into the private prefix (the /// existing "Running…" spinner covers the one-time download). #[tauri::command] -pub async fn run_doctor_fix( - app_handle: tauri::AppHandle, - check_id: String, - fix_type: FixType, -) -> Result<(), String> { +pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> { if check_id == NODE_RUNTIME_CHECK_ID { return ensure_managed_node_runtime_for_fix().await; } @@ -133,8 +127,7 @@ pub async fn run_doctor_fix( return install_managed_tool_logged(tool_id, &check_id).await; } } - let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); - let env_vars = doctor_env_vars(&acp_tools_dirs).await; + let env_vars = doctor_env_vars().await; if doctor::agents::lookup_fix_command(&check_id, &fix_type) .as_deref() .is_some_and(crate::managed_acp_tools::is_npm_backed_command) @@ -193,7 +186,6 @@ async fn ensure_managed_node_runtime_for_fix() -> Result<(), String> { /// validated against the authoritative backend derivation. #[tauri::command] pub async fn run_doctor_update( - app_handle: tauri::AppHandle, check_id: String, fix_type: FixType, command: String, @@ -203,19 +195,18 @@ pub async fn run_doctor_update( // the frontend-supplied command needs no validation here. Readouts // resolved from the managed shim dir derive no update command at all // (they are labeled bundled), so this arm only fires for a bridge copy - // that resolved elsewhere (e.g. the resource fallback on a profile the - // reconciler has not populated yet) — and the managed install is the - // correct upgrade for that state too. + // that resolved elsewhere (e.g. a user install found on PATH before the + // first reconcile lands) — and the managed install is the correct + // upgrade for that state too. if let Some(tool_id) = managed_tool_for_check(&check_id) { return install_managed_tool_logged(tool_id, &check_id).await; } - let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); - let env_vars = doctor_env_vars(&acp_tools_dirs).await; + let env_vars = doctor_env_vars().await; let expected = expected_update_command( &check_id, &fix_type, env_vars.clone(), - acp_tools_dirs.primary.clone(), + crate::acp_tools::primary_tools_dir(), ) .await?; if expected != command { @@ -523,11 +514,11 @@ mod tests { assert!(installed_npm_tool_names(&dir.path().join("absent")).is_empty()); } - /// Bundled-readout labeling lives in the doctor crate now; Staged's job is - /// only to hand the resolved bundled dir into the run options. + /// Bundled-readout labeling lives in the doctor crate; Staged's job is + /// only to hand the managed shim dir into the run options. #[test] fn run_checks_options_carries_bundled_tools_dir() { - let dir = PathBuf::from("/bundle/resources/acp/bin"); + let dir = PathBuf::from("/data/packages/bin"); let opts = run_checks_options(false, Vec::new(), Some(dir.clone())); assert_eq!(opts.bundled_tools_dir, Some(dir)); diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 87f58991f..feb2ec047 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1877,12 +1877,11 @@ pub fn run() { // Register the primary ACP bridge tools dir before any command // runs so binary resolution (session spawn, provider discovery) // prefers the managed bridge shims (or the STAGED_ACP_TOOLS_DIR - // dev override) over user-installed copies, falling back to the - // pinned bridges Staged still ships as resources. The shim dir - // path is stable even before the first reconcile writes shims — + // dev override) over user-installed copies. The shim dir path is + // stable even before the first reconcile writes shims — // find_command probes per call and falls through to PATH until // then. - if let Some(dir) = acp_tools::resolve_acp_tools_dirs(app.handle()).primary { + if let Some(dir) = acp_tools::primary_tools_dir() { acp_client::set_bundled_tools_dir(dir); } diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index a414eef49..6aa7a1bb2 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -46,8 +46,8 @@ use tokio::io::AsyncBufReadExt; use crate::managed_node; -/// Dev/bridge-developer override (exported by `just dev`): a directory of -/// bridge binaries that replaces managed bridge resolution. +/// Dev/bridge-developer override: a directory of bridge binaries that +/// replaces managed bridge resolution. pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_ACP_TOOLS_DIR"; /// Block's internal Artifactory npm registry. Direct access to @@ -148,9 +148,8 @@ pub fn state_path(packages_root: &Path) -> PathBuf { /// the `STAGED_ACP_TOOLS_DIR` dev override when active (it replaces the /// managed shim dir), then the managed bridge shims, the private prefix's /// bin shims, and the managed Node runtime's bin dir — the latter is what -/// makes npm's `#!/usr/bin/env node` shims (and, until the bundle flip, the -/// bundled bridge wrappers) run without host Node, and what resolves `npm` -/// itself for install fixes. +/// makes npm's `#!/usr/bin/env node` shims run without host Node, and what +/// resolves `npm` itself for install fixes. pub fn managed_prepend_dirs() -> Vec { managed_prepend_dirs_from_parts( dev_tools_override_dir(), @@ -222,13 +221,13 @@ pub fn is_npm_backed_command(command: &str) -> bool { // ============================================================================= /// A Staged-managed ACP bridge: installed and upgraded from the npm registry -/// at runtime (see [`install_managed_tool`]) rather than pinned and bundled. +/// at runtime (see [`install_managed_tool`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ManagedTool { /// The install id (`tools/` dir name, `state.json` key). pub id: &'static str, - /// The bin name the shim is written under — the same command name the - /// bundled bridges resolve as, so shims shadow the bundle seamlessly. + /// The bin name the shim is written under — the command name bridge + /// resolution (`find_command`, provider discovery) looks up. pub binary: &'static str, /// The npm package installed from the registry. pub package: &'static str, @@ -618,8 +617,8 @@ async fn run_floating_npm_install( } } -/// Shim body for a managed bridge. Both paths are absolute, so the shim needs -/// no `node` on PATH and cannot hit the bundled wrapper's exit-127 mode. +/// Shim body for a managed bridge. Both paths are absolute, so the shim runs +/// with no `node` on PATH at all. fn shim_contents(node: &Path, entrypoint: &Path) -> String { format!( "#!/bin/sh\n# Written by Staged's managed ACP tools installer; do not edit.\nexec {} {} \"$@\"\n", diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 9108b687b..90d11d877 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -486,11 +486,6 @@ pub fn start_session( let cancel_token = registry.register(&config.session_id); - // Resolve the ACP tools dirs before the AppHandle moves into the - // session thread; the snapshots built there prepend them to the agent - // PATH. - let acp_tools_dirs = crate::acp_tools::resolve_acp_tools_dirs(&app_handle); - // The agent protocol may use !Send futures, so we spin up a dedicated // thread with its own single-threaded Tokio runtime + LocalSet. let session_id_for_status = config.session_id.clone(); @@ -521,20 +516,20 @@ pub fn start_session( // cwd capture failure is non-fatal: log it and let the driver fall // back to spawning `$SHELL -ils` and exec'ing the agent. // - // Both snapshots are shaped through `apply_bundled_tools_env` - // after capture, so the managed/bundled ACP bridges win over + // Both snapshots are shaped through `apply_managed_tools_env` + // after capture, so the managed ACP bridges win over // user-installed copies (and over `GOOSE_SEARCH_PATHS` values // from the user's shell) for the agent and everything it spawns. let driver = if config.workspace_name.is_none() { let cache = shell_env_cache(); let mut home_snapshot = crate::shell_env::home_env_vars_with_extended_path(cache.as_ref()).await; - crate::acp_tools::apply_bundled_tools_env(&mut home_snapshot, &acp_tools_dirs); + crate::acp_tools::apply_managed_tools_env(&mut home_snapshot); let driver = driver.with_interpreter_env_snapshot(home_snapshot); match cache.get(&config.working_dir).await { Ok(snapshot) => { let mut vars = snapshot.vars().to_vec(); - crate::acp_tools::apply_bundled_tools_env(&mut vars, &acp_tools_dirs); + crate::acp_tools::apply_managed_tools_env(&mut vars); driver.with_env_snapshot(vars) } Err(e) => { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 1cfcc4880..283136046 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3755,25 +3755,24 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { - let report = crate::doctor::run_doctor(app_handle.clone()).await; + let report = crate::doctor::run_doctor().await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_freshness" => { - let report = crate::doctor::run_doctor_freshness(app_handle.clone()).await; + let report = crate::doctor::run_doctor_freshness().await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_fix" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; - crate::doctor::run_doctor_fix(app_handle.clone(), check_id, fix_type).await?; + crate::doctor::run_doctor_fix(check_id, fix_type).await?; Ok(Value::Null) } "run_doctor_update" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; let command: String = arg(&args, "command")?; - crate::doctor::run_doctor_update(app_handle.clone(), check_id, fix_type, command) - .await?; + crate::doctor::run_doctor_update(check_id, fix_type, command).await?; Ok(Value::Null) } diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index e0f58500b..a632f8d2b 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -44,8 +44,7 @@ "icons/icon.ico" ], "resources": [ - "resources/pikchr/grammar.md", - "resources/acp" + "resources/pikchr/grammar.md" ], "macOS": { "infoPlist": "Info.plist" diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 79e52fa06..2663936ae 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1332,8 +1332,8 @@ export interface AgentVersionInfo { updateCommand: string | null; /** 'updateMain' or 'updateBridge', matching this readout's slot. */ updateFixType: 'updateMain' | 'updateBridge' | null; - /** True when this binary ships bundled with Staged (resolved from the app's - * bundled ACP tools dir rather than a user install). Stamped by the doctor + /** True when this binary is managed by Staged (resolved from the managed + * bridge shim dir rather than a user install). Stamped by the doctor * crate alongside installSource === 'bundled'. */ bundled: boolean | null; } diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index 0d2d51134..f54026081 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -167,9 +167,8 @@ {check.label} {check.message} - + {#if check.path} {#if check.main?.bundled} Managed by Staged From 68ba7648c445d5ba2295cea1b7424564881ae70d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 12:36:04 +1000 Subject: [PATCH 5/6] feat(staged): re-run the managed ACP bridge reconcile once a day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup reconciler floated the claude/codex ACP bridges to `@latest` only once, at launch, so a Staged instance left running for days never picked up a freshly-published bridge until the next restart. Turn the one-shot startup pass into a loop that reconciles at launch and then once every 24h for the process lifetime, following the interval-loop pattern already used by pr_poll_scheduler and background_sync. - acp_tools_reconciler.rs: rename spawn_startup_reconcile -> spawn_reconcile_loop; the loop runs an immediate launch pass (the first tokio interval tick resolves right away) then re-runs on a RECONCILE_INTERVAL (24h) cadence. MissedTickBehavior::Skip collapses the catch-up burst after a multi-day sleep into a single reconcile. The loop returns early when nothing is managed (dev override / no-managed-acp-tools / unsupported target) so an unmanaged build never spins a daily no-op timer; reconcile() re-checks the same predicate. - lib.rs: call the renamed spawn and note the launch + daily cadence. - acpToolsListener.ts: the reconciled event now also fires on the daily pass, so the mirrored comments describe launch + daily and the `ok` field doc drops "this launch". Listener logic is unchanged — it already re-probes providers/doctor on every event. Each daily pass still installs in place, holds the same in-process mutex + cross-process flock, records the outcome in state.json, and success-gates the superseded-Node prune, so an offline daily pass never removes a working bridge — identical semantics to the launch pass. Gates: just check-all passes (cargo fmt, clippy -D warnings, svelte typecheck, 551 Rust tests, 481 frontend tests); clippy also passes under --features no-block-npm-registry,no-managed-acp-tools. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matt Toohey --- .../src-tauri/src/acp_tools_reconciler.rs | 55 +++++++++++++++---- apps/staged/src-tauri/src/lib.rs | 8 +-- .../src/lib/listeners/acpToolsListener.ts | 18 +++--- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/apps/staged/src-tauri/src/acp_tools_reconciler.rs b/apps/staged/src-tauri/src/acp_tools_reconciler.rs index 25fd41ebf..62bfd8578 100644 --- a/apps/staged/src-tauri/src/acp_tools_reconciler.rs +++ b/apps/staged/src-tauri/src/acp_tools_reconciler.rs @@ -1,13 +1,16 @@ -//! Startup reconciler for the Staged-managed ACP bridges. +//! Reconciler for the Staged-managed ACP bridges. //! //! Spawned from app setup: installs or upgrades every managed bridge //! ([`crate::managed_acp_tools::MANAGED_TOOLS`]) to the latest published -//! version on launch, so a new bridge release ships to users the next time -//! Staged starts. Each install runs a floating `npm install @latest` -//! onto the Staged-managed Node runtime in `~/.staged/packages`. Failures are -//! logged, recorded in `state.json`, and retried on the next launch; a -//! previously installed version keeps working in the meantime, so an offline -//! launch never removes a working bridge. Superseded managed Node runtimes +//! version on launch, and then re-runs once a day for the lifetime of the +//! process, so a new bridge release ships to users the next time Staged +//! starts *and* a Staged instance left running for days keeps its private +//! npm packages current without a restart. Each install runs a floating +//! `npm install @latest` onto the Staged-managed Node runtime in +//! `~/.staged/packages`. Failures are logged, recorded in `state.json`, and +//! retried on the next daily pass or launch; a previously installed version +//! keeps working in the meantime, so an offline launch never removes a +//! working bridge. Superseded managed Node runtimes //! are pruned only in the epilogue of a fully-successful run — every bridge //! shim execs its Node by absolute versioned path, so an old runtime must //! outlive the last shim that references it. @@ -23,15 +26,22 @@ //! own — without a signal the agent picker keeps reporting missing bridges //! that are already installed until the user manually refreshes or restarts. +use std::time::Duration; + use tauri::AppHandle; use crate::managed_acp_tools; -/// Emitted once per launch after the reconciler finishes, successful or not — -/// a partial failure still installs the other bridge, so the renderer should -/// re-probe either way. Mirrored in `src/lib/listeners/acpToolsListener.ts`. +/// Emitted after every reconcile pass finishes, successful or not — a partial +/// failure still installs the other bridge, so the renderer should re-probe +/// either way. Mirrored in `src/lib/listeners/acpToolsListener.ts`. pub const ACP_TOOLS_RECONCILED_EVENT: &str = "acp-tools-reconciled"; +/// Re-run cadence beyond the launch pass. The bridges float to `@latest`, so a +/// daily sweep keeps a long-running Staged instance's private npm packages +/// current without hammering the registry. +const RECONCILE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] struct AcpToolsReconciledPayload { @@ -40,13 +50,34 @@ struct AcpToolsReconciledPayload { provider_ids: Vec<&'static str>, } -pub fn spawn_startup_reconcile(app: &AppHandle) { +pub fn spawn_reconcile_loop(app: &AppHandle) { let app = app.clone(); tauri::async_runtime::spawn(async move { - reconcile(app).await; + reconcile_loop(app).await; }); } +/// Reconcile at launch, then once a day for the lifetime of the process. +async fn reconcile_loop(app: AppHandle) { + // Nothing to manage on this build/target/override — don't spin a daily + // timer that would only no-op. `reconcile` re-checks the same predicate, + // so a build that does manage bridges is unaffected. + if managed_acp_tools::managed_tools().is_empty() { + return; + } + + let mut interval = tokio::time::interval(RECONCILE_INTERVAL); + // The first `interval.tick()` resolves immediately, so the launch pass runs + // with no delay. `Skip` collapses the catch-up burst after the machine + // wakes from a multi-day sleep into a single reconcile rather than one per + // missed day. + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + reconcile(app.clone()).await; + } +} + async fn reconcile(app: AppHandle) { let tools = managed_acp_tools::managed_tools(); if tools.is_empty() { diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index feb2ec047..9f9305c04 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1885,10 +1885,10 @@ pub fn run() { acp_client::set_bundled_tools_dir(dir); } - // Install/upgrade the managed ACP bridges in the background; the - // renderer refreshes doctor + provider discovery on the - // completion event. - acp_tools_reconciler::spawn_startup_reconcile(app.handle()); + // Install/upgrade the managed ACP bridges in the background at + // launch and once a day thereafter; the renderer refreshes doctor + + // provider discovery on each completion event. + acp_tools_reconciler::spawn_reconcile_loop(app.handle()); let updater_pubkey_present = app .config() diff --git a/apps/staged/src/lib/listeners/acpToolsListener.ts b/apps/staged/src/lib/listeners/acpToolsListener.ts index 8789af906..27487e7dd 100644 --- a/apps/staged/src/lib/listeners/acpToolsListener.ts +++ b/apps/staged/src/lib/listeners/acpToolsListener.ts @@ -1,13 +1,15 @@ /** * Listener for the backend's ACP tools reconcile completion. * - * On launch the backend installs/upgrades the Staged-managed ACP bridges - * (claude, codex) in the background (`acp_tools_reconciler.rs`). On a fresh - * profile, provider discovery and any doctor report are cached long before - * that finishes, and nothing re-probes on its own — without this signal the - * agent picker keeps reporting missing bridges that are already installed - * until a manual refresh or restart. The event also fires on partial - * failure: the bridges that did land should become selectable. + * The backend installs/upgrades the Staged-managed ACP bridges (claude, codex) + * in the background at launch and once a day thereafter + * (`acp_tools_reconciler.rs`). On a fresh profile, provider discovery and any + * doctor report are cached long before the launch pass finishes, and nothing + * re-probes on its own — without this signal the agent picker keeps reporting + * missing bridges that are already installed until a manual refresh or restart; + * the daily pass likewise surfaces a freshly-published bridge version without a + * restart. The event also fires on partial failure: the bridges that did land + * should become selectable. */ import { listenToEvent, type UnlistenFn } from '../transport'; @@ -16,7 +18,7 @@ import { doctorState, runChecks } from '../features/doctor/doctor.svelte'; /** Mirrors `ACP_TOOLS_RECONCILED_EVENT` in `acp_tools_reconciler.rs`. */ interface AcpToolsReconciledEvent { - /** False when at least one managed bridge install failed this launch. */ + /** False when at least one managed bridge install failed this pass. */ ok: boolean; /** Managed tool ids the reconciler handled (e.g. `claude-acp`). */ providerIds: string[]; From 1287a48294223b13b4e06ed7c7a6ac0bf08dfc7e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 13:26:56 +1000 Subject: [PATCH 6/6] fix(staged): stage-and-swap managed ACP bridge installs instead of mutating the live prefix in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating bridge install ran `npm install @latest --prefix tools/` directly against the live prefix, then floor-checked the entrypoint afterward. Because the shim points at the version-independent `tools//node_modules//dist/index.js` path, an npm run that aborts mid-reify — or an upstream `@latest` that drops the entrypoint — could leave the live tree half-migrated while the still-valid shim and state pointed at it, so a session spawned before the next successful reconcile executed a broken bridge. The in-code claim that "a failed floating upgrade leaves the previous tree untouched" did not actually hold. (Flagged by PR review and by the branch's own review of 68ba764.) Mirror the sibling managed_node runtime swap: install into a scratch `tools/.staging` prefix, floor-check its entrypoint, then atomically swap the verified tree into the live prefix with a `.old` rollback. The live tree is now only ever replaced by a rename of an already-verified tree, so a failed or partial install genuinely leaves the previous bridge in place. npm re-unpacks from the shared download cache each reconcile (no network re-fetch), which is an acceptable cost on the background launch/daily path in exchange for never serving a broken bridge. - managed_acp_tools.rs: install_npm_tool stages into staging_install_dir and swaps via new swap_into_place (with `.old` rollback) + reset_dir helpers; failure paths clean up the staging dir. Docs on install_managed_tool and tool_install_dir updated to describe the swap and drop the now-inaccurate in-place claim. - new failed_upgrade_preserves_the_previous_install test: a fake npm that wipes its --prefix node_modules and exits non-zero leaves the pre-installed tree, shim, and recorded version intact and leaves no scratch dirs — a regression that fails against the old in-place code. Gates: cargo fmt --check, cargo clippy --lib -D warnings (default and --features no-block-npm-registry,no-managed-acp-tools), and the full 552-test Rust lib suite (managed_acp_tools now 19 tests) all pass. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matt Toohey --- .../staged/src-tauri/src/managed_acp_tools.rs | 177 +++++++++++++++--- 1 file changed, 154 insertions(+), 23 deletions(-) diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index 6aa7a1bb2..cbb275a34 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -131,13 +131,22 @@ pub fn tools_root(packages_root: &Path) -> PathBuf { packages_root.join("tools") } -/// `/tools/` — the npm `--prefix` a managed bridge installs -/// into. Floating upgrades reuse the same prefix, so the entrypoint path a -/// shim points at is version-independent. +/// `/tools/` — the live npm `--prefix` a managed bridge resolves +/// from. Floating upgrades stage a fresh tree beside it and swap it in +/// atomically (see [`install_managed_tool`]), so this path is stable and +/// version-independent — a shim can point at it once and never be rewritten. pub fn tool_install_dir(packages_root: &Path, id: &str) -> PathBuf { tools_root(packages_root).join(id) } +/// `/tools/.staging` — the scratch `--prefix` a floating install +/// lands in before it is verified and atomically swapped into the live +/// [`tool_install_dir`]. A sibling of the live prefix so the swap is a single +/// same-filesystem rename. +fn staging_install_dir(packages_root: &Path, id: &str) -> PathBuf { + tools_root(packages_root).join(format!("{id}.staging")) +} + /// `/state.json` — installed bridge versions + the last reconcile /// outcome. pub fn state_path(packages_root: &Path) -> PathBuf { @@ -375,15 +384,18 @@ fn tool_install_lock() -> &'static tokio::sync::Mutex<()> { /// Install (or upgrade) one managed bridge to the latest published version: /// ensure the managed Node runtime, run the floating `npm install -/// @latest --prefix`, write the absolute-path shim, and record the -/// installed version in `state.json`. Safe to call concurrently — doctor -/// fixes and the startup reconciler serialize on one process-wide install -/// mutex, and mutations of the shared packages tree additionally hold the -/// cross-process flock (other Staged instances reconcile the same -/// `~/.staged/packages`). A failed install leaves any previously installed -/// version in place — including the Node runtime its shim execs, since -/// superseded runtimes are pruned only after a fully-successful reconcile — -/// so an offline launch never removes a working bridge. +/// @latest` into a scratch prefix, swap the verified tree into the live +/// prefix, write the absolute-path shim, and record the installed version in +/// `state.json`. Safe to call concurrently — doctor fixes and the startup +/// reconciler serialize on one process-wide install mutex, and mutations of +/// the shared packages tree additionally hold the cross-process flock (other +/// Staged instances reconcile the same `~/.staged/packages`). A failed +/// install — including one npm aborts mid-reify, or an upstream `@latest` +/// that drops the entrypoint — leaves any previously installed version fully +/// in place, since the live tree is only ever replaced by an atomic swap of a +/// verified staging tree; the Node runtime its shim execs is likewise kept, +/// as superseded runtimes are pruned only after a fully-successful reconcile. +/// So an offline or partial launch never removes a working bridge. pub async fn install_managed_tool( id: &str, on_line: &InstallLineFn<'_>, @@ -433,35 +445,52 @@ async fn install_npm_tool( on_line: &InstallLineFn<'_>, ) -> Result<(), ManagedToolError> { let install_dir = tool_install_dir(packages_root, tool.id); - // Install in place: a failed floating upgrade leaves the previous tree, - // shim, and state untouched, so the old bridge keeps working. - std::fs::create_dir_all(&install_dir) - .map_err(|error| ManagedToolError::Io(format!("create tool install dir: {error}")))?; + // Stage the floating install into a scratch prefix and swap it into the + // live tree only after the entrypoint floor-check passes. npm reifies in + // place, so installing straight into `install_dir` would let a failure + // mid-reify — or an upstream `@latest` that drops the entrypoint — replace + // the live tree the (version-independent) shim already points at, breaking + // the previously working bridge. Staging keeps the old tree untouched + // until a verified new one is ready to swap in atomically. + let staging_dir = staging_install_dir(packages_root, tool.id); + reset_dir(&staging_dir) + .map_err(|error| ManagedToolError::Io(format!("prepare staging dir: {error}")))?; on_line(&format!( "Installing {}@latest into ~/.staged/packages", tool.package )); - run_floating_npm_install( + if let Err(error) = run_floating_npm_install( packages_root, node_install_dir, - &install_dir, + &staging_dir, tool, registry, on_line, ) - .await?; + .await + { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(error); + } - let entrypoint = npm_entrypoint(&install_dir, tool.package); - if !entrypoint.is_file() { + let staged_entrypoint = npm_entrypoint(&staging_dir, tool.package); + if !staged_entrypoint.is_file() { + let _ = std::fs::remove_dir_all(&staging_dir); return Err(ManagedToolError::Incomplete(format!( "{}: bridge entrypoint {} is missing after install", tool.package, - entrypoint.display() + staged_entrypoint.display() ))); } - let version = installed_version(&install_dir, tool.package).unwrap_or_default(); + let version = installed_version(&staging_dir, tool.package).unwrap_or_default(); + // Atomically replace the live tree with the verified staging tree, keeping + // the previous tree aside as `.old` to roll back to if the rename fails. + swap_into_place(&staging_dir, &install_dir) + .map_err(|error| ManagedToolError::Io(format!("install staged bridge: {error}")))?; + + let entrypoint = npm_entrypoint(&install_dir, tool.package); write_shim( &shim_bin_dir(packages_root), tool.binary, @@ -492,6 +521,43 @@ async fn install_npm_tool( Ok(()) } +/// Remove `dir` if it exists, then recreate it empty — a clean scratch prefix +/// for a fresh floating install (a stale staging tree from a crashed run must +/// not seed the next one). +fn reset_dir(dir: &Path) -> std::io::Result<()> { + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + std::fs::create_dir_all(dir) +} + +/// Atomically replace `final_dir` with `staging_dir`: stage any previous tree +/// aside as `.old`, rename the verified staging tree into place, +/// and roll the previous tree back if that rename fails. Mirrors +/// `managed_node`'s runtime swap; both dirs are siblings under `tools/`, so +/// each rename is a single same-filesystem operation. +fn swap_into_place(staging_dir: &Path, final_dir: &Path) -> std::io::Result<()> { + if let Some(parent) = final_dir.parent() { + std::fs::create_dir_all(parent)?; + } + let old_dir = final_dir.with_extension("old"); + if old_dir.exists() { + std::fs::remove_dir_all(&old_dir)?; + } + if final_dir.exists() { + std::fs::rename(final_dir, &old_dir)?; + } + if let Err(error) = std::fs::rename(staging_dir, final_dir) { + if old_dir.exists() { + let _ = std::fs::rename(&old_dir, final_dir); + } + let _ = std::fs::remove_dir_all(staging_dir); + return Err(error); + } + let _ = std::fs::remove_dir_all(&old_dir); + Ok(()) +} + /// `/node_modules//dist/index.js` — the bridge /// entrypoint convention both managed bridges follow. fn npm_entrypoint(install_dir: &Path, package: &str) -> PathBuf { @@ -1003,6 +1069,25 @@ mod tests { } } + /// A fake managed-node `npm` that wipes its `--prefix` node_modules before + /// exiting — stands in for a floating upgrade npm aborts mid-reify. Proves + /// the swap fix: a live install this touched would be broken, so the test + /// installs into a staging prefix and the live tree must survive. + fn write_fake_node_with_destructive_npm(node_install_dir: &Path, exit_code: i32) { + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), "#!/bin/sh\necho v9.9.9\n").unwrap(); + let npm = format!( + "#!/bin/sh\nprefix=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n prev=\"$arg\"\ndone\nrm -rf \"$prefix/node_modules\"\nexit {exit_code}\n" + ); + std::fs::write(bin.join("npm"), npm).unwrap(); + use std::os::unix::fs::PermissionsExt; + for name in ["node", "npm"] { + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + } + #[tokio::test] async fn install_npm_tool_installs_shims_and_records_version() { let dir = tempfile::tempdir().unwrap(); @@ -1108,6 +1193,52 @@ mod tests { assert!(read_state(&packages_root).tools.is_empty()); } + #[tokio::test] + async fn failed_upgrade_preserves_the_previous_install() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path().join("packages"); + let node_install_dir = packages_root.join("node").join("v9.9.9").join("plat"); + let tool = test_tool(); + + // A healthy previously-installed bridge: tree + shim + state at 1.2.3. + write_installed_tool(&packages_root, &node_install_dir, &tool); + let install_dir = tool_install_dir(&packages_root, tool.id); + let entrypoint = npm_entrypoint(&install_dir, tool.package); + let shim = shim_bin_dir(&packages_root).join(tool.binary); + let shim_before = std::fs::read_to_string(&shim).unwrap(); + + // An upgrade whose npm destroys its --prefix tree and then fails. It + // only ever touches the staging prefix, so the live install survives. + write_fake_node_with_destructive_npm(&node_install_dir, 7); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!( + entrypoint.is_file(), + "live entrypoint clobbered by failed upgrade" + ); + assert_eq!(std::fs::read_to_string(&shim).unwrap(), shim_before); + assert_eq!( + read_state(&packages_root) + .tools + .get(tool.id) + .map(|pin| pin.version.clone()), + Some("1.2.3".to_string()) + ); + // No scratch dirs are left behind for the next reconcile to trip over. + assert!(!staging_install_dir(&packages_root, tool.id).exists()); + assert!(!install_dir.with_extension("old").exists()); + } + // -- reconcile epilogue -------------------------------------------------- /// Lay down a complete healthy install (tree + shim + state) for `tool`.