From 4628d2b518336ec85abba6f7933d609824c6ee15 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 12:00:12 +1100 Subject: [PATCH 1/7] feat(staged): honor npm config and nodejs.org for managed installs instead of hardcoding Block URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staged passed `--registry ` to every npm command and downloaded the managed Node.js runtime from the Artifactory mirror, so a from-source build or a personal Mac off WARP could never get a managed Claude/Codex bridge. Follow Berd #1107's split: public code carries no Block URL, and the one population that needs one (Block laptops with no ~/.npmrc behind Cloudflare Gateway) gets it at build time. npm registry precedence: 1. `STAGED_NPM_REGISTRY` (explicit override, passed as `--registry`); 2. otherwise no `--registry` — npm resolves `NPM_CONFIG_REGISTRY`, `~/.npmrc` (incl. scoped entries and auth), then registry.npmjs.org; 3. if that fails with a gateway block page (stderr names blocked.teams.cloudflare.com, or FETCH_ERROR/EJSONPARSE/"Unexpected token"/"invalid json" mentioning registry.npmjs.org), no override was given, and `option_env!("STAGED_FALLBACK_NPM_REGISTRY")` is set, reset the staging dir and retry once through the fallback. 404s, dead mirrors and ETARGET are not retried. Both the floating bridge install and the `npm view` freshness probe follow the same two-step. Probes additionally pass `--fetch-retries=0` and `--fetch-timeout=` so a dead `~/.npmrc` host costs 15 s, not npm's ~70 s retry schedule; installs keep npm's defaults. Install errors name the registry used ("npm default (per npm config)" when none was passed) so Doctor can show it without a state.json change. Node dist: `STAGED_NODE_DIST_URL` override, else https://nodejs.org/dist (SHA-256 pinned by node-runtime.lock.json, so the host is not a trust boundary). `option_env!("STAGED_FALLBACK_NODE_DIST_URL")` is retried once on `ManagedNodeError::Network` only — never for a bad checksum, archive, or HTTP status. Network errors now name the URL. Removed: the `no-block-npm-registry` Cargo feature, `BLOCK_NPM_REGISTRY_URL`, `BLOCK_NODE_DIST_BASE_URL`, and the Artifactory default in scripts/update-node-runtime-lock.mjs. Doctor's option builders take `npm_registry_override()` so checks and fixes still agree. staged-release.yml exports both `STAGED_FALLBACK_*` values from repository variables (no literal in the workflow); the README documents the precedence and that Block builds must set those variables. Tests: env-override/fallback helpers, the stderr classifier, the fallback gate, a fake npm that fails with a block page until `--registry` is passed (install retries once; override/404/no-fallback do not), probe flag bounds and probe retry, and Node fixtures for refused-primary → fallback success plus checksum-failure → no retry. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .github/workflows/staged-release.yml | 7 + apps/staged/README.md | 27 + .../scripts/update-node-runtime-lock.mjs | 10 +- apps/staged/src-tauri/Cargo.toml | 6 - apps/staged/src-tauri/src/doctor.rs | 22 +- .../staged/src-tauri/src/managed_acp_tools.rs | 715 ++++++++++++++++-- .../src/managed_acp_tools/tests/validation.rs | 5 + apps/staged/src-tauri/src/managed_node.rs | 230 +++++- 8 files changed, 936 insertions(+), 86 deletions(-) diff --git a/.github/workflows/staged-release.yml b/.github/workflows/staged-release.yml index 89335b715..7ceb00b65 100644 --- a/.github/workflows/staged-release.yml +++ b/.github/workflows/staged-release.yml @@ -81,6 +81,13 @@ jobs: working-directory: apps/staged env: VITE_UPDATER_ENABLED: 'true' + # Optional build-time network fallbacks (see apps/staged/README.md, + # "npm registry and Node.js download sources"). Repository + # variables, not literals, so no internal URL lives in the tree; + # unset means the build has no fallback and relies on npm's own + # config and nodejs.org. + STAGED_FALLBACK_NPM_REGISTRY: ${{ vars.STAGED_FALLBACK_NPM_REGISTRY }} + STAGED_FALLBACK_NODE_DIST_URL: ${{ vars.STAGED_FALLBACK_NODE_DIST_URL }} run: pnpm tauri build --no-sign --target "$TAURI_TARGET" --config src-tauri/tauri.release.conf.json - name: Codesign and Notarize diff --git a/apps/staged/README.md b/apps/staged/README.md index 258f05db8..3dac070ac 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -79,6 +79,33 @@ 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. +#### npm registry and Node.js download sources + +Staged does not hardcode where packages come from: + +- **npm registry.** Staged passes no `--registry` of its own, so npm uses + its normal configuration (`NPM_CONFIG_REGISTRY`, `~/.npmrc` including + scoped `@scope:registry` entries and auth tokens, then + `registry.npmjs.org`). A corporate laptop whose `~/.npmrc` already points + at an internal mirror keeps using it. Set `STAGED_NPM_REGISTRY` to force a + registry for every Staged-run npm command (bridge installs, Doctor's + npm-based installs, and update checks). +- **Node.js runtime.** The pinned runtime (`node-runtime.lock.json`) is + downloaded from `https://nodejs.org/dist` and verified against the lock's + SHA-256. Set `STAGED_NODE_DIST_URL` to a mirror with the same path layout + (`//`) if your network blocks `nodejs.org`. + +A build can additionally compile in fallbacks for networks where the above +fail: `STAGED_FALLBACK_NPM_REGISTRY` is retried once when npm's configured +registry turns out to be `registry.npmjs.org` behind a network gateway that +answers with a block page (and no `STAGED_NPM_REGISTRY` was set), and +`STAGED_FALLBACK_NODE_DIST_URL` is retried once when the runtime download +fails to reach its host. Both are read at *compile time* and are unset for +from-source builds. Block's release workflow passes them from the +`STAGED_FALLBACK_NPM_REGISTRY` / `STAGED_FALLBACK_NODE_DIST_URL` repository +variables, which must be set for Block builds to keep working on managed +laptops that have no `~/.npmrc`. + ## Development ### Prerequisites diff --git a/apps/staged/scripts/update-node-runtime-lock.mjs b/apps/staged/scripts/update-node-runtime-lock.mjs index 0b7afa246..62246eb56 100755 --- a/apps/staged/scripts/update-node-runtime-lock.mjs +++ b/apps/staged/scripts/update-node-runtime-lock.mjs @@ -6,11 +6,11 @@ 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"; +// Upstream Node.js releases — the same host the app downloads the runtime +// from by default. `--base-url` accepts any mirror with an identical path +// shape (`//SHASUMS256.txt`); the checksums it serves must be +// byte-identical to upstream's, since they become the app's trust root. +const DEFAULT_BASE_URL = "https://nodejs.org/dist"; // The rust target triples Staged builds for (macOS release targets plus // Linux local builds), mapped to the platform component of Node's release diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index e86a81eb1..350bc35dd 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -129,12 +129,6 @@ tokio = { version = "1.53.1", features = ["test-util"] } tauri = { version = "2.12.1", features = ["test"] } [features] -# no-block-npm-registry: downloads the managed Node.js runtime from upstream -# 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 = [] - # 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 diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index e496120c1..c259c4950 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -304,11 +304,10 @@ fn run_checks_options( RunChecksOptions { check_freshness, offline: false, - // 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), + // npm-backed checks and fixes pass a `--registry` only when the user + // set `STAGED_NPM_REGISTRY`; otherwise npm resolves its own config + // (see the registry policy note in `managed_acp_tools`). + npm_registry: crate::managed_acp_tools::npm_registry_override(), env: None, // Doctor labels binaries resolved from this dir as bundled (install // source + readout flag) and suppresses registry update fixes for @@ -335,7 +334,7 @@ fn execute_fix_options( // compiles but `staged-ci.yml` does. ExecuteFixOptions { command_override, - npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), + npm_registry: crate::managed_acp_tools::npm_registry_override(), ..Default::default() } .with_env_snapshot(env_vars) @@ -2218,18 +2217,17 @@ mod tests { } /// Checks and fixes must agree on the registry: both option builders take - /// it from the same `managed_acp_tools::npm_registry()` gate. + /// it from the same `managed_acp_tools::npm_registry_override()` — the + /// explicit `STAGED_NPM_REGISTRY`, or `None` so npm resolves its own + /// config. #[test] - fn doctor_options_route_npm_through_the_managed_registry() { - let expected = crate::managed_acp_tools::npm_registry().map(str::to_string); + fn doctor_options_route_npm_through_the_registry_override() { + let expected = crate::managed_acp_tools::npm_registry_override(); 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()); - } } /// The tail numbers every line from zero and keeps only the newest diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index 73b2221ee..42779e4e0 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -53,22 +53,105 @@ mod validation; /// 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) +// ----------------------------------------------------------------------------- +// npm registry policy +// +// Staged passes no `--registry` of its own: npm resolves the registry from +// its normal config chain (`NPM_CONFIG_REGISTRY`, `~/.npmrc` including +// `@scope:registry` entries, then `registry.npmjs.org`). That keeps a +// corporate laptop whose `~/.npmrc` already points at an internal mirror — +// with whatever auth token it needs — on that mirror, and gives everyone +// else their own registry; a hardcoded URL served exactly one network and +// could not even override a scoped `~/.npmrc` entry. +// +// Two escape hatches sit around that default. `STAGED_NPM_REGISTRY` is an +// explicit override passed as `--registry` to every Staged-run npm command. +// `STAGED_FALLBACK_NPM_REGISTRY` is a *build-time* registry (compiled in via +// `option_env!`, never written into source) for the one population npm's own +// config fails: Block laptops with no `~/.npmrc` behind the Cloudflare +// Gateway, where `registry.npmjs.org` answers with a redirect to an HTML +// block page and npm reports `FETCH_ERROR`/invalid JSON. Only a failure that +// reads as that block page is retried through the fallback — a 404 from a +// mirror, a dead host, or an unresolvable version is not a registry problem a +// different registry can fix, and an explicit override is never second- +// guessed. +// ----------------------------------------------------------------------------- + +/// Explicit npm registry override, passed as `--registry` when set. +pub const NPM_REGISTRY_ENV: &str = "STAGED_NPM_REGISTRY"; + +/// The `STAGED_NPM_REGISTRY` override, trimmed, when set and non-empty. +/// `None` means npm decides from its own config. +pub fn npm_registry_override() -> Option { + registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) +} + +fn registry_from_env_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// The registry compiled in through `STAGED_FALLBACK_NPM_REGISTRY` at build +/// time, retried once when npm's configured registry turns out to be +/// gateway-blocked (see the policy note above). `None` for builds that set +/// no such variable. +pub fn fallback_npm_registry() -> Option<&'static str> { + option_env!("STAGED_FALLBACK_NPM_REGISTRY") + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// How an npm command names its registry in errors and progress lines. +fn registry_label(registry: Option<&str>) -> String { + registry + .map(str::to_string) + .unwrap_or_else(|| "npm default (per npm config)".to_string()) +} + +/// What a failed npm command's stderr says about why it failed, as far as +/// the registry retry needs to know. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NpmFailure { + /// A network gateway answered for `registry.npmjs.org` with a block page + /// instead of registry JSON — Cloudflare Gateway on managed Block laptops + /// redirects to `blocked.teams.cloudflare.com`, and npm surfaces the HTML + /// as `FETCH_ERROR` or a JSON parse error naming the public registry. + GatewayBlocked, + Other, +} + +fn classify_npm_failure(stderr: &str) -> NpmFailure { + if stderr.contains("blocked.teams.cloudflare.com") { + return NpmFailure::GatewayBlocked; + } + if stderr.contains("registry.npmjs.org") { + let lower = stderr.to_ascii_lowercase(); + if stderr.contains("FETCH_ERROR") + || stderr.contains("EJSONPARSE") + || lower.contains("unexpected token") + || lower.contains("invalid json") + { + return NpmFailure::GatewayBlocked; + } } + NpmFailure::Other +} + +/// The registry to retry a failed npm command through, if any: only when no +/// explicit override was given, a build-time fallback exists, and `stderr` +/// reads as a gateway block of `registry.npmjs.org`. +fn gateway_fallback_registry<'a>( + override_registry: Option<&str>, + fallback: Option<&'a str>, + stderr: &str, +) -> Option<&'a str> { + if override_registry.is_some() { + return None; + } + let fallback = fallback?; + (classify_npm_failure(stderr) == NpmFailure::GatewayBlocked).then_some(fallback) } /// The `STAGED_ACP_TOOLS_DIR` override dir, when set and non-empty. @@ -430,7 +513,8 @@ pub async fn install_managed_tool( &node_install_dir, &managed_node::node_runtime_lock().version, &tool, - npm_registry(), + npm_registry_override().as_deref(), + fallback_npm_registry(), validation::VALIDATION_TIMEOUT, on_line, ) @@ -439,13 +523,17 @@ pub async fn install_managed_tool( /// 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. +/// the runtime. `registry` is the explicit override (or `None` for npm's own +/// config) and `fallback_registry` the build-time fallback tried once if the +/// first attempt fails as gateway-blocked (see the registry policy note). +#[allow(clippy::too_many_arguments)] async fn install_npm_tool( packages_root: &Path, node_install_dir: &Path, node_version: &str, tool: &ManagedTool, registry: Option<&str>, + fallback_registry: Option<&str>, validation_timeout: Duration, on_line: &InstallLineFn<'_>, ) -> Result<(), ManagedToolError> { @@ -465,7 +553,7 @@ async fn install_npm_tool( "Installing {}@latest into ~/.staged/packages", tool.package )); - if let Err(error) = run_floating_npm_install( + if let Err(failure) = run_floating_npm_install( packages_root, node_install_dir, &staging_dir, @@ -475,8 +563,36 @@ async fn install_npm_tool( ) .await { - let _ = std::fs::remove_dir_all(&staging_dir); - return Err(error); + let Some(fallback) = + gateway_fallback_registry(registry, fallback_registry, &failure.stderr) + else { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(failure.error); + }; + on_line(&format!( + "npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" + )); + // A fresh scratch prefix: whatever the blocked attempt reified must + // not seed the retry. + if let Err(error) = reset_dir(&staging_dir) { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(ManagedToolError::Io(format!( + "prepare staging dir for registry retry: {error}" + ))); + } + if let Err(failure) = run_floating_npm_install( + packages_root, + node_install_dir, + &staging_dir, + tool, + Some(fallback), + on_line, + ) + .await + { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(failure.error); + } } let staged_entrypoint = npm_entrypoint(&staging_dir, tool.package); @@ -602,6 +718,17 @@ fn installed_version(install_dir: &Path, package: &str) -> Option { .map(str::to_string) } +/// A failed floating install: the error to surface plus what npm wrote to +/// stderr, kept so the caller can classify the failure for a registry retry. +struct NpmInstallFailure { + error: ManagedToolError, + stderr: String, +} + +/// npm's error block fits comfortably in this; the retry classifier only +/// needs the first lines, not a full debug log. +const NPM_STDERR_CAPTURE_LIMIT: usize = 64 * 1024; + async fn run_floating_npm_install( packages_root: &Path, node_install_dir: &Path, @@ -609,6 +736,33 @@ async fn run_floating_npm_install( tool: &ManagedTool, registry: Option<&str>, on_line: &InstallLineFn<'_>, +) -> Result<(), NpmInstallFailure> { + let mut stderr_capture = String::new(); + run_floating_npm_install_capturing( + packages_root, + node_install_dir, + install_dir, + tool, + registry, + on_line, + &mut stderr_capture, + ) + .await + .map_err(|error| NpmInstallFailure { + error, + stderr: stderr_capture, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_floating_npm_install_capturing( + packages_root: &Path, + node_install_dir: &Path, + install_dir: &Path, + tool: &ManagedTool, + registry: Option<&str>, + on_line: &InstallLineFn<'_>, + stderr_capture: &mut String, ) -> Result<(), ManagedToolError> { let node_bin_dir = node_install_dir.join("bin"); let mut command = tokio::process::Command::new(node_bin_dir.join("npm")); @@ -669,9 +823,14 @@ async fn run_floating_npm_install( let mut lines = tokio::io::BufReader::new(stream).lines(); while let Ok(Some(line)) = lines.next_line().await { on_line(&line); + if stderr_capture.len() < NPM_STDERR_CAPTURE_LIMIT { + stderr_capture.push_str(&line); + stderr_capture.push('\n'); + } } } }; + let label = registry_label(registry); let wait = async { match tokio::time::timeout(NPM_INSTALL_TIMEOUT, child.wait()).await { Ok(result) => result @@ -679,7 +838,7 @@ async fn run_floating_npm_install( Err(_) => { let _ = child.kill().await; Err(ManagedToolError::NpmInstall(format!( - "timed out after {} seconds", + "timed out after {} seconds (registry: {label})", NPM_INSTALL_TIMEOUT.as_secs() ))) } @@ -691,7 +850,7 @@ async fn run_floating_npm_install( Ok(()) } else { Err(ManagedToolError::NpmInstall(format!( - "npm install exited with {status}" + "npm install exited with {status} (registry: {label})" ))) } } @@ -784,7 +943,8 @@ fn latest_version_cache() -> &'static Mutex { /// /// Runs `npm view version` through the same env snapshot doctor's own /// probes use, so the managed runtime's `npm` is preferred and the lookup sees -/// the registry/config the install itself will. Bounded by +/// the registry/config the install itself will — the same override → npm +/// config → gateway-blocked fallback precedence as the install. Bounded by /// [`LATEST_VERSION_PROBE_TIMEOUT`] and cached for [`LATEST_VERSION_CACHE_TTL`]. pub async fn latest_published_version( tool: &ManagedTool, @@ -799,10 +959,11 @@ pub async fn latest_published_version( return cached; } let npm = find_on_env_path(env_vars, "npm")?; - let version = npm_view_version( + let version = probe_latest_version( &npm, tool.package, - npm_registry(), + npm_registry_override().as_deref(), + fallback_npm_registry(), env_vars, LATEST_VERSION_PROBE_TIMEOUT, ) @@ -814,6 +975,40 @@ pub async fn latest_published_version( Some(version) } +/// One uncached latest-version lookup with the registry precedence applied: +/// `override_registry` if given, else npm's own config, and — only when that +/// fails as a gateway block of `registry.npmjs.org` and no override was +/// given — one retry through `fallback_registry` within whatever is left of +/// `timeout`. +async fn probe_latest_version( + npm_path: &Path, + package: &str, + override_registry: Option<&str>, + fallback_registry: Option<&str>, + env_vars: &[(String, String)], + timeout: Duration, +) -> Option { + let started = Instant::now(); + let failure = + match npm_view_version(npm_path, package, override_registry, env_vars, timeout).await { + Ok(version) => return Some(version), + Err(failure) => failure, + }; + let NpmViewFailure::Exit { stderr } = failure else { + return None; + }; + let fallback = gateway_fallback_registry(override_registry, fallback_registry, &stderr)?; + let remaining = timeout + .checked_sub(started.elapsed()) + .filter(|remaining| !remaining.is_zero())?; + log::info!( + "npm view {package} version: registry.npmjs.org is gateway-blocked; retrying through {fallback}" + ); + npm_view_version(npm_path, package, Some(fallback), env_vars, remaining) + .await + .ok() +} + /// The first `/` that is a file along the snapshot's `PATH` — /// resolved by hand because `Command::new("npm")` would search the *parent* /// process's PATH, not the snapshot's, and the snapshot is what puts the @@ -825,18 +1020,34 @@ fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option version [--registry ]` with `npm` at `npm_path`, -/// under exactly the `env_vars` snapshot, killed at `timeout`. `Some` only for +/// under exactly the `env_vars` snapshot, killed at `timeout`. `Ok` only for /// a clean exit whose stdout is a version-shaped token. +/// +/// npm's own retry/timeout defaults (`fetch-retries=2`, backoff up to 60 s) +/// would let a dead registry host run ~70 s; a probe is not worth that, so +/// retries are off and npm's fetch timeout is pinned to the probe budget. +/// Installs keep npm's defaults. async fn npm_view_version( npm_path: &Path, package: &str, registry: Option<&str>, env_vars: &[(String, String)], timeout: Duration, -) -> Option { +) -> Result { let mut command = tokio::process::Command::new(npm_path); - command.args(["view", package, "version"]); + command.args(["view", package, "version", "--fetch-retries=0"]); + command.arg(format!("--fetch-timeout={}", timeout.as_millis())); if let Some(registry) = registry { command.args(["--registry", registry]); } @@ -853,23 +1064,25 @@ async fn npm_view_version( Ok(Ok(output)) => output, Ok(Err(error)) => { log::debug!("npm view {package} version failed to run: {error}"); - return None; + return Err(NpmViewFailure::Other); } Err(_) => { log::debug!( - "npm view {package} version timed out after {}s", - timeout.as_secs() + "npm view {package} version timed out after {}s (registry: {})", + timeout.as_secs(), + registry_label(registry) ); - return None; + return Err(NpmViewFailure::Other); } }; if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); log::debug!( - "npm view {package} version exited with {}: {}", + "npm view {package} version exited with {} (registry: {}): {stderr}", output.status, - String::from_utf8_lossy(&output.stderr).trim() + registry_label(registry) ); - return None; + return Err(NpmViewFailure::Exit { stderr }); } let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); version @@ -877,6 +1090,7 @@ async fn npm_view_version( .next() .is_some_and(|first| first.is_ascii_digit()) .then_some(version) + .ok_or(NpmViewFailure::Other) } // ============================================================================= @@ -988,15 +1202,105 @@ mod tests { mod validation; use super::*; + // -- registry policy ----------------------------------------------------- + + /// `STAGED_NPM_REGISTRY` is honored only when it carries a value; unset, + /// empty, or whitespace all mean "npm decides". #[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)); + fn npm_registry_override_is_the_trimmed_non_empty_env_value() { + assert_eq!(registry_from_env_value(None), None); + assert_eq!(registry_from_env_value(Some("")), None); + assert_eq!(registry_from_env_value(Some(" \n")), None); + assert_eq!( + registry_from_env_value(Some(" https://registry.example.test/npm/ ")), + Some("https://registry.example.test/npm/".to_string()) + ); + assert_eq!( + npm_registry_override(), + registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) + ); + } + + /// The fallback is whatever the build injected — no URL lives in source, + /// so a from-source build has none and a value is never blank. + #[test] + fn fallback_npm_registry_is_build_time_only() { + let expected = option_env!("STAGED_FALLBACK_NPM_REGISTRY") + .map(str::trim) + .filter(|value| !value.is_empty()); + assert_eq!(fallback_npm_registry(), expected); + if let Some(fallback) = fallback_npm_registry() { + assert!(!fallback.is_empty()); + assert_eq!(fallback, fallback.trim()); } } + #[test] + fn registry_label_names_npm_config_when_no_registry_is_passed() { + assert_eq!( + registry_label(Some("https://registry.example.test/npm/")), + "https://registry.example.test/npm/" + ); + assert_eq!(registry_label(None), "npm default (per npm config)"); + } + + #[test] + fn classify_npm_failure_spots_gateway_block_pages_only() { + let blocked = [ + // The redirect target itself shows up in npm's error output. + "npm error request to https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp failed: https://blocked.teams.cloudflare.com?block_reason=...", + "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<'", + "npm error code EJSONPARSE\nnpm error JSON.parse Unexpected token < in JSON at position 0 while parsing near '' from https://registry.npmjs.org/x", + "npm error Invalid JSON response from https://registry.npmjs.org/foo", + ]; + for stderr in blocked { + assert_eq!( + classify_npm_failure(stderr), + NpmFailure::GatewayBlocked, + "{stderr}" + ); + } + let other = [ + "", + "npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp - Not found", + "npm error code ETARGET\nnpm error notarget No matching version found for foo@latest", + // A dead *mirror* is not a gateway block of the public registry. + "npm error code FETCH_ERROR\nnpm error request to https://mirror.example.test/npm/foo failed, reason: getaddrinfo ENOTFOUND mirror.example.test", + "npm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/foo failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + ]; + for stderr in other { + assert_eq!(classify_npm_failure(stderr), NpmFailure::Other, "{stderr}"); + } + } + + /// The fallback is consulted only with no override, a compiled-in + /// fallback, and a gateway-shaped failure — all three at once. + #[test] + fn gateway_fallback_registry_requires_no_override_a_fallback_and_a_block_page() { + let blocked = "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed"; + let fallback = Some("https://fallback.example.test/npm/"); + assert_eq!( + gateway_fallback_registry(None, fallback, blocked), + fallback, + "no override + fallback + block page retries" + ); + assert_eq!( + gateway_fallback_registry(Some("https://mine.example.test/"), fallback, blocked), + None, + "an explicit override is never second-guessed" + ); + assert_eq!( + gateway_fallback_registry(None, None, blocked), + None, + "nothing to retry through" + ); + assert_eq!( + gateway_fallback_registry(None, fallback, "npm error code E404"), + None, + "a 404 is not a gateway block" + ); + } + #[test] fn path_helpers_lay_out_the_packages_tree() { let root = Path::new("/home/.staged/packages"); @@ -1278,6 +1582,7 @@ mod tests { TEST_NODE_VERSION, &tool, None, + None, super::validation::VALIDATION_TIMEOUT, &on_line, ) @@ -1326,6 +1631,7 @@ mod tests { TEST_NODE_VERSION, &tool, None, + None, super::validation::VALIDATION_TIMEOUT, &|_| {}, ) @@ -1355,6 +1661,7 @@ mod tests { TEST_NODE_VERSION, &tool, None, + None, super::validation::VALIDATION_TIMEOUT, &|_| {}, ) @@ -1389,6 +1696,7 @@ mod tests { TEST_NODE_VERSION, &tool, None, + None, super::validation::VALIDATION_TIMEOUT, &|_| {}, ) @@ -1440,6 +1748,7 @@ mod tests { TEST_NODE_VERSION, &tool, None, + None, super::validation::VALIDATION_TIMEOUT, &|_| {}, ) @@ -1460,6 +1769,199 @@ mod tests { ); } + // -- install flow: registry fallback ------------------------------------- + + /// A fake managed-node `npm` standing in for a laptop whose network + /// gateway blocks `registry.npmjs.org`: without `--registry` it prints + /// npm's block-page failure and exits 1; with one it performs the install + /// from `template`. Every invocation's argv is appended to `argv_log`. + fn write_fake_node_with_gateway_blocked_npm( + node_install_dir: &Path, + template: &Path, + argv_log: &Path, + stderr_without_registry: &str, + ) { + 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\necho \"$*\" >> '{log}'\nprefix=\"\"\nregistry=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n if [ \"$prev\" = \"--registry\" ]; then registry=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -z \"$registry\" ]; then\n printf '%s\\n' '{stderr}' >&2\n exit 1\nfi\ncp -R '{template}/.' \"$prefix/\"\necho \"added 3 packages\"\nexit 0\n", + log = argv_log.display(), + stderr = stderr_without_registry.replace('\'', "'\\''"), + template = 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(); + } + } + + const GATEWAY_BLOCK_STDERR: &str = "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token < in JSON at position 0"; + + fn logged_argv(argv_log: &Path) -> Vec { + std::fs::read_to_string(argv_log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } + + /// No override, a compiled-in fallback, and npm failing on a gateway + /// block page: the install is retried once through the fallback from a + /// fresh staging prefix, and succeeds. + #[tokio::test] + async fn gateway_blocked_install_retries_once_through_the_fallback_registry() { + 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.5"); + let argv_log = dir.path().join("npm-argv.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + + 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, + Some("https://fallback.example.test/npm/"), + super::validation::VALIDATION_TIMEOUT, + &on_line, + ) + .await + .unwrap(); + + let argv = logged_argv(&argv_log); + assert_eq!(argv.len(), 2, "{argv:?}"); + assert!(!argv[0].contains("--registry"), "{}", argv[0]); + assert!( + argv[1].contains("--registry https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + assert_eq!( + installed_tool_version(&packages_root, &tool).as_deref(), + Some("1.2.5") + ); + let recorded = lines.lock().unwrap().clone(); + assert!( + recorded + .iter() + .any(|line| line.contains("retrying through https://fallback.example.test/npm/")), + "{recorded:?}" + ); + assert!(!staging_install_dir(&packages_root, tool.id).exists()); + } + + /// An explicit `STAGED_NPM_REGISTRY` is never second-guessed, and a + /// failure that is not a gateway block page is not retried either; both + /// surface the first failure, naming the registry npm used. + #[tokio::test] + async fn install_does_not_retry_with_an_override_or_for_other_failures() { + 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.5"); + let fallback = Some("https://fallback.example.test/npm/"); + + // A gateway block page, but no fallback compiled in: plain failure. + let argv_log = dir.path().join("no-fallback.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!( + error + .to_string() + .contains("(registry: npm default (per npm config))"), + "{error}" + ); + assert_eq!(logged_argv(&argv_log).len(), 1); + + // A 404 from whichever registry npm used: a different registry cannot + // fix that, so no retry even with a fallback available. + let argv_log = dir.path().join("not-found.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + "npm error code E404\nnpm error 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", + ); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + None, + fallback, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert_eq!(logged_argv(&argv_log).len(), 1); + + // An explicit override: the one and only call carries it (the fake + // succeeds on any `--registry`), and the fallback is never consulted. + let argv_log = dir.path().join("override.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + Some("https://mine.example.test/npm/"), + fallback, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap(); + let argv = logged_argv(&argv_log); + assert_eq!(argv.len(), 1, "{argv:?}"); + assert!( + argv[0].contains("--registry https://mine.example.test/npm/"), + "{}", + argv[0] + ); + } + // -- doctor readouts ------------------------------------------------------ /// The live tree's `package.json` is the authority for the installed ACP @@ -1583,7 +2085,8 @@ mod tests { &env, Duration::from_secs(10), ) - .await; + .await + .ok(); assert_eq!(version.as_deref(), Some("4.5.6")); } @@ -1601,10 +2104,129 @@ mod tests { &env, Duration::from_secs(10), ) - .await; + .await + .ok(); assert_eq!(version.as_deref(), Some("4.5.6")); } + /// Probes must not inherit npm's ~70 s dead-host retry schedule: retries + /// are off and npm's own fetch timeout is pinned to the probe budget. + #[tokio::test] + async fn npm_view_version_bounds_npm_fetch_retries_and_timeout() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let argv_log = dir.path().join("argv.log"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\necho \"$*\" >> '{}'\necho 1.0.0\n", + argv_log.display() + ), + ); + let env = path_env(&bin); + npm_view_version( + &bin.join("npm"), + "@agentclientprotocol/codex-acp", + None, + &env, + Duration::from_secs(15), + ) + .await + .unwrap(); + let argv = std::fs::read_to_string(&argv_log).unwrap(); + assert!(argv.contains(" --fetch-retries=0"), "{argv}"); + assert!(argv.contains(" --fetch-timeout=15000"), "{argv}"); + } + + /// A fake `npm view` that reports a gateway block page unless `--registry` + /// is passed, logging each invocation's argv. + fn write_fake_gateway_blocked_npm_view(bin_dir: &Path, argv_log: &Path) { + std::fs::create_dir_all(bin_dir).unwrap(); + write_executable( + &bin_dir.join("npm"), + &format!( + "#!/bin/sh\necho \"$*\" >> '{}'\ncase \" $* \" in *\" --registry \"*) echo 7.8.9; exit 0;; esac\necho 'npm error code FETCH_ERROR' >&2\necho 'npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com?block_reason=x' >&2\nexit 1\n", + argv_log.display() + ), + ); + } + + /// The freshness probe follows the install's precedence: npm's own config + /// first, then — for a gateway block with no override — one retry through + /// the fallback. + #[tokio::test] + async fn probe_latest_version_retries_a_gateway_block_through_the_fallback() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + let argv_log = dir.path().join("argv.log"); + write_fake_gateway_blocked_npm_view(&bin, &argv_log); + let env = path_env(&bin); + let package = "@agentclientprotocol/claude-agent-acp"; + let fallback = Some("https://fallback.example.test/npm/"); + + let version = probe_latest_version( + &bin.join("npm"), + package, + None, + fallback, + &env, + Duration::from_secs(15), + ) + .await; + assert_eq!(version.as_deref(), Some("7.8.9")); + let argv: Vec = std::fs::read_to_string(&argv_log) + .unwrap() + .lines() + .map(str::to_string) + .collect(); + assert_eq!(argv.len(), 2, "{argv:?}"); + assert!(!argv[0].contains("--registry"), "{}", argv[0]); + assert!( + argv[1].contains("--registry https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + + // No fallback compiled in: the block page is simply unknown. + std::fs::remove_file(&argv_log).unwrap(); + assert_eq!( + probe_latest_version( + &bin.join("npm"), + package, + None, + None, + &env, + Duration::from_secs(15) + ) + .await, + None + ); + assert_eq!( + std::fs::read_to_string(&argv_log).unwrap().lines().count(), + 1 + ); + + // An override is used as-is and never falls back. + std::fs::remove_file(&argv_log).unwrap(); + assert_eq!( + probe_latest_version( + &bin.join("npm"), + package, + Some("https://mine.example.test/npm/"), + fallback, + &env, + Duration::from_secs(15) + ) + .await + .as_deref(), + Some("7.8.9") + ); + let argv = std::fs::read_to_string(&argv_log).unwrap(); + assert_eq!(argv.lines().count(), 1, "{argv}"); + assert!(argv.contains("--registry https://mine.example.test/npm/")); + } + /// Every failure mode reads as unknown — never as a version. #[tokio::test] async fn npm_view_version_is_unknown_on_failure_garbage_or_timeout() { @@ -1623,7 +2245,8 @@ mod tests { &env, Duration::from_secs(10) ) - .await, + .await + .ok(), None ); @@ -1637,7 +2260,8 @@ mod tests { &env, Duration::from_secs(10) ) - .await, + .await + .ok(), None ); @@ -1652,7 +2276,8 @@ mod tests { &env, Duration::from_millis(200) ) - .await, + .await + .ok(), None ); assert!(started.elapsed() < Duration::from_secs(10)); diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs index f2683a381..d125b1eeb 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs @@ -52,6 +52,7 @@ async fn every_managed_tool_promotes_the_validated_staging_tree() { TEST_NODE_VERSION, tool, None, + None, TIMEOUT, &|_| {}, ) @@ -93,6 +94,7 @@ async fn incomplete_first_install_creates_no_live_tree_shim_or_state() { TEST_NODE_VERSION, tool, None, + None, TIMEOUT, &|_| {}, ) @@ -134,6 +136,7 @@ async fn rejected_upgrade( TEST_NODE_VERSION, tool, None, + None, timeout, &|line| lines.lock().unwrap().push(line.to_string()), ) @@ -178,6 +181,7 @@ async fn missing_native_binary_preserves_live_install_and_partial_reconcile_runt TEST_NODE_VERSION, other, None, + None, TIMEOUT, &|_| {}, ) @@ -387,6 +391,7 @@ async fn new_managed_tool_cannot_silently_skip_validation() { TEST_NODE_VERSION, &tool, None, + None, TIMEOUT, &|_| {}, ) diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs index d2e0a9ac1..2aabab4a5 100644 --- a/apps/staged/src-tauri/src/managed_node.rs +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -4,9 +4,11 @@ //! 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. +//! `https://nodejs.org/dist` unless `STAGED_NODE_DIST_URL` names a mirror +//! with the same path shape; the lock's hash pin is the trust root, not the +//! host. A build may compile in a second host via +//! `STAGED_FALLBACK_NODE_DIST_URL`, tried once when the first download fails +//! at the network level (never for a bad checksum or archive). //! //! `~/.staged/packages` is shared by every running Staged instance (several //! worktree `just dev` processes routinely run alongside the installed app), @@ -24,9 +26,12 @@ 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"; +/// Explicit Node.js dist mirror override: a base URL laid out like +/// `https://nodejs.org/dist` (`//`). +pub const NODE_DIST_URL_ENV: &str = "STAGED_NODE_DIST_URL"; + const PACKAGES_LOCK_FILENAME: &str = ".lock"; /// Hard cap on the compressed tarball; the largest pinned artifact today is @@ -86,12 +91,27 @@ pub(crate) fn current_target_triple() -> Option<&'static str> { } } -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 - } +/// Where the pinned runtime is downloaded from: the trimmed, non-empty +/// `STAGED_NODE_DIST_URL` override, else upstream `nodejs.org`. +fn node_dist_base_url() -> String { + node_dist_base_url_from(std::env::var(NODE_DIST_URL_ENV).ok().as_deref()) +} + +fn node_dist_base_url_from(override_url: Option<&str>) -> String { + override_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .unwrap_or(UPSTREAM_NODE_DIST_BASE_URL) + .to_string() +} + +/// A second dist host compiled in through `STAGED_FALLBACK_NODE_DIST_URL` at +/// build time (no URL lives in source), for networks where the primary host +/// cannot be reached. `None` for builds that set no such variable. +fn fallback_node_dist_base_url() -> Option<&'static str> { + option_env!("STAGED_FALLBACK_NODE_DIST_URL") + .map(str::trim) + .filter(|url| !url.is_empty()) } #[derive(Debug)] @@ -200,15 +220,45 @@ fn install_dir(node_root: &Path, version: &str, platform: &str) -> PathBuf { 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( + ensure_managed_node_runtime_with_fallback( &packages_root, - node_dist_base_url(), + &node_dist_base_url(), + fallback_node_dist_base_url(), node_runtime_lock(), MAX_ARCHIVE_BYTES, ) .await } +/// [`ensure_managed_node_runtime_at`] from `base_url`, retried once from +/// `fallback_url` when — and only when — the first attempt failed at the +/// network level (unreachable host, refused connection, TLS). A bad +/// checksum, an oversized or unsafe archive, or an HTTP error status is a +/// problem with what the host served, not with reaching it, so those fail +/// as they are rather than being papered over by a second host. +async fn ensure_managed_node_runtime_with_fallback( + packages_root: &Path, + base_url: &str, + fallback_url: Option<&str>, + lock: &NodeRuntimeLock, + max_archive_bytes: u64, +) -> Result<(), ManagedNodeError> { + let result = + ensure_managed_node_runtime_at(packages_root, base_url, lock, max_archive_bytes).await; + let Err(ManagedNodeError::Network(message)) = &result else { + return result; + }; + let Some(fallback_url) = + fallback_url.filter(|url| url.trim_end_matches('/') != base_url.trim_end_matches('/')) + else { + return result; + }; + log::warn!( + "Managed Node.js download from {base_url} failed ({message}); retrying from {fallback_url}" + ); + ensure_managed_node_runtime_at(packages_root, fallback_url, lock, max_archive_bytes).await +} + async fn ensure_managed_node_runtime_at( packages_root: &Path, base_url: &str, @@ -494,7 +544,7 @@ async fn stream_archive( .get(url) .send() .await - .map_err(|error| ManagedNodeError::Network(error.to_string()))?; + .map_err(|error| ManagedNodeError::Network(format!("{url}: {error}")))?; let status = response.status(); if !status.is_success() { return Err(ManagedNodeError::HttpStatus(status.as_u16())); @@ -728,6 +778,45 @@ mod tests { format!("http://{addr}") } + /// A server that counts the connections it accepts and answers each with + /// `body`; lets a test prove a host was (or was not) contacted. + async fn serve_counting( + body: Vec, + ) -> (String, std::sync::Arc) { + use std::sync::atomic::{AtomicUsize, Ordering}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let hits = std::sync::Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + counter.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 1024]; + let _ = socket.read(&mut request).await; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(&body).await; + let _ = socket.shutdown().await; + } + }); + (format!("http://{addr}"), hits) + } + + /// A `http://127.0.0.1:` nothing listens on: binding then dropping + /// a listener yields a port that refuses connections. + async fn refusing_url() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + format!("http://{addr}") + } + fn test_node_root(packages_root: &Path) -> PathBuf { node_root_under(packages_root) } @@ -775,12 +864,39 @@ mod tests { assert_eq!(artifact.platform("v24.12.0"), None); } + /// `STAGED_NODE_DIST_URL` wins when it carries a value; unset, empty, or + /// whitespace all mean upstream `nodejs.org`. #[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); + fn base_url_is_the_override_else_upstream() { + assert_eq!(node_dist_base_url_from(None), UPSTREAM_NODE_DIST_BASE_URL); + assert_eq!( + node_dist_base_url_from(Some("")), + UPSTREAM_NODE_DIST_BASE_URL + ); + assert_eq!( + node_dist_base_url_from(Some(" \t")), + UPSTREAM_NODE_DIST_BASE_URL + ); + assert_eq!( + node_dist_base_url_from(Some(" https://mirror.example.test/nodejs ")), + "https://mirror.example.test/nodejs" + ); + assert_eq!( + node_dist_base_url(), + node_dist_base_url_from(std::env::var(NODE_DIST_URL_ENV).ok().as_deref()) + ); + } + + /// The fallback host is whatever the build injected — never a URL from + /// source, never blank. + #[test] + fn fallback_base_url_is_build_time_only() { + let expected = option_env!("STAGED_FALLBACK_NODE_DIST_URL") + .map(str::trim) + .filter(|url| !url.is_empty()); + assert_eq!(fallback_node_dist_base_url(), expected); + if let Some(url) = fallback_node_dist_base_url() { + assert_eq!(url, url.trim()); } } @@ -872,6 +988,84 @@ mod tests { assert!(!node_root.join("node-v9.9.8-old.tar.gz.download").exists()); } + /// The primary host refuses connections; the compiled-in fallback serves + /// the pinned tarball and the install completes from it. + #[tokio::test] + async fn network_failure_on_the_primary_host_retries_the_fallback_host() { + let packages_dir = tempfile::tempdir().unwrap(); + let archive = node_tarball(TEST_VERSION); + let lock = test_lock(&sha256_hex(&archive)); + let primary = refusing_url().await; + let (fallback, fallback_hits) = serve_counting(archive).await; + + ensure_managed_node_runtime_with_fallback( + packages_dir.path(), + &primary, + Some(&fallback), + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap(); + + assert_eq!(fallback_hits.load(std::sync::atomic::Ordering::SeqCst), 1); + let bin = install_dir( + &test_node_root(packages_dir.path()), + TEST_VERSION, + TEST_PLATFORM, + ) + .join("bin"); + assert!(bin.join("node").is_file()); + assert!(bin.join("npm").is_file()); + } + + /// Without a fallback the network error surfaces as-is, naming the URL. + #[tokio::test] + async fn network_failure_without_a_fallback_names_the_url() { + let packages_dir = tempfile::tempdir().unwrap(); + let lock = test_lock(&"0".repeat(64)); + let primary = refusing_url().await; + + let error = ensure_managed_node_runtime_with_fallback( + packages_dir.path(), + &primary, + None, + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedNodeError::Network(_)), "{error}"); + assert!(error.to_string().contains(&primary), "{error}"); + } + + /// A checksum mismatch is the host serving the wrong bytes, not the host + /// being unreachable: the fallback is never contacted. + #[tokio::test] + async fn checksum_failure_does_not_retry_the_fallback_host() { + let packages_dir = tempfile::tempdir().unwrap(); + let lock = test_lock(&sha256_hex(b"something else entirely")); + let primary = serve_once(node_tarball(TEST_VERSION), true).await; + let (fallback, fallback_hits) = serve_counting(node_tarball(TEST_VERSION)).await; + + let error = ensure_managed_node_runtime_with_fallback( + packages_dir.path(), + &primary, + Some(&fallback), + &lock, + MAX_ARCHIVE_BYTES, + ) + .await + .unwrap_err(); + + assert!( + matches!(error, ManagedNodeError::Sha256Mismatch { .. }), + "{error}" + ); + assert_eq!(fallback_hits.load(std::sync::atomic::Ordering::SeqCst), 0); + } + #[tokio::test] async fn fast_path_skips_download_when_runtime_matches_pin() { let packages_dir = tempfile::tempdir().unwrap(); From f3fde561bb8f056f48c2e0ec5106f27c647eeaa7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 12:17:48 +1100 Subject: [PATCH 2/7] fix(staged): run managed installs under the doctor env snapshot and tighten the registry fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the review of 4628d2b5 (honor npm config instead of hardcoding Block URLs). Install/probe env divergence: the managed `npm install` inherited the Tauri process env while `latest_published_version` and Doctor's checks and fixes ran under the sanitized login-shell snapshot, so an `NPM_CONFIG_REGISTRY` exported from the shell rc steered the probe but not the install when the app was launched from Finder. `install_managed_tool` now takes the snapshot (`doctor::doctor_env_vars`, now pub(crate); the reconciler builds the same one), runs npm with `env_clear()` + snapshot, and overlays only the managed node bin on PATH and the private `NPM_CONFIG_CACHE`; `--prefix` stays on the command line. The `latest_published_version` doc now describes exactly that. Classifier: `classify_npm_failure` reports GatewayBlocked only for `blocked.teams.cloudflare.com` itself, or a FETCH_ERROR / EJSONPARSE / "invalid json" / "Unexpected token" failure whose *request* went to `registry.npmjs.org` (`request to https://registry.npmjs.org/`, `response body at https://registry.npmjs.org/`, or a `registry: https://registry.npmjs.org` line). A mirror's error body mentioning its upstream, ENOTFOUND for a custom mirror, and E404 are Other. A truncated body from the public registry is still accepted as blocked — one cheap extra attempt — and documented as such. When the fallback attempt also fails, the surfaced error keeps the original diagnosis: "npm default registry (registry.npmjs.org) was blocked by a network gateway; fallback also failed: ". stderr capture keeps the TAIL (npm writes its `npm error code` block last, after warnings), dropping whole lines from the front past 64 KiB. Doctor-crate npm fixes: `run_doctor_fix` and `run_doctor_update` go through `execute_fix_with_gateway_fallback`, which — for an npm-backed command, with no `STAGED_NPM_REGISTRY` and a compiled-in fallback — reuses `gateway_fallback_registry` on the executor's error string (the doctor crate already surfaces the failed command's trimmed stderr as its error) and retries once with `ExecuteFixOptions { npm_registry: Some(fallback) }`. No doctor-crate plumbing was needed. Node dist: an explicit `STAGED_NODE_DIST_URL` now suppresses `STAGED_FALLBACK_NODE_DIST_URL`, matching the npm rule that an explicit override is never second-guessed (`fallback_for`); mid-body read `Network` errors name the URL like the `send()` error does. README and module docs updated. Tests: classifier cases (real block page, inline block page, `registry:` line, truncated body → blocked; Artifactory upstream mention, custom mirror ENOTFOUND, E404, offline npmjs → other), combined fallback error message, stderr tail (whole-line drop + oversized single line), a fake npm that dumps its env proving `NPM_CONFIG_REGISTRY`/`npm_config_registry` from the snapshot reach the install while the managed cache overlay and node-bin-first PATH win, a fallback-also-fails install, and the Node override-suppresses-fallback gate. No new backend data model fields or types; `NpmFailure`, `classify_npm_failure`, `gateway_fallback_registry` and `gateway_retry_failed_message` became pub(crate). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- apps/staged/README.md | 8 +- .../src-tauri/src/acp_tools_reconciler.rs | 6 +- apps/staged/src-tauri/src/doctor.rs | 98 ++++- .../staged/src-tauri/src/managed_acp_tools.rs | 387 ++++++++++++++++-- .../src/managed_acp_tools/tests/validation.rs | 5 + apps/staged/src-tauri/src/managed_node.rs | 70 +++- 6 files changed, 506 insertions(+), 68 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index 3dac070ac..ab075a849 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -99,9 +99,11 @@ A build can additionally compile in fallbacks for networks where the above fail: `STAGED_FALLBACK_NPM_REGISTRY` is retried once when npm's configured registry turns out to be `registry.npmjs.org` behind a network gateway that answers with a block page (and no `STAGED_NPM_REGISTRY` was set), and -`STAGED_FALLBACK_NODE_DIST_URL` is retried once when the runtime download -fails to reach its host. Both are read at *compile time* and are unset for -from-source builds. Block's release workflow passes them from the +`STAGED_FALLBACK_NODE_DIST_URL` is retried once when the download from +`nodejs.org` fails to reach the host (and no `STAGED_NODE_DIST_URL` was set — +an explicit override is never second-guessed, for either). Both are read at +*compile time* and are unset for from-source builds. Block's release workflow +passes them from the `STAGED_FALLBACK_NPM_REGISTRY` / `STAGED_FALLBACK_NODE_DIST_URL` repository variables, which must be set for Block builds to keep working on managed laptops that have no `~/.npmrc`. diff --git a/apps/staged/src-tauri/src/acp_tools_reconciler.rs b/apps/staged/src-tauri/src/acp_tools_reconciler.rs index 62bfd8578..57365b8a0 100644 --- a/apps/staged/src-tauri/src/acp_tools_reconciler.rs +++ b/apps/staged/src-tauri/src/acp_tools_reconciler.rs @@ -84,11 +84,15 @@ async fn reconcile(app: AppHandle) { return; } + // The same login-shell snapshot doctor's checks and fixes use, so the + // reconciler's npm resolves the user's registry config (`~/.npmrc`, an + // `NPM_CONFIG_REGISTRY` from the shell rc) exactly as a doctor fix would. + let env_vars = crate::doctor::doctor_env_vars().await; 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 { + match managed_acp_tools::install_managed_tool(tool.id, &env_vars, &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}"); diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index c259c4950..402698107 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -276,14 +276,17 @@ fn new_login_run_id() -> String { uuid::Uuid::new_v4().to_string() } -/// Environment snapshot for doctor checks and fixes. Shaped through -/// `apply_managed_tools_env` so checks resolve binaries from the same PATH -/// the agent spawn path uses — a bridge Staged manages must never be +/// Environment snapshot for doctor checks and fixes — and for the managed +/// bridge installs themselves (see +/// [`crate::managed_acp_tools::install_managed_tool`]), so the registry npm +/// resolves is the same for a check, its fix, and the reconciler. Shaped +/// through `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() -> Vec<(String, String)> { +pub(crate) 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(), ) @@ -321,6 +324,20 @@ fn run_checks_options( fn execute_fix_options( command_override: Option, env_vars: Vec<(String, String)>, +) -> ExecuteFixOptions { + execute_fix_options_through( + command_override, + crate::managed_acp_tools::npm_registry_override(), + env_vars, + ) +} + +/// [`execute_fix_options`] with the registry chosen by the caller — the +/// gateway-blocked retry passes the compiled-in fallback here. +fn execute_fix_options_through( + command_override: Option, + npm_registry: Option, + env_vars: Vec<(String, String)>, ) -> ExecuteFixOptions { // Everything else stays at doctor's defaults: the fixes that reach this // builder are the non-interactive ones (installs and updates), so nothing @@ -334,12 +351,60 @@ fn execute_fix_options( // compiles but `staged-ci.yml` does. ExecuteFixOptions { command_override, - npm_registry: crate::managed_acp_tools::npm_registry_override(), + npm_registry, ..Default::default() } .with_env_snapshot(env_vars) } +/// Run a doctor fix through the crate's executor with the registry precedence +/// the managed installer applies: npm's own config (or the explicit +/// `STAGED_NPM_REGISTRY`) first, and — only for an npm-backed command that +/// failed as a gateway block of `registry.npmjs.org`, with no override set +/// and a fallback compiled in — one retry with `--registry `. The +/// executor surfaces the failed command's stderr as its error string, which +/// is exactly what the classifier reads; when the retry fails too, the +/// surfaced error keeps the original diagnosis in front of the retry's. +async fn execute_fix_with_gateway_fallback( + check_id: String, + fix_type: FixType, + command_override: Option, + npm_backed: bool, + env_vars: Vec<(String, String)>, +) -> Result<(), String> { + let first = doctor::execute_fix_with_env_options( + check_id.clone(), + fix_type.clone(), + execute_fix_options(command_override.clone(), env_vars.clone()), + ) + .await; + let Err(error) = first else { + return Ok(()); + }; + if !npm_backed { + return Err(error); + } + let Some(fallback) = crate::managed_acp_tools::gateway_fallback_registry( + crate::managed_acp_tools::npm_registry_override().as_deref(), + crate::managed_acp_tools::fallback_npm_registry(), + &error, + ) else { + return Err(error); + }; + log::info!( + "[doctor fix {check_id}] npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" + ); + doctor::execute_fix_with_env_options( + check_id, + fix_type, + execute_fix_options_through(command_override, Some(fallback.to_string()), env_vars), + ) + .await + .map_err(|retry_error| { + crate::managed_acp_tools::gateway_retry_failed_message(fallback, &retry_error) + }) +} + /// Run all health checks and return the report. /// /// This is the cheap, no-network path: it resolves binaries and reports @@ -714,14 +779,13 @@ pub async fn run_doctor_fix( } } let env_vars = doctor_env_vars().await; - if doctor::agents::lookup_fix_command(&check_id, &fix_type) + let npm_backed = doctor::agents::lookup_fix_command(&check_id, &fix_type) .as_deref() - .is_some_and(crate::managed_acp_tools::is_npm_backed_command) - { + .is_some_and(crate::managed_acp_tools::is_npm_backed_command); + if npm_backed { ensure_managed_node_runtime_for_fix().await?; } - doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) - .await + execute_fix_with_gateway_fallback(check_id, fix_type, None, npm_backed, env_vars).await } /// The install id of the ACP bridge a doctor check fronts, for the two checks @@ -776,7 +840,8 @@ fn managed_update_target(check_id: &str, fix_type: &FixType) -> Option<&'static /// 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| { + let env_vars = doctor_env_vars().await; + crate::managed_acp_tools::install_managed_tool(tool_id, &env_vars, &|line| { log::info!("{log_prefix} {line}"); }) .await @@ -840,19 +905,16 @@ pub async fn run_doctor_update( } // 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) { + let npm_backed = crate::managed_acp_tools::is_npm_backed_command(&expected); + if npm_backed { 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 // the equality check surviving future edits. - doctor::execute_fix_with_env_options( - check_id, - fix_type, - execute_fix_options(Some(expected), env_vars), - ) - .await + execute_fix_with_gateway_fallback(check_id, fix_type, Some(expected), npm_backed, env_vars) + .await } /// Re-run freshness and return the authoritative update command for the given diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index 42779e4e0..be637d5bc 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -113,20 +113,43 @@ fn registry_label(registry: Option<&str>) -> String { /// What a failed npm command's stderr says about why it failed, as far as /// the registry retry needs to know. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum NpmFailure { +pub(crate) enum NpmFailure { /// A network gateway answered for `registry.npmjs.org` with a block page /// instead of registry JSON — Cloudflare Gateway on managed Block laptops /// redirects to `blocked.teams.cloudflare.com`, and npm surfaces the HTML - /// as `FETCH_ERROR` or a JSON parse error naming the public registry. + /// as `FETCH_ERROR` or a JSON parse error for a request it made to the + /// public registry. GatewayBlocked, Other, } -fn classify_npm_failure(stderr: &str) -> NpmFailure { +/// How npm names `registry.npmjs.org` as the host it *requested* — minipass- +/// fetch's `request to ` / `invalid json response body at ` and +/// npm's `registry: ` detail line. A bare `registry.npmjs.org` anywhere +/// in stderr is not enough: a mirror's own error body mentions its upstream +/// ("failed to fetch from registry.npmjs.org") when *it* could not reach it, +/// and that is the mirror failing, not a gateway blocking the public registry. +const NPMJS_REQUEST_MARKERS: [&str; 3] = [ + "request to https://registry.npmjs.org/", + "response body at https://registry.npmjs.org/", + "registry: https://registry.npmjs.org", +]; + +/// Gateway-blocked iff stderr names the block page itself, or a non-JSON / +/// fetch-level failure (`FETCH_ERROR`, `EJSONPARSE`, "invalid json", +/// "Unexpected token") happened on a request npm made to +/// `registry.npmjs.org`. `FETCH_ERROR` is minipass-fetch's catch-all for a +/// fetch that died without a system errno, so a truncated body from the +/// public registry classifies as blocked too — that costs one cheap extra +/// attempt through the fallback, which then reports its own error. +pub(crate) fn classify_npm_failure(stderr: &str) -> NpmFailure { if stderr.contains("blocked.teams.cloudflare.com") { return NpmFailure::GatewayBlocked; } - if stderr.contains("registry.npmjs.org") { + let requested_npmjs = NPMJS_REQUEST_MARKERS + .iter() + .any(|marker| stderr.contains(marker)); + if requested_npmjs { let lower = stderr.to_ascii_lowercase(); if stderr.contains("FETCH_ERROR") || stderr.contains("EJSONPARSE") @@ -142,7 +165,7 @@ fn classify_npm_failure(stderr: &str) -> NpmFailure { /// The registry to retry a failed npm command through, if any: only when no /// explicit override was given, a build-time fallback exists, and `stderr` /// reads as a gateway block of `registry.npmjs.org`. -fn gateway_fallback_registry<'a>( +pub(crate) fn gateway_fallback_registry<'a>( override_registry: Option<&str>, fallback: Option<&'a str>, stderr: &str, @@ -154,6 +177,17 @@ fn gateway_fallback_registry<'a>( (classify_npm_failure(stderr) == NpmFailure::GatewayBlocked).then_some(fallback) } +/// The error to surface when the retry through `fallback` failed as well: +/// keeps the original diagnosis (npm's default registry was gateway-blocked) +/// in front of the retry's own error, so the surfaced message explains why a +/// registry the user never configured was contacted at all. +pub(crate) fn gateway_retry_failed_message(fallback: &str, retry_error: &str) -> String { + format!( + "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ + fallback {fallback} also failed: {retry_error}" + ) +} + /// 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) @@ -482,8 +516,14 @@ fn tool_install_lock() -> &'static tokio::sync::Mutex<()> { /// 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. +/// +/// `env_vars` is the sanitized login-shell snapshot doctor's checks and the +/// freshness probe run under ([`crate::doctor::doctor_env_vars`]); npm runs +/// under exactly that, so the registry config it resolves (`~/.npmrc`, +/// `NPM_CONFIG_REGISTRY` from the shell rc) is the one the probe saw. pub async fn install_managed_tool( id: &str, + env_vars: &[(String, String)], on_line: &InstallLineFn<'_>, ) -> Result<(), ManagedToolError> { let tool = managed_tool(id).ok_or_else(|| { @@ -513,6 +553,7 @@ pub async fn install_managed_tool( &node_install_dir, &managed_node::node_runtime_lock().version, &tool, + env_vars, npm_registry_override().as_deref(), fallback_npm_registry(), validation::VALIDATION_TIMEOUT, @@ -523,15 +564,17 @@ pub async fn install_managed_tool( /// 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. `registry` is the explicit override (or `None` for npm's own -/// config) and `fallback_registry` the build-time fallback tried once if the -/// first attempt fails as gateway-blocked (see the registry policy note). +/// the runtime. npm runs under the `env_vars` snapshot; `registry` is the +/// explicit override (or `None` for npm's own config) and `fallback_registry` +/// the build-time fallback tried once if the first attempt fails as +/// gateway-blocked (see the registry policy note). #[allow(clippy::too_many_arguments)] async fn install_npm_tool( packages_root: &Path, node_install_dir: &Path, node_version: &str, tool: &ManagedTool, + env_vars: &[(String, String)], registry: Option<&str>, fallback_registry: Option<&str>, validation_timeout: Duration, @@ -558,6 +601,7 @@ async fn install_npm_tool( node_install_dir, &staging_dir, tool, + env_vars, registry, on_line, ) @@ -585,13 +629,23 @@ async fn install_npm_tool( node_install_dir, &staging_dir, tool, + env_vars, Some(fallback), on_line, ) .await { let _ = std::fs::remove_dir_all(&staging_dir); - return Err(failure.error); + // The retry's own error is an `NpmInstall` too; its bare message + // goes inside so the prefix is not doubled. + let retry_error = match &failure.error { + ManagedToolError::NpmInstall(message) => message.clone(), + other => other.to_string(), + }; + return Err(ManagedToolError::NpmInstall(gateway_retry_failed_message( + fallback, + &retry_error, + ))); } } @@ -725,15 +779,41 @@ struct NpmInstallFailure { stderr: String, } -/// npm's error block fits comfortably in this; the retry classifier only -/// needs the first lines, not a full debug log. +/// How much of npm's stderr is kept for the retry classifier. npm writes its +/// `npm error code …` block *last*, after any deprecation and peer warnings, +/// so the capture keeps the newest lines (see [`push_captured_line`]) — a +/// warning-heavy install must not push the error block out of view. const NPM_STDERR_CAPTURE_LIMIT: usize = 64 * 1024; +/// Append `line` to the capture, dropping whole lines from the *front* once +/// it outgrows [`NPM_STDERR_CAPTURE_LIMIT`] (a single oversized line keeps +/// its tail). +fn push_captured_line(capture: &mut String, line: &str) { + capture.push_str(line); + capture.push('\n'); + while capture.len() > NPM_STDERR_CAPTURE_LIMIT { + match capture.find('\n') { + Some(end) if end + 1 < capture.len() => { + capture.drain(..=end); + } + _ => { + let mut cut = capture.len() - NPM_STDERR_CAPTURE_LIMIT; + while !capture.is_char_boundary(cut) { + cut += 1; + } + capture.drain(..cut); + } + } + } +} + +#[allow(clippy::too_many_arguments)] async fn run_floating_npm_install( packages_root: &Path, node_install_dir: &Path, install_dir: &Path, tool: &ManagedTool, + env_vars: &[(String, String)], registry: Option<&str>, on_line: &InstallLineFn<'_>, ) -> Result<(), NpmInstallFailure> { @@ -743,6 +823,7 @@ async fn run_floating_npm_install( node_install_dir, install_dir, tool, + env_vars, registry, on_line, &mut stderr_capture, @@ -760,6 +841,7 @@ async fn run_floating_npm_install_capturing( node_install_dir: &Path, install_dir: &Path, tool: &ManagedTool, + env_vars: &[(String, String)], registry: Option<&str>, on_line: &InstallLineFn<'_>, stderr_capture: &mut String, @@ -785,11 +867,24 @@ async fn run_floating_npm_install_capturing( // 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. + // Exactly the snapshot, not the Tauri process env: launched from Finder + // the process carries none of the user's shell rc, so an + // `NPM_CONFIG_REGISTRY`/`npm_config_registry` exported there would steer + // the freshness probe and doctor's fixes but not this install. The + // snapshot is what all of those run under. + command.env_clear(); + for (key, value) in env_vars { + command.env(key, value); + } + // npm's own `#!/usr/bin/env node` shebang must resolve the managed node: + // its bin dir goes first on the snapshot's PATH (the process PATH when + // the snapshot carries none). + let inherited_path = snapshot_var(env_vars, "PATH") + .map(std::ffi::OsString::from) + .or_else(|| std::env::var_os("PATH")) + .unwrap_or_default(); let mut paths = vec![node_bin_dir.clone()]; - paths.extend(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - )); + paths.extend(std::env::split_paths(&inherited_path)); if let Ok(path_value) = std::env::join_paths(paths) { command.env("PATH", path_value); } @@ -823,10 +918,7 @@ async fn run_floating_npm_install_capturing( let mut lines = tokio::io::BufReader::new(stream).lines(); while let Ok(Some(line)) = lines.next_line().await { on_line(&line); - if stderr_capture.len() < NPM_STDERR_CAPTURE_LIMIT { - stderr_capture.push_str(&line); - stderr_capture.push('\n'); - } + push_captured_line(stderr_capture, &line); } } }; @@ -941,10 +1033,12 @@ fn latest_version_cache() -> &'static Mutex { /// unreachable, no `npm` to run, or the probe timed out. The caller must keep /// `None` as *unknown*, never read it as "up to date". /// -/// Runs `npm view version` through the same env snapshot doctor's own -/// probes use, so the managed runtime's `npm` is preferred and the lookup sees -/// the registry/config the install itself will — the same override → npm -/// config → gateway-blocked fallback precedence as the install. Bounded by +/// Runs `npm view version` under the same env snapshot doctor's own +/// probes and the managed install itself run under (see +/// [`install_managed_tool`]), so the managed runtime's `npm` is preferred and +/// the lookup resolves the registry the install will — `~/.npmrc` and any +/// `NPM_CONFIG_REGISTRY` the user's shell rc exports alike — with the same +/// override → npm config → gateway-blocked fallback precedence. Bounded by /// [`LATEST_VERSION_PROBE_TIMEOUT`] and cached for [`LATEST_VERSION_CACHE_TTL`]. pub async fn latest_published_version( tool: &ManagedTool, @@ -1014,12 +1108,21 @@ async fn probe_latest_version( /// process's PATH, not the snapshot's, and the snapshot is what puts the /// managed runtime's bin dir first. fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option { - let (_, path) = env_vars.iter().rev().find(|(key, _)| key == "PATH")?; - std::env::split_paths(path) + std::env::split_paths(snapshot_var(env_vars, "PATH")?) .map(|dir| dir.join(name)) .find(|candidate| candidate.is_file()) } +/// The last value of `key` in an env snapshot — the one a `Command` applying +/// the snapshot in order would end up with. +fn snapshot_var<'a>(env_vars: &'a [(String, String)], key: &str) -> Option<&'a str> { + env_vars + .iter() + .rev() + .find(|(existing, _)| existing == key) + .map(|(_, value)| value.as_str()) +} + /// Why an `npm view` probe produced no version. #[derive(Debug)] enum NpmViewFailure { @@ -1244,14 +1347,24 @@ mod tests { assert_eq!(registry_label(None), "npm default (per npm config)"); } + /// Gateway-blocked only for the block page itself, or a fetch/JSON failure + /// on a request npm made *to* `registry.npmjs.org`. The public registry's + /// hostname appearing elsewhere in stderr proves nothing — mirrors name + /// their upstream in their own error bodies. #[test] fn classify_npm_failure_spots_gateway_block_pages_only() { let blocked = [ - // The redirect target itself shows up in npm's error output. - "npm error request to https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp failed: https://blocked.teams.cloudflare.com?block_reason=...", - "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<'", - "npm error code EJSONPARSE\nnpm error JSON.parse Unexpected token < in JSON at position 0 while parsing near '' from https://registry.npmjs.org/x", - "npm error Invalid JSON response from https://registry.npmjs.org/foo", + // The real thing: Cloudflare Gateway redirects the packument fetch + // to its block page, and npm reports the redirect target. + "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error request to https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp failed: https://blocked.teams.cloudflare.com?block_reason=...", + // The block page served inline as the response body. + "npm warn deprecated foo@1.0.0: use bar\nnpm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<', \"Failed to fetch from registry.npmjs.org (https://registry.npmjs.org/foo)\"... is not valid JSON", + // The public registry itself unreachable (offline): a network + // error, not a block page. "npm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/foo failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", ]; for stderr in other { @@ -1273,6 +1393,50 @@ mod tests { } } + #[test] + fn gateway_retry_failed_message_keeps_the_original_diagnosis() { + let message = gateway_retry_failed_message( + "https://fallback.example.test/npm/", + "npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", + ); + assert_eq!( + message, + "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ + fallback https://fallback.example.test/npm/ also failed: npm install exited with \ + exit status: 1 (registry: https://fallback.example.test/npm/)" + ); + } + + /// The capture keeps the *newest* lines: npm's `npm error code …` block + /// comes last, after however many warnings preceded it. + #[test] + fn stderr_capture_keeps_the_tail() { + let mut capture = String::new(); + let warning = format!("npm warn deprecated {}", "x".repeat(1000)); + for _ in 0..(NPM_STDERR_CAPTURE_LIMIT / warning.len() + 5) { + push_captured_line(&mut capture, &warning); + } + push_captured_line(&mut capture, "npm error code FETCH_ERROR"); + push_captured_line( + &mut capture, + "npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com", + ); + assert!(capture.len() <= NPM_STDERR_CAPTURE_LIMIT); + assert!(capture.starts_with("npm warn"), "drops whole lines only"); + assert!(capture.ends_with("blocked.teams.cloudflare.com\n")); + assert_eq!(classify_npm_failure(&capture), NpmFailure::GatewayBlocked); + + // A single line larger than the whole budget keeps its tail, cut on a + // char boundary. + let mut capture = String::new(); + push_captured_line( + &mut capture, + &format!("{}é end", "a".repeat(NPM_STDERR_CAPTURE_LIMIT)), + ); + assert_eq!(capture.len(), NPM_STDERR_CAPTURE_LIMIT); + assert!(capture.ends_with("é end\n")); + } + /// The fallback is consulted only with no override, a compiled-in /// fallback, and a gateway-shaped failure — all three at once. #[test] @@ -1448,6 +1612,15 @@ mod tests { const TEST_NODE_VERSION: &str = "v9.9.9"; + /// The env snapshot the fixture installs run under: enough of the test + /// process env for a `#!/bin/sh` fake npm to find `cp` and its home. + fn install_env() -> Vec<(String, String)> { + ["PATH", "HOME"] + .into_iter() + .filter_map(|key| Some((key.to_string(), std::env::var(key).ok()?))) + .collect() + } + 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(); @@ -1581,6 +1754,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1630,6 +1804,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1660,6 +1835,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1695,6 +1871,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1747,6 +1924,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1835,6 +2013,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, Some("https://fallback.example.test/npm/"), super::validation::VALIDATION_TIMEOUT, @@ -1865,6 +2044,149 @@ mod tests { assert!(!staging_install_dir(&packages_root, tool.id).exists()); } + /// When the fallback attempt fails too, the surfaced error still says why + /// the fallback was contacted: the default registry was gateway-blocked. + #[tokio::test] + async fn gateway_blocked_install_names_both_failures_when_the_fallback_fails() { + 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(); + // Fails whatever registry it is given: a block page without one, a + // plain exit 7 with one. + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + write_executable(&bin.join("node"), "#!/bin/sh\necho v9.9.9\n"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\ncase \" $* \" in\n *\" --registry \"*) echo 'npm error code E500' >&2; exit 7 ;;\nesac\nprintf '%s\\n' '{}' >&2\nexit 1\n", + GATEWAY_BLOCK_STDERR.replace('\'', "'\\''") + ), + ); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + None, + Some("https://fallback.example.test/npm/"), + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + let message = error.to_string(); + assert_eq!( + message, + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ + network gateway; fallback https://fallback.example.test/npm/ also failed: \ + npm install exited with exit status: 7 (registry: https://fallback.example.test/npm/)" + ); + assert!( + message.contains( + "fallback https://fallback.example.test/npm/ also failed: npm install exited with" + ), + "{message}" + ); + assert!( + message.contains("(registry: https://fallback.example.test/npm/)"), + "{message}" + ); + assert!(!staging_install_dir(&packages_root, tool.id).exists()); + } + + /// npm runs under the env snapshot, not the Tauri process env: a registry + /// the user's shell rc exports reaches the install exactly as it reaches + /// the freshness probe, while the managed node bin leads PATH and the + /// private cache outranks whatever cache the snapshot named. + #[tokio::test] + async fn install_runs_npm_under_the_env_snapshot() { + 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); + // The fake npm also dumps the env it saw. + let env_log = dir.path().join("npm-env.log"); + let npm = node_install_dir.join("bin").join("npm"); + let script = std::fs::read_to_string(&npm).unwrap(); + write_executable( + &npm, + &script.replacen( + "#!/bin/sh\n", + &format!("#!/bin/sh\nenv > '{}'\n", env_log.display()), + 1, + ), + ); + + let mut env_vars = install_env(); + env_vars.push(( + "NPM_CONFIG_REGISTRY".to_string(), + "https://mine.example.test/npm/".to_string(), + )); + env_vars.push(( + "npm_config_registry".to_string(), + "https://mine.example.test/npm/".to_string(), + )); + env_vars.push(( + "NPM_CONFIG_CACHE".to_string(), + "/somewhere/else/cache".to_string(), + )); + env_vars.push(("STAGED_TEST_SNAPSHOT_ONLY".to_string(), "1".to_string())); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &env_vars, + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap(); + + let seen: std::collections::HashMap = std::fs::read_to_string(&env_log) + .unwrap() + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + assert_eq!( + seen.get("NPM_CONFIG_REGISTRY").map(String::as_str), + Some("https://mine.example.test/npm/") + ); + assert_eq!( + seen.get("npm_config_registry").map(String::as_str), + Some("https://mine.example.test/npm/") + ); + assert_eq!( + seen.get("STAGED_TEST_SNAPSHOT_ONLY").map(String::as_str), + Some("1"), + "snapshot-only vars reach npm" + ); + let expected_cache = packages_root.join("npm-prefix").join("cache"); + assert_eq!( + seen.get("NPM_CONFIG_CACHE").map(Path::new), + Some(expected_cache.as_path()), + "the managed cache overlay wins over the snapshot's" + ); + let first_on_path = std::env::split_paths(seen.get("PATH").unwrap()).next(); + assert_eq!( + first_on_path.as_deref(), + Some(node_install_dir.join("bin").as_path()) + ); + } + /// An explicit `STAGED_NPM_REGISTRY` is never second-guessed, and a /// failure that is not a gateway block page is not retried either; both /// surface the first failure, naming the registry npm used. @@ -1892,6 +2214,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, None, super::validation::VALIDATION_TIMEOUT, @@ -1922,6 +2245,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), None, fallback, super::validation::VALIDATION_TIMEOUT, @@ -1946,6 +2270,7 @@ mod tests { &node_install_dir, TEST_NODE_VERSION, &tool, + &install_env(), Some("https://mine.example.test/npm/"), fallback, super::validation::VALIDATION_TIMEOUT, diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs index d125b1eeb..44782648b 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs @@ -51,6 +51,7 @@ async fn every_managed_tool_promotes_the_validated_staging_tree() { &node, TEST_NODE_VERSION, tool, + &install_env(), None, None, TIMEOUT, @@ -93,6 +94,7 @@ async fn incomplete_first_install_creates_no_live_tree_shim_or_state() { &node, TEST_NODE_VERSION, tool, + &install_env(), None, None, TIMEOUT, @@ -135,6 +137,7 @@ async fn rejected_upgrade( &node, TEST_NODE_VERSION, tool, + &install_env(), None, None, timeout, @@ -180,6 +183,7 @@ async fn missing_native_binary_preserves_live_install_and_partial_reconcile_runt &node, TEST_NODE_VERSION, other, + &install_env(), None, None, TIMEOUT, @@ -390,6 +394,7 @@ async fn new_managed_tool_cannot_silently_skip_validation() { &node, TEST_NODE_VERSION, &tool, + &install_env(), None, None, TIMEOUT, diff --git a/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs index 2aabab4a5..ec712a534 100644 --- a/apps/staged/src-tauri/src/managed_node.rs +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -7,8 +7,10 @@ //! `https://nodejs.org/dist` unless `STAGED_NODE_DIST_URL` names a mirror //! with the same path shape; the lock's hash pin is the trust root, not the //! host. A build may compile in a second host via -//! `STAGED_FALLBACK_NODE_DIST_URL`, tried once when the first download fails -//! at the network level (never for a bad checksum or archive). +//! `STAGED_FALLBACK_NODE_DIST_URL`, tried once when the download from +//! `nodejs.org` fails at the network level (never for a bad checksum or +//! archive, and never when `STAGED_NODE_DIST_URL` is set — an explicit +//! override is not second-guessed). //! //! `~/.staged/packages` is shared by every running Staged instance (several //! worktree `just dev` processes routinely run alongside the installed app), @@ -91,10 +93,13 @@ pub(crate) fn current_target_triple() -> Option<&'static str> { } } -/// Where the pinned runtime is downloaded from: the trimmed, non-empty -/// `STAGED_NODE_DIST_URL` override, else upstream `nodejs.org`. -fn node_dist_base_url() -> String { - node_dist_base_url_from(std::env::var(NODE_DIST_URL_ENV).ok().as_deref()) +/// The `STAGED_NODE_DIST_URL` override, trimmed, when set and non-empty. +/// `None` means upstream `nodejs.org` (see [`node_dist_base_url_from`]). +fn node_dist_override() -> Option { + std::env::var(NODE_DIST_URL_ENV) + .ok() + .map(|url| url.trim().to_string()) + .filter(|url| !url.is_empty()) } fn node_dist_base_url_from(override_url: Option<&str>) -> String { @@ -105,6 +110,21 @@ fn node_dist_base_url_from(override_url: Option<&str>) -> String { .to_string() } +/// The fallback host to retry from, if any: only when the user gave no +/// `STAGED_NODE_DIST_URL`. An explicit override is never second-guessed — the +/// same rule `STAGED_NPM_REGISTRY` follows — so a private mirror that is +/// briefly down fails as such instead of silently downloading from a host the +/// user never configured. +fn fallback_for( + override_url: Option<&str>, + fallback: Option<&'static str>, +) -> Option<&'static str> { + if override_url.is_some() { + return None; + } + fallback +} + /// A second dist host compiled in through `STAGED_FALLBACK_NODE_DIST_URL` at /// build time (no URL lives in source), for networks where the primary host /// cannot be reached. `None` for builds that set no such variable. @@ -220,10 +240,11 @@ fn install_dir(node_root: &Path, version: &str, platform: &str) -> PathBuf { 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()))?; + let override_url = node_dist_override(); ensure_managed_node_runtime_with_fallback( &packages_root, - &node_dist_base_url(), - fallback_node_dist_base_url(), + &node_dist_base_url_from(override_url.as_deref()), + fallback_for(override_url.as_deref(), fallback_node_dist_base_url()), node_runtime_lock(), MAX_ARCHIVE_BYTES, ) @@ -232,10 +253,12 @@ pub async fn ensure_managed_node_runtime() -> Result<(), ManagedNodeError> { /// [`ensure_managed_node_runtime_at`] from `base_url`, retried once from /// `fallback_url` when — and only when — the first attempt failed at the -/// network level (unreachable host, refused connection, TLS). A bad -/// checksum, an oversized or unsafe archive, or an HTTP error status is a -/// problem with what the host served, not with reaching it, so those fail -/// as they are rather than being papered over by a second host. +/// network level (unreachable host, refused connection, TLS, a connection +/// dropped mid-download). A bad checksum, an oversized or unsafe archive, or +/// an HTTP error status is a problem with what the host served, not with +/// reaching it, so those fail as they are rather than being papered over by +/// a second host. The caller passes `fallback_url` only when `base_url` is +/// not a user override (see [`fallback_for`]). async fn ensure_managed_node_runtime_with_fallback( packages_root: &Path, base_url: &str, @@ -567,7 +590,7 @@ async fn stream_archive( while let Some(chunk) = response .chunk() .await - .map_err(|error| ManagedNodeError::Network(error.to_string()))? + .map_err(|error| ManagedNodeError::Network(format!("{url}: {error}")))? { received_bytes += chunk.len() as u64; if received_bytes > max_bytes { @@ -882,9 +905,26 @@ mod tests { "https://mirror.example.test/nodejs" ); assert_eq!( - node_dist_base_url(), - node_dist_base_url_from(std::env::var(NODE_DIST_URL_ENV).ok().as_deref()) + node_dist_override(), + std::env::var(NODE_DIST_URL_ENV) + .ok() + .map(|url| url.trim().to_string()) + .filter(|url| !url.is_empty()) + ); + } + + /// An explicit `STAGED_NODE_DIST_URL` is never second-guessed: the + /// compiled-in fallback is only offered when the primary is upstream + /// `nodejs.org` by default. + #[test] + fn an_explicit_dist_override_suppresses_the_fallback() { + let fallback = Some("https://fallback.example.test/nodejs"); + assert_eq!(fallback_for(None, fallback), fallback); + assert_eq!( + fallback_for(Some("https://mirror.example.test/nodejs"), fallback), + None ); + assert_eq!(fallback_for(None, None), None); } /// The fallback host is whatever the build injected — never a URL from From e905c94ea88af69ca805f8707eca81d93133ad5c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 12:32:41 +1100 Subject: [PATCH 3/7] feat(staged): make Doctor say when a managed bridge is missing, why, and offer the managed install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor labels a Claude/Codex row "Managed by Staged" only when the bridge resolved from ~/.staged/packages/bin. Because that dir is first on the probe PATH, a Homebrew or npm-global copy can only win while the managed copy is absent — the launch reconcile has not run yet, or it failed (no route to the registry, a gateway block page, a mirror 404, a validation failure). The row then read as a plain green "Installed" pointing at /opt/homebrew/bin, indistinguishable from a healthy setup, with no hint that Staged's copy was missing and no way to get it short of waiting for a newer release to make the update button appear. Implements the Doctor surfacing from the registry-precedence note and proposals 1, 2 and 5 of the classification note. Claude/Codex rows (`flag_missing_managed_copy`): on a managing build, a row whose executable is not the managed shim drops to Warn (a row already warning keeps its own message in front) with "Using — Staged's managed copy is missing", followed by the last reconcile's recorded error for that tool when there is one. raw_output gains the expected shim path, the last reconcile's outcome and age, the error with a classified hint, and the npm registry Doctor detected. The row's fix becomes `FixType::Bridge` with `managed_install_description(...)` — offered whether or not an update is available — which `run_doctor_fix` already routes to `install_managed_tool_logged` for these two checks. The freshness-pass redirect (`redirect_unmanaged_update_to_managed_install`) still fires and produces the identical description, so the Update and Fix buttons never disagree. Management off (`note_managed_installs_disabled`): when `managed_shim_bin_dir()` is None, the two rows keep their status and gain "(managed installs disabled: )" in message and raw_output, where `managed_tools_disabled_reason()` names the STAGED_ACP_TOOLS_DIR override dir, the `no-managed-acp-tools` build, or an unsupported platform. Node runtime row: a Missing runtime with a failed `lastReconcile` on record now warns — "Staged-managed Node.js could not be installed: " — even with no installed tools, leading with the runtime download error (minus the reconciler's `: ` prefix) and listing every recorded error, which already embed the URL, plus a hint; the reinstall fix is kept. Missing with no record, or a clean one, behaves as before. Hint classification lives next to `classify_npm_failure` as `classify_install_failure` / `install_failure_hint`: a gateway block page (including the "blocked by a network gateway; fallback … also failed" message) → add `registry=` to ~/.npmrc or set STAGED_NPM_REGISTRY; E404 → the configured registry does not carry ; ENOTFOUND/ECONNREFUSED/ETIMEDOUT/reqwest "error sending request"/an install timeout naming its registry → check `registry=` in ~/.npmrc or STAGED_NODE_DIST_URL. Validation and entrypoint failures get no hint. For that to classify anything, the recorded npm error had to carry more than an exit status: `run_floating_npm_install_capturing` now appends npm's own `npm error code ` line and up to three detail lines (`npm_error_summary`, minus the `errno` repeat and log pointer, clipped) to the `NpmInstall` error. Error strings only — state.json's schema is unchanged, and nothing new is persisted. Registry detection: `configured_npm_registry` runs `npm config get registry` with the snapshot's npm (`find_on_env_path`, now pub(crate)) under the snapshot env, 5 s timeout, only when a report actually has an unmanaged Claude/Codex row; the label reads " (per npm config)", " (STAGED_NPM_REGISTRY)", or "npm default (…)" with no npm to ask. Frontend: no change needed — `canFix` already accepts a `bridge` fix on a non-pass row and `.check-message` wraps long text. README documents what the rows now say. Tests: user copy flagged with Bridge fix and otherwise byte-identical; recorded error + hint + registry on the row, other tool's error kept off it, already-warning row keeps its message; disabled note without a downgrade; Node Missing + failed reconcile → Warn with all errors and a hint, Missing + no/clean record unchanged; classifier cases for the three categories and the no-hint ones; npm error summary (old/new npm spelling, clipping, no block); disabled-reason parts; a fake npm proving the registry probe asks the snapshot's npm under the snapshot env. Verified: cargo fmt --check; cargo clippy --lib -- -D warnings (the three `cloned_ref_to_slice_refs` hits under `--tests` are pre-existing in session_queue/tests.rs and session_runner.rs); cargo test --lib -- doctor managed_acp_tools: 84 passed; pnpm run check: 0 errors 0 warnings; vitest src/lib/features/doctor: 59 passed. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- apps/staged/README.md | 10 + apps/staged/src-tauri/src/doctor.rs | 713 +++++++++++++++++- .../staged/src-tauri/src/managed_acp_tools.rs | 375 ++++++++- 3 files changed, 1053 insertions(+), 45 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index ab075a849..9f2d896f9 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -74,6 +74,16 @@ 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. +A Claude or Codex row that shows "Managed by Staged" is running Staged's copy. +If the managed install failed, the row instead warns with the copy it is using +(a Homebrew or npm global install found on your PATH, say), the error the +last install attempt recorded — which names the registry or URL it used — and +a **Fix** that installs the managed copy; **Copy details** adds a hint on what +to change (a blocked `registry.npmjs.org`, a mirror without the package, an +unreachable host) and the npm registry Doctor detected. The **Node.js +Runtime** row likewise reports a failed runtime download rather than staying +hidden. When managed installs are disabled (see `STAGED_ACP_TOOLS_DIR` below), +the rows say so instead. To develop against a local bridge checkout, export `STAGED_ACP_TOOLS_DIR` pointing at a directory with the bridge binaries — this disables the managed diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 402698107..ea1680a71 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; use tauri::Manager; -use crate::managed_acp_tools::ManagedTool; +use crate::managed_acp_tools::{ManagedTool, ReconcileRecord}; use crate::session_queue::sweep::{self, QueueHealth}; use crate::session_runner::SessionRegistry; use crate::store::Store; @@ -460,13 +460,24 @@ async fn run_doctor_report( run_node_runtime_check(), managed_bridge_versions(check_freshness, &env_vars), ); - if let Some(shim_bin_dir) = crate::managed_acp_tools::managed_shim_bin_dir() { - apply_managed_bridge_readouts( - &mut report, - &managed_versions, - &shim_bin_dir, - check_freshness, - ); + match crate::managed_acp_tools::managed_shim_bin_dir() { + Some(shim_bin_dir) => { + let diagnostics = + managed_install_diagnostics(&report, &managed_versions, &shim_bin_dir, &env_vars) + .await; + apply_managed_bridge_readouts( + &mut report, + &managed_versions, + &shim_bin_dir, + check_freshness, + &diagnostics, + ); + } + None => { + if let Some(reason) = crate::managed_acp_tools::managed_tools_disabled_reason() { + note_managed_installs_disabled(&mut report, &reason); + } + } } if let Some(check) = node_runtime { report.checks.push(check); @@ -1016,16 +1027,135 @@ async fn managed_bridge_versions( versions } +/// What Doctor says about the managed installer on a Claude/Codex row whose +/// executable is *not* the managed shim: the last reconcile's outcome from +/// `state.json` — the only record a failed launch install leaves behind — and +/// the npm registry a managed install resolves on this machine. Gathered once +/// per report; nothing here is persisted. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ManagedInstallDiagnostics { + /// `state.json`'s `lastReconcile`, `None` before the first reconcile has + /// finished (or with no `state.json` at all). + last_reconcile: Option, + /// The registry label for the row's raw output: the `STAGED_NPM_REGISTRY` + /// override, what `npm config get registry` answered under the doctor + /// env snapshot, or "npm default" when neither could be asked. + npm_registry: String, + /// Wall-clock milliseconds the reconcile age is rendered against. + now_ms: u64, +} + +impl ManagedInstallDiagnostics { + /// The last reconcile's error for `tool_id`, without the `: ` prefix + /// the reconciler records it under. + fn reconcile_error_for(&self, tool_id: &str) -> Option<&str> { + reconcile_error_for(self.last_reconcile.as_ref()?, tool_id) + } + + /// One raw-output line on the last managed install run. + fn last_reconcile_line(&self) -> String { + match &self.last_reconcile { + None => "last managed install: none recorded yet".to_string(), + Some(record) => format!( + "last managed install: {} {}", + if record.ok { "succeeded" } else { "failed" }, + format_age(record.at_ms, self.now_ms) + ), + } + } +} + +/// The reconciler records each failure as `: `; the error on +/// its own, for the tool asked about. +fn reconcile_error_for<'a>(record: &'a ReconcileRecord, tool_id: &str) -> Option<&'a str> { + record + .errors + .iter() + .find_map(|error| error.strip_prefix(tool_id)?.strip_prefix(": ")) +} + +/// `just now`, `N minutes ago`, `N hours ago`, `N days ago` — enough to tell a +/// stale record from the launch reconcile that just ran. +fn format_age(at_ms: u64, now_ms: u64) -> String { + let seconds = now_ms.saturating_sub(at_ms) / 1000; + let ago = |count: u64, unit: &str| { + let plural = if count == 1 { "" } else { "s" }; + format!("{count} {unit}{plural} ago") + }; + match seconds { + 0..=59 => "just now".to_string(), + 60..=3599 => ago(seconds / 60, "minute"), + 3600..=86_399 => ago(seconds / 3600, "hour"), + _ => ago(seconds / 86_400, "day"), + } +} + +/// Gather [`ManagedInstallDiagnostics`] for a report. The registry probe +/// spawns `npm`, so it runs only when a Claude/Codex row will actually show +/// it: one that resolved to a copy outside the managed shim dir. +async fn managed_install_diagnostics( + report: &DoctorReport, + versions: &[ManagedBridgeVersions], + shim_bin_dir: &Path, + env_vars: &[(String, String)], +) -> ManagedInstallDiagnostics { + let unmanaged_row_present = report.checks.iter().any(|check| { + let Some(tool_id) = managed_tool_id_for_check(&check.id) else { + return false; + }; + let Some(tool) = versions.iter().find(|v| v.tool.id == tool_id) else { + return false; + }; + check + .path + .as_deref() + .is_some_and(|path| !is_managed_shim_path(path, shim_bin_dir, tool.tool.binary)) + }); + let npm_registry = if unmanaged_row_present { + detect_npm_registry_label(env_vars).await + } else { + String::new() + }; + ManagedInstallDiagnostics { + last_reconcile: last_reconcile_record(), + npm_registry, + now_ms: crate::managed_acp_tools::now_ms(), + } +} + +/// `state.json`'s last reconcile record, if any. +fn last_reconcile_record() -> Option { + let packages_root = crate::paths::packages_dir()?; + crate::managed_acp_tools::read_state(&packages_root).last_reconcile +} + +/// The npm registry a managed install resolves, labeled with where that +/// answer came from — so a row can say which mirror (or the public registry) +/// the failed install actually asked. +async fn detect_npm_registry_label(env_vars: &[(String, String)]) -> String { + if let Some(registry) = crate::managed_acp_tools::npm_registry_override() { + return format!("{registry} (STAGED_NPM_REGISTRY)"); + } + match crate::managed_acp_tools::configured_npm_registry(env_vars).await { + Some(registry) => format!("{registry} (per npm config)"), + None => "npm default (per npm config; no npm on PATH to ask)".to_string(), + } +} + /// Layer the managed ACP readout onto every check whose executable is one of -/// the managed shims in `shim_bin_dir` (see [`apply_managed_bridge_readout`]), -/// and point the rest of these checks' updates at the managed installer too -/// (see [`redirect_unmanaged_update_to_managed_install`]) — on a managing -/// build it is the only writer for these two packages. +/// the managed shims in `shim_bin_dir` (see [`apply_managed_bridge_readout`]). +/// Every other Claude/Codex row is a copy Staged does not manage: it is +/// flagged as such, with why the managed copy is missing and the managed +/// install as its fix (see [`flag_missing_managed_copy`]), and its update is +/// pointed at the managed installer too (see +/// [`redirect_unmanaged_update_to_managed_install`]) — on a managing build it +/// is the only writer for these two packages. fn apply_managed_bridge_readouts( report: &mut DoctorReport, versions: &[ManagedBridgeVersions], shim_bin_dir: &Path, check_freshness: bool, + diagnostics: &ManagedInstallDiagnostics, ) { for check in &mut report.checks { let Some(tool_id) = managed_tool_id_for_check(&check.id) else { @@ -1033,12 +1163,122 @@ fn apply_managed_bridge_readouts( }; if let Some(versions) = versions.iter().find(|v| v.tool.id == tool_id) { if !apply_managed_bridge_readout(check, versions, shim_bin_dir, check_freshness) { + flag_missing_managed_copy(check, versions, shim_bin_dir, diagnostics); redirect_unmanaged_update_to_managed_install(check, versions, shim_bin_dir); } } } } +/// Note on the Claude/Codex rows that this build does not manage bridge +/// installs, and why — the `STAGED_ACP_TOOLS_DIR` dev override, the +/// `no-managed-acp-tools` feature, an unsupported target. A copy resolved from +/// Homebrew (or the override dir) is then by design, and the row must not read +/// like a failed install. Status is kept; only the message and raw output +/// gain the note. +fn note_managed_installs_disabled(report: &mut DoctorReport, reason: &str) { + for check in &mut report.checks { + if managed_tool_id_for_check(&check.id).is_none() { + continue; + } + let note = format!("managed installs disabled: {reason}"); + check.message = format!("{} ({note})", check.message); + append_raw_output(check, ¬e); + } +} + +/// Add a block to a check's raw output, below whatever the crate recorded. +fn append_raw_output(check: &mut DoctorCheck, block: &str) { + check.raw_output = Some(match check.raw_output.take() { + Some(existing) if !existing.trim().is_empty() => { + format!("{}\n\n{block}", existing.trim_end()) + } + _ => block.to_string(), + }); +} + +/// Flag a Claude/Codex row whose executable is a copy Staged does not manage +/// — Homebrew, the user's npm prefix, nvm — and report whether it did. +/// +/// On a managing build the managed shim dir is first on the probe PATH, so a +/// copy from anywhere else can only win while the managed copy is *absent*: +/// the launch reconcile has not finished yet, or it failed (no network to the +/// registry, a gateway block page, a 404 from a mirror, a validation failure). +/// Left as the crate built it, the row is a green "Installed" pointing at +/// `/opt/homebrew/bin`, indistinguishable from a healthy setup and with no +/// way to get the managed copy. So: +/// +/// - `status` drops to `Warn` (a row already warning or failing keeps its +/// status), and `message` says which copy is in use, that the managed copy +/// is missing, and — when the last reconcile recorded an error for this +/// tool — that error, which names the registry or URL it used. +/// - `raw_output` gains the expected shim path, the last reconcile's outcome +/// and age, the error with a classified hint on what to change +/// (`install_failure_hint`), and the npm registry Doctor detected. +/// - The row's fix becomes the managed install (`Bridge`, which +/// `run_doctor_fix` routes to `install_managed_tool_logged` for these two +/// checks), described by [`managed_install_description`] — the same text +/// [`redirect_unmanaged_update_to_managed_install`] gives the row's update +/// when a newer release is known, so the two buttons never disagree. The +/// fix is offered whether or not an update is available: the point is to +/// get the managed copy, not to chase a version. +/// +/// `path`, `main` and the version fields stay the crate's: the row still shows +/// the copy that is actually on PATH and how old it is. +fn flag_missing_managed_copy( + check: &mut DoctorCheck, + versions: &ManagedBridgeVersions, + shim_bin_dir: &Path, + diagnostics: &ManagedInstallDiagnostics, +) -> bool { + let Some(path) = check.path.clone() else { + return false; + }; + let tool = &versions.tool; + if is_managed_shim_path(&path, shim_bin_dir, tool.binary) { + return false; + } + let reconcile_error = diagnostics.reconcile_error_for(tool.id); + let error_suffix = reconcile_error + .map(|error| format!(": {error}")) + .unwrap_or_default(); + if check.status == CheckStatus::Pass { + check.status = CheckStatus::Warn; + check.message = format!("Using {path} — Staged's managed copy is missing{error_suffix}"); + } else { + check.message = format!( + "{}; using {path} — Staged's managed copy is missing{error_suffix}", + check.message + ); + } + + let mut detail = vec![ + format!( + "Staged-managed copy: missing (expected {})", + shim_bin_dir.join(tool.binary).display() + ), + diagnostics.last_reconcile_line(), + ]; + if let Some(error) = reconcile_error { + detail.push(format!("{}: {error}", tool.id)); + if let Some(hint) = crate::managed_acp_tools::install_failure_hint(error, tool.package) { + detail.push(format!("hint: {hint}")); + } + } + detail.push(format!("npm registry: {}", diagnostics.npm_registry)); + append_raw_output(check, &detail.join("\n")); + + let latest = versions.latest.as_deref().or_else(|| { + check + .main + .as_ref() + .and_then(|main| main.latest_version.as_deref()) + }); + check.fix_type = Some(FixType::Bridge); + check.fix_command = Some(managed_install_description(tool, latest, &path)); + true +} + /// Give a managed Claude/Codex row the two readouts its one executable /// stands for, and report whether it did. /// @@ -1061,10 +1301,11 @@ fn apply_managed_bridge_readouts( /// /// Nothing is touched unless the check's executable is the managed shim for /// this tool and the crate labeled it bundled: a copy that resolved elsewhere -/// keeps its source-aware readout; its update is redirected to the managed -/// install (see [`redirect_unmanaged_update_to_managed_install`]). An unknown -/// installed or latest version leaves `update_available` unknown rather than -/// false. +/// keeps its source-aware readout, is flagged as missing the managed copy +/// (see [`flag_missing_managed_copy`]), and has its update redirected to the +/// managed install (see [`redirect_unmanaged_update_to_managed_install`]). An +/// unknown installed or latest version leaves `update_available` unknown +/// rather than false. fn apply_managed_bridge_readout( check: &mut DoctorCheck, versions: &ManagedBridgeVersions, @@ -1367,7 +1608,12 @@ async fn run_node_runtime_check() -> Option { .collect(); npm_tools.sort(); npm_tools.dedup(); - build_node_runtime_check(state, &install_dir, &npm_tools) + build_node_runtime_check( + state, + &install_dir, + &npm_tools, + last_reconcile_record().as_ref(), + ) } /// Names of the bin shims npm wrote into the Staged-private prefix — the @@ -1383,23 +1629,40 @@ fn installed_npm_tool_names(bin_dir: &Path) -> Vec { .collect() } +/// Build the runtime row. `last_reconcile` is `state.json`'s record of the +/// last managed install run: a runtime that is `Missing` after a reconcile +/// that *failed* is not "never needed", it is the install that could not +/// happen — every managed bridge install starts by ensuring the runtime — so +/// that case warns with the recorded error even when nothing is installed +/// yet, instead of staying silent. With no record (first launch still +/// reconciling, or a profile that never ran one) a missing runtime is +/// reported only when installed tools need it, as before. fn build_node_runtime_check( state: ManagedNodeRuntimeState, install_dir: &Path, npm_tools: &[String], + last_reconcile: Option<&ReconcileRecord>, ) -> Option { let version = &crate::managed_node::node_runtime_lock().version; - let (status, message) = match state { - ManagedNodeRuntimeState::Ready => ( + let failed_reconcile = last_reconcile.filter(|record| !record.ok); + let (status, message) = match (&state, failed_reconcile) { + (ManagedNodeRuntimeState::Ready, _) => ( CheckStatus::Pass, format!("Staged-managed Node.js {version} is installed"), ), - ManagedNodeRuntimeState::Broken => ( + (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 => ( + (ManagedNodeRuntimeState::Missing, Some(record)) => ( + CheckStatus::Warn, + format!( + "Staged-managed Node.js {version} could not be installed: {}", + reconcile_headline(record) + ), + ), + (ManagedNodeRuntimeState::Missing, None) if npm_tools.is_empty() => return None, + (ManagedNodeRuntimeState::Missing, None) => ( CheckStatus::Warn, format!( "Staged-managed Node.js {version} is not installed; Staged-installed agent tools require it" @@ -1424,6 +1687,19 @@ fn build_node_runtime_check( detail.push("Staged-installed npm tools:".to_string()); detail.extend(npm_tools.iter().map(|name| format!("- {name}"))); } + if let Some(record) = failed_reconcile { + detail.push(format!( + "last managed install: failed {} with {} error(s):", + format_age(record.at_ms, crate::managed_acp_tools::now_ms()), + record.errors.len() + )); + detail.extend(record.errors.iter().map(|error| format!("- {error}"))); + if let Some(hint) = + crate::managed_acp_tools::install_failure_hint(&reconcile_headline(record), "Node.js") + { + detail.push(format!("hint: {hint}")); + } + } let node_path = (state == ManagedNodeRuntimeState::Ready) .then(|| install_dir.join("bin").join("node").display().to_string()); @@ -1445,6 +1721,34 @@ fn build_node_runtime_check( )) } +/// The one error a failed reconcile is summarised by on the runtime row: the +/// first that names the Node.js runtime download — the failure every bridge +/// shares when the runtime could not be fetched — else the first recorded, +/// in both cases without the reconciler's `: ` prefix. +fn reconcile_headline(record: &ReconcileRecord) -> String { + record + .errors + .iter() + .find(|error| error.contains("Node.js runtime")) + .or_else(|| record.errors.first()) + .map(|error| strip_tool_id_prefix(error).to_string()) + .unwrap_or_else(|| "no error recorded".to_string()) +} + +/// `: ` → ``, for the ids this build knows; anything +/// else is returned whole. +fn strip_tool_id_prefix(error: &str) -> &str { + error + .split_once(": ") + .filter(|(id, _)| { + crate::managed_acp_tools::MANAGED_TOOLS + .iter() + .any(|tool| tool.id == *id) + }) + .map(|(_, rest)| rest) + .unwrap_or(error) +} + fn node_runtime_doctor_check( status: CheckStatus, message: String, @@ -1547,6 +1851,7 @@ mod tests { ManagedNodeRuntimeState::Ready, Path::new("/data/packages/node/v9.9.9/plat"), &["copilot".to_string()], + None, ) .expect("ready runtime is reported"); @@ -1573,6 +1878,7 @@ mod tests { ManagedNodeRuntimeState::Broken, Path::new("/data/packages/node/v9.9.9/plat"), &[], + None, ) .expect("damaged runtime is reported"); @@ -1596,6 +1902,7 @@ mod tests { ManagedNodeRuntimeState::Missing, Path::new("/data/packages/node/v9.9.9/plat"), &["amp-acp".to_string(), "copilot".to_string()], + None, ) .expect("needed-but-missing runtime is reported"); assert_eq!(check.status, CheckStatus::Warn); @@ -1604,14 +1911,113 @@ mod tests { let output = check.raw_output.as_deref().expect("raw output"); assert!(output.contains("- amp-acp")); assert!(output.contains("- copilot")); + assert!(!output.contains("last managed install"), "{output}"); + + // Nothing installed that needs it, and no reconcile on record (or a + // clean one): stay silent. + for record in [None, Some(reconcile_record(true, Vec::new()))] { + assert!(build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + record.as_ref(), + ) + .is_none()); + } + } + + fn reconcile_record(ok: bool, errors: Vec<&str>) -> ReconcileRecord { + ReconcileRecord { + at_ms: 1_000, + ok, + errors: errors.into_iter().map(str::to_string).collect(), + } + } + + /// A runtime that is missing *because* the last reconcile failed is the + /// install that could not happen, not one that was never needed: the row + /// warns with the recorded error even with nothing installed yet, lists + /// every error (and the URL they name) in its detail, and keeps the + /// reinstall fix. + #[test] + fn missing_runtime_after_a_failed_reconcile_warns_with_the_recorded_errors() { + let node_error = "claude-acp: Node.js runtime download failed: \ + https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ + error sending request for url"; + let record = reconcile_record( + false, + vec![ + "codex-acp: npm install failed: npm install exited with exit status: 1", + node_error, + ], + ); + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&record), + ) + .expect("a failed install is reported"); + + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!( + check.message, + format!( + "Staged-managed Node.js {} could not be installed: Node.js runtime download \ + failed: https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ + error sending request for url", + pinned_version() + ), + "leads with the runtime download error, minus the tool id" + ); + assert_eq!(check.fix_type, Some(FixType::Command)); + assert!(check.fix_command.is_some()); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("state: missing"), "{output}"); + assert!(output.contains("last managed install: failed"), "{output}"); + assert!(output.contains("with 2 error(s):"), "{output}"); + assert!(output.contains(&format!("- {node_error}")), "{output}"); + assert!( + output.contains("- codex-acp: npm install failed"), + "{output}" + ); + assert!( + output.contains("hint: the configured registry/host did not respond"), + "{output}" + ); - // Nothing installed that needs it: stay silent. - assert!(build_node_runtime_check( + // With no runtime-specific error the first recorded one is the + // headline, still without its tool id. + let record = reconcile_record( + false, + vec!["claude-acp: installed ACP bridge is incomplete: probe exited with 1"], + ); + let check = build_node_runtime_check( ManagedNodeRuntimeState::Missing, Path::new("/data/packages/node/v9.9.9/plat"), &[], + Some(&record), ) - .is_none()); + .unwrap(); + assert!( + check.message.ends_with( + "could not be installed: installed ACP bridge is incomplete: probe exited with 1" + ), + "{}", + check.message + ); + assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); + } + + #[test] + fn format_age_rounds_to_the_largest_whole_unit() { + assert_eq!(format_age(1_000, 1_000), "just now"); + assert_eq!(format_age(0, 59_000), "just now"); + assert_eq!(format_age(0, 60_000), "1 minute ago"); + assert_eq!(format_age(0, 2 * 3_600_000 + 5), "2 hours ago"); + assert_eq!(format_age(0, 3 * 86_400_000), "3 days ago"); + // A clock that went backwards is not a panic. + assert_eq!(format_age(5_000, 1_000), "just now"); } #[test] @@ -1800,6 +2206,20 @@ mod tests { check: DoctorCheck, versions: &ManagedBridgeVersions, check_freshness: bool, + ) -> DoctorCheck { + apply_with( + check, + versions, + check_freshness, + &ManagedInstallDiagnostics::default(), + ) + } + + fn apply_with( + check: DoctorCheck, + versions: &ManagedBridgeVersions, + check_freshness: bool, + diagnostics: &ManagedInstallDiagnostics, ) -> DoctorCheck { let mut report = DoctorReport { checks: vec![check], @@ -1809,10 +2229,22 @@ mod tests { std::slice::from_ref(versions), Path::new(SHIM_BIN_DIR), check_freshness, + diagnostics, ); report.checks.pop().unwrap() } + /// Undo what [`flag_missing_managed_copy`] sets on a user-copy row, so a + /// test can assert that *nothing else* changed. + fn without_missing_copy_flag(mut check: DoctorCheck) -> DoctorCheck { + check.status = CheckStatus::Pass; + check.message = "Installed".to_string(); + check.fix_type = None; + check.fix_command = None; + check.raw_output = None; + check + } + fn versions(installed: Option<&str>, latest: Option<&str>) -> ManagedBridgeVersions { ManagedBridgeVersions { tool: claude_tool(), @@ -1988,11 +2420,13 @@ mod tests { assert_eq!(check.bridge.as_ref().unwrap().update_available, None); } - /// A copy the user installed themselves never gets the managed readout, and - /// with nothing to update is left byte-identical: its own location, source - /// and version are the honest ones for what is on PATH. + /// A copy the user installed themselves never gets the managed readout. + /// It is flagged — the managed copy is missing, and the managed install is + /// its fix — but nothing else changes: its own location, source and + /// version are the honest ones for what is on PATH, and with nothing to + /// update no update action is invented. #[test] - fn a_user_copy_without_an_update_is_left_untouched() { + fn a_user_copy_without_an_update_is_flagged_and_otherwise_left_alone() { let installed = versions(Some("0.16.2"), Some("0.17.0")); let path = "/opt/homebrew/bin/claude-agent-acp"; @@ -2000,20 +2434,186 @@ mod tests { let check = user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); let before = json(&check); - assert_eq!(json(&apply_to(check, &installed, true)), before); + let after = apply_to(check, &installed, true); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + "Using /opt/homebrew/bin/claude-agent-acp — Staged's managed copy is missing" + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!( + after.fix_command.as_deref(), + Some( + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ) + ); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains("Staged-managed copy: missing (expected /data/.staged/packages/bin/claude-agent-acp)"), + "{detail}" + ); + assert!( + detail.contains("last managed install: none recorded yet"), + "{detail}" + ); + assert!(!detail.contains("hint:"), "{detail}"); + assert_eq!(json(&without_missing_copy_flag(after)), before); // The registry lookup failed: unknown, so no action is invented. let check = user_copy_claude_check(path, &InstallSource::Brew, None, None); let before = json(&check); - assert_eq!(json(&apply_to(check, &installed, true)), before); + let after = apply_to(check, &installed, true); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!(json(&without_missing_copy_flag(after)), before); // The initial (no-network) pass sets no update fields at all, so there - // is nothing to redirect there either. + // is nothing to redirect there either — but the flag and fix do not + // wait for the network: the base report already carries them. let mut check = managed_claude_check(path, false); check.main.as_mut().unwrap().bundled = None; check.main.as_mut().unwrap().install_source = Some(InstallSource::Npm); let before = json(&check); - assert_eq!(json(&apply_to(check, &installed, false)), before); + let after = apply_to(check, &installed, false); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!(json(&without_missing_copy_flag(after)), before); + } + + /// With a failed reconcile on record the row says why the managed copy is + /// missing — the recorded error for *this* tool, which names the registry + /// it used — and the detail adds the classified hint and the registry + /// Doctor detected. The other tool's error stays off this row. + #[test] + fn a_user_copy_names_the_recorded_install_error_and_its_hint() { + let installed = versions(None, None); + let path = "/opt/homebrew/bin/claude-agent-acp"; + let claude_error = "npm install failed: npm install exited with exit status: 1 \ + (registry: npm default (per npm config)): npm error code FETCH_ERROR; \ + invalid json response body at \ + https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: \ + Unexpected token '<'"; + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: Some(ReconcileRecord { + at_ms: 0, + ok: false, + errors: vec![ + format!("claude-acp: {claude_error}"), + "codex-acp: npm install failed: npm install exited with exit status: 1 \ + (registry: npm default (per npm config)): npm error code E404" + .to_string(), + ], + }), + npm_registry: "https://registry.npmjs.org/ (per npm config)".to_string(), + now_ms: 5 * 60_000, + }; + + let check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!("Using {path} — Staged's managed copy is missing: {claude_error}") + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains("last managed install: failed 5 minutes ago"), + "{detail}" + ); + assert!( + detail.contains(&format!("claude-acp: {claude_error}")), + "{detail}" + ); + assert!(!detail.contains("codex-acp"), "{detail}"); + assert!( + detail.contains( + "hint: this network blocks registry.npmjs.org and npm has no registry configured" + ), + "{detail}" + ); + assert!( + detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), + "{detail}" + ); + + // A row that already warns (installed, not signed in) keeps its own + // message in front and its status. + let mut check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + check.status = CheckStatus::Warn; + check.message = "Installed, not authenticated".to_string(); + check.raw_output = + Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!( + "Installed, not authenticated; using {path} — Staged's managed copy is missing: {claude_error}" + ) + ); + let detail = after.raw_output.as_deref().unwrap(); + assert!( + detail.starts_with("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\nStaged-managed copy: missing"), + "the crate's trace stays on top: {detail}" + ); + } + + /// When this build does not manage bridges at all, a Homebrew copy is by + /// design: the rows say so and keep their status, and no fix is added. + /// Rows for other agents are untouched. + #[test] + fn managed_installs_disabled_is_noted_on_the_bridge_rows_without_a_downgrade() { + let path = "/opt/homebrew/bin/claude-agent-acp"; + let mut claude = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + claude.raw_output = + Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); + let mut codex = user_copy_claude_check( + "/opt/homebrew/bin/codex-acp", + &InstallSource::Brew, + None, + None, + ); + codex.id = "ai-agent-codex".to_string(); + codex.status = CheckStatus::Fail; + codex.message = "Not installed (optional)".to_string(); + codex.path = None; + let amp = DoctorCheck { + id: "ai-agent-amp".to_string(), + ..claude.clone() + }; + let amp_before = json(&); + let mut report = DoctorReport { + checks: vec![claude, codex, amp], + }; + + note_managed_installs_disabled(&mut report, "STAGED_ACP_TOOLS_DIR is set to /dev/acp"); + + let claude = &report.checks[0]; + assert_eq!(claude.status, CheckStatus::Pass); + assert_eq!( + claude.message, + "Installed (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" + ); + assert!(claude.fix_type.is_none()); + assert_eq!( + claude.raw_output.as_deref(), + Some( + "strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\n\ + managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp" + ) + ); + let codex = &report.checks[1]; + assert_eq!(codex.status, CheckStatus::Fail); + assert_eq!( + codex.message, + "Not installed (optional) (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" + ); + assert_eq!(json(&report.checks[2]), amp_before); } /// A user copy with a newer release keeps its readout but has its action @@ -2082,14 +2682,27 @@ mod tests { assert!(!description.contains("npm install"), "{description}"); assert!(!description.contains("brew upgrade"), "{description}"); - // The action is the *only* change: path, source, both versions and - // `updateAvailable` still describe the copy that is on PATH — the - // readout's `latestVersion` stays the crate's, even though the - // description names the registry's. + // The row's fix is the same managed install, word for word, so the + // Update and Fix buttons never describe two different things. + assert_eq!(after.fix_type, Some(FixType::Bridge), "{source:?}"); + assert_eq!( + after.fix_command.as_deref(), + Some(description), + "{source:?}" + ); + + // The action (and the missing-copy flag) are the *only* changes: + // path, source, both versions and `updateAvailable` still describe + // the copy that is on PATH — the readout's `latestVersion` stays + // the crate's, even though the description names the registry's. let main = after.main.as_mut().unwrap(); main.update_command = command.map(str::to_string); main.update_fix_type = command.map(|_| FixType::UpdateMain); - assert_eq!(json(&after), before, "{source:?}"); + assert_eq!( + json(&without_missing_copy_flag(after)), + before, + "{source:?}" + ); } } @@ -2182,7 +2795,13 @@ mod tests { ], }; let before: Vec<_> = report.checks.iter().map(json).collect(); - apply_managed_bridge_readouts(&mut report, &[], shim_bin_dir, true); + apply_managed_bridge_readouts( + &mut report, + &[], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); let after: Vec<_> = report.checks.iter().map(json).collect(); assert_eq!(after, before); @@ -2196,10 +2815,22 @@ mod tests { installed: Some("1.1.2".to_string()), latest: Some("1.2.0".to_string()), }; - apply_managed_bridge_readouts(&mut report, &[codex_versions], shim_bin_dir, true); + apply_managed_bridge_readouts( + &mut report, + &[codex_versions], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); let after: Vec<_> = report.checks.iter().map(json).collect(); assert_eq!(after, before); - apply_managed_bridge_readouts(&mut report, &[installed], shim_bin_dir, true); + apply_managed_bridge_readouts( + &mut report, + &[installed], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); assert!(report.checks[0].bridge.is_some()); assert!(report.checks[1].bridge.is_none()); } diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index be637d5bc..9432d227f 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -188,6 +188,84 @@ pub(crate) fn gateway_retry_failed_message(fallback: &str, retry_error: &str) -> ) } +/// What a recorded managed-install error says the user can change, as far +/// as the error text tells. Coarser than [`NpmFailure`] on purpose: this +/// classifies the *recorded* error (`state.json`'s `lastReconcile.errors`, +/// the Node download error), which carries npm's `error code` block via +/// [`npm_error_summary`] rather than the full stderr, and it also covers the +/// Node.js runtime download. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallFailureHint { + /// `registry.npmjs.org` answered with a network gateway's block page and + /// npm had no other registry configured. + GatewayBlocked, + /// The registry npm used answered 404 for the package. + PackageMissing, + /// The registry or download host could not be reached at all: DNS, + /// refused/reset connections, or a timeout on the request. + HostUnreachable, +} + +/// Classify a recorded install error (see [`InstallFailureHint`]). `None` +/// when the text names no network or registry cause Doctor has advice for — +/// a validation failure, a missing entrypoint, a disk error. +pub(crate) fn classify_install_failure(error: &str) -> Option { + if error.contains("blocked by a network gateway") + || classify_npm_failure(error) == NpmFailure::GatewayBlocked + { + return Some(InstallFailureHint::GatewayBlocked); + } + if error.contains("E404") || error.contains("404 Not Found") { + return Some(InstallFailureHint::PackageMissing); + } + const UNREACHABLE_MARKERS: [&str; 11] = [ + "ENOTFOUND", + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + // reqwest's wording for the Node.js runtime download. + "error sending request", + "dns error", + "connection refused", + "operation timed out", + ]; + if UNREACHABLE_MARKERS + .iter() + .any(|marker| error.contains(marker)) + { + return Some(InstallFailureHint::HostUnreachable); + } + // The managed `npm install` itself timing out names its registry; a + // validation probe timing out does not, and is not a network problem. + if error.contains("timed out after") && error.contains("(registry:") { + return Some(InstallFailureHint::HostUnreachable); + } + None +} + +/// The one-line hint Doctor shows under a classified install failure, naming +/// what to change: `package` is the npm package the install was for. +pub(crate) fn install_failure_hint(error: &str, package: &str) -> Option { + Some(match classify_install_failure(error)? { + InstallFailureHint::GatewayBlocked => { + "this network blocks registry.npmjs.org and npm has no registry configured — \ + add `registry=` to ~/.npmrc or set STAGED_NPM_REGISTRY" + .to_string() + } + InstallFailureHint::PackageMissing => { + format!("the configured registry does not carry {package}") + } + InstallFailureHint::HostUnreachable => { + "the configured registry/host did not respond — check `registry=` in ~/.npmrc \ + or STAGED_NODE_DIST_URL" + .to_string() + } + }) +} + /// 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) @@ -220,6 +298,34 @@ fn managed_tools_enabled_from_parts( !override_active && !managed_tools_disabled && supported_target } +/// Why this build does not manage bridge installs, for Doctor to say so on +/// the Claude/Codex rows — a copy resolved from Homebrew is then by design, +/// not a failed install. `None` when management is on. +pub(crate) fn managed_tools_disabled_reason() -> Option { + managed_tools_disabled_reason_from_parts( + dev_tools_override_dir().as_deref(), + cfg!(feature = "no-managed-acp-tools"), + managed_node::current_target_triple().is_some(), + ) +} + +fn managed_tools_disabled_reason_from_parts( + override_dir: Option<&Path>, + managed_tools_disabled: bool, + supported_target: bool, +) -> Option { + if let Some(dir) = override_dir { + return Some(format!("{ACP_TOOLS_DIR_ENV} is set to {}", dir.display())); + } + if managed_tools_disabled { + return Some("this build was compiled without managed ACP tools".to_string()); + } + if !supported_target { + return Some("no managed Node.js runtime is available for this platform".to_string()); + } + None +} + /// 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")) @@ -807,6 +913,53 @@ fn push_captured_line(capture: &mut String, line: &str) { } } +/// How many `npm error` detail lines after the `npm error code` line are +/// kept in the recorded error, and how long each may be. +const NPM_ERROR_SUMMARY_LINES: usize = 3; +const NPM_ERROR_SUMMARY_LINE_LIMIT: usize = 240; + +/// npm's own account of why it failed, from its captured stderr: the last +/// `npm error code ` line and the first few `npm error` detail lines +/// after it (minus `errno`, which repeats the code, and the log-file pointer). +/// Appended to the recorded install error so `state.json` and Doctor can say +/// *why* — the exit status alone says nothing about a block page, a 404, or +/// an unreachable mirror. `None` when npm printed no error block. +fn npm_error_summary(stderr: &str) -> Option { + let lines: Vec<&str> = stderr.lines().map(str::trim_end).collect(); + let code_index = lines.iter().rposition(|line| { + line.starts_with("npm error code ") || line.starts_with("npm ERR! code ") + })?; + let mut summary = vec![clip_line(lines[code_index])]; + let details = lines[code_index + 1..] + .iter() + .filter(|line| line.starts_with("npm error") || line.starts_with("npm ERR!")) + .map(|line| { + line.trim_start_matches("npm error") + .trim_start_matches("npm ERR!") + .trim() + }) + .filter(|detail| { + !detail.is_empty() + && !detail.starts_with("errno ") + && !detail.starts_with("A complete log") + }) + .take(NPM_ERROR_SUMMARY_LINES) + .map(clip_line); + summary.extend(details); + Some(summary.join("; ")) +} + +fn clip_line(line: &str) -> String { + if line.len() <= NPM_ERROR_SUMMARY_LINE_LIMIT { + return line.to_string(); + } + let mut cut = NPM_ERROR_SUMMARY_LINE_LIMIT; + while !line.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &line[..cut]) +} + #[allow(clippy::too_many_arguments)] async fn run_floating_npm_install( packages_root: &Path, @@ -941,8 +1094,11 @@ async fn run_floating_npm_install_capturing( if status.success() { Ok(()) } else { + let summary = npm_error_summary(stderr_capture) + .map(|summary| format!(": {summary}")) + .unwrap_or_default(); Err(ManagedToolError::NpmInstall(format!( - "npm install exited with {status} (registry: {label})" + "npm install exited with {status} (registry: {label}){summary}" ))) } } @@ -1107,7 +1263,7 @@ async fn probe_latest_version( /// resolved by hand because `Command::new("npm")` would search the *parent* /// process's PATH, not the snapshot's, and the snapshot is what puts the /// managed runtime's bin dir first. -fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option { +pub(crate) fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option { std::env::split_paths(snapshot_var(env_vars, "PATH")?) .map(|dir| dir.join(name)) .find(|candidate| candidate.is_file()) @@ -1196,6 +1352,39 @@ async fn npm_view_version( .ok_or(NpmViewFailure::Other) } +/// How long Doctor waits on `npm config get registry`. +const NPM_CONFIG_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// The registry npm would use under the `env_vars` snapshot with no +/// `--registry` — `npm config get registry` asked of the snapshot's own +/// `npm` (the managed runtime's, when installed), under exactly that env, so +/// the answer is the one a managed install resolves. `None` when the +/// snapshot has no `npm`, the probe fails or times out, or the answer is not +/// a URL; Doctor then says "npm default" rather than guessing. +pub(crate) async fn configured_npm_registry(env_vars: &[(String, String)]) -> Option { + let npm_path = find_on_env_path(env_vars, "npm")?; + let mut command = tokio::process::Command::new(npm_path); + command.args(["config", "get", "registry"]); + command.env_clear(); + for (key, value) in env_vars { + command.env(key, value); + } + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(NPM_CONFIG_PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let registry = String::from_utf8_lossy(&output.stdout).trim().to_string(); + registry.starts_with("http").then_some(registry) +} + // ============================================================================= // Reconcile epilogue — prune stale ids, record the outcome, gate the Node prune // ============================================================================= @@ -1293,7 +1482,7 @@ pub(crate) fn record_reconcile(packages_root: &Path, errors: Vec) { } } -fn now_ms() -> u64 { +pub(crate) fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|elapsed| elapsed.as_millis() as u64) @@ -1407,6 +1596,181 @@ mod tests { ); } + /// The Doctor hint classifies the *recorded* error: the block page (with + /// or without a fallback having been tried), a 404 from whichever + /// registry, and an unreachable registry or download host. A validation + /// failure, a missing entrypoint, and a probe timeout get no hint — there + /// is nothing network-side to change. + #[test] + fn classify_install_failure_names_the_three_fixable_causes() { + let cases = [ + ( + "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code FETCH_ERROR; invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<'", + Some(InstallFailureHint::GatewayBlocked), + ), + ( + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a network gateway; fallback https://fallback.example.test/npm/ also failed: npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", + Some(InstallFailureHint::GatewayBlocked), + ), + ( + "npm install failed: npm install exited with exit status: 1 (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", + Some(InstallFailureHint::PackageMissing), + ), + ( + "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code ENOTFOUND; network request to https://mirror.example.test/npm/x failed, reason: getaddrinfo ENOTFOUND mirror.example.test", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "Node.js runtime download failed: https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz: error sending request for url", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "npm install failed: timed out after 900 seconds (registry: npm default (per npm config))", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: validation probe timed out after 20 seconds", + None, + ), + ( + "installed ACP bridge is incomplete: bridge entrypoint /x/index.js is missing after install", + None, + ), + ("Node.js runtime download failed: HTTP 503", None), + ("", None), + ]; + for (error, expected) in cases { + assert_eq!(classify_install_failure(error), expected, "{error}"); + } + + let hint = install_failure_hint( + "npm install failed: … npm error code E404; 404 Not Found", + "@agentclientprotocol/codex-acp", + ) + .unwrap(); + assert_eq!( + hint, + "the configured registry does not carry @agentclientprotocol/codex-acp" + ); + assert!(install_failure_hint( + "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed", + "x" + ) + .unwrap() + .contains("STAGED_NPM_REGISTRY")); + assert!(install_failure_hint("getaddrinfo ENOTFOUND mirror", "x") + .unwrap() + .contains("STAGED_NODE_DIST_URL")); + assert!(install_failure_hint("disk full", "x").is_none()); + } + + /// The recorded install error carries npm's own account: the last + /// `npm error code` line and the detail lines after it, minus the `errno` + /// repeat and the log-file pointer, each clipped. Without an error block + /// nothing is appended. + #[test] + fn npm_error_summary_keeps_the_code_and_the_first_details() { + let stderr = "npm warn deprecated foo@1.0.0: use bar\n\ + npm error code FETCH_ERROR\n\ + npm error errno FETCH_ERROR\n\ + npm error invalid json response body at https://registry.npmjs.org/x reason: Unexpected token '<'\n\ + npm error\n\ + npm error A complete log of this run can be found in: /tmp/x.log\n"; + assert_eq!( + npm_error_summary(stderr).as_deref(), + Some( + "npm error code FETCH_ERROR; invalid json response body at \ + https://registry.npmjs.org/x reason: Unexpected token '<'" + ) + ); + assert_eq!( + classify_npm_failure(&npm_error_summary(stderr).unwrap()), + NpmFailure::GatewayBlocked + ); + + // Older npm spelling, and more details than are kept. + let stderr = "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://r/x\nnpm ERR! 404\nnpm ERR! 404 'x@latest' is not in this registry.\nnpm ERR! 404 note\nnpm ERR! 404 more\n"; + assert_eq!( + npm_error_summary(stderr).as_deref(), + Some("npm ERR! code E404; 404 Not Found - GET https://r/x; 404; 404 'x@latest' is not in this registry.") + ); + + assert_eq!(npm_error_summary("npm warn only\n"), None); + assert_eq!(npm_error_summary(""), None); + + let long = format!("npm error code E1\nnpm error {}\n", "y".repeat(1000)); + let summary = npm_error_summary(&long).unwrap(); + assert!(summary.len() < 300, "{}", summary.len()); + assert!(summary.ends_with('…')); + } + + #[test] + fn managed_tools_disabled_reason_names_the_cause() { + assert_eq!( + managed_tools_disabled_reason_from_parts(None, false, true), + None + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(Some(Path::new("/dev/acp")), false, true) + .as_deref(), + Some("STAGED_ACP_TOOLS_DIR is set to /dev/acp") + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(None, true, true).as_deref(), + Some("this build was compiled without managed ACP tools") + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(None, false, false).as_deref(), + Some("no managed Node.js runtime is available for this platform") + ); + // The override is the reason even on a build that is disabled anyway. + assert!( + managed_tools_disabled_reason_from_parts(Some(Path::new("/d")), true, false) + .unwrap() + .starts_with("STAGED_ACP_TOOLS_DIR") + ); + } + + /// The registry probe asks the snapshot's own `npm`, under the snapshot's + /// env, and reports only a URL-shaped answer. + #[tokio::test] + async fn configured_npm_registry_asks_the_snapshots_npm() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + write_executable( + &bin.join("npm"), + "#!/bin/sh\n[ \"$1 $2 $3\" = \"config get registry\" ] || exit 2\nprintf '%s\\n' \"${FAKE_REGISTRY:-https://mirror.example.test/npm/}\"\n", + ); + let env = vec![("PATH".to_string(), bin.display().to_string())]; + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + + // The env is the snapshot's, not the process's. + let mut env_with_registry = env.clone(); + env_with_registry.push(( + "FAKE_REGISTRY".to_string(), + "https://other.example.test/".to_string(), + )); + assert_eq!( + configured_npm_registry(&env_with_registry).await.as_deref(), + Some("https://other.example.test/") + ); + + // Not a URL: no answer. No npm on the snapshot PATH: no answer. + let mut env_garbage = env.clone(); + env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + let empty = dir.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + assert_eq!( + configured_npm_registry(&[("PATH".to_string(), empty.display().to_string())]).await, + None + ); + } + /// The capture keeps the *newest* lines: npm's `npm error code …` block /// comes last, after however many warnings preceded it. #[test] @@ -2081,11 +2445,14 @@ mod tests { assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); let message = error.to_string(); + // The retry's own `npm error code` line rides along, so the recorded + // error says what the fallback answered, not just that it failed. assert_eq!( message, "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ network gateway; fallback https://fallback.example.test/npm/ also failed: \ - npm install exited with exit status: 7 (registry: https://fallback.example.test/npm/)" + npm install exited with exit status: 7 (registry: https://fallback.example.test/npm/): \ + npm error code E500" ); assert!( message.contains( From 80fb4613f33f068a1649825f7aeef55cb2e77306 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 12:50:27 +1100 Subject: [PATCH 4/7] fix(staged): keep the Doctor registry probe off the report's critical path and soften the first-launch row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the review of e905c94e (Doctor says when a managed bridge is missing). Report latency: `configured_npm_registry` ran after the `tokio::join!` of the other probes, so an unmanaged Claude/Codex row — the fresh-profile and failed-reconcile cases this feature targets — added up to 5 s of serial latency to `run_doctor_report`. The probe now joins the other probes. Whether to run it is decided up front from the shim dir (`any_managed_shim_missing`: the shim dir is first on the probe PATH, so a row can only resolve elsewhere while a shim is absent) rather than from the finished report; `managed_install_diagnostics` is now a synchronous assembly of the record and the label. The answer is cached per env snapshot for 60 s (`NpmRegistryCache`, the `LatestVersionCache` pattern; a `None` answer is cached too, since re-asking a hung or absent npm every pass is exactly the stall being avoided). The 5 s probe timeout is kept. `npm_registry` became `Option`; a row rendered without a probe says "npm registry: not probed". First-launch window: with no reconcile on record, the row says "Using — Staged's managed install hasn't completed yet" (still Warn, Fix kept) and the detail reads "last managed install: not run yet (runs at launch and daily)", so a healthy first launch no longer reads as a failure. Message length: the row stops at "… — Staged's managed copy is missing" plus `short_error_fragment` of the recorded error — npm's own `npm error code ` when present, else the first 120 chars ellipsized. The full error, hint and registry stay in raw_output for Copy details. Node runtime hint: `install_failure_hint` is consulted only when the headline is a runtime download failure ("Node.js runtime download failed…"), so a bridge's npm E404 as the headline can no longer yield "the configured registry does not carry Node.js". New `InstallFailureHint::NodeDownloadRejected(status)` for `HTTP 4xx` from the dist host → "the Node.js download URL returned HTTP — check STAGED_NODE_DIST_URL"; 5xx gets no hint. `reconcile_headline` still picks any "Node.js runtime" error (archive, checksum, incomplete) as before. README: the row says "hasn't completed yet" before the first reconcile completes, and the hint list gains the download-URL case. No new persisted fields; state.json is unchanged. Tests: first-launch row (message, detail, Fix kept, already-warning variant); error-code fragment on the row with the full error in detail; ellipsized no-code error and "not probed"; `short_error_fragment` cases; shim-dir probe gate; Node row with a bridge E404 headline (no hint), an HTTP 404 download (URL hint), a checksum mismatch (no hint); classifier cases for HTTP 404/403/503 and the hint text; registry cache per snapshot (one npm spawn for two asks, `None` cached) and TTL expiry. Verified: cargo fmt --check (clean); cargo clippy --lib -- -D warnings (clean); cargo test --lib -- doctor managed_acp_tools: 90 passed; pnpm run check: 3453 files, 0 errors, 0 warnings; vitest src/lib/features/doctor: 4 files, 59 passed. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- apps/staged/README.md | 22 +- apps/staged/src-tauri/src/doctor.rs | 430 +++++++++++++++--- .../staged/src-tauri/src/managed_acp_tools.rs | 166 ++++++- 3 files changed, 543 insertions(+), 75 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index 9f2d896f9..ad9143535 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -75,15 +75,19 @@ into the same Staged-private prefix. Open **Settings -> Doctor** to verify agent checks are detected as installed. A Claude or Codex row that shows "Managed by Staged" is running Staged's copy. -If the managed install failed, the row instead warns with the copy it is using -(a Homebrew or npm global install found on your PATH, say), the error the -last install attempt recorded — which names the registry or URL it used — and -a **Fix** that installs the managed copy; **Copy details** adds a hint on what -to change (a blocked `registry.npmjs.org`, a mirror without the package, an -unreachable host) and the npm registry Doctor detected. The **Node.js -Runtime** row likewise reports a failed runtime download rather than staying -hidden. When managed installs are disabled (see `STAGED_ACP_TOOLS_DIR` below), -the rows say so instead. +Until the first launch install has completed, a row that found another copy +on your PATH (a Homebrew or npm global install, say) says Staged's managed +install "hasn't completed yet" — not a failure; it corrects itself when the +install lands, and a **Fix** installs the managed copy right away if you would +rather not wait. If the managed install failed, the row instead warns that the +managed copy is missing, with the error code the last install attempt +recorded; **Copy details** has the full error — which names the registry or +URL it used — a hint on what to change (a blocked `registry.npmjs.org`, a +mirror without the package, an unreachable host, a wrong Node.js download +URL) and the npm registry Doctor detected. The **Node.js Runtime** row +likewise reports a failed runtime download rather than staying hidden. When +managed installs are disabled (see `STAGED_ACP_TOOLS_DIR` below), the rows say +so instead. To develop against a local bridge checkout, export `STAGED_ACP_TOOLS_DIR` pointing at a directory with the bridge binaries — this disables the managed diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index ea1680a71..8d0d7ee7d 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -445,13 +445,20 @@ pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport /// The managed versions are gathered concurrently with the crate's run: the /// installed version is a local `package.json` read on both passes, and the /// registry lookup for the latest version runs only when `check_freshness` -/// is set, so the first paint never waits on the network. +/// is set, so the first paint never waits on the network. The npm registry +/// probe for a missing-managed-copy row (see [`managed_install_diagnostics`]) +/// joins them too, decided from the shim dir up front, so it never adds to +/// the report's wall-clock after the crate's checks are in. async fn run_doctor_report( check_freshness: bool, queue_check: Option, ) -> DoctorReport { let env_vars = doctor_env_vars().await; - let (mut report, node_runtime, managed_versions) = tokio::join!( + let shim_bin_dir = crate::managed_acp_tools::managed_shim_bin_dir(); + let probe_registry = shim_bin_dir + .as_deref() + .is_some_and(any_managed_shim_missing); + let (mut report, node_runtime, managed_versions, npm_registry) = tokio::join!( doctor::run_checks_with_options(run_checks_options( check_freshness, env_vars.clone(), @@ -459,12 +466,11 @@ async fn run_doctor_report( )), run_node_runtime_check(), managed_bridge_versions(check_freshness, &env_vars), + detect_npm_registry_label_if(probe_registry, &env_vars), ); - match crate::managed_acp_tools::managed_shim_bin_dir() { + match shim_bin_dir { Some(shim_bin_dir) => { - let diagnostics = - managed_install_diagnostics(&report, &managed_versions, &shim_bin_dir, &env_vars) - .await; + let diagnostics = managed_install_diagnostics(npm_registry); apply_managed_bridge_readouts( &mut report, &managed_versions, @@ -1039,8 +1045,10 @@ struct ManagedInstallDiagnostics { last_reconcile: Option, /// The registry label for the row's raw output: the `STAGED_NPM_REGISTRY` /// override, what `npm config get registry` answered under the doctor - /// env snapshot, or "npm default" when neither could be asked. - npm_registry: String, + /// env snapshot, or "npm default" when neither could be asked. `None` + /// when the probe was skipped because every managed shim was present + /// when the report started (see [`any_managed_shim_missing`]). + npm_registry: Option, /// Wall-clock milliseconds the reconcile age is rendered against. now_ms: u64, } @@ -1055,7 +1063,7 @@ impl ManagedInstallDiagnostics { /// One raw-output line on the last managed install run. fn last_reconcile_line(&self) -> String { match &self.last_reconcile { - None => "last managed install: none recorded yet".to_string(), + None => "last managed install: not run yet (runs at launch and daily)".to_string(), Some(record) => format!( "last managed install: {} {}", if record.ok { "succeeded" } else { "failed" }, @@ -1063,6 +1071,14 @@ impl ManagedInstallDiagnostics { ), } } + + /// One raw-output line naming the npm registry Doctor detected. + fn npm_registry_line(&self) -> String { + format!( + "npm registry: {}", + self.npm_registry.as_deref().unwrap_or("not probed") + ) + } } /// The reconciler records each failure as `: `; the error on @@ -1090,32 +1106,10 @@ fn format_age(at_ms: u64, now_ms: u64) -> String { } } -/// Gather [`ManagedInstallDiagnostics`] for a report. The registry probe -/// spawns `npm`, so it runs only when a Claude/Codex row will actually show -/// it: one that resolved to a copy outside the managed shim dir. -async fn managed_install_diagnostics( - report: &DoctorReport, - versions: &[ManagedBridgeVersions], - shim_bin_dir: &Path, - env_vars: &[(String, String)], -) -> ManagedInstallDiagnostics { - let unmanaged_row_present = report.checks.iter().any(|check| { - let Some(tool_id) = managed_tool_id_for_check(&check.id) else { - return false; - }; - let Some(tool) = versions.iter().find(|v| v.tool.id == tool_id) else { - return false; - }; - check - .path - .as_deref() - .is_some_and(|path| !is_managed_shim_path(path, shim_bin_dir, tool.tool.binary)) - }); - let npm_registry = if unmanaged_row_present { - detect_npm_registry_label(env_vars).await - } else { - String::new() - }; +/// Gather [`ManagedInstallDiagnostics`] for a report from `state.json` and +/// the registry label the report's probes produced (see +/// [`detect_npm_registry_label_if`]). +fn managed_install_diagnostics(npm_registry: Option) -> ManagedInstallDiagnostics { ManagedInstallDiagnostics { last_reconcile: last_reconcile_record(), npm_registry, @@ -1123,6 +1117,30 @@ async fn managed_install_diagnostics( } } +/// Whether any managed bridge's shim is absent from `shim_bin_dir`. The shim +/// dir is first on the probe PATH, so this is the only way a Claude/Codex row +/// can resolve to a copy Staged does not manage — and so the only time the +/// npm registry probe, which spawns `npm`, has a row to show its answer on. +/// Decided before the probes run so that probe can join them rather than +/// wait on the report. +fn any_managed_shim_missing(shim_bin_dir: &Path) -> bool { + crate::managed_acp_tools::managed_tools() + .iter() + .any(|tool| !shim_bin_dir.join(tool.binary).is_file()) +} + +/// [`detect_npm_registry_label`] when `probe` is set; `None` otherwise. +async fn detect_npm_registry_label_if( + probe: bool, + env_vars: &[(String, String)], +) -> Option { + if probe { + Some(detect_npm_registry_label(env_vars).await) + } else { + None + } +} + /// `state.json`'s last reconcile record, if any. fn last_reconcile_record() -> Option { let packages_root = crate::paths::packages_dir()?; @@ -1131,7 +1149,9 @@ fn last_reconcile_record() -> Option { /// The npm registry a managed install resolves, labeled with where that /// answer came from — so a row can say which mirror (or the public registry) -/// the failed install actually asked. +/// the failed install actually asked. The underlying `npm config get +/// registry` is bounded to 5 s and cached per env snapshot for a minute, so +/// a Doctor refresh does not spawn npm again. async fn detect_npm_registry_label(env_vars: &[(String, String)]) -> String { if let Some(registry) = crate::managed_acp_tools::npm_registry_override() { return format!("{registry} (STAGED_NPM_REGISTRY)"); @@ -1209,12 +1229,14 @@ fn append_raw_output(check: &mut DoctorCheck, block: &str) { /// way to get the managed copy. So: /// /// - `status` drops to `Warn` (a row already warning or failing keeps its -/// status), and `message` says which copy is in use, that the managed copy -/// is missing, and — when the last reconcile recorded an error for this -/// tool — that error, which names the registry or URL it used. +/// status), and `message` says which copy is in use and, before any +/// reconcile has finished, that the managed install "hasn't completed yet" +/// — a healthy first launch, not a failure; after one, that the managed +/// copy is missing, with — when the last reconcile recorded an error for +/// this tool — the short of that error (see [`short_error_fragment`]). /// - `raw_output` gains the expected shim path, the last reconcile's outcome -/// and age, the error with a classified hint on what to change -/// (`install_failure_hint`), and the npm registry Doctor detected. +/// and age, the full recorded error with a classified hint on what to +/// change (`install_failure_hint`), and the npm registry Doctor detected. /// - The row's fix becomes the managed install (`Bridge`, which /// `run_doctor_fix` routes to `install_managed_tool_logged` for these two /// checks), described by [`managed_install_description`] — the same text @@ -1239,17 +1261,19 @@ fn flag_missing_managed_copy( return false; } let reconcile_error = diagnostics.reconcile_error_for(tool.id); - let error_suffix = reconcile_error - .map(|error| format!(": {error}")) - .unwrap_or_default(); + let situation = match (&diagnostics.last_reconcile, reconcile_error) { + (None, _) => "Staged's managed install hasn't completed yet".to_string(), + (Some(_), None) => "Staged's managed copy is missing".to_string(), + (Some(_), Some(error)) => format!( + "Staged's managed copy is missing: {}", + short_error_fragment(error) + ), + }; if check.status == CheckStatus::Pass { check.status = CheckStatus::Warn; - check.message = format!("Using {path} — Staged's managed copy is missing{error_suffix}"); + check.message = format!("Using {path} — {situation}"); } else { - check.message = format!( - "{}; using {path} — Staged's managed copy is missing{error_suffix}", - check.message - ); + check.message = format!("{}; using {path} — {situation}", check.message); } let mut detail = vec![ @@ -1265,7 +1289,7 @@ fn flag_missing_managed_copy( detail.push(format!("hint: {hint}")); } } - detail.push(format!("npm registry: {}", diagnostics.npm_registry)); + detail.push(diagnostics.npm_registry_line()); append_raw_output(check, &detail.join("\n")); let latest = versions.latest.as_deref().or_else(|| { @@ -1279,6 +1303,31 @@ fn flag_missing_managed_copy( true } +/// Most a recorded install error contributes to a row's one-line message: +/// npm's own `npm error code ` when the error carries one (what +/// `npm_error_summary` leads with), else the error's first +/// [`SHORT_ERROR_CHARS`] characters, ellipsized. The full text, its hint and +/// the registry stay in the raw output for **Copy details**. +fn short_error_fragment(error: &str) -> String { + const NPM_CODE: &str = "npm error code "; + if let Some((_, rest)) = error.split_once(NPM_CODE) { + let code = rest + .split(|c: char| c == ';' || c.is_whitespace()) + .next() + .unwrap_or_default(); + if !code.is_empty() { + return format!("{NPM_CODE}{code}"); + } + } + match error.char_indices().nth(SHORT_ERROR_CHARS) { + Some((cut, _)) => format!("{}…", error[..cut].trim_end()), + None => error.to_string(), + } +} + +/// How much of a recorded error without an npm code fits on the row. +const SHORT_ERROR_CHARS: usize = 120; + /// Give a managed Claude/Codex row the two readouts its one executable /// stands for, and report whether it did. /// @@ -1694,8 +1743,14 @@ fn build_node_runtime_check( record.errors.len() )); detail.extend(record.errors.iter().map(|error| format!("- {error}"))); - if let Some(hint) = - crate::managed_acp_tools::install_failure_hint(&reconcile_headline(record), "Node.js") + // Only a runtime download error is this row's to advise on: a bridge's + // npm 404 as the headline must not read as "no registry carries + // Node.js". + let headline = reconcile_headline(record); + if let Some(hint) = headline + .starts_with(NODE_DOWNLOAD_ERROR_PREFIX) + .then(|| crate::managed_acp_tools::install_failure_hint(&headline, "Node.js")) + .flatten() { detail.push(format!("hint: {hint}")); } @@ -1721,6 +1776,10 @@ fn build_node_runtime_check( )) } +/// How `ManagedNodeError`'s download failures (network, HTTP status) render, +/// so the runtime row can tell its own download failure from a bridge's. +const NODE_DOWNLOAD_ERROR_PREFIX: &str = "Node.js runtime download failed"; + /// The one error a failed reconcile is summarised by on the runtime row: the /// first that names the Node.js runtime download — the failure every bridge /// shares when the runtime could not be fetched — else the first recorded, @@ -2009,6 +2068,87 @@ mod tests { assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); } + /// The hint is the runtime row's own only when the headline is a runtime + /// download failure: a bridge's npm 404 is still reported, but never as + /// "the configured registry does not carry Node.js". A 4xx from the dist + /// host points at the download URL. + #[test] + fn runtime_row_hints_only_on_its_own_download_failure() { + let bridge_404 = "codex-acp: npm install failed: npm install exited with exit status: 1 \ + (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ + GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp"; + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record(false, vec![bridge_404])), + ) + .unwrap(); + assert_eq!(check.status, CheckStatus::Warn); + assert!( + check + .message + .ends_with("could not be installed: npm install failed: npm install exited with exit status: 1 \ + (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ + GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp"), + "{}", + check.message + ); + let output = check.raw_output.as_deref().unwrap(); + assert!(output.contains(&format!("- {bridge_404}")), "{output}"); + assert!(!output.contains("hint:"), "{output}"); + assert!(!output.contains("Node.js\n"), "{output}"); + + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record( + false, + vec![ + bridge_404, + "claude-acp: Node.js runtime download failed: HTTP 404", + ], + )), + ) + .unwrap(); + assert!( + check + .message + .ends_with("could not be installed: Node.js runtime download failed: HTTP 404"), + "{}", + check.message + ); + let output = check.raw_output.as_deref().unwrap(); + assert!( + output.contains( + "hint: the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL" + ), + "{output}" + ); + + // A checksum mismatch is the headline but not a download failure Doctor + // has advice for. + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record( + false, + vec!["claude-acp: Node.js runtime archive SHA-256 mismatch: expected a, got b"], + )), + ) + .unwrap(); + assert!( + check + .message + .ends_with("Node.js runtime archive SHA-256 mismatch: expected a, got b"), + "{}", + check.message + ); + assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); + } + #[test] fn format_age_rounds_to_the_largest_whole_unit() { assert_eq!(format_age(1_000, 1_000), "just now"); @@ -2207,12 +2347,17 @@ mod tests { versions: &ManagedBridgeVersions, check_freshness: bool, ) -> DoctorCheck { - apply_with( - check, - versions, - check_freshness, - &ManagedInstallDiagnostics::default(), - ) + apply_with(check, versions, check_freshness, &clean_diagnostics()) + } + + /// A reconcile that succeeded a minute ago and left nothing to report: + /// the managed copy is then simply missing, with no error to name. + fn clean_diagnostics() -> ManagedInstallDiagnostics { + ManagedInstallDiagnostics { + last_reconcile: Some(reconcile_record(true, vec![])), + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), + now_ms: 61_000, + } } fn apply_with( @@ -2454,7 +2599,7 @@ mod tests { "{detail}" ); assert!( - detail.contains("last managed install: none recorded yet"), + detail.contains("last managed install: succeeded 1 minute ago"), "{detail}" ); assert!(!detail.contains("hint:"), "{detail}"); @@ -2480,10 +2625,69 @@ mod tests { assert_eq!(json(&without_missing_copy_flag(after)), before); } + /// Before the first reconcile has finished — a fresh profile whose launch + /// install is still downloading — a Homebrew copy on PATH is expected, so + /// the row must not read as a failure: it says the managed install hasn't + /// completed yet, the detail says when it runs, and the Fix stays + /// available for whoever would rather not wait. An already-warning row + /// keeps its own message in front. + #[test] + fn a_user_copy_before_the_first_reconcile_is_not_called_a_failure() { + let installed = versions(None, Some("0.17.0")); + let path = "/opt/homebrew/bin/claude-agent-acp"; + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: None, + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), + now_ms: 0, + }; + + let check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!("Using {path} — Staged's managed install hasn't completed yet") + ); + assert!(!after.message.contains("missing"), "{}", after.message); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!( + after.fix_command.as_deref(), + Some( + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ) + ); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains("last managed install: not run yet (runs at launch and daily)"), + "{detail}" + ); + assert!(!detail.contains("hint:"), "{detail}"); + assert!( + detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), + "{detail}" + ); + + let mut check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + check.status = CheckStatus::Warn; + check.message = "Installed, not authenticated".to_string(); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!( + after.message, + format!( + "Installed, not authenticated; using {path} — Staged's managed install hasn't completed yet" + ) + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + } + /// With a failed reconcile on record the row says why the managed copy is - /// missing — the recorded error for *this* tool, which names the registry - /// it used — and the detail adds the classified hint and the registry - /// Doctor detected. The other tool's error stays off this row. + /// missing — only the `npm error code` of the recorded error for *this* + /// tool, the rest of which (naming the registry it used) goes to the + /// detail with the classified hint and the registry Doctor detected. The + /// other tool's error stays off this row. #[test] fn a_user_copy_names_the_recorded_install_error_and_its_hint() { let installed = versions(None, None); @@ -2504,7 +2708,7 @@ mod tests { .to_string(), ], }), - npm_registry: "https://registry.npmjs.org/ (per npm config)".to_string(), + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), now_ms: 5 * 60_000, }; @@ -2515,7 +2719,12 @@ mod tests { assert_eq!(after.status, CheckStatus::Warn); assert_eq!( after.message, - format!("Using {path} — Staged's managed copy is missing: {claude_error}") + format!("Using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR") + ); + assert!( + !after.message.contains("invalid json"), + "the full error stays out of the one-line message: {}", + after.message ); assert_eq!(after.fix_type, Some(FixType::Bridge)); let detail = after.raw_output.as_deref().expect("detail"); @@ -2552,7 +2761,7 @@ mod tests { assert_eq!( after.message, format!( - "Installed, not authenticated; using {path} — Staged's managed copy is missing: {claude_error}" + "Installed, not authenticated; using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR" ) ); let detail = after.raw_output.as_deref().unwrap(); @@ -2560,6 +2769,97 @@ mod tests { detail.starts_with("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\nStaged-managed copy: missing"), "the crate's trace stays on top: {detail}" ); + assert!( + detail.contains(&format!("claude-acp: {claude_error}")), + "{detail}" + ); + + // An error with no npm code is ellipsized on the row and whole in the + // detail; the registry line says when the probe did not run. + let long_error = format!( + "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: {}", + "validation probe exited with 1 and printed a very long explanation ".repeat(3) + ); + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: Some(reconcile_record( + false, + vec![&format!("claude-acp: {long_error}")], + )), + npm_registry: None, + now_ms: 0, + }; + let check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + let expected_fragment: String = long_error.chars().take(SHORT_ERROR_CHARS).collect(); + assert_eq!( + after.message, + format!( + "Using {path} — Staged's managed copy is missing: {}…", + expected_fragment.trim_end() + ) + ); + let detail = after.raw_output.as_deref().unwrap(); + assert!( + detail.contains(&format!("claude-acp: {long_error}")), + "{detail}" + ); + assert!(detail.contains("npm registry: not probed"), "{detail}"); + } + + #[test] + fn short_error_fragment_prefers_the_npm_code_then_ellipsizes() { + assert_eq!( + short_error_fragment( + "npm install failed: npm install exited with exit status: 1 (registry: npm default \ + (per npm config)): npm error code FETCH_ERROR; invalid json response body at x" + ), + "npm error code FETCH_ERROR" + ); + assert_eq!( + short_error_fragment( + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ + network gateway; fallback https://f.example.test/ also failed: npm install exited \ + with exit status: 1 (registry: https://f.example.test/): npm error code E404" + ), + "npm error code E404" + ); + assert_eq!(short_error_fragment("short"), "short"); + let exact: String = "é".repeat(SHORT_ERROR_CHARS); + assert_eq!( + short_error_fragment(&exact), + exact, + "no ellipsis at the limit" + ); + let over = format!("{exact}x"); + assert_eq!(short_error_fragment(&over), format!("{exact}…")); + assert_eq!( + short_error_fragment(&format!( + "{} {}", + "a".repeat(SHORT_ERROR_CHARS - 1), + "b".repeat(5) + )), + format!("{}…", "a".repeat(SHORT_ERROR_CHARS - 1)), + "trailing whitespace before the cut is dropped" + ); + } + + /// The registry probe is decided from the shim dir alone: any managed + /// shim absent means a Claude/Codex row may resolve elsewhere. + #[test] + fn registry_probe_runs_only_while_a_managed_shim_is_absent() { + let tools = crate::managed_acp_tools::managed_tools(); + if tools.is_empty() { + return; + } + let dir = tempfile::tempdir().unwrap(); + assert!(any_managed_shim_missing(dir.path())); + for tool in &tools { + std::fs::write(dir.path().join(tool.binary), "#!/bin/sh\n").unwrap(); + } + assert!(!any_managed_shim_missing(dir.path())); + std::fs::remove_file(dir.path().join(tools[0].binary)).unwrap(); + assert!(any_managed_shim_missing(dir.path())); } /// When this build does not manage bridges at all, a Homebrew copy is by diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs index 9432d227f..d968fd0a5 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools.rs @@ -204,12 +204,23 @@ pub(crate) enum InstallFailureHint { /// The registry or download host could not be reached at all: DNS, /// refused/reset connections, or a timeout on the request. HostUnreachable, + /// The Node.js dist host answered the runtime download with a 4xx — the + /// URL (`STAGED_NODE_DIST_URL`) points at the wrong path or host. + NodeDownloadRejected(u16), } /// Classify a recorded install error (see [`InstallFailureHint`]). `None` /// when the text names no network or registry cause Doctor has advice for — /// a validation failure, a missing entrypoint, a disk error. pub(crate) fn classify_install_failure(error: &str) -> Option { + // Checked first: `HTTP 404` from the dist host is a download URL problem, + // not a registry without the package. A 5xx is the host's, not the + // user's, so it gets no hint. + if let Some(status) = node_download_http_status(error) { + return (400..500) + .contains(&status) + .then_some(InstallFailureHint::NodeDownloadRejected(status)); + } if error.contains("blocked by a network gateway") || classify_npm_failure(error) == NpmFailure::GatewayBlocked { @@ -246,6 +257,19 @@ pub(crate) fn classify_install_failure(error: &str) -> Option` error +/// (`ManagedNodeError::HttpStatus`'s wording), if that is what `error` is. +fn node_download_http_status(error: &str) -> Option { + let (_, rest) = error.split_once(NODE_DOWNLOAD_HTTP_STATUS_PREFIX)?; + rest.split(|c: char| !c.is_ascii_digit()) + .next()? + .parse() + .ok() +} + +/// How `ManagedNodeError::HttpStatus` renders, up to the status code. +const NODE_DOWNLOAD_HTTP_STATUS_PREFIX: &str = "Node.js runtime download failed: HTTP "; + /// The one-line hint Doctor shows under a classified install failure, naming /// what to change: `package` is the npm package the install was for. pub(crate) fn install_failure_hint(error: &str, package: &str) -> Option { @@ -263,6 +287,9 @@ pub(crate) fn install_failure_hint(error: &str, package: &str) -> Option or STAGED_NODE_DIST_URL" .to_string() } + InstallFailureHint::NodeDownloadRejected(status) => { + format!("the Node.js download URL returned HTTP {status} — check STAGED_NODE_DIST_URL") + } }) } @@ -1355,13 +1382,70 @@ async fn npm_view_version( /// How long Doctor waits on `npm config get registry`. const NPM_CONFIG_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// How long a `npm config get registry` answer is reused for the same env +/// snapshot. Doctor asks on every report while a managed bridge is missing — +/// two passes per open, plus refreshes — and each ask spawns npm; one spawn a +/// minute keeps that off the report's critical path without hiding a +/// `~/.npmrc` edit for long. Unlike [`LatestVersionCache`] a `None` answer +/// is cached too: it is what a hung or absent npm costs, and re-asking every +/// pass is exactly the stall this avoids. +const NPM_REGISTRY_CACHE_TTL: Duration = Duration::from_secs(60); + +/// The env snapshot a registry answer was asked under: the cache key, since +/// the answer depends on the snapshot's `npm`, `~/.npmrc` and exports. +type EnvSnapshot = Vec<(String, String)>; + +/// `npm config get registry` answers by the env snapshot they were asked +/// under, with the time each was fetched. +#[derive(Debug, Default)] +struct NpmRegistryCache { + entries: HashMap, Instant)>, +} + +impl NpmRegistryCache { + /// `Some(answer)` when a fresh answer (itself possibly `None`) is cached. + fn get_fresh(&self, env_vars: &[(String, String)], now: Instant) -> Option> { + let (registry, fetched_at) = self.entries.get(env_vars)?; + (now.saturating_duration_since(*fetched_at) <= NPM_REGISTRY_CACHE_TTL) + .then(|| registry.clone()) + } + + fn insert(&mut self, env_vars: &[(String, String)], registry: Option, now: Instant) { + self.entries.insert(env_vars.to_vec(), (registry, now)); + } +} + +fn npm_registry_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(NpmRegistryCache::default())) +} + /// The registry npm would use under the `env_vars` snapshot with no /// `--registry` — `npm config get registry` asked of the snapshot's own /// `npm` (the managed runtime's, when installed), under exactly that env, so /// the answer is the one a managed install resolves. `None` when the /// snapshot has no `npm`, the probe fails or times out, or the answer is not -/// a URL; Doctor then says "npm default" rather than guessing. +/// a URL; Doctor then says "npm default" rather than guessing. Cached per +/// snapshot for [`NPM_REGISTRY_CACHE_TTL`]. pub(crate) async fn configured_npm_registry(env_vars: &[(String, String)]) -> Option { + let now = Instant::now(); + let cached = npm_registry_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_fresh(env_vars, now); + if let Some(registry) = cached { + return registry; + } + let registry = probe_configured_npm_registry(env_vars).await; + npm_registry_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(env_vars, registry.clone(), now); + registry +} + +/// One uncached `npm config get registry` (see [`configured_npm_registry`]). +async fn probe_configured_npm_registry(env_vars: &[(String, String)]) -> Option { let npm_path = find_on_env_path(env_vars, "npm")?; let mut command = tokio::process::Command::new(npm_path); command.args(["config", "get", "registry"]); @@ -1636,6 +1720,14 @@ mod tests { "installed ACP bridge is incomplete: bridge entrypoint /x/index.js is missing after install", None, ), + ( + "Node.js runtime download failed: HTTP 404", + Some(InstallFailureHint::NodeDownloadRejected(404)), + ), + ( + "Node.js runtime download failed: HTTP 403", + Some(InstallFailureHint::NodeDownloadRejected(403)), + ), ("Node.js runtime download failed: HTTP 503", None), ("", None), ]; @@ -1643,6 +1735,12 @@ mod tests { assert_eq!(classify_install_failure(error), expected, "{error}"); } + assert_eq!( + install_failure_hint("Node.js runtime download failed: HTTP 404", "Node.js").as_deref(), + Some("the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL"), + "a 404 from the dist host is a URL problem, never a missing package" + ); + let hint = install_failure_hint( "npm install failed: … npm error code E404; 404 Not Found", "@agentclientprotocol/codex-acp", @@ -1771,6 +1869,72 @@ mod tests { ); } + /// Asking twice under one snapshot spawns npm once: the answer — a URL or + /// `None` alike — is reused within the TTL, so a Doctor refresh never + /// re-pays the probe. A different snapshot is a different question. + #[tokio::test] + async fn configured_npm_registry_is_cached_per_env_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let calls = dir.path().join("calls"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\nprintf x >> '{}'\nprintf '%s\\n' \"${{FAKE_REGISTRY:-https://mirror.example.test/npm/}}\"\n", + calls.display() + ), + ); + let spawns = || { + std::fs::read_to_string(&calls) + .map(|s| s.len()) + .unwrap_or(0) + }; + let env = vec![("PATH".to_string(), bin.display().to_string())]; + + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + assert_eq!(spawns(), 1, "the second ask is answered from the cache"); + + // A `None` answer is cached as well. + let mut env_garbage = env.clone(); + env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + assert_eq!(spawns(), 2); + } + + #[test] + fn npm_registry_cache_expires_after_the_ttl() { + let mut cache = NpmRegistryCache::default(); + let now = Instant::now(); + let env = vec![("PATH".to_string(), "/x".to_string())]; + let other = vec![("PATH".to_string(), "/y".to_string())]; + assert_eq!(cache.get_fresh(&env, now), None); + + cache.insert(&env, Some("https://r.example.test/".to_string()), now); + cache.insert(&other, None, now); + assert_eq!( + cache.get_fresh(&env, now + Duration::from_secs(30)), + Some(Some("https://r.example.test/".to_string())) + ); + assert_eq!( + cache.get_fresh(&other, now), + Some(None), + "a cached `None` is an answer, not a miss" + ); + assert_eq!( + cache.get_fresh(&env, now + NPM_REGISTRY_CACHE_TTL + Duration::from_secs(1)), + None + ); + } + /// The capture keeps the *newest* lines: npm's `npm error code …` block /// comes last, after however many warnings preceded it. #[test] From 5003bb53a3b94d6a24888cf7a44343d8826b83d6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 14:06:16 +1100 Subject: [PATCH 5/7] refactor(staged): split managed_acp_tools and doctor into submodules; force the scoped registry and key the version cache by registry config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Codex review of PR #998 (80fb4613). Scoped registry (P1): `--registry` only sets npm's *default* registry. Both managed bridges live under `@agentclientprotocol`, and npm resolves a scoped package's registry from its `@scope:registry` config first, so a `~/.npmrc` carrying `@agentclientprotocol:registry=` kept winning over `STAGED_NPM_REGISTRY` and the gateway fallback alike (verified with npm 11.19: `npm config get @agentclientprotocol:registry --registry=` still answers the scoped URL; adding `--@agentclientprotocol:registry=` wins). `npm_registry_args` now emits `--registry ` plus `--@:registry=` for a scoped package, and every site that passed a registry uses it: the floating bridge install, the `npm view` freshness probe, and — in the doctor crate — `apply_npm_registry` (fix commands, taking the scope from the first `@scope/` token in the command) and `latest_npm`. README and the policy module doc say so. Latest-version cache (P2): `LatestVersionCache` was keyed by package name only, so a `~/.npmrc` or `STAGED_NPM_REGISTRY` change while Staged ran let Doctor show the old registry's version for up to an hour while the installer fetched `@latest` from the new one. Entries are now keyed by `LatestVersionKey { package, RegistryFingerprint }`, where the fingerprint is the override, the env snapshot (its `npm`, `HOME`, `NPM_CONFIG_*` exports) and the user npmrc's identity (path, size, mtime — `NPM_CONFIG_USERCONFIG` in either spelling, else `$HOME/.npmrc`). Inserts drop entries past the TTL so superseded fingerprints do not linger. Module split (P1, AGENTS.md "split growing modules"): the two files had reached 3,287 and 3,560 lines, half of it tests. Pure moves, no behaviour change; cross-module items became `pub(super)`. managed_acp_tools/ mod.rs layout, env, the managed set, state.json, re-exports registry.rs registry policy: override/fallback, classifiers, hints install.rs install_managed_tool: stage, validate, swap, shim probe.rs npm view / npm config probes and their caches reconcile.rs reconcile epilogue validation.rs (unchanged) tests/{support,layout,registry,install,probe,reconcile,validation}.rs doctor/ mod.rs commands, option builders, fix/update dispatch login.rs interactive login runs (pub: the four login commands) managed_bridges.rs Claude/Codex row readouts and missing-copy flags runtime_checks.rs Node.js runtime and session-queue rows tests/{support,commands,login,managed_bridges,runtime_checks}.rs Each submodule includes its tests with `#[path]`; shared fixtures live in a `test_support` module. `lib.rs` registers the login commands as `doctor::login::*` (Tauri's handler macro needs the command's own module); `crate::doctor::start_doctor_login` etc. stay re-exported for web_server. The self-respawning validation test names its new test path. Tests: `npm_registry_args` scoped/unscoped; scoped-flag assertions on the install fallback/override argv and the probe argv; cache keyed by override, env and npmrc; `user_npmrc_path` precedence; a fake npm that answers with its npmrc's contents proving an edited npmrc re-probes within the TTL and an untouched one is a cache hit with npm gone; TTL insert pruning; doctor crate `apply_npm_registry` with a scoped package. Verified: cargo fmt --check; cargo clippy --lib -- -D warnings (clean; the three `--tests` lints in session_queue/tests.rs and session_runner.rs are pre-existing); cargo test --lib -- doctor managed_acp_tools: 94 passed (90 before + 4 new; test names diffed against HEAD); crates/doctor clippy --lib --tests clean and cargo test --lib: 152 passed. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- apps/staged/README.md | 7 +- apps/staged/src-tauri/src/doctor.rs | 3560 ----------------- apps/staged/src-tauri/src/doctor/login.rs | 544 +++ .../src-tauri/src/doctor/managed_bridges.rs | 569 +++ apps/staged/src-tauri/src/doctor/mod.rs | 456 +++ .../src-tauri/src/doctor/runtime_checks.rs | 342 ++ .../src-tauri/src/doctor/tests/commands.rs | 89 + .../src-tauri/src/doctor/tests/login.rs | 333 ++ .../src/doctor/tests/managed_bridges.rs | 972 +++++ .../src/doctor/tests/runtime_checks.rs | 309 ++ .../src-tauri/src/doctor/tests/support.rs | 15 + apps/staged/src-tauri/src/lib.rs | 8 +- .../staged/src-tauri/src/managed_acp_tools.rs | 3287 --------------- .../src/managed_acp_tools/install.rs | 602 +++ .../src-tauri/src/managed_acp_tools/mod.rs | 356 ++ .../src-tauri/src/managed_acp_tools/probe.rs | 392 ++ .../src/managed_acp_tools/reconcile.rs | 121 + .../src/managed_acp_tools/registry.rs | 268 ++ .../src/managed_acp_tools/tests/install.rs | 721 ++++ .../src/managed_acp_tools/tests/layout.rs | 186 + .../src/managed_acp_tools/tests/probe.rs | 489 +++ .../src/managed_acp_tools/tests/reconcile.rs | 83 + .../src/managed_acp_tools/tests/registry.rs | 235 ++ .../src/managed_acp_tools/tests/support.rs | 147 + .../src/managed_acp_tools/tests/validation.rs | 18 +- .../src/managed_acp_tools/validation.rs | 7 +- crates/doctor/src/agents.rs | 62 +- crates/doctor/src/freshness.rs | 14 +- 28 files changed, 7324 insertions(+), 6868 deletions(-) delete mode 100644 apps/staged/src-tauri/src/doctor.rs create mode 100644 apps/staged/src-tauri/src/doctor/login.rs create mode 100644 apps/staged/src-tauri/src/doctor/managed_bridges.rs create mode 100644 apps/staged/src-tauri/src/doctor/mod.rs create mode 100644 apps/staged/src-tauri/src/doctor/runtime_checks.rs create mode 100644 apps/staged/src-tauri/src/doctor/tests/commands.rs create mode 100644 apps/staged/src-tauri/src/doctor/tests/login.rs create mode 100644 apps/staged/src-tauri/src/doctor/tests/managed_bridges.rs create mode 100644 apps/staged/src-tauri/src/doctor/tests/runtime_checks.rs create mode 100644 apps/staged/src-tauri/src/doctor/tests/support.rs delete mode 100644 apps/staged/src-tauri/src/managed_acp_tools.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/install.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/mod.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/probe.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/reconcile.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/registry.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/install.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/layout.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/reconcile.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/registry.rs create mode 100644 apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs diff --git a/apps/staged/README.md b/apps/staged/README.md index ad9143535..993afee0c 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -103,7 +103,12 @@ Staged does not hardcode where packages come from: `registry.npmjs.org`). A corporate laptop whose `~/.npmrc` already points at an internal mirror keeps using it. Set `STAGED_NPM_REGISTRY` to force a registry for every Staged-run npm command (bridge installs, Doctor's - npm-based installs, and update checks). + npm-based installs, and update checks); it is passed as `--registry` and, + for a scoped package such as `@agentclientprotocol/*`, as that scope's + `--@scope:registry` too, so it also overrides a scoped `~/.npmrc` entry. + Doctor's cached "latest version" answers are keyed by this configuration + (override, environment, and `~/.npmrc` identity), so editing either is + picked up on the next check rather than after the cache's hour. - **Node.js runtime.** The pinned runtime (`node-runtime.lock.json`) is downloaded from `https://nodejs.org/dist` and verified against the lock's SHA-256. Set `STAGED_NODE_DIST_URL` to a mirror with the same path layout diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs deleted file mode 100644 index 8d0d7ee7d..000000000 --- a/apps/staged/src-tauri/src/doctor.rs +++ /dev/null @@ -1,3560 +0,0 @@ -//! Tauri command wrappers for the doctor health-check system. - -use std::collections::{HashMap, VecDeque}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; - -use serde::Serialize; -use tauri::Manager; - -use crate::managed_acp_tools::{ManagedTool, ReconcileRecord}; -use crate::session_queue::sweep::{self, QueueHealth}; -use crate::session_runner::SessionRegistry; -use crate::store::Store; - -pub use doctor::types::{AuthStatus, InstallSource}; -pub use doctor::{ - AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, ExecuteFixOptions, FixCancelHandle, - FixCancellation, FixStdin, FixStdinWriter, FixType, RunChecksOptions, -}; - -/// One `doctor-login-output` event: a line of a running login's output, or -/// its end. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DoctorLoginOutput { - pub check_id: String, - /// The run this event belongs to — see [`ActiveLogin::run_id`]. A client - /// follows one run and drops events carrying any other id: a check id - /// alone cannot tell an earlier run's late `done` from the current run's. - pub run_id: String, - pub line: Option, - /// Position of `line` in the run's output, counted from zero; on the final - /// event, the number of lines the run emitted. A client that attached late - /// compares it against [`DoctorLoginStatus::next_seq`] to tell a line its - /// snapshot already covered from one that arrived after the snapshot. - pub seq: u64, - pub done: bool, - /// The fix's failure, when it failed. `None` on a cancelled run: doctor's - /// runner reports a cancellation as an `Err`, but a client should render - /// "cancelled", not a failure — that is what `cancelled` is for. - pub error: Option, - /// The run ended because [`cancel_doctor_login`] was called on it. - pub cancelled: bool, -} - -/// Answer to [`doctor_login_status`]: whether a login is running for the check, -/// and what it has printed so far. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DoctorLoginStatus { - pub running: bool, - /// The running login's run id, which its events carry and which - /// [`send_doctor_login_code`] and [`cancel_doctor_login`] take. `None` - /// when nothing is running. - pub run_id: Option, - /// The last [`LOGIN_OUTPUT_TAIL_LINES`] lines the run emitted, oldest - /// first. Empty when nothing is running. - pub output: Vec, - /// The `seq` the run's next line will carry. `output` covers the `seq`s - /// from `next_seq - output.len()` up to but excluding `next_seq`, which is - /// how a client merges this snapshot with lines it received live. Counted - /// per run: a new run for the same check starts again from zero. - pub next_seq: u64, -} - -impl DoctorLoginStatus { - fn idle() -> Self { - Self { - running: false, - run_id: None, - output: Vec::new(), - next_seq: 0, - } - } -} - -/// What [`start_doctor_login`] did, and the run id of the login it either -/// began or found. Serialized as `{ "outcome": "started" | "alreadyRunning", -/// "runId": … }`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde( - tag = "outcome", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -pub enum LoginStart { - Started { - run_id: String, - }, - /// A login for the check was already running. The caller can attach to it: - /// [`doctor_login_status`] has its output so far, the `doctor-login-output` - /// stream carries the rest, and [`send_doctor_login_code`] reaches it — - /// all under this run id. - AlreadyRunning { - run_id: String, - }, -} - -/// Lines of a login's output retained for a client that attaches after they -/// were streamed — a web client refreshed mid-login, a second client, a -/// reloaded webview. The same count the frontend keeps (`MAX_OUTPUT_LINES` in -/// `agentLogin.svelte.ts`), so a replay restores exactly what a client that -/// watched from the start is showing. -const LOGIN_OUTPUT_TAIL_LINES: usize = 40; - -/// Bounded, oldest-first tail of a login's output, with a count of every line -/// that went through it so each line's event can carry its position. -#[derive(Debug, Default)] -struct LoginOutputTail { - lines: VecDeque, - /// Lines pushed so far — the `seq` the next one gets. - next_seq: u64, -} - -impl LoginOutputTail { - /// Record `line`, dropping the oldest once the tail is full, and return the - /// `seq` the line's event carries. - fn push(&mut self, line: &str) -> u64 { - let seq = self.next_seq; - self.next_seq += 1; - if self.lines.len() == LOGIN_OUTPUT_TAIL_LINES { - self.lines.pop_front(); - } - self.lines.push_back(line.to_string()); - seq - } - - /// The snapshot a late client replays, for the running login `run_id`. - fn status(&self, run_id: &str) -> DoctorLoginStatus { - DoctorLoginStatus { - running: true, - run_id: Some(run_id.to_string()), - output: self.lines.iter().cloned().collect(), - next_seq: self.next_seq, - } - } -} - -/// A login fix in flight, keyed by check id in [`ACTIVE_LOGINS`]. -struct ActiveLogin { - /// Identity of this run, minted by [`claim_login`] and unique across every - /// login Staged has ever run — a UUID, so it stays unique across a backend - /// restart that a web client's record may outlive. - /// - /// Events, the status snapshot and the start answer all carry it, and a - /// code or a cancel names it. The check id is the slot, not the run: the - /// slot is released *before* a run's `done` is emitted (see - /// [`run_login_fix`]), so a fresh start for the same check can land between - /// the two, and that run's client would otherwise take the earlier run's - /// `done` as its own end — and a code typed for the earlier run would be - /// delivered to the new one. - run_id: String, - /// The write end of the fix's stdin. Held here for the whole run — this - /// entry is its only long-lived owner — because dropping the last - /// `FixStdinWriter` is what closes the pipe: the clone - /// [`send_doctor_login_code`] takes lives for one write. Without this one - /// the CLI would read EOF at spawn, before the user had a code to give it. - /// - /// The flip side: a fix that reads stdin *to EOF* never gets it while its - /// slot is held, which is until the run ends — so such a fix would sit - /// until doctor's `FixTimeout`. None of the login commands does that (each - /// reads one line), and closing stdin is not how a login is ended anyway: - /// the Claude CLI ignores EOF and keeps waiting on its browser callback. - /// Ending a run early is `cancel`'s job. - writer: FixStdinWriter, - /// Stops the run through doctor's runner, which owns the child and kills - /// its whole process tree. See [`cancel_doctor_login`]. - cancel: FixCancelHandle, - /// The lines streamed so far, shared with the run's `on_line` callback, - /// which appends to it before emitting each event. - output: Arc>, -} - -/// Login fixes currently running, by check id. This is intentionally only a -/// lifetime map for active subprocesses, not a cache of authentication state; -/// doctor remains the source of truth for whether login is available. -static ACTIVE_LOGINS: OnceLock>> = OnceLock::new(); - -fn active_logins() -> &'static Mutex> { - ACTIVE_LOGINS.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// A claimed login slot in [`ACTIVE_LOGINS`], released when dropped. -/// -/// The release is structural rather than a statement at the end of -/// [`run_login_fix`]: a panic in `doctor_env_vars().await` or in the emit -/// closure, or the awaiting future being dropped, would otherwise leave the -/// entry in place — and with it [`claim_login`] refusing the check until Staged -/// restarts. -/// -/// It releases only its own run: the entry is removed when it still carries -/// this slot's `run_id`, never a later run's that took the slot after this one -/// was already gone. -#[derive(Debug)] -struct LoginSlot { - check_id: String, - run_id: String, -} - -impl Drop for LoginSlot { - fn drop(&mut self) { - let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); - if logins - .get(&self.check_id) - .is_some_and(|login| login.run_id == self.run_id) - { - logins.remove(&self.check_id); - } - } -} - -/// Everything a claimed login needs to run: its run id, the pipe and token the -/// fix is given, the handle and tail the entry keeps, and the slot whose drop -/// releases the entry. -#[derive(Debug)] -struct ClaimedLogin { - run_id: String, - stdin: FixStdin, - cancellation: FixCancellation, - cancel: FixCancelHandle, - output: Arc>, - slot: LoginSlot, -} - -/// What [`claim_login`] found. -#[derive(Debug)] -enum LoginClaim { - /// The slot was free and is now this caller's. - Claimed(ClaimedLogin), - /// A login for the check is already running, under this run id. - AlreadyRunning { run_id: String }, -} - -/// Why a command aimed at one run of a check's login could not reach it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StaleLoginRun { - /// Nothing is running for the check: the run has ended. - Ended, - /// The run has ended and a newer one has since taken the check's slot. - Superseded, -} - -impl StaleLoginRun { - /// The refusal a client shows for a code typed into a login that is gone. - fn code_refusal(self, check_id: &str) -> String { - match self { - Self::Ended => format!( - "The login this code was typed for has ended; no login is running for {check_id}" - ), - Self::Superseded => format!( - "The login this code was typed for has ended; a newer login is running for \ - {check_id} and the code was not delivered to it" - ), - } - } -} - -/// The entry for `check_id` in `logins`, provided it is the run `run_id` the -/// caller is attached to. The map is keyed by check id, so this is where a -/// command aimed at a finished run is told apart from one aimed at the run -/// that replaced it. -fn find_login_run<'a>( - logins: &'a HashMap, - check_id: &str, - run_id: &str, -) -> Result<&'a ActiveLogin, StaleLoginRun> { - match logins.get(check_id) { - None => Err(StaleLoginRun::Ended), - Some(login) if login.run_id != run_id => Err(StaleLoginRun::Superseded), - Some(login) => Ok(login), - } -} - -/// A fresh run id — see [`ActiveLogin::run_id`]. -fn new_login_run_id() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// Environment snapshot for doctor checks and fixes — and for the managed -/// bridge installs themselves (see -/// [`crate::managed_acp_tools::install_managed_tool`]), so the registry npm -/// resolves is the same for a check, its fix, and the reconciler. Shaped -/// through `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. -pub(crate) 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_managed_tools_env(&mut env_vars); - crate::managed_acp_tools::apply_managed_npm_env( - &mut env_vars, - &crate::managed_acp_tools::managed_npm_env(), - ); - env_vars -} - -fn run_checks_options( - check_freshness: bool, - env_vars: Vec<(String, String)>, - bundled_dir: Option, -) -> RunChecksOptions { - RunChecksOptions { - check_freshness, - offline: false, - // npm-backed checks and fixes pass a `--registry` only when the user - // set `STAGED_NPM_REGISTRY`; otherwise npm resolves its own config - // (see the registry policy note in `managed_acp_tools`). - npm_registry: crate::managed_acp_tools::npm_registry_override(), - env: None, - // Doctor labels binaries resolved from this dir as bundled (install - // source + readout flag) and suppresses registry update fixes for - // 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) -} - -fn execute_fix_options( - command_override: Option, - env_vars: Vec<(String, String)>, -) -> ExecuteFixOptions { - execute_fix_options_through( - command_override, - crate::managed_acp_tools::npm_registry_override(), - env_vars, - ) -} - -/// [`execute_fix_options`] with the registry chosen by the caller — the -/// gateway-blocked retry passes the compiled-in fallback here. -fn execute_fix_options_through( - command_override: Option, - npm_registry: Option, - env_vars: Vec<(String, String)>, -) -> ExecuteFixOptions { - // Everything else stays at doctor's defaults: the fixes that reach this - // builder are the non-interactive ones (installs and updates), so nothing - // here feeds a prompt and the child keeps inheriting stdin rather than - // getting a piped one; the standard fix timeout is far above any install - // this runs. Interactive logins do *not* come through here — every - // `FixType::Auth` run goes through [`run_login_fix`], which pipes stdin so - // the code the CLI asks for can actually be delivered. Spelled with - // `..Default::default()` so a new doctor option doesn't break this - // workspace-excluded crate, which `cargo check` under `crates/` never - // compiles but `staged-ci.yml` does. - ExecuteFixOptions { - command_override, - npm_registry, - ..Default::default() - } - .with_env_snapshot(env_vars) -} - -/// Run a doctor fix through the crate's executor with the registry precedence -/// the managed installer applies: npm's own config (or the explicit -/// `STAGED_NPM_REGISTRY`) first, and — only for an npm-backed command that -/// failed as a gateway block of `registry.npmjs.org`, with no override set -/// and a fallback compiled in — one retry with `--registry `. The -/// executor surfaces the failed command's stderr as its error string, which -/// is exactly what the classifier reads; when the retry fails too, the -/// surfaced error keeps the original diagnosis in front of the retry's. -async fn execute_fix_with_gateway_fallback( - check_id: String, - fix_type: FixType, - command_override: Option, - npm_backed: bool, - env_vars: Vec<(String, String)>, -) -> Result<(), String> { - let first = doctor::execute_fix_with_env_options( - check_id.clone(), - fix_type.clone(), - execute_fix_options(command_override.clone(), env_vars.clone()), - ) - .await; - let Err(error) = first else { - return Ok(()); - }; - if !npm_backed { - return Err(error); - } - let Some(fallback) = crate::managed_acp_tools::gateway_fallback_registry( - crate::managed_acp_tools::npm_registry_override().as_deref(), - crate::managed_acp_tools::fallback_npm_registry(), - &error, - ) else { - return Err(error); - }; - log::info!( - "[doctor fix {check_id}] npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" - ); - doctor::execute_fix_with_env_options( - check_id, - fix_type, - execute_fix_options_through(command_override, Some(fallback.to_string()), env_vars), - ) - .await - .map_err(|retry_error| { - crate::managed_acp_tools::gateway_retry_failed_message(fallback, &retry_error) - }) -} - -/// Run all health checks and return the report. -/// -/// This is the cheap, no-network path: it resolves binaries and reports -/// install/auth status but does not probe registries for version freshness. -/// The frontend calls this first for an instant paint, then follows up with -/// [`run_doctor_freshness`] to fill in version/update information. -/// -/// Takes the app handle rather than `State` borrows so the command keeps its -/// plain `DoctorReport` return: the session-queue check reads store and -/// registry state synchronously before the first await. -#[tauri::command] -pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { - let queue = session_queue_doctor_check(&app_handle); - run_doctor_report(false, queue).await -} - -/// Run all health checks with version freshness enabled. -/// -/// This is the slower second pass: it probes each readout's installed version -/// and looks up the latest version from the relevant registry (npm, brew, -/// crates.io, GitHub releases), populating `installedVersion`, `latestVersion`, -/// `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 { - let queue = session_queue_doctor_check(&app_handle); - run_doctor_report(true, queue).await -} - -/// Run the doctor crate's checks plus Staged-local ones over one shared env -/// snapshot: the managed Node.js runtime check, the session-queue check the -/// caller already took (`queue_check`; both passes carry it, since the -/// frontend merges the freshness report into the first by check id), and the -/// managed-bridge version readouts layered onto the Claude/Codex rows (see -/// [`apply_managed_bridge_readouts`]). Bundled readouts are labeled by the -/// doctor crate itself via `RunChecksOptions::bundled_tools_dir`. -/// -/// The managed versions are gathered concurrently with the crate's run: the -/// installed version is a local `package.json` read on both passes, and the -/// registry lookup for the latest version runs only when `check_freshness` -/// is set, so the first paint never waits on the network. The npm registry -/// probe for a missing-managed-copy row (see [`managed_install_diagnostics`]) -/// joins them too, decided from the shim dir up front, so it never adds to -/// the report's wall-clock after the crate's checks are in. -async fn run_doctor_report( - check_freshness: bool, - queue_check: Option, -) -> DoctorReport { - let env_vars = doctor_env_vars().await; - let shim_bin_dir = crate::managed_acp_tools::managed_shim_bin_dir(); - let probe_registry = shim_bin_dir - .as_deref() - .is_some_and(any_managed_shim_missing); - let (mut report, node_runtime, managed_versions, npm_registry) = tokio::join!( - doctor::run_checks_with_options(run_checks_options( - check_freshness, - env_vars.clone(), - crate::acp_tools::primary_tools_dir(), - )), - run_node_runtime_check(), - managed_bridge_versions(check_freshness, &env_vars), - detect_npm_registry_label_if(probe_registry, &env_vars), - ); - match shim_bin_dir { - Some(shim_bin_dir) => { - let diagnostics = managed_install_diagnostics(npm_registry); - apply_managed_bridge_readouts( - &mut report, - &managed_versions, - &shim_bin_dir, - check_freshness, - &diagnostics, - ); - } - None => { - if let Some(reason) = crate::managed_acp_tools::managed_tools_disabled_reason() { - note_managed_installs_disabled(&mut report, &reason); - } - } - } - if let Some(check) = node_runtime { - report.checks.push(check); - } - report.checks.extend(queue_check); - report -} - -/// Reserve the login slot for `check_id` under a fresh run id, returning what -/// the run needs — or the id of the login already running for the check. The -/// entry parked in [`ACTIVE_LOGINS`] is also the "a login is running" flag: -/// doctor's `FixStdin` is single-use, so a second concurrent login for one -/// check is refused here rather than spawning a CLI nothing can type into. -fn claim_login(check_id: &str) -> Result { - doctor::agents::lookup_fix_command(check_id, &FixType::Auth) - .ok_or_else(|| format!("No login fix available for {check_id}"))?; - let run_id = new_login_run_id(); - let (writer, stdin) = FixStdin::pipe(); - // A fresh token per run: a cancelled one stays cancelled and would refuse - // the retry before it spawned. - let (cancel, cancellation) = FixCancellation::token(); - let output = Arc::new(Mutex::new(LoginOutputTail::default())); - let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); - if let Some(running) = logins.get(check_id) { - return Ok(LoginClaim::AlreadyRunning { - run_id: running.run_id.clone(), - }); - } - logins.insert( - check_id.to_string(), - ActiveLogin { - run_id: run_id.clone(), - writer, - cancel: cancel.clone(), - output: output.clone(), - }, - ); - Ok(LoginClaim::Claimed(ClaimedLogin { - run_id: run_id.clone(), - stdin, - cancellation, - cancel, - output, - slot: LoginSlot { - check_id: check_id.to_string(), - run_id, - }, - })) -} - -/// The final `doctor-login-output` event for a run. -/// -/// A cancelled run comes back from doctor's runner as an `Err`; `cancelled` is -/// what lets a client tell it from a failure, and the runner's message is kept -/// out of `error` so no client renders it as one. A cancel that landed after -/// the fix had already finished changes nothing — the fix's own result stands, -/// as the runner documents — so `cancelled` is only reported on a run that -/// actually ended early. -fn login_done_event( - check_id: String, - run_id: String, - result: &Result<(), String>, - cancel_requested: bool, - lines_emitted: u64, -) -> DoctorLoginOutput { - let cancelled = result.is_err() && cancel_requested; - DoctorLoginOutput { - check_id, - run_id, - line: None, - seq: lines_emitted, - done: true, - error: if cancelled { - None - } else { - result.as_ref().err().cloned() - }, - cancelled, - } -} - -/// Run a claimed login fix to completion on a piped stdin, streaming every -/// output line to the frontend as a `doctor-login-output` event and releasing -/// the slot afterwards. -/// -/// The final `done` event carries the outcome *and* the outcome is returned, so -/// this serves both entry points: [`start_doctor_login`], which spawns it and -/// watches the stream, and [`run_doctor_fix`], which awaits it. -async fn run_login_fix( - app_handle: tauri::AppHandle, - check_id: String, - login: ClaimedLogin, -) -> Result<(), String> { - let ClaimedLogin { - run_id, - stdin, - cancellation, - cancel, - output, - slot, - } = login; - let env_vars = doctor_env_vars().await; - let event_check_id = check_id.clone(); - let event_run_id = run_id.clone(); - let event_app = app_handle.clone(); - let tail = output.clone(); - let result = doctor::execute_fix_streaming_with_env_options( - check_id.clone(), - FixType::Auth, - ExecuteFixOptions::default() - .with_env_snapshot(env_vars) - .with_stdin(stdin) - .with_cancellation(cancellation), - move |line| { - // Recorded before it is emitted, so a `doctor_login_status` - // snapshot taken between the two already covers the line whose - // event is about to follow it — the client's `seq` comparison then - // drops the event rather than showing the line twice. - let seq = tail.lock().unwrap_or_else(|e| e.into_inner()).push(line); - crate::web_server::emit_to_all( - &event_app, - "doctor-login-output", - DoctorLoginOutput { - check_id: event_check_id.clone(), - run_id: event_run_id.clone(), - line: Some(line.to_string()), - seq, - done: false, - error: None, - cancelled: false, - }, - ); - }, - ) - .await; - - let lines_emitted = output.lock().unwrap_or_else(|e| e.into_inner()).next_seq; - let done = login_done_event( - check_id.clone(), - run_id, - &result, - cancel.is_cancelled(), - lines_emitted, - ); - if done.cancelled { - if let Err(message) = &result { - log::info!("[doctor login {check_id}] cancelled: {message}"); - } - } - // Released *before* the `done` goes out. A client attaches by registering - // its listener and then asking `doctor_login_status`, so this order gives - // it a guarantee: a snapshot that says `running` was taken before the - // `done` was emitted, and the `done` is still ahead of the listener. The - // other order would let a snapshot report a run whose `done` had already - // passed, leaving that client waiting for an end it can never see. - // - // The price of this order is that a fresh start for the same check can be - // claimed between the release and the emit, and its client then sees this - // run's `done`. That is what `run_id` on the event is for: the client - // follows the run id its start returned and drops this one. - drop(slot); - crate::web_server::emit_to_all(&app_handle, "doctor-login-output", done); - result -} - -/// Start an interactive login fix and stream its output to the frontend. -/// -/// Returns as soon as the fix is claimed and spawned, with the run id its -/// events will carry; the caller learns the outcome from the `done` event, -/// which lets it feed a code through [`send_doctor_login_code`] while the fix -/// is still running. A login already running for the check is reported as -/// [`LoginStart::AlreadyRunning`] with *its* run id rather than as an error: -/// it is the same subprocess the caller wanted, and it can attach to it (see -/// [`doctor_login_status`]). -#[tauri::command] -pub async fn start_doctor_login( - app_handle: tauri::AppHandle, - check_id: String, -) -> Result { - let login = match claim_login(&check_id)? { - LoginClaim::Claimed(login) => login, - LoginClaim::AlreadyRunning { run_id } => return Ok(LoginStart::AlreadyRunning { run_id }), - }; - let run_id = login.run_id.clone(); - tokio::spawn(async move { - // A failure is reported to the frontend by the final `done` event; this - // handle has no caller to return it to. - let _ = run_login_fix(app_handle, check_id, login).await; - }); - Ok(LoginStart::Started { run_id }) -} - -/// Ask run `run_id` of `check_id`'s login to stop, reporting whether it was -/// found. Idempotent, and harmless when nothing is running — or when the run -/// has ended and a newer one holds the check's slot, which is left alone: it -/// is someone else's login, and `false` tells the caller its own is gone. -/// -/// The stop goes through doctor's cancellation token, which makes the runner — -/// the owner of the child — kill the fix's process tree, and the run then ends -/// with a `done` event carrying `cancelled: true`. It is deliberately *not* -/// done by dropping the stdin writer: the Claude CLI ignores EOF on stdin once -/// it has printed its URL and keeps waiting on its browser callback (the -/// stdin-vs-TTY experiments ran it with `< /dev/null` and killed it 25s later, -/// still waiting), so a closed pipe would leave the slot held until the fix -/// timeout with nothing to show for it. -#[tauri::command] -pub async fn cancel_doctor_login(check_id: String, run_id: String) -> bool { - let cancel = find_login_run( - &active_logins().lock().unwrap_or_else(|e| e.into_inner()), - &check_id, - &run_id, - ) - .map(|login| login.cancel.clone()); - match cancel { - Ok(cancel) => { - cancel.cancel(); - true - } - Err(_) => false, - } -} - -/// Whether a login is running for `check_id`, under which run id, and what it -/// has printed so far — for a client that lost its own record of the login (a -/// web refresh, a second client, a reloaded webview) and needs the sign-in URL -/// and code entry back. -/// -/// Register the `doctor-login-output` listener *before* calling this, then -/// follow the run id it names and merge that run's lines by `seq`: lines below -/// [`DoctorLoginStatus::next_seq`] are in the snapshot, lines at or above it -/// arrived after it. -#[tauri::command] -pub async fn doctor_login_status(check_id: String) -> DoctorLoginStatus { - // The map lock and the tail lock are never held together: the `on_line` - // callback takes only the tail's, the slot release only the map's. - let running = active_logins() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get(&check_id) - .map(|login| (login.run_id.clone(), login.output.clone())); - match running { - Some((run_id, output)) => output - .lock() - .unwrap_or_else(|e| e.into_inner()) - .status(&run_id), - None => DoctorLoginStatus::idle(), - } -} - -/// Deliver a line — in practice the authentication code the agent CLI asked -/// for — to run `run_id` of a login started by [`start_doctor_login`] or -/// [`run_doctor_fix`]. A code typed for a run that has since ended is refused, -/// with the refusal saying so, rather than delivered to whatever login now -/// holds the check's slot: that one printed a different URL, and a code for -/// the old one would only be rejected by it — or, worse, accepted. -/// -/// `async` deliberately: `send_line` writes into the fix's stdin pipe inline -/// and can block if the fix isn't reading, and under Tauri 2 a non-`async` -/// command body runs on the main thread — the worst possible place to discover -/// a full pipe. The write itself goes to a blocking thread for the same reason. -#[tauri::command] -pub async fn send_doctor_login_code( - check_id: String, - run_id: String, - code: String, -) -> Result<(), String> { - let writer = find_login_run( - &active_logins().lock().unwrap_or_else(|e| e.into_inner()), - &check_id, - &run_id, - ) - .map(|login| login.writer.clone()) - .map_err(|stale| stale.code_refusal(&check_id))?; - tokio::task::spawn_blocking(move || writer.send_line(code)) - .await - .map_err(|e| format!("Failed to deliver the login code for {check_id}: {e}"))? -} - -#[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; - } - // An auth fix is the one interactive fix: it prints a verification URL and - // then blocks reading a code from stdin. Route it through the same piped, - // streamed path `start_doctor_login` uses so both entry points behave the - // same. On inherited stdin — `/dev/null` in the GUI — this printed its URL - // to a log nobody reads and could only ever finish through the CLI's own - // browser callback, otherwise dying at the fix timeout with no way to enter - // the code (block/berd#99). - if matches!(fix_type, FixType::Auth) { - // This entry point awaits the fix to its end, so there is no run to - // hand an "already running" answer to — the caller asked for a fix and - // there is one it can't have. - let login = match claim_login(&check_id)? { - LoginClaim::Claimed(login) => login, - LoginClaim::AlreadyRunning { .. } => { - return Err(format!("A login is already running for {check_id}")); - } - }; - return run_login_fix(app_handle, check_id, login).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 env_vars = doctor_env_vars().await; - let npm_backed = doctor::agents::lookup_fix_command(&check_id, &fix_type) - .as_deref() - .is_some_and(crate::managed_acp_tools::is_npm_backed_command); - if npm_backed { - ensure_managed_node_runtime_for_fix().await?; - } - execute_fix_with_gateway_fallback(check_id, fix_type, None, npm_backed, env_vars).await -} - -/// The install id of the ACP bridge a doctor check fronts, for the two checks -/// whose bridge Staged can manage — whether or not this build does. -fn managed_tool_id_for_check(check_id: &str) -> Option<&'static str> { - match check_id { - "ai-agent-claude" => Some("claude-acp"), - "ai-agent-codex" => Some("codex-acp"), - _ => None, - } -} - -/// 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> { - crate::managed_acp_tools::managed_tool(managed_tool_id_for_check(check_id)?).map(|tool| tool.id) -} - -/// The managed bridge a doctor *update* targets: on a build that manages these -/// two packages, the managed installer is the only writer for them, so *every* -/// update of the Claude and Codex checks goes to it — `UpdateBridge` from a -/// managed row's ACP readout, and `UpdateMain` from a copy that resolved -/// elsewhere (a user install found on PATH before the first reconcile lands, -/// or while it keeps failing). Letting that `UpdateMain` take the -/// command-validated path instead would run `npm install -g @latest` -/// under the fix env's `NPM_CONFIG_PREFIX`, landing a third, unowned copy in -/// `~/.staged/packages/npm-prefix` that nothing prunes, updates or runs — and -/// leaving the user's copy stale anyway. -/// -/// There is still exactly one install per row, because the two update fix -/// types never both carry an action on the same check: -/// [`apply_managed_bridge_readout`] gives the managed row its `bridge` action -/// while `main` stays bundled and update-free, and an unmanaged row has only -/// the `main` action that -/// [`redirect_unmanaged_update_to_managed_install`] rewrites — the crate emits -/// no `bridge` readout for the single-binary Claude and Codex checks. -/// -/// Non-update fix types, and every check when management is off (dev override, -/// `no-managed-acp-tools`, unsupported target), fall through to the -/// command-validated path. -fn managed_update_target(check_id: &str, fix_type: &FixType) -> Option<&'static str> { - if !matches!(fix_type, FixType::UpdateMain | FixType::UpdateBridge) { - return None; - } - managed_tool_for_check(check_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}]"); - let env_vars = doctor_env_vars().await; - crate::managed_acp_tools::install_managed_tool(tool_id, &env_vars, &|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. -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 -/// derived per-readout at freshness time rather than living in the static check -/// table, so the executor needs the command passed in as an override. -/// -/// **Trust boundary:** we do not execute the frontend-supplied `command` -/// blindly. We re-run freshness, re-derive the expected `updateCommand` for -/// `(check_id, fix_type)` backend-side, and only proceed if the two match. This -/// keeps `run_doctor_update` from becoming an arbitrary-shell-exec hole — the -/// `command` argument is effectively a confirmation of what the UI displayed, -/// validated against the authoritative backend derivation. -#[tauri::command] -pub async fn run_doctor_update( - check_id: String, - fix_type: FixType, - command: String, -) -> Result<(), String> { - // On a build that manages these two bridges, every update of their checks - // is the floating installer itself: `@latest` staged, verified and - // swapped into `~/.staged/packages` on the managed runtime. That covers - // both the managed row's ACP readout (see `apply_managed_bridge_readout`) - // and a copy that resolved elsewhere, whose source-aware `UpdateMain` is - // rewritten into a managed-install description by - // `redirect_unmanaged_update_to_managed_install` — its own `npm install -g` - // would write into the private prefix and orphan a copy there. No shell - // command runs, so the frontend-supplied `command` — the description the - // confirmation showed — needs no validation; what is validated is the - // target: a check whose bridge this build manages, and an update fix type. - if let Some(tool_id) = managed_update_target(&check_id, &fix_type) { - return install_managed_tool_logged(tool_id, &check_id).await; - } - let env_vars = doctor_env_vars().await; - let expected = expected_update_command( - &check_id, - &fix_type, - env_vars.clone(), - crate::acp_tools::primary_tools_dir(), - ) - .await?; - if expected != command { - return Err(format!( - "Update command mismatch for {check_id}: refusing to run a command \ - 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. - let npm_backed = crate::managed_acp_tools::is_npm_backed_command(&expected); - if npm_backed { - 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 - // the equality check surviving future edits. - execute_fix_with_gateway_fallback(check_id, fix_type, Some(expected), npm_backed, env_vars) - .await -} - -/// Re-run freshness and return the authoritative update command for the given -/// check + slot, or an error if no actionable update is derivable. Passes the -/// bundled dir through so bundled readouts derive no update command here, -/// exactly as in the report the UI rendered. -async fn expected_update_command( - check_id: &str, - fix_type: &FixType, - env_vars: Vec<(String, String)>, - bundled_dir: Option, -) -> Result { - let report = - doctor::run_checks_with_options(run_checks_options(true, env_vars, bundled_dir)).await; - - let check = report - .checks - .iter() - .find(|c| c.id == check_id) - .ok_or_else(|| format!("No such check: {check_id}"))?; - - // The fix type selects which readout's update applies. - let readout = match fix_type { - FixType::UpdateMain => check.main.as_ref(), - FixType::UpdateBridge => check.bridge.as_ref(), - _ => return Err(format!("{fix_type:?} is not an update fix type")), - }; - - readout - .and_then(|r| r.update_command.clone()) - .ok_or_else(|| format!("No actionable update available for {check_id}")) -} - -// ============================================================================= -// Managed bridge readouts — the versions of the ACP bridges Staged installs -// ============================================================================= - -/// What Staged knows about one managed bridge's versions at report time. -/// -/// Gathered outside the doctor crate: the crate labels the managed shims -/// bundled and, on that policy, probes only the vendored agent CLI's version -/// (`main`) and offers no update. Staged updates these packages itself at -/// launch and daily, so its rows additionally show — and can update — the ACP -/// package, as a second readout on the same executable. -#[derive(Debug, Clone, PartialEq, Eq)] -struct ManagedBridgeVersions { - tool: ManagedTool, - /// The ACP package version installed in `~/.staged/packages`, from the - /// live package's `package.json` (`state.json` as fallback). - installed: Option, - /// The newest published version of the *same* package. `None` outside the - /// freshness pass and whenever the lookup failed — unknown, not current. - latest: Option, -} - -/// The versions of every bridge this build manages, gathered concurrently. -/// Empty when nothing is managed (dev override, `no-managed-acp-tools`, -/// unsupported target), which is also what keeps those builds from ever -/// growing a managed update action. The registry is asked only when -/// `check_freshness` is set. -async fn managed_bridge_versions( - check_freshness: bool, - env_vars: &[(String, String)], -) -> Vec { - let tools = crate::managed_acp_tools::managed_tools(); - if tools.is_empty() { - return Vec::new(); - } - let Some(packages_root) = crate::paths::packages_dir() else { - return Vec::new(); - }; - let handles: Vec<_> = tools - .into_iter() - .map(|tool| { - let env_vars = env_vars.to_vec(); - let packages_root = packages_root.clone(); - tokio::spawn(async move { - let installed = - crate::managed_acp_tools::installed_tool_version(&packages_root, &tool); - let latest = if check_freshness { - crate::managed_acp_tools::latest_published_version(&tool, &env_vars).await - } else { - None - }; - ManagedBridgeVersions { - tool, - installed, - latest, - } - }) - }) - .collect(); - let mut versions = Vec::with_capacity(handles.len()); - for handle in handles { - if let Ok(version) = handle.await { - versions.push(version); - } - } - versions -} - -/// What Doctor says about the managed installer on a Claude/Codex row whose -/// executable is *not* the managed shim: the last reconcile's outcome from -/// `state.json` — the only record a failed launch install leaves behind — and -/// the npm registry a managed install resolves on this machine. Gathered once -/// per report; nothing here is persisted. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct ManagedInstallDiagnostics { - /// `state.json`'s `lastReconcile`, `None` before the first reconcile has - /// finished (or with no `state.json` at all). - last_reconcile: Option, - /// The registry label for the row's raw output: the `STAGED_NPM_REGISTRY` - /// override, what `npm config get registry` answered under the doctor - /// env snapshot, or "npm default" when neither could be asked. `None` - /// when the probe was skipped because every managed shim was present - /// when the report started (see [`any_managed_shim_missing`]). - npm_registry: Option, - /// Wall-clock milliseconds the reconcile age is rendered against. - now_ms: u64, -} - -impl ManagedInstallDiagnostics { - /// The last reconcile's error for `tool_id`, without the `: ` prefix - /// the reconciler records it under. - fn reconcile_error_for(&self, tool_id: &str) -> Option<&str> { - reconcile_error_for(self.last_reconcile.as_ref()?, tool_id) - } - - /// One raw-output line on the last managed install run. - fn last_reconcile_line(&self) -> String { - match &self.last_reconcile { - None => "last managed install: not run yet (runs at launch and daily)".to_string(), - Some(record) => format!( - "last managed install: {} {}", - if record.ok { "succeeded" } else { "failed" }, - format_age(record.at_ms, self.now_ms) - ), - } - } - - /// One raw-output line naming the npm registry Doctor detected. - fn npm_registry_line(&self) -> String { - format!( - "npm registry: {}", - self.npm_registry.as_deref().unwrap_or("not probed") - ) - } -} - -/// The reconciler records each failure as `: `; the error on -/// its own, for the tool asked about. -fn reconcile_error_for<'a>(record: &'a ReconcileRecord, tool_id: &str) -> Option<&'a str> { - record - .errors - .iter() - .find_map(|error| error.strip_prefix(tool_id)?.strip_prefix(": ")) -} - -/// `just now`, `N minutes ago`, `N hours ago`, `N days ago` — enough to tell a -/// stale record from the launch reconcile that just ran. -fn format_age(at_ms: u64, now_ms: u64) -> String { - let seconds = now_ms.saturating_sub(at_ms) / 1000; - let ago = |count: u64, unit: &str| { - let plural = if count == 1 { "" } else { "s" }; - format!("{count} {unit}{plural} ago") - }; - match seconds { - 0..=59 => "just now".to_string(), - 60..=3599 => ago(seconds / 60, "minute"), - 3600..=86_399 => ago(seconds / 3600, "hour"), - _ => ago(seconds / 86_400, "day"), - } -} - -/// Gather [`ManagedInstallDiagnostics`] for a report from `state.json` and -/// the registry label the report's probes produced (see -/// [`detect_npm_registry_label_if`]). -fn managed_install_diagnostics(npm_registry: Option) -> ManagedInstallDiagnostics { - ManagedInstallDiagnostics { - last_reconcile: last_reconcile_record(), - npm_registry, - now_ms: crate::managed_acp_tools::now_ms(), - } -} - -/// Whether any managed bridge's shim is absent from `shim_bin_dir`. The shim -/// dir is first on the probe PATH, so this is the only way a Claude/Codex row -/// can resolve to a copy Staged does not manage — and so the only time the -/// npm registry probe, which spawns `npm`, has a row to show its answer on. -/// Decided before the probes run so that probe can join them rather than -/// wait on the report. -fn any_managed_shim_missing(shim_bin_dir: &Path) -> bool { - crate::managed_acp_tools::managed_tools() - .iter() - .any(|tool| !shim_bin_dir.join(tool.binary).is_file()) -} - -/// [`detect_npm_registry_label`] when `probe` is set; `None` otherwise. -async fn detect_npm_registry_label_if( - probe: bool, - env_vars: &[(String, String)], -) -> Option { - if probe { - Some(detect_npm_registry_label(env_vars).await) - } else { - None - } -} - -/// `state.json`'s last reconcile record, if any. -fn last_reconcile_record() -> Option { - let packages_root = crate::paths::packages_dir()?; - crate::managed_acp_tools::read_state(&packages_root).last_reconcile -} - -/// The npm registry a managed install resolves, labeled with where that -/// answer came from — so a row can say which mirror (or the public registry) -/// the failed install actually asked. The underlying `npm config get -/// registry` is bounded to 5 s and cached per env snapshot for a minute, so -/// a Doctor refresh does not spawn npm again. -async fn detect_npm_registry_label(env_vars: &[(String, String)]) -> String { - if let Some(registry) = crate::managed_acp_tools::npm_registry_override() { - return format!("{registry} (STAGED_NPM_REGISTRY)"); - } - match crate::managed_acp_tools::configured_npm_registry(env_vars).await { - Some(registry) => format!("{registry} (per npm config)"), - None => "npm default (per npm config; no npm on PATH to ask)".to_string(), - } -} - -/// Layer the managed ACP readout onto every check whose executable is one of -/// the managed shims in `shim_bin_dir` (see [`apply_managed_bridge_readout`]). -/// Every other Claude/Codex row is a copy Staged does not manage: it is -/// flagged as such, with why the managed copy is missing and the managed -/// install as its fix (see [`flag_missing_managed_copy`]), and its update is -/// pointed at the managed installer too (see -/// [`redirect_unmanaged_update_to_managed_install`]) — on a managing build it -/// is the only writer for these two packages. -fn apply_managed_bridge_readouts( - report: &mut DoctorReport, - versions: &[ManagedBridgeVersions], - shim_bin_dir: &Path, - check_freshness: bool, - diagnostics: &ManagedInstallDiagnostics, -) { - for check in &mut report.checks { - let Some(tool_id) = managed_tool_id_for_check(&check.id) else { - continue; - }; - if let Some(versions) = versions.iter().find(|v| v.tool.id == tool_id) { - if !apply_managed_bridge_readout(check, versions, shim_bin_dir, check_freshness) { - flag_missing_managed_copy(check, versions, shim_bin_dir, diagnostics); - redirect_unmanaged_update_to_managed_install(check, versions, shim_bin_dir); - } - } - } -} - -/// Note on the Claude/Codex rows that this build does not manage bridge -/// installs, and why — the `STAGED_ACP_TOOLS_DIR` dev override, the -/// `no-managed-acp-tools` feature, an unsupported target. A copy resolved from -/// Homebrew (or the override dir) is then by design, and the row must not read -/// like a failed install. Status is kept; only the message and raw output -/// gain the note. -fn note_managed_installs_disabled(report: &mut DoctorReport, reason: &str) { - for check in &mut report.checks { - if managed_tool_id_for_check(&check.id).is_none() { - continue; - } - let note = format!("managed installs disabled: {reason}"); - check.message = format!("{} ({note})", check.message); - append_raw_output(check, ¬e); - } -} - -/// Add a block to a check's raw output, below whatever the crate recorded. -fn append_raw_output(check: &mut DoctorCheck, block: &str) { - check.raw_output = Some(match check.raw_output.take() { - Some(existing) if !existing.trim().is_empty() => { - format!("{}\n\n{block}", existing.trim_end()) - } - _ => block.to_string(), - }); -} - -/// Flag a Claude/Codex row whose executable is a copy Staged does not manage -/// — Homebrew, the user's npm prefix, nvm — and report whether it did. -/// -/// On a managing build the managed shim dir is first on the probe PATH, so a -/// copy from anywhere else can only win while the managed copy is *absent*: -/// the launch reconcile has not finished yet, or it failed (no network to the -/// registry, a gateway block page, a 404 from a mirror, a validation failure). -/// Left as the crate built it, the row is a green "Installed" pointing at -/// `/opt/homebrew/bin`, indistinguishable from a healthy setup and with no -/// way to get the managed copy. So: -/// -/// - `status` drops to `Warn` (a row already warning or failing keeps its -/// status), and `message` says which copy is in use and, before any -/// reconcile has finished, that the managed install "hasn't completed yet" -/// — a healthy first launch, not a failure; after one, that the managed -/// copy is missing, with — when the last reconcile recorded an error for -/// this tool — the short of that error (see [`short_error_fragment`]). -/// - `raw_output` gains the expected shim path, the last reconcile's outcome -/// and age, the full recorded error with a classified hint on what to -/// change (`install_failure_hint`), and the npm registry Doctor detected. -/// - The row's fix becomes the managed install (`Bridge`, which -/// `run_doctor_fix` routes to `install_managed_tool_logged` for these two -/// checks), described by [`managed_install_description`] — the same text -/// [`redirect_unmanaged_update_to_managed_install`] gives the row's update -/// when a newer release is known, so the two buttons never disagree. The -/// fix is offered whether or not an update is available: the point is to -/// get the managed copy, not to chase a version. -/// -/// `path`, `main` and the version fields stay the crate's: the row still shows -/// the copy that is actually on PATH and how old it is. -fn flag_missing_managed_copy( - check: &mut DoctorCheck, - versions: &ManagedBridgeVersions, - shim_bin_dir: &Path, - diagnostics: &ManagedInstallDiagnostics, -) -> bool { - let Some(path) = check.path.clone() else { - return false; - }; - let tool = &versions.tool; - if is_managed_shim_path(&path, shim_bin_dir, tool.binary) { - return false; - } - let reconcile_error = diagnostics.reconcile_error_for(tool.id); - let situation = match (&diagnostics.last_reconcile, reconcile_error) { - (None, _) => "Staged's managed install hasn't completed yet".to_string(), - (Some(_), None) => "Staged's managed copy is missing".to_string(), - (Some(_), Some(error)) => format!( - "Staged's managed copy is missing: {}", - short_error_fragment(error) - ), - }; - if check.status == CheckStatus::Pass { - check.status = CheckStatus::Warn; - check.message = format!("Using {path} — {situation}"); - } else { - check.message = format!("{}; using {path} — {situation}", check.message); - } - - let mut detail = vec![ - format!( - "Staged-managed copy: missing (expected {})", - shim_bin_dir.join(tool.binary).display() - ), - diagnostics.last_reconcile_line(), - ]; - if let Some(error) = reconcile_error { - detail.push(format!("{}: {error}", tool.id)); - if let Some(hint) = crate::managed_acp_tools::install_failure_hint(error, tool.package) { - detail.push(format!("hint: {hint}")); - } - } - detail.push(diagnostics.npm_registry_line()); - append_raw_output(check, &detail.join("\n")); - - let latest = versions.latest.as_deref().or_else(|| { - check - .main - .as_ref() - .and_then(|main| main.latest_version.as_deref()) - }); - check.fix_type = Some(FixType::Bridge); - check.fix_command = Some(managed_install_description(tool, latest, &path)); - true -} - -/// Most a recorded install error contributes to a row's one-line message: -/// npm's own `npm error code ` when the error carries one (what -/// `npm_error_summary` leads with), else the error's first -/// [`SHORT_ERROR_CHARS`] characters, ellipsized. The full text, its hint and -/// the registry stay in the raw output for **Copy details**. -fn short_error_fragment(error: &str) -> String { - const NPM_CODE: &str = "npm error code "; - if let Some((_, rest)) = error.split_once(NPM_CODE) { - let code = rest - .split(|c: char| c == ';' || c.is_whitespace()) - .next() - .unwrap_or_default(); - if !code.is_empty() { - return format!("{NPM_CODE}{code}"); - } - } - match error.char_indices().nth(SHORT_ERROR_CHARS) { - Some((cut, _)) => format!("{}…", error[..cut].trim_end()), - None => error.to_string(), - } -} - -/// How much of a recorded error without an npm code fits on the row. -const SHORT_ERROR_CHARS: usize = 120; - -/// Give a managed Claude/Codex row the two readouts its one executable -/// stands for, and report whether it did. -/// -/// - `main` is left as the doctor crate built it: the vendored agent CLI's -/// version, probed through the bridge passthrough, with the crate's bundled -/// suppression (no update) intact — the agent is updated as part of the -/// package, never on its own. -/// - `bridge` is added: the ACP package's installed version, and on the -/// freshness pass the latest published version of that same package, -/// `update_available` from comparing the two, and — only when newer — the -/// one actionable update, typed `UpdateBridge`, which `run_doctor_update` -/// routes to the managed installer. The command text describes updating -/// Staged's managed copy; nothing runs it as a shell command. -/// - `bridge_path` is set to the same executable so the row's path gate -/// renders the readout, on both the initial and the freshness report — the -/// frontend's freshness merge copies `bridge` but not `bridge_path`, so a -/// readout that appeared only on the second pass would stay hidden. -/// - The flat version fields mirror the ACP readout, as the crate does for -/// any check with a bridge readout. -/// -/// Nothing is touched unless the check's executable is the managed shim for -/// this tool and the crate labeled it bundled: a copy that resolved elsewhere -/// keeps its source-aware readout, is flagged as missing the managed copy -/// (see [`flag_missing_managed_copy`]), and has its update redirected to the -/// managed install (see [`redirect_unmanaged_update_to_managed_install`]). An -/// unknown installed or latest version leaves `update_available` unknown -/// rather than false. -fn apply_managed_bridge_readout( - check: &mut DoctorCheck, - versions: &ManagedBridgeVersions, - shim_bin_dir: &Path, - check_freshness: bool, -) -> bool { - let Some(path) = check.path.clone() else { - return false; - }; - if !is_managed_shim_path(&path, shim_bin_dir, versions.tool.binary) { - return false; - } - if check.main.as_ref().and_then(|main| main.bundled) != Some(true) { - return false; - } - - let update_available = if check_freshness { - newer_version_available(versions.installed.as_deref(), versions.latest.as_deref()) - } else { - None - }; - let update_command = match (&update_available, &versions.latest) { - (Some(true), Some(latest)) => Some(managed_update_description(&versions.tool, latest)), - _ => None, - }; - let bridge = AgentVersionInfo { - install_source: Some(InstallSource::Bundled), - bundled: Some(true), - installed_version: versions.installed.clone(), - latest_version: versions.latest.clone(), - update_available, - // Only the freshness pass says anything about updates, matching the - // crate; and what it says is "not self-updating": the update is the - // user's to action here, unlike the crate's bundled default. - self_updating: check_freshness.then_some(false), - update_fix_type: update_command.as_ref().map(|_| FixType::UpdateBridge), - update_command, - }; - check.installed_version = bridge.installed_version.clone(); - check.latest_version = bridge.latest_version.clone(); - check.update_available = bridge.update_available; - check.self_updating = bridge.self_updating; - check.bridge_path = Some(path); - check.bridge = Some(bridge); - true -} - -/// Point a non-managed copy's update at the managed installer, and report -/// whether it did. -/// -/// Doctor can resolve one of these two checks to a copy of the bridge the user -/// installed themselves (Homebrew, their own npm prefix, nvm) — before the -/// launch reconcile has written the managed shim, or for as long as it keeps -/// failing. On a managing build the crate's source-aware `UpdateMain` must not -/// run: `npm install -g @latest` under the fix env's `NPM_CONFIG_PREFIX` -/// would install a third copy into `~/.staged/packages/npm-prefix` that -/// nothing owns, and would not upgrade the user's copy the confirmation named. -/// -/// So the action becomes the managed install, described as such, and only the -/// action: `path`, `install_source`, both versions and `update_available` stay -/// as the crate reported them, so the row keeps showing the copy that is -/// actually on PATH, where it is, and how old it is. The fix type is set even -/// when the crate derived no command (an `Unknown` source, say) — Staged can -/// install its own copy where the crate had nothing to offer. -/// -/// The version the description names is the one the installer will fetch: -/// `versions.latest`, Staged's own `npm view version` against the -/// configured registry, gathered alongside the managed readout. The crate's -/// `latest_version` is derived per install source — `brew info` for a Homebrew -/// copy — and can name a release the configured registry does not (yet) carry, -/// or lag one it does; it is only the fallback for when the managed probe -/// failed, so the text at least names the release the row is flagged stale -/// against. -/// -/// Only ever fires on the freshness pass, since `update_available` is unset -/// until then, and never on a management-off build, where -/// [`managed_bridge_versions`] returns nothing. -fn redirect_unmanaged_update_to_managed_install( - check: &mut DoctorCheck, - versions: &ManagedBridgeVersions, - shim_bin_dir: &Path, -) -> bool { - let Some(path) = check.path.clone() else { - return false; - }; - if is_managed_shim_path(&path, shim_bin_dir, versions.tool.binary) { - return false; - } - let Some(main) = check.main.as_mut() else { - return false; - }; - if main.update_available != Some(true) { - return false; - } - let latest = versions - .latest - .as_deref() - .or(main.latest_version.as_deref()); - main.update_command = Some(managed_install_description(&versions.tool, latest, &path)); - main.update_fix_type = Some(FixType::UpdateMain); - true -} - -/// Whether `path` is the shim Staged writes for `binary` in `shim_bin_dir` — -/// the executable of a managed install, as opposed to a same-named copy that -/// resolved from anywhere else on PATH. -fn is_managed_shim_path(path: &str, shim_bin_dir: &Path, binary: &str) -> bool { - let path = Path::new(path); - path.starts_with(shim_bin_dir) && path.file_name().is_some_and(|name| name == binary) -} - -/// What the update confirmation shows for a managed bridge. Not a shell -/// command: `run_doctor_update` dispatches this readout to the managed -/// installer, which installs `@latest` into `~/.staged/packages` — so -/// the text says that, rather than offering a global `npm install -g` the -/// user might run by hand and end up with a second copy. -fn managed_update_description(tool: &ManagedTool, latest: &str) -> String { - format!( - "update Staged's managed {} to {latest} in ~/.staged/packages", - tool.package - ) -} - -/// What the update confirmation shows when the row is a copy Staged does not -/// manage: the managed copy is absent or stale, so the installer *installs* -/// rather than updates, and the text is explicit that `replaced` — the copy on -/// PATH, the one the version beside it was read from — is not touched. -fn managed_install_description(tool: &ManagedTool, latest: Option<&str>, replaced: &str) -> String { - let version = latest - .map(|latest| format!(" {latest}")) - .unwrap_or_default(); - format!( - "install Staged's managed {}{version} in ~/.staged/packages \ - (the copy at {replaced} is left as is)", - tool.package - ) -} - -/// `Some(true)` when `latest` is a newer release than `installed`, comparing -/// the two versions of one package as semver triples. `None` — unknown, not -/// "current" — when either side is missing or does not parse. -fn newer_version_available(installed: Option<&str>, latest: Option<&str>) -> Option { - let installed = parse_version_triple(installed?)?; - let latest = parse_version_triple(latest?)?; - Some(latest > installed) -} - -/// `X.Y.Z[-pre][+build]`, with an optional leading `v`, as `(X, Y, Z)`. -fn parse_version_triple(version: &str) -> Option<(u64, u64, u64)> { - let core = version - .trim() - .trim_start_matches('v') - .split(['-', '+', ' ']) - .next()?; - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - Some((major, minor, patch)) -} - -// ============================================================================= -// Managed Node.js runtime check -// ============================================================================= - -const NODE_RUNTIME_CHECK_ID: &str = "node-runtime"; -const NODE_RUNTIME_CHECK_LABEL: &str = "Node.js Runtime"; -const SESSION_QUEUE_CHECK_ID: &str = "session-queue"; -const SESSION_QUEUE_CHECK_LABEL: &str = "Session Queue"; - -/// The session-queue readout: what the stranded-session sweep sees right now -/// and how often it has had to act. `None` until the store is open — the -/// needs-reset flow runs the Doctor with no store managed. -fn session_queue_doctor_check( - app_handle: &tauri::AppHandle, -) -> Option { - let store = crate::get_store(&app_handle.try_state::>>>()?).ok()?; - let registry = app_handle.try_state::>()?; - match sweep::queue_health(&store, ®istry) { - Ok(health) => Some(build_session_queue_check(&health)), - Err(e) => { - log::warn!("[doctor] session-queue check failed to read the store: {e}"); - None - } - } -} - -/// `Warn` while any row would be swept right now; `Pass` otherwise, naming -/// the lifetime sweep count when it is non-zero so a recurrence stays -/// visible after the sweep has already cleaned up. -fn build_session_queue_check(health: &QueueHealth) -> DoctorCheck { - let stranded = health.stranded.len(); - let (status, message) = if stranded > 0 { - ( - CheckStatus::Warn, - format!( - "{} {} running with nothing in this process owning {}; the stranded-session sweep ends {} within a minute", - stranded, - if stranded == 1 { "session is" } else { "sessions are" }, - if stranded == 1 { "it" } else { "them" }, - if stranded == 1 { "it" } else { "them" }, - ), - ) - } else if health.swept_total > 0 { - ( - CheckStatus::Pass, - format!( - "No stranded sessions; the sweep has ended {} so far", - if health.swept_total == 1 { - "1 session".to_string() - } else { - format!("{} sessions", health.swept_total) - } - ), - ) - } else { - (CheckStatus::Pass, "No stranded sessions".to_string()) - }; - - let mut detail = vec![ - "checked: running session rows owned by this process".to_string(), - format!("owned running rows: {}", health.owned_running), - format!( - "of which owned by a registered runner or a liveness hold: {}", - health.live - ), - format!("stranded now: {stranded}"), - ]; - detail.extend(health.stranded.iter().map(|row| { - format!( - "- session {} (branch {})", - row.session_id, - row.branch_id.as_deref().unwrap_or("none"), - ) - })); - detail.push(format!("ended by the sweep so far: {}", health.swept_total)); - - DoctorCheck { - id: SESSION_QUEUE_CHECK_ID.to_string(), - label: SESSION_QUEUE_CHECK_LABEL.to_string(), - status, - message, - fix_url: None, - fix_command: None, - fix_type: None, - path: None, - bridge_path: None, - raw_output: Some(detail.join("\n")), - auth_status: None, - login_command: None, - installed_version: None, - latest_version: None, - update_available: None, - install_source: None, - self_updating: None, - main: None, - bridge: None, - } -} - -/// 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, -} - -/// 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 - }; - // 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, - last_reconcile_record().as_ref(), - ) -} - -/// 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(); - }; - entries - .flatten() - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| !name.starts_with('.')) - .collect() -} - -/// Build the runtime row. `last_reconcile` is `state.json`'s record of the -/// last managed install run: a runtime that is `Missing` after a reconcile -/// that *failed* is not "never needed", it is the install that could not -/// happen — every managed bridge install starts by ensuring the runtime — so -/// that case warns with the recorded error even when nothing is installed -/// yet, instead of staying silent. With no record (first launch still -/// reconciling, or a profile that never ran one) a missing runtime is -/// reported only when installed tools need it, as before. -fn build_node_runtime_check( - state: ManagedNodeRuntimeState, - install_dir: &Path, - npm_tools: &[String], - last_reconcile: Option<&ReconcileRecord>, -) -> Option { - let version = &crate::managed_node::node_runtime_lock().version; - let failed_reconcile = last_reconcile.filter(|record| !record.ok); - let (status, message) = match (&state, failed_reconcile) { - (ManagedNodeRuntimeState::Ready, _) => ( - CheckStatus::Pass, - 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, Some(record)) => ( - CheckStatus::Warn, - format!( - "Staged-managed Node.js {version} could not be installed: {}", - reconcile_headline(record) - ), - ), - (ManagedNodeRuntimeState::Missing, None) if npm_tools.is_empty() => return None, - (ManagedNodeRuntimeState::Missing, None) => ( - CheckStatus::Warn, - format!( - "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: Staged-managed Node.js runtime".to_string(), - format!("pinned version: {version}"), - format!("install dir: {}", install_dir.display()), - format!("state: {state_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}"))); - } - if let Some(record) = failed_reconcile { - detail.push(format!( - "last managed install: failed {} with {} error(s):", - format_age(record.at_ms, crate::managed_acp_tools::now_ms()), - record.errors.len() - )); - detail.extend(record.errors.iter().map(|error| format!("- {error}"))); - // Only a runtime download error is this row's to advise on: a bridge's - // npm 404 as the headline must not read as "no registry carries - // Node.js". - let headline = reconcile_headline(record); - if let Some(hint) = headline - .starts_with(NODE_DOWNLOAD_ERROR_PREFIX) - .then(|| crate::managed_acp_tools::install_failure_hint(&headline, "Node.js")) - .flatten() - { - detail.push(format!("hint: {hint}")); - } - } - - 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, - )) -} - -/// How `ManagedNodeError`'s download failures (network, HTTP status) render, -/// so the runtime row can tell its own download failure from a bridge's. -const NODE_DOWNLOAD_ERROR_PREFIX: &str = "Node.js runtime download failed"; - -/// The one error a failed reconcile is summarised by on the runtime row: the -/// first that names the Node.js runtime download — the failure every bridge -/// shares when the runtime could not be fetched — else the first recorded, -/// in both cases without the reconciler's `: ` prefix. -fn reconcile_headline(record: &ReconcileRecord) -> String { - record - .errors - .iter() - .find(|error| error.contains("Node.js runtime")) - .or_else(|| record.errors.first()) - .map(|error| strip_tool_id_prefix(error).to_string()) - .unwrap_or_else(|| "no error recorded".to_string()) -} - -/// `: ` → ``, for the ids this build knows; anything -/// else is returned whole. -fn strip_tool_id_prefix(error: &str) -> &str { - error - .split_once(": ") - .filter(|(id, _)| { - crate::managed_acp_tools::MANAGED_TOOLS - .iter() - .any(|tool| tool.id == *id) - }) - .map(|(_, rest)| rest) - .unwrap_or(error) -} - -fn node_runtime_doctor_check( - status: CheckStatus, - 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, - fix_url: None, - fix_command, - fix_type, - path, - bridge_path: None, - raw_output, - auth_status: None, - login_command: None, - installed_version: None, - latest_version: None, - update_available: None, - install_source: None, - self_updating: None, - main: None, - bridge: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pinned_version() -> String { - crate::managed_node::node_runtime_lock().version.clone() - } - - fn queue_health(stranded: Vec, swept_total: usize) -> QueueHealth { - QueueHealth { - owned_running: stranded.len() + 1, - live: 1, - stranded, - swept_total, - } - } - - #[test] - fn session_queue_check_passes_when_nothing_is_stranded() { - let check = build_session_queue_check(&queue_health(Vec::new(), 0)); - - assert_eq!(check.id, SESSION_QUEUE_CHECK_ID); - assert_eq!(check.status, CheckStatus::Pass); - assert_eq!(check.message, "No stranded sessions"); - assert!(check.fix_type.is_none()); - let detail = check.raw_output.expect("detail is attached"); - assert!(detail.contains("owned running rows: 1"), "{detail}"); - assert!(detail.contains("stranded now: 0"), "{detail}"); - assert!(detail.contains("ended by the sweep so far: 0"), "{detail}"); - } - - /// A sweep that has already acted leaves nothing stranded, so the status - /// stays `Pass` — but the count is what makes a recurrence visible. - #[test] - fn session_queue_check_reports_past_sweeps_without_warning() { - let check = build_session_queue_check(&queue_health(Vec::new(), 2)); - - assert_eq!(check.status, CheckStatus::Pass); - assert_eq!( - check.message, - "No stranded sessions; the sweep has ended 2 sessions so far" - ); - } - - #[test] - fn session_queue_check_warns_while_a_row_is_stranded() { - let row = sweep::StrandedRow { - session_id: "s-1".to_string(), - branch_id: Some("b-1".to_string()), - }; - let check = build_session_queue_check(&queue_health(vec![row], 0)); - - assert_eq!(check.status, CheckStatus::Warn); - assert_eq!( - check.message, - "1 session is running with nothing in this process owning it; the stranded-session sweep ends it within a minute" - ); - let detail = check.raw_output.expect("detail is attached"); - assert!(detail.contains("- session s-1 (branch b-1)"), "{detail}"); - assert!( - detail.contains("of which owned by a registered runner or a liveness hold: 1"), - "{detail}" - ); - } - - #[test] - fn ready_runtime_passes_without_a_fix() { - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Ready, - Path::new("/data/packages/node/v9.9.9/plat"), - &["copilot".to_string()], - None, - ) - .expect("ready runtime is reported"); - - assert_eq!(check.status, CheckStatus::Pass); - assert_eq!( - check.message, - format!("Staged-managed Node.js {} is installed", pinned_version()) - ); - assert_eq!( - check.path.as_deref(), - Some("/data/packages/node/v9.9.9/plat/bin/node") - ); - 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("state: ready")); - assert!(output.contains("- copilot")); - } - - #[test] - fn damaged_runtime_warns_with_a_native_reinstall_fix() { - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Broken, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - None, - ) - .expect("damaged runtime is reported"); - - assert_eq!(check.status, CheckStatus::Warn); - assert!(check.message.contains("is damaged")); - assert!(check.path.is_none()); - 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 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( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &["amp-acp".to_string(), "copilot".to_string()], - None, - ) - .expect("needed-but-missing runtime is reported"); - assert_eq!(check.status, CheckStatus::Warn); - 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")); - assert!(!output.contains("last managed install"), "{output}"); - - // Nothing installed that needs it, and no reconcile on record (or a - // clean one): stay silent. - for record in [None, Some(reconcile_record(true, Vec::new()))] { - assert!(build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - record.as_ref(), - ) - .is_none()); - } - } - - fn reconcile_record(ok: bool, errors: Vec<&str>) -> ReconcileRecord { - ReconcileRecord { - at_ms: 1_000, - ok, - errors: errors.into_iter().map(str::to_string).collect(), - } - } - - /// A runtime that is missing *because* the last reconcile failed is the - /// install that could not happen, not one that was never needed: the row - /// warns with the recorded error even with nothing installed yet, lists - /// every error (and the URL they name) in its detail, and keeps the - /// reinstall fix. - #[test] - fn missing_runtime_after_a_failed_reconcile_warns_with_the_recorded_errors() { - let node_error = "claude-acp: Node.js runtime download failed: \ - https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ - error sending request for url"; - let record = reconcile_record( - false, - vec![ - "codex-acp: npm install failed: npm install exited with exit status: 1", - node_error, - ], - ); - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - Some(&record), - ) - .expect("a failed install is reported"); - - assert_eq!(check.status, CheckStatus::Warn); - assert_eq!( - check.message, - format!( - "Staged-managed Node.js {} could not be installed: Node.js runtime download \ - failed: https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ - error sending request for url", - pinned_version() - ), - "leads with the runtime download error, minus the tool id" - ); - assert_eq!(check.fix_type, Some(FixType::Command)); - assert!(check.fix_command.is_some()); - let output = check.raw_output.as_deref().expect("raw output"); - assert!(output.contains("state: missing"), "{output}"); - assert!(output.contains("last managed install: failed"), "{output}"); - assert!(output.contains("with 2 error(s):"), "{output}"); - assert!(output.contains(&format!("- {node_error}")), "{output}"); - assert!( - output.contains("- codex-acp: npm install failed"), - "{output}" - ); - assert!( - output.contains("hint: the configured registry/host did not respond"), - "{output}" - ); - - // With no runtime-specific error the first recorded one is the - // headline, still without its tool id. - let record = reconcile_record( - false, - vec!["claude-acp: installed ACP bridge is incomplete: probe exited with 1"], - ); - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - Some(&record), - ) - .unwrap(); - assert!( - check.message.ends_with( - "could not be installed: installed ACP bridge is incomplete: probe exited with 1" - ), - "{}", - check.message - ); - assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); - } - - /// The hint is the runtime row's own only when the headline is a runtime - /// download failure: a bridge's npm 404 is still reported, but never as - /// "the configured registry does not carry Node.js". A 4xx from the dist - /// host points at the download URL. - #[test] - fn runtime_row_hints_only_on_its_own_download_failure() { - let bridge_404 = "codex-acp: npm install failed: npm install exited with exit status: 1 \ - (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ - GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp"; - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - Some(&reconcile_record(false, vec![bridge_404])), - ) - .unwrap(); - assert_eq!(check.status, CheckStatus::Warn); - assert!( - check - .message - .ends_with("could not be installed: npm install failed: npm install exited with exit status: 1 \ - (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ - GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp"), - "{}", - check.message - ); - let output = check.raw_output.as_deref().unwrap(); - assert!(output.contains(&format!("- {bridge_404}")), "{output}"); - assert!(!output.contains("hint:"), "{output}"); - assert!(!output.contains("Node.js\n"), "{output}"); - - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - Some(&reconcile_record( - false, - vec![ - bridge_404, - "claude-acp: Node.js runtime download failed: HTTP 404", - ], - )), - ) - .unwrap(); - assert!( - check - .message - .ends_with("could not be installed: Node.js runtime download failed: HTTP 404"), - "{}", - check.message - ); - let output = check.raw_output.as_deref().unwrap(); - assert!( - output.contains( - "hint: the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL" - ), - "{output}" - ); - - // A checksum mismatch is the headline but not a download failure Doctor - // has advice for. - let check = build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - Some(&reconcile_record( - false, - vec!["claude-acp: Node.js runtime archive SHA-256 mismatch: expected a, got b"], - )), - ) - .unwrap(); - assert!( - check - .message - .ends_with("Node.js runtime archive SHA-256 mismatch: expected a, got b"), - "{}", - check.message - ); - assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); - } - - #[test] - fn format_age_rounds_to_the_largest_whole_unit() { - assert_eq!(format_age(1_000, 1_000), "just now"); - assert_eq!(format_age(0, 59_000), "just now"); - assert_eq!(format_age(0, 60_000), "1 minute ago"); - assert_eq!(format_age(0, 2 * 3_600_000 + 5), "2 hours ago"); - assert_eq!(format_age(0, 3 * 86_400_000), "3 days ago"); - // A clock that went backwards is not a panic. - assert_eq!(format_age(5_000, 1_000), "just now"); - } - - #[test] - fn installed_npm_tool_names_lists_visible_entries_only() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("copilot"), "").unwrap(); - std::fs::write(dir.path().join(".copilot.tmp"), "").unwrap(); - - let names = installed_npm_tool_names(dir.path()); - assert_eq!(names, vec!["copilot".to_string()]); - - // 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; 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("/data/packages/bin"); - let opts = run_checks_options(false, Vec::new(), Some(dir.clone())); - assert_eq!(opts.bundled_tools_dir, Some(dir)); - - let opts = run_checks_options(false, Vec::new(), None); - 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); - } - - /// Both update fix types on a managed check dispatch to the managed - /// installer — `UpdateBridge` from the managed row's ACP readout, and the - /// `UpdateMain` a copy that resolved outside the managed dir carries, whose - /// own `npm install -g` would orphan a copy in the private prefix. Non-update - /// fix types take the command-validated path, and nothing is managed when - /// bridge management is off (dev override, `no-managed-acp-tools`, - /// unsupported target). - #[test] - fn both_update_fix_types_dispatch_to_the_managed_installer() { - let managed = crate::managed_acp_tools::managed_tools_enabled(); - for fix_type in [FixType::UpdateMain, FixType::UpdateBridge] { - assert_eq!( - managed_update_target("ai-agent-claude", &fix_type), - managed.then_some("claude-acp"), - "{fix_type:?}" - ); - assert_eq!( - managed_update_target("ai-agent-codex", &fix_type), - managed.then_some("codex-acp"), - "{fix_type:?}" - ); - // Checks whose bridge Staged never manages keep the crate's own - // fixes for both update types. - assert_eq!( - managed_update_target("ai-agent-amp", &fix_type), - None, - "{fix_type:?}" - ); - assert_eq!( - managed_update_target("ai-agent-copilot", &fix_type), - None, - "{fix_type:?}" - ); - } - for fix_type in [FixType::Command, FixType::Bridge, FixType::Auth] { - assert_eq!( - managed_update_target("ai-agent-claude", &fix_type), - None, - "{fix_type:?}" - ); - } - } - - // -- managed bridge readouts --------------------------------------------- - - fn claude_tool() -> ManagedTool { - ManagedTool { - id: "claude-acp", - binary: "claude-agent-acp", - package: "@agentclientprotocol/claude-agent-acp", - } - } - - const SHIM_BIN_DIR: &str = "/data/.staged/packages/bin"; - /// The vendored Claude Code version the crate's freshness pass probes - /// through `claude-agent-acp --cli --version` — a different identity from - /// the ACP package's own version. - const VENDORED_CLI_VERSION: &str = "2.1.205"; - - /// A Claude row as the doctor crate reports the managed shim: bundled - /// `main`, no `bridge`. With `freshness`, the vendored CLI's probed version - /// and the crate's bundled suppression (`self_updating`, no update). - fn managed_claude_check(path: &str, freshness: bool) -> DoctorCheck { - DoctorCheck { - id: "ai-agent-claude".to_string(), - label: "Claude Code".to_string(), - status: CheckStatus::Pass, - message: "Installed".to_string(), - fix_url: None, - fix_command: None, - fix_type: None, - path: Some(path.to_string()), - bridge_path: None, - raw_output: None, - auth_status: Some(AuthStatus::Authenticated), - login_command: Some("claude-agent-acp --cli auth login".to_string()), - installed_version: freshness.then(|| VENDORED_CLI_VERSION.to_string()), - latest_version: None, - update_available: None, - install_source: Some(InstallSource::Bundled), - self_updating: freshness.then_some(true), - main: Some(AgentVersionInfo { - install_source: Some(InstallSource::Bundled), - bundled: Some(true), - installed_version: freshness.then(|| VENDORED_CLI_VERSION.to_string()), - self_updating: freshness.then_some(true), - ..AgentVersionInfo::default() - }), - bridge: None, - } - } - - fn managed_shim_path() -> String { - Path::new(SHIM_BIN_DIR) - .join("claude-agent-acp") - .display() - .to_string() - } - - /// The ACP package version a copy the user installed themselves reports. - const USER_COPY_VERSION: &str = "0.16.2"; - - /// A Claude row as the doctor crate reports a copy the *user* installed — - /// found on PATH before the launch reconcile has written the managed shim, - /// or for as long as that reconcile keeps failing. Labeled by source rather - /// than bundled, with the crate's freshness fields: `latest` as the registry - /// answered it, and `command` the source-aware update the crate derived - /// (`None` for a source it cannot update, such as `Unknown`). - fn user_copy_claude_check( - path: &str, - source: &InstallSource, - latest: Option<&str>, - command: Option<&str>, - ) -> DoctorCheck { - let main = AgentVersionInfo { - install_source: Some(source.clone()), - bundled: None, - installed_version: Some(USER_COPY_VERSION.to_string()), - latest_version: latest.map(str::to_string), - update_available: latest.map(|latest| latest != USER_COPY_VERSION), - self_updating: Some(false), - update_command: command.map(str::to_string), - update_fix_type: command.map(|_| FixType::UpdateMain), - }; - DoctorCheck { - installed_version: main.installed_version.clone(), - latest_version: main.latest_version.clone(), - update_available: main.update_available, - install_source: Some(source.clone()), - self_updating: Some(false), - main: Some(main), - ..managed_claude_check(path, true) - } - } - - /// Run the report-wide pass over a single check, so the tests exercise the - /// managed-readout and redirect branches through the same dispatch the - /// report does. - fn apply_to( - check: DoctorCheck, - versions: &ManagedBridgeVersions, - check_freshness: bool, - ) -> DoctorCheck { - apply_with(check, versions, check_freshness, &clean_diagnostics()) - } - - /// A reconcile that succeeded a minute ago and left nothing to report: - /// the managed copy is then simply missing, with no error to name. - fn clean_diagnostics() -> ManagedInstallDiagnostics { - ManagedInstallDiagnostics { - last_reconcile: Some(reconcile_record(true, vec![])), - npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), - now_ms: 61_000, - } - } - - fn apply_with( - check: DoctorCheck, - versions: &ManagedBridgeVersions, - check_freshness: bool, - diagnostics: &ManagedInstallDiagnostics, - ) -> DoctorCheck { - let mut report = DoctorReport { - checks: vec![check], - }; - apply_managed_bridge_readouts( - &mut report, - std::slice::from_ref(versions), - Path::new(SHIM_BIN_DIR), - check_freshness, - diagnostics, - ); - report.checks.pop().unwrap() - } - - /// Undo what [`flag_missing_managed_copy`] sets on a user-copy row, so a - /// test can assert that *nothing else* changed. - fn without_missing_copy_flag(mut check: DoctorCheck) -> DoctorCheck { - check.status = CheckStatus::Pass; - check.message = "Installed".to_string(); - check.fix_type = None; - check.fix_command = None; - check.raw_output = None; - check - } - - fn versions(installed: Option<&str>, latest: Option<&str>) -> ManagedBridgeVersions { - ManagedBridgeVersions { - tool: claude_tool(), - installed: installed.map(str::to_string), - latest: latest.map(str::to_string), - } - } - - fn json(check: &DoctorCheck) -> serde_json::Value { - serde_json::to_value(check).unwrap() - } - - /// The initial (no-network) report already carries both readouts: the - /// crate's `main`, untouched, and the ACP package's installed version - /// under `bridge`, with `bridge_path` set so the row renders it — the - /// same structure the freshness report has, so the frontend's merge (which - /// copies `bridge` but not `bridge_path`) never hides a readout. - #[test] - fn initial_report_adds_the_acp_readout_without_update_fields() { - let path = managed_shim_path(); - let mut check = managed_claude_check(&path, false); - let main_before = check.main.clone(); - - assert!(apply_managed_bridge_readout( - &mut check, - &versions(Some("0.16.2"), None), - Path::new(SHIM_BIN_DIR), - false, - )); - - assert_eq!( - check.main, main_before, - "the vendored-agent readout is preserved" - ); - assert_eq!(check.bridge_path.as_deref(), Some(path.as_str())); - let bridge = check.bridge.as_ref().expect("the ACP readout is added"); - assert_eq!(bridge.install_source, Some(InstallSource::Bundled)); - assert_eq!(bridge.bundled, Some(true)); - assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); - assert_eq!(bridge.latest_version, None); - assert_eq!(bridge.update_available, None); - assert_eq!(bridge.self_updating, None); - assert_eq!(bridge.update_command, None); - assert_eq!(bridge.update_fix_type, None); - // The flat fields mirror the ACP readout, as the crate does for any - // check with a bridge. - assert_eq!(check.installed_version.as_deref(), Some("0.16.2")); - assert_eq!(check.update_available, None); - } - - /// The freshness report shows the two different versions side by side and - /// attaches exactly one update — to the ACP readout — when the registry has - /// a newer release of the same package. The text describes updating - /// Staged's managed copy; it is not an npm command. - #[test] - fn freshness_with_a_newer_release_offers_one_update_on_the_acp_readout() { - let path = managed_shim_path(); - let mut check = managed_claude_check(&path, true); - - assert!(apply_managed_bridge_readout( - &mut check, - &versions(Some("0.16.2"), Some("0.17.0")), - Path::new(SHIM_BIN_DIR), - true, - )); - - let main = check.main.as_ref().unwrap(); - let bridge = check.bridge.as_ref().unwrap(); - assert_eq!( - main.installed_version.as_deref(), - Some(VENDORED_CLI_VERSION) - ); - assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); - assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); - assert_eq!(bridge.update_available, Some(true)); - assert_eq!(bridge.self_updating, Some(false)); - assert_eq!(bridge.update_fix_type, Some(FixType::UpdateBridge)); - let command = bridge.update_command.as_deref().expect("actionable update"); - assert!( - command.contains("@agentclientprotocol/claude-agent-acp"), - "{command}" - ); - assert!(command.contains("0.17.0"), "{command}"); - assert!(command.contains("~/.staged/packages"), "{command}"); - assert!(!command.contains("npm install"), "{command}"); - // Exactly one actionable readout: the agent vendored inside the same - // package is never offered a second install of its own. - assert_eq!(main.update_available, None); - assert_eq!(main.update_command, None); - assert_eq!(main.update_fix_type, None); - assert_eq!(main.self_updating, Some(true)); - // The flat headline follows the ACP readout. - assert_eq!(check.installed_version.as_deref(), Some("0.16.2")); - assert_eq!(check.latest_version.as_deref(), Some("0.17.0")); - assert_eq!(check.update_available, Some(true)); - assert_eq!(check.self_updating, Some(false)); - } - - #[test] - fn freshness_with_the_current_release_reports_no_update() { - let path = managed_shim_path(); - let mut check = managed_claude_check(&path, true); - - assert!(apply_managed_bridge_readout( - &mut check, - &versions(Some("0.17.0"), Some("0.17.0")), - Path::new(SHIM_BIN_DIR), - true, - )); - - let bridge = check.bridge.as_ref().unwrap(); - assert_eq!(bridge.installed_version.as_deref(), Some("0.17.0")); - assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); - assert_eq!(bridge.update_available, Some(false)); - assert_eq!(bridge.update_command, None); - assert_eq!(bridge.update_fix_type, None); - assert_eq!(check.update_available, Some(false)); - - // A registry that is *behind* the install (a just-published version - // not yet on the mirror) is likewise not an update. - let mut check = managed_claude_check(&path, true); - apply_managed_bridge_readout( - &mut check, - &versions(Some("0.17.1"), Some("0.17.0")), - Path::new(SHIM_BIN_DIR), - true, - ); - assert_eq!(check.bridge.as_ref().unwrap().update_available, Some(false)); - } - - /// A failed or absent registry answer — and an unreadable installed - /// version — leave `update_available` unknown, never "up to date", while - /// whatever *was* read is still shown. - #[test] - fn unknown_installed_or_latest_versions_stay_unknown() { - let path = managed_shim_path(); - - let mut check = managed_claude_check(&path, true); - assert!(apply_managed_bridge_readout( - &mut check, - &versions(Some("0.16.2"), None), - Path::new(SHIM_BIN_DIR), - true, - )); - let bridge = check.bridge.as_ref().unwrap(); - assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); - assert_eq!(bridge.latest_version, None); - assert_eq!(bridge.update_available, None); - assert_eq!(bridge.update_command, None); - assert_eq!(check.update_available, None); - - let mut check = managed_claude_check(&path, true); - assert!(apply_managed_bridge_readout( - &mut check, - &versions(None, Some("0.17.0")), - Path::new(SHIM_BIN_DIR), - true, - )); - let bridge = check.bridge.as_ref().unwrap(); - assert_eq!(bridge.installed_version, None); - assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); - assert_eq!(bridge.update_available, None); - assert_eq!(bridge.update_command, None); - - // Unparseable output on either side is not compared as a string. - let mut check = managed_claude_check(&path, true); - apply_managed_bridge_readout( - &mut check, - &versions(Some("0.16.2"), Some("latest")), - Path::new(SHIM_BIN_DIR), - true, - ); - assert_eq!(check.bridge.as_ref().unwrap().update_available, None); - } - - /// A copy the user installed themselves never gets the managed readout. - /// It is flagged — the managed copy is missing, and the managed install is - /// its fix — but nothing else changes: its own location, source and - /// version are the honest ones for what is on PATH, and with nothing to - /// update no update action is invented. - #[test] - fn a_user_copy_without_an_update_is_flagged_and_otherwise_left_alone() { - let installed = versions(Some("0.16.2"), Some("0.17.0")); - let path = "/opt/homebrew/bin/claude-agent-acp"; - - // The registry answered, and the user copy is current. - let check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - let before = json(&check); - let after = apply_to(check, &installed, true); - assert_eq!(after.status, CheckStatus::Warn); - assert_eq!( - after.message, - "Using /opt/homebrew/bin/claude-agent-acp — Staged's managed copy is missing" - ); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - assert_eq!( - after.fix_command.as_deref(), - Some( - "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ - ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" - ) - ); - let detail = after.raw_output.as_deref().expect("detail"); - assert!( - detail.contains("Staged-managed copy: missing (expected /data/.staged/packages/bin/claude-agent-acp)"), - "{detail}" - ); - assert!( - detail.contains("last managed install: succeeded 1 minute ago"), - "{detail}" - ); - assert!(!detail.contains("hint:"), "{detail}"); - assert_eq!(json(&without_missing_copy_flag(after)), before); - - // The registry lookup failed: unknown, so no action is invented. - let check = user_copy_claude_check(path, &InstallSource::Brew, None, None); - let before = json(&check); - let after = apply_to(check, &installed, true); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - assert_eq!(json(&without_missing_copy_flag(after)), before); - - // The initial (no-network) pass sets no update fields at all, so there - // is nothing to redirect there either — but the flag and fix do not - // wait for the network: the base report already carries them. - let mut check = managed_claude_check(path, false); - check.main.as_mut().unwrap().bundled = None; - check.main.as_mut().unwrap().install_source = Some(InstallSource::Npm); - let before = json(&check); - let after = apply_to(check, &installed, false); - assert_eq!(after.status, CheckStatus::Warn); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - assert_eq!(json(&without_missing_copy_flag(after)), before); - } - - /// Before the first reconcile has finished — a fresh profile whose launch - /// install is still downloading — a Homebrew copy on PATH is expected, so - /// the row must not read as a failure: it says the managed install hasn't - /// completed yet, the detail says when it runs, and the Fix stays - /// available for whoever would rather not wait. An already-warning row - /// keeps its own message in front. - #[test] - fn a_user_copy_before_the_first_reconcile_is_not_called_a_failure() { - let installed = versions(None, Some("0.17.0")); - let path = "/opt/homebrew/bin/claude-agent-acp"; - let diagnostics = ManagedInstallDiagnostics { - last_reconcile: None, - npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), - now_ms: 0, - }; - - let check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - let after = apply_with(check, &installed, true, &diagnostics); - assert_eq!(after.status, CheckStatus::Warn); - assert_eq!( - after.message, - format!("Using {path} — Staged's managed install hasn't completed yet") - ); - assert!(!after.message.contains("missing"), "{}", after.message); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - assert_eq!( - after.fix_command.as_deref(), - Some( - "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ - ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" - ) - ); - let detail = after.raw_output.as_deref().expect("detail"); - assert!( - detail.contains("last managed install: not run yet (runs at launch and daily)"), - "{detail}" - ); - assert!(!detail.contains("hint:"), "{detail}"); - assert!( - detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), - "{detail}" - ); - - let mut check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - check.status = CheckStatus::Warn; - check.message = "Installed, not authenticated".to_string(); - let after = apply_with(check, &installed, true, &diagnostics); - assert_eq!( - after.message, - format!( - "Installed, not authenticated; using {path} — Staged's managed install hasn't completed yet" - ) - ); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - } - - /// With a failed reconcile on record the row says why the managed copy is - /// missing — only the `npm error code` of the recorded error for *this* - /// tool, the rest of which (naming the registry it used) goes to the - /// detail with the classified hint and the registry Doctor detected. The - /// other tool's error stays off this row. - #[test] - fn a_user_copy_names_the_recorded_install_error_and_its_hint() { - let installed = versions(None, None); - let path = "/opt/homebrew/bin/claude-agent-acp"; - let claude_error = "npm install failed: npm install exited with exit status: 1 \ - (registry: npm default (per npm config)): npm error code FETCH_ERROR; \ - invalid json response body at \ - https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: \ - Unexpected token '<'"; - let diagnostics = ManagedInstallDiagnostics { - last_reconcile: Some(ReconcileRecord { - at_ms: 0, - ok: false, - errors: vec![ - format!("claude-acp: {claude_error}"), - "codex-acp: npm install failed: npm install exited with exit status: 1 \ - (registry: npm default (per npm config)): npm error code E404" - .to_string(), - ], - }), - npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), - now_ms: 5 * 60_000, - }; - - let check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - let after = apply_with(check, &installed, true, &diagnostics); - - assert_eq!(after.status, CheckStatus::Warn); - assert_eq!( - after.message, - format!("Using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR") - ); - assert!( - !after.message.contains("invalid json"), - "the full error stays out of the one-line message: {}", - after.message - ); - assert_eq!(after.fix_type, Some(FixType::Bridge)); - let detail = after.raw_output.as_deref().expect("detail"); - assert!( - detail.contains("last managed install: failed 5 minutes ago"), - "{detail}" - ); - assert!( - detail.contains(&format!("claude-acp: {claude_error}")), - "{detail}" - ); - assert!(!detail.contains("codex-acp"), "{detail}"); - assert!( - detail.contains( - "hint: this network blocks registry.npmjs.org and npm has no registry configured" - ), - "{detail}" - ); - assert!( - detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), - "{detail}" - ); - - // A row that already warns (installed, not signed in) keeps its own - // message in front and its status. - let mut check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - check.status = CheckStatus::Warn; - check.message = "Installed, not authenticated".to_string(); - check.raw_output = - Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); - let after = apply_with(check, &installed, true, &diagnostics); - assert_eq!(after.status, CheckStatus::Warn); - assert_eq!( - after.message, - format!( - "Installed, not authenticated; using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR" - ) - ); - let detail = after.raw_output.as_deref().unwrap(); - assert!( - detail.starts_with("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\nStaged-managed copy: missing"), - "the crate's trace stays on top: {detail}" - ); - assert!( - detail.contains(&format!("claude-acp: {claude_error}")), - "{detail}" - ); - - // An error with no npm code is ellipsized on the row and whole in the - // detail; the registry line says when the probe did not run. - let long_error = format!( - "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: {}", - "validation probe exited with 1 and printed a very long explanation ".repeat(3) - ); - let diagnostics = ManagedInstallDiagnostics { - last_reconcile: Some(reconcile_record( - false, - vec![&format!("claude-acp: {long_error}")], - )), - npm_registry: None, - now_ms: 0, - }; - let check = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - let after = apply_with(check, &installed, true, &diagnostics); - let expected_fragment: String = long_error.chars().take(SHORT_ERROR_CHARS).collect(); - assert_eq!( - after.message, - format!( - "Using {path} — Staged's managed copy is missing: {}…", - expected_fragment.trim_end() - ) - ); - let detail = after.raw_output.as_deref().unwrap(); - assert!( - detail.contains(&format!("claude-acp: {long_error}")), - "{detail}" - ); - assert!(detail.contains("npm registry: not probed"), "{detail}"); - } - - #[test] - fn short_error_fragment_prefers_the_npm_code_then_ellipsizes() { - assert_eq!( - short_error_fragment( - "npm install failed: npm install exited with exit status: 1 (registry: npm default \ - (per npm config)): npm error code FETCH_ERROR; invalid json response body at x" - ), - "npm error code FETCH_ERROR" - ); - assert_eq!( - short_error_fragment( - "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ - network gateway; fallback https://f.example.test/ also failed: npm install exited \ - with exit status: 1 (registry: https://f.example.test/): npm error code E404" - ), - "npm error code E404" - ); - assert_eq!(short_error_fragment("short"), "short"); - let exact: String = "é".repeat(SHORT_ERROR_CHARS); - assert_eq!( - short_error_fragment(&exact), - exact, - "no ellipsis at the limit" - ); - let over = format!("{exact}x"); - assert_eq!(short_error_fragment(&over), format!("{exact}…")); - assert_eq!( - short_error_fragment(&format!( - "{} {}", - "a".repeat(SHORT_ERROR_CHARS - 1), - "b".repeat(5) - )), - format!("{}…", "a".repeat(SHORT_ERROR_CHARS - 1)), - "trailing whitespace before the cut is dropped" - ); - } - - /// The registry probe is decided from the shim dir alone: any managed - /// shim absent means a Claude/Codex row may resolve elsewhere. - #[test] - fn registry_probe_runs_only_while_a_managed_shim_is_absent() { - let tools = crate::managed_acp_tools::managed_tools(); - if tools.is_empty() { - return; - } - let dir = tempfile::tempdir().unwrap(); - assert!(any_managed_shim_missing(dir.path())); - for tool in &tools { - std::fs::write(dir.path().join(tool.binary), "#!/bin/sh\n").unwrap(); - } - assert!(!any_managed_shim_missing(dir.path())); - std::fs::remove_file(dir.path().join(tools[0].binary)).unwrap(); - assert!(any_managed_shim_missing(dir.path())); - } - - /// When this build does not manage bridges at all, a Homebrew copy is by - /// design: the rows say so and keep their status, and no fix is added. - /// Rows for other agents are untouched. - #[test] - fn managed_installs_disabled_is_noted_on_the_bridge_rows_without_a_downgrade() { - let path = "/opt/homebrew/bin/claude-agent-acp"; - let mut claude = - user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); - claude.raw_output = - Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); - let mut codex = user_copy_claude_check( - "/opt/homebrew/bin/codex-acp", - &InstallSource::Brew, - None, - None, - ); - codex.id = "ai-agent-codex".to_string(); - codex.status = CheckStatus::Fail; - codex.message = "Not installed (optional)".to_string(); - codex.path = None; - let amp = DoctorCheck { - id: "ai-agent-amp".to_string(), - ..claude.clone() - }; - let amp_before = json(&); - let mut report = DoctorReport { - checks: vec![claude, codex, amp], - }; - - note_managed_installs_disabled(&mut report, "STAGED_ACP_TOOLS_DIR is set to /dev/acp"); - - let claude = &report.checks[0]; - assert_eq!(claude.status, CheckStatus::Pass); - assert_eq!( - claude.message, - "Installed (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" - ); - assert!(claude.fix_type.is_none()); - assert_eq!( - claude.raw_output.as_deref(), - Some( - "strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\n\ - managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp" - ) - ); - let codex = &report.checks[1]; - assert_eq!(codex.status, CheckStatus::Fail); - assert_eq!( - codex.message, - "Not installed (optional) (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" - ); - assert_eq!(json(&report.checks[2]), amp_before); - } - - /// A user copy with a newer release keeps its readout but has its action - /// redirected: on a managing build the managed installer is the only writer - /// for these two packages, so the crate's `npm install -g` (which would - /// orphan a copy in the private prefix) and `brew upgrade` (which Staged - /// does not run for a copy it never uses) are replaced by a description of - /// the managed install. A source the crate cannot update gets the action it - /// had none for. - /// - /// The version the description names is the managed probe's — what the - /// installer will actually fetch from the configured registry — not the - /// crate's per-source answer (`brew info` for a Homebrew copy), which the - /// readout itself keeps showing. - #[test] - fn a_user_copy_with_a_newer_release_is_redirected_to_the_managed_install() { - // The configured registry is one release ahead of what the crate's - // source-aware lookup found for the user's copy. - let installed = versions(Some("0.16.2"), Some("0.17.1")); - let cases = [ - ( - "/Users/u/.nvm/versions/node/v22.14.0/bin/claude-agent-acp", - InstallSource::Npm, - Some("npm install -g @agentclientprotocol/claude-agent-acp@latest"), - ), - ( - "/opt/homebrew/bin/claude-agent-acp", - InstallSource::Brew, - Some("brew upgrade claude-agent-acp"), - ), - ( - "/usr/local/bin/claude-agent-acp", - InstallSource::Unknown, - None, - ), - ]; - - for (path, source, command) in &cases { - let check = user_copy_claude_check(path, source, Some("0.17.0"), *command); - let before = json(&check); - let mut after = apply_to(check, &installed, true); - - assert!(after.bridge.is_none(), "{source:?}"); - assert!(after.bridge_path.is_none(), "{source:?}"); - let main = after.main.as_ref().unwrap(); - assert_eq!( - main.update_fix_type, - Some(FixType::UpdateMain), - "{source:?}" - ); - let description = main.update_command.as_deref().expect("an action"); - assert!( - description.contains("@agentclientprotocol/claude-agent-acp"), - "{description}" - ); - assert!( - description.contains("0.17.1"), - "names the registry release the installer fetches: {description}" - ); - assert!( - !description.contains("0.17.0"), - "not the crate's per-source answer: {description}" - ); - assert!(description.contains("~/.staged/packages"), "{description}"); - assert!(description.contains(path), "{description}"); - assert!(!description.contains("npm install"), "{description}"); - assert!(!description.contains("brew upgrade"), "{description}"); - - // The row's fix is the same managed install, word for word, so the - // Update and Fix buttons never describe two different things. - assert_eq!(after.fix_type, Some(FixType::Bridge), "{source:?}"); - assert_eq!( - after.fix_command.as_deref(), - Some(description), - "{source:?}" - ); - - // The action (and the missing-copy flag) are the *only* changes: - // path, source, both versions and `updateAvailable` still describe - // the copy that is on PATH — the readout's `latestVersion` stays - // the crate's, even though the description names the registry's. - let main = after.main.as_mut().unwrap(); - main.update_command = command.map(str::to_string); - main.update_fix_type = command.map(|_| FixType::UpdateMain); - assert_eq!( - json(&without_missing_copy_flag(after)), - before, - "{source:?}" - ); - } - } - - /// When the managed probe failed — registry unreachable, garbage, timed - /// out — the description falls back to the version the crate flagged the - /// copy stale against, rather than naming nothing: the row is still - /// redirected, since the installer's `@latest` needs no version up front. - #[test] - fn a_redirected_description_falls_back_to_the_crates_latest_when_the_probe_failed() { - let probe_failed = versions(Some("0.16.2"), None); - let path = "/opt/homebrew/bin/claude-agent-acp"; - let check = user_copy_claude_check( - path, - &InstallSource::Brew, - Some("0.17.0"), - Some("brew upgrade claude-agent-acp"), - ); - - let after = apply_to(check, &probe_failed, true); - - let main = after.main.as_ref().unwrap(); - assert_eq!(main.update_fix_type, Some(FixType::UpdateMain)); - assert_eq!( - main.update_command.as_deref(), - Some( - "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ - ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" - ) - ); - } - - /// Only the managed shim gets the managed readout. A row whose path sits - /// under the shim dir under another name, a row with no executable, a row - /// the crate did not label bundled, another tool's versions, and a build - /// with nothing managed all leave the check exactly as the crate produced - /// it. - #[test] - fn unmanaged_copies_and_other_checks_are_left_alone() { - let shim_bin_dir = Path::new(SHIM_BIN_DIR); - let installed = versions(Some("0.16.2"), Some("0.17.0")); - - // Under the shim dir but a different binary name. - let other = Path::new(SHIM_BIN_DIR) - .join("codex-acp") - .display() - .to_string(); - let mut check = managed_claude_check(&other, true); - let before = json(&check); - assert!(!apply_managed_bridge_readout( - &mut check, - &installed, - shim_bin_dir, - true - )); - assert_eq!(json(&check), before); - - // Not installed at all. - let mut check = managed_claude_check(&managed_shim_path(), false); - check.path = None; - check.main = None; - check.status = CheckStatus::Warn; - let before = json(&check); - assert!(!apply_managed_bridge_readout( - &mut check, - &installed, - shim_bin_dir, - false - )); - assert_eq!(json(&check), before); - - // The right path, but the crate did not call it bundled (a dev override - // dir that happens to sit at the shim path is not a managed install). - let mut check = managed_claude_check(&managed_shim_path(), true); - check.main.as_mut().unwrap().bundled = None; - let before = json(&check); - assert!(!apply_managed_bridge_readout( - &mut check, - &installed, - shim_bin_dir, - true - )); - assert_eq!(json(&check), before); - - // Report-wide: nothing managed (dev override, disabled feature, - // unsupported target) means no versions and no readout anywhere. - let mut report = DoctorReport { - checks: vec![ - managed_claude_check(&managed_shim_path(), true), - node_runtime_doctor_check(CheckStatus::Pass, "ok".to_string(), None, None, None), - ], - }; - let before: Vec<_> = report.checks.iter().map(json).collect(); - apply_managed_bridge_readouts( - &mut report, - &[], - shim_bin_dir, - true, - &ManagedInstallDiagnostics::default(), - ); - let after: Vec<_> = report.checks.iter().map(json).collect(); - assert_eq!(after, before); - - // Report-wide: versions for one tool reach only its own check. - let codex_versions = ManagedBridgeVersions { - tool: ManagedTool { - id: "codex-acp", - binary: "codex-acp", - package: "@agentclientprotocol/codex-acp", - }, - installed: Some("1.1.2".to_string()), - latest: Some("1.2.0".to_string()), - }; - apply_managed_bridge_readouts( - &mut report, - &[codex_versions], - shim_bin_dir, - true, - &ManagedInstallDiagnostics::default(), - ); - let after: Vec<_> = report.checks.iter().map(json).collect(); - assert_eq!(after, before); - apply_managed_bridge_readouts( - &mut report, - &[installed], - shim_bin_dir, - true, - &ManagedInstallDiagnostics::default(), - ); - assert!(report.checks[0].bridge.is_some()); - assert!(report.checks[1].bridge.is_none()); - } - - /// The redirected action's text names the package, the version, where the - /// managed copy goes, and the copy it leaves alone — an unknown latest - /// version drops the version rather than printing a placeholder. - #[test] - fn managed_install_description_names_the_package_version_and_replaced_copy() { - let description = managed_install_description( - &claude_tool(), - Some("0.17.0"), - "/opt/homebrew/bin/claude-agent-acp", - ); - assert_eq!( - description, - "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ - ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" - ); - - let description = - managed_install_description(&claude_tool(), None, "/opt/homebrew/bin/claude-agent-acp"); - assert_eq!( - description, - "install Staged's managed @agentclientprotocol/claude-agent-acp in \ - ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" - ); - } - - #[test] - fn newer_version_available_compares_semver_triples() { - assert_eq!( - newer_version_available(Some("0.16.2"), Some("0.17.0")), - Some(true) - ); - assert_eq!( - newer_version_available(Some("0.17.0"), Some("0.17.0")), - Some(false) - ); - assert_eq!( - newer_version_available(Some("1.0.0"), Some("0.99.99")), - Some(false) - ); - assert_eq!( - newer_version_available(Some("v1.2.3"), Some("1.2.4-rc.1")), - Some(true) - ); - assert_eq!(newer_version_available(None, Some("1.0.0")), None); - assert_eq!(newer_version_available(Some("1.0.0"), None), None); - assert_eq!(newer_version_available(Some("1.0"), Some("1.0.1")), None); - assert_eq!(newer_version_available(Some("1.0.0"), Some("latest")), None); - } - - #[test] - fn is_managed_shim_path_requires_the_shim_dir_and_binary_name() { - let dir = Path::new(SHIM_BIN_DIR); - assert!(is_managed_shim_path( - "/data/.staged/packages/bin/claude-agent-acp", - dir, - "claude-agent-acp" - )); - assert!(!is_managed_shim_path( - "/data/.staged/packages/bin/codex-acp", - dir, - "claude-agent-acp" - )); - assert!(!is_managed_shim_path( - "/data/.staged/packages/npm-prefix/bin/claude-agent-acp", - dir, - "claude-agent-acp" - )); - assert!(!is_managed_shim_path( - "/usr/local/bin/claude-agent-acp", - dir, - "claude-agent-acp" - )); - } - - /// Checks and fixes must agree on the registry: both option builders take - /// it from the same `managed_acp_tools::npm_registry_override()` — the - /// explicit `STAGED_NPM_REGISTRY`, or `None` so npm resolves its own - /// config. - #[test] - fn doctor_options_route_npm_through_the_registry_override() { - let expected = crate::managed_acp_tools::npm_registry_override(); - assert_eq!( - run_checks_options(false, Vec::new(), None).npm_registry, - expected - ); - assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected); - } - - /// The tail numbers every line from zero and keeps only the newest - /// `LOGIN_OUTPUT_TAIL_LINES`, and its snapshot states both — a client merges - /// live lines against `next_seq`, so a snapshot whose `output` did not sit - /// exactly below it would show lines twice or lose them. - #[test] - fn login_output_tail_numbers_lines_and_keeps_the_newest() { - let mut tail = LoginOutputTail::default(); - assert_eq!(tail.push("Opening browser to sign in…"), 0); - assert_eq!(tail.push("If the browser didn't open, visit: https://x"), 1); - assert_eq!( - tail.status("run-1"), - DoctorLoginStatus { - running: true, - run_id: Some("run-1".to_string()), - output: vec![ - "Opening browser to sign in…".to_string(), - "If the browser didn't open, visit: https://x".to_string(), - ], - next_seq: 2, - } - ); - - for i in 2..(LOGIN_OUTPUT_TAIL_LINES as u64 + 10) { - assert_eq!(tail.push(&format!("line {i}")), i); - } - let status = tail.status("run-1"); - assert_eq!(status.output.len(), LOGIN_OUTPUT_TAIL_LINES); - assert_eq!(status.next_seq, LOGIN_OUTPUT_TAIL_LINES as u64 + 10); - // `output[i]` carries seq `next_seq - output.len() + i`. - let first_seq = status.next_seq - status.output.len() as u64; - assert_eq!(status.output.first().unwrap(), &format!("line {first_seq}")); - assert_eq!( - status.output.last().unwrap(), - &format!("line {}", status.next_seq - 1) - ); - } - - /// A cancelled run is reported as cancelled and not as a failure; a run - /// that failed on its own keeps its error; and a cancel that only landed - /// after the fix had finished changes nothing about its result. - #[test] - fn login_done_event_tells_a_cancellation_from_a_failure() { - let cancelled_by_runner = - Err("Fix cancelled before finishing: claude-agent-acp --cli auth login".to_string()); - - let done = login_done_event( - "ai-agent-claude".into(), - "run-1".into(), - &cancelled_by_runner, - true, - 3, - ); - assert_eq!( - done, - DoctorLoginOutput { - check_id: "ai-agent-claude".into(), - run_id: "run-1".into(), - line: None, - seq: 3, - done: true, - error: None, - cancelled: true, - } - ); - - let failed = Err("Fix timed out after 10m without finishing: …".to_string()); - let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &failed, false, 3); - assert!(!done.cancelled); - assert_eq!( - done.error.as_deref(), - Some("Fix timed out after 10m without finishing: …") - ); - - let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &Ok(()), true, 0); - assert!(done.done && !done.cancelled && done.error.is_none()); - } - - /// The start answer is what a client keys its whole record on, so its wire - /// shape is pinned: one tag field for the outcome and the run id beside it - /// in camelCase, for both variants. - #[test] - fn login_start_serializes_as_a_tagged_object_with_the_run_id() { - assert_eq!( - serde_json::to_value(LoginStart::Started { - run_id: "run-1".into() - }) - .unwrap(), - serde_json::json!({ "outcome": "started", "runId": "run-1" }) - ); - assert_eq!( - serde_json::to_value(LoginStart::AlreadyRunning { - run_id: "run-1".into() - }) - .unwrap(), - serde_json::json!({ "outcome": "alreadyRunning", "runId": "run-1" }) - ); - // The status and the events spell it the same way. - let status = serde_json::to_value(LoginOutputTail::default().status("run-1")).unwrap(); - assert_eq!(status["runId"], "run-1"); - assert_eq!(status["nextSeq"], 0); - let idle = serde_json::to_value(DoctorLoginStatus::idle()).unwrap(); - assert_eq!(idle["runId"], serde_json::Value::Null); - let done = serde_json::to_value(login_done_event( - "ai-agent-claude".into(), - "run-1".into(), - &Ok(()), - false, - 0, - )) - .unwrap(); - assert_eq!(done["runId"], "run-1"); - } - - /// Build an entry the way `claim_login` does, without touching the global - /// map — for tests of the lookup logic alone. - fn active_login(run_id: &str) -> ActiveLogin { - let (writer, _stdin) = FixStdin::pipe(); - let (cancel, _cancellation) = FixCancellation::token(); - ActiveLogin { - run_id: run_id.to_string(), - writer, - cancel, - output: Arc::new(Mutex::new(LoginOutputTail::default())), - } - } - - /// A command names a run, and reaches it only while that run holds the - /// check's slot. Nothing running is one kind of stale, a *different* run - /// holding the slot is another, and the refusal for a code says which — - /// and, for the second, that the code did not go to the newer login. - #[test] - fn find_login_run_tells_a_finished_run_from_the_one_that_replaced_it() { - let mut logins = HashMap::new(); - assert_eq!( - find_login_run(&logins, "ai-agent-claude", "run-1").err(), - Some(StaleLoginRun::Ended) - ); - - logins.insert("ai-agent-claude".to_string(), active_login("run-2")); - let found = find_login_run(&logins, "ai-agent-claude", "run-2").expect("the live run"); - assert_eq!(found.run_id, "run-2"); - assert_eq!( - find_login_run(&logins, "ai-agent-claude", "run-1").err(), - Some(StaleLoginRun::Superseded) - ); - // The slot is per check: another check's run id never matches. - assert_eq!( - find_login_run(&logins, "ai-agent-codex", "run-2").err(), - Some(StaleLoginRun::Ended) - ); - - let ended = StaleLoginRun::Ended.code_refusal("ai-agent-claude"); - assert!(ended.contains("has ended"), "{ended}"); - assert!( - ended.contains("no login is running for ai-agent-claude"), - "{ended}" - ); - let superseded = StaleLoginRun::Superseded.code_refusal("ai-agent-claude"); - assert!(superseded.contains("has ended"), "{superseded}"); - assert!( - superseded.contains("a newer login is running for ai-agent-claude"), - "{superseded}" - ); - assert!(superseded.contains("was not delivered"), "{superseded}"); - } - - /// Every claim mints its own id, and ids are not recycled: two runs of one - /// check, and two checks' runs, never share one. - #[test] - fn login_run_ids_are_unique() { - let ids: std::collections::HashSet = (0..64).map(|_| new_login_run_id()).collect(); - assert_eq!(ids.len(), 64); - assert!(ids.iter().all(|id| !id.is_empty())); - } - - /// A slot releases only the run it was claimed for. Its `Drop` cannot - /// normally run while a later run holds the entry — the later claim needs - /// the entry gone first — but the release is keyed to the run id so that - /// stays true by construction rather than by call order. - /// - /// Uses `ai-agent-cursor`: `ACTIVE_LOGINS` is process-global and the other - /// login tests in this module claim other checks. - #[test] - fn login_slot_releases_only_its_own_run() { - let check_id = "ai-agent-cursor"; - let LoginClaim::Claimed(login) = claim_login(check_id).expect("cursor has a login fix") - else { - panic!("the slot is free"); - }; - let live_run_id = login.run_id.clone(); - - // A stale slot for a run that no longer holds the entry. - drop(LoginSlot { - check_id: check_id.to_string(), - run_id: "an-earlier-run".to_string(), - }); - assert_eq!( - active_logins() - .lock() - .unwrap() - .get(check_id) - .map(|login| login.run_id.clone()), - Some(live_run_id), - "a stale slot's drop leaves the live run's entry alone" - ); - - drop(login); - assert!( - !active_logins().lock().unwrap().contains_key(check_id), - "the run's own slot releases it" - ); - } - - /// The slot lifecycle end to end: a claim holds the check under a run id, a - /// second claim reports that id rather than an error, the status names it, - /// the cancel and code commands reach the run only under it, dropping the - /// claim releases the slot, and the next claim gets a new id and a token the - /// earlier cancel does not refuse. - /// - /// Uses `ai-agent-codex` because `ACTIVE_LOGINS` is process-global and the - /// other login tests in this module claim other checks. - #[tokio::test] - async fn login_slot_is_released_on_drop_and_the_cancel_reaches_the_run() { - let check_id = "ai-agent-codex"; - let LoginClaim::Claimed(login) = claim_login(check_id).expect("codex has a login fix") - else { - panic!("the first claim takes the slot"); - }; - let run_id = login.run_id.clone(); - assert_eq!( - login.slot.run_id, run_id, - "the slot releases the run it was claimed for" - ); - match claim_login(check_id).expect("still a valid check") { - LoginClaim::AlreadyRunning { run_id: running } => assert_eq!( - running, run_id, - "a second claim names the run that holds the slot" - ), - LoginClaim::Claimed(_) => panic!("the slot is held"), - } - - assert_eq!( - doctor_login_status(check_id.into()).await, - DoctorLoginStatus { - running: true, - run_id: Some(run_id.clone()), - output: Vec::new(), - next_seq: 0, - } - ); - // What the run's `on_line` records is what a late client is shown. - login - .output - .lock() - .unwrap() - .push("Opening browser to sign in…"); - assert_eq!( - doctor_login_status(check_id.into()).await.output, - vec!["Opening browser to sign in…".to_string()] - ); - - // A code for a run that isn't the one holding the slot is refused — - // and never reaches this run's pipe. The right id queues it (the fix - // has not spawned here, so `send_line` buffers it for the replay). - let refused = - send_doctor_login_code(check_id.into(), "an-earlier-run".into(), "abc".into()) - .await - .expect_err("a code for another run is refused"); - assert!( - refused.contains("a newer login is running for ai-agent-codex"), - "{refused}" - ); - send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) - .await - .expect("a code for the live run reaches its pipe"); - - assert!(!login.cancel.is_cancelled()); - assert!( - !cancel_doctor_login(check_id.into(), "an-earlier-run".into()).await, - "a cancel for another run is not this run's" - ); - assert!(!login.cancel.is_cancelled()); - assert!(cancel_doctor_login(check_id.into(), run_id.clone()).await); - assert!( - login.cancel.is_cancelled(), - "the command reaches the token the run was given" - ); - assert!( - cancel_doctor_login(check_id.into(), run_id.clone()).await, - "repeating it is a no-op that still reports the running login" - ); - - drop(login); - assert_eq!( - doctor_login_status(check_id.into()).await, - DoctorLoginStatus::idle() - ); - assert!( - !cancel_doctor_login(check_id.into(), run_id.clone()).await, - "nothing running: nothing to cancel" - ); - let refused = send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) - .await - .expect_err("a code for a run that has ended is refused"); - assert!( - refused.contains("no login is running for ai-agent-codex"), - "{refused}" - ); - - let LoginClaim::Claimed(again) = claim_login(check_id).expect("still a valid check") else { - panic!("a released slot can be claimed again"); - }; - assert_ne!( - again.run_id, run_id, - "each run gets its own id, so the earlier run's events and commands can't be taken for this one's" - ); - assert!( - !again.cancel.is_cancelled(), - "each run gets a fresh token, so the earlier cancel can't refuse the retry" - ); - } - - #[test] - fn claim_login_refuses_a_check_with_no_login_fix() { - let err = claim_login("ai-agent-goose").expect_err("goose has no auth command"); - assert!( - err.contains("No login fix available for ai-agent-goose"), - "{err}" - ); - assert!(!active_logins() - .lock() - .unwrap() - .contains_key("ai-agent-goose")); - } -} diff --git a/apps/staged/src-tauri/src/doctor/login.rs b/apps/staged/src-tauri/src/doctor/login.rs new file mode 100644 index 000000000..e973e0b65 --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/login.rs @@ -0,0 +1,544 @@ +//! Interactive doctor logins: one piped-stdin CLI login per check, its +//! streamed output, cancellation, and the code hand-off. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +use serde::Serialize; + +use super::{ + doctor_env_vars, ExecuteFixOptions, FixCancelHandle, FixCancellation, FixStdin, FixStdinWriter, + FixType, +}; + +#[cfg(test)] +#[path = "tests/login.rs"] +mod tests; + +/// One `doctor-login-output` event: a line of a running login's output, or +/// its end. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorLoginOutput { + pub check_id: String, + /// The run this event belongs to — see [`ActiveLogin::run_id`]. A client + /// follows one run and drops events carrying any other id: a check id + /// alone cannot tell an earlier run's late `done` from the current run's. + pub run_id: String, + pub line: Option, + /// Position of `line` in the run's output, counted from zero; on the final + /// event, the number of lines the run emitted. A client that attached late + /// compares it against [`DoctorLoginStatus::next_seq`] to tell a line its + /// snapshot already covered from one that arrived after the snapshot. + pub seq: u64, + pub done: bool, + /// The fix's failure, when it failed. `None` on a cancelled run: doctor's + /// runner reports a cancellation as an `Err`, but a client should render + /// "cancelled", not a failure — that is what `cancelled` is for. + pub error: Option, + /// The run ended because [`cancel_doctor_login`] was called on it. + pub cancelled: bool, +} + +/// Answer to [`doctor_login_status`]: whether a login is running for the check, +/// and what it has printed so far. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorLoginStatus { + pub running: bool, + /// The running login's run id, which its events carry and which + /// [`send_doctor_login_code`] and [`cancel_doctor_login`] take. `None` + /// when nothing is running. + pub run_id: Option, + /// The last [`LOGIN_OUTPUT_TAIL_LINES`] lines the run emitted, oldest + /// first. Empty when nothing is running. + pub output: Vec, + /// The `seq` the run's next line will carry. `output` covers the `seq`s + /// from `next_seq - output.len()` up to but excluding `next_seq`, which is + /// how a client merges this snapshot with lines it received live. Counted + /// per run: a new run for the same check starts again from zero. + pub next_seq: u64, +} + +impl DoctorLoginStatus { + fn idle() -> Self { + Self { + running: false, + run_id: None, + output: Vec::new(), + next_seq: 0, + } + } +} + +/// What [`start_doctor_login`] did, and the run id of the login it either +/// began or found. Serialized as `{ "outcome": "started" | "alreadyRunning", +/// "runId": … }`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "outcome", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LoginStart { + Started { + run_id: String, + }, + /// A login for the check was already running. The caller can attach to it: + /// [`doctor_login_status`] has its output so far, the `doctor-login-output` + /// stream carries the rest, and [`send_doctor_login_code`] reaches it — + /// all under this run id. + AlreadyRunning { + run_id: String, + }, +} + +/// Lines of a login's output retained for a client that attaches after they +/// were streamed — a web client refreshed mid-login, a second client, a +/// reloaded webview. The same count the frontend keeps (`MAX_OUTPUT_LINES` in +/// `agentLogin.svelte.ts`), so a replay restores exactly what a client that +/// watched from the start is showing. +const LOGIN_OUTPUT_TAIL_LINES: usize = 40; + +/// Bounded, oldest-first tail of a login's output, with a count of every line +/// that went through it so each line's event can carry its position. +#[derive(Debug, Default)] +struct LoginOutputTail { + lines: VecDeque, + /// Lines pushed so far — the `seq` the next one gets. + next_seq: u64, +} + +impl LoginOutputTail { + /// Record `line`, dropping the oldest once the tail is full, and return the + /// `seq` the line's event carries. + fn push(&mut self, line: &str) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + if self.lines.len() == LOGIN_OUTPUT_TAIL_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line.to_string()); + seq + } + + /// The snapshot a late client replays, for the running login `run_id`. + fn status(&self, run_id: &str) -> DoctorLoginStatus { + DoctorLoginStatus { + running: true, + run_id: Some(run_id.to_string()), + output: self.lines.iter().cloned().collect(), + next_seq: self.next_seq, + } + } +} + +/// A login fix in flight, keyed by check id in [`ACTIVE_LOGINS`]. +struct ActiveLogin { + /// Identity of this run, minted by [`claim_login`] and unique across every + /// login Staged has ever run — a UUID, so it stays unique across a backend + /// restart that a web client's record may outlive. + /// + /// Events, the status snapshot and the start answer all carry it, and a + /// code or a cancel names it. The check id is the slot, not the run: the + /// slot is released *before* a run's `done` is emitted (see + /// [`run_login_fix`]), so a fresh start for the same check can land between + /// the two, and that run's client would otherwise take the earlier run's + /// `done` as its own end — and a code typed for the earlier run would be + /// delivered to the new one. + run_id: String, + /// The write end of the fix's stdin. Held here for the whole run — this + /// entry is its only long-lived owner — because dropping the last + /// `FixStdinWriter` is what closes the pipe: the clone + /// [`send_doctor_login_code`] takes lives for one write. Without this one + /// the CLI would read EOF at spawn, before the user had a code to give it. + /// + /// The flip side: a fix that reads stdin *to EOF* never gets it while its + /// slot is held, which is until the run ends — so such a fix would sit + /// until doctor's `FixTimeout`. None of the login commands does that (each + /// reads one line), and closing stdin is not how a login is ended anyway: + /// the Claude CLI ignores EOF and keeps waiting on its browser callback. + /// Ending a run early is `cancel`'s job. + writer: FixStdinWriter, + /// Stops the run through doctor's runner, which owns the child and kills + /// its whole process tree. See [`cancel_doctor_login`]. + cancel: FixCancelHandle, + /// The lines streamed so far, shared with the run's `on_line` callback, + /// which appends to it before emitting each event. + output: Arc>, +} + +/// Login fixes currently running, by check id. This is intentionally only a +/// lifetime map for active subprocesses, not a cache of authentication state; +/// doctor remains the source of truth for whether login is available. +static ACTIVE_LOGINS: OnceLock>> = OnceLock::new(); + +fn active_logins() -> &'static Mutex> { + ACTIVE_LOGINS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// A claimed login slot in [`ACTIVE_LOGINS`], released when dropped. +/// +/// The release is structural rather than a statement at the end of +/// [`run_login_fix`]: a panic in `doctor_env_vars().await` or in the emit +/// closure, or the awaiting future being dropped, would otherwise leave the +/// entry in place — and with it [`claim_login`] refusing the check until Staged +/// restarts. +/// +/// It releases only its own run: the entry is removed when it still carries +/// this slot's `run_id`, never a later run's that took the slot after this one +/// was already gone. +#[derive(Debug)] +struct LoginSlot { + check_id: String, + run_id: String, +} + +impl Drop for LoginSlot { + fn drop(&mut self) { + let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); + if logins + .get(&self.check_id) + .is_some_and(|login| login.run_id == self.run_id) + { + logins.remove(&self.check_id); + } + } +} + +/// Everything a claimed login needs to run: its run id, the pipe and token the +/// fix is given, the handle and tail the entry keeps, and the slot whose drop +/// releases the entry. +#[derive(Debug)] +pub(super) struct ClaimedLogin { + run_id: String, + stdin: FixStdin, + cancellation: FixCancellation, + cancel: FixCancelHandle, + output: Arc>, + slot: LoginSlot, +} + +/// What [`claim_login`] found. +#[derive(Debug)] +pub(super) enum LoginClaim { + /// The slot was free and is now this caller's. + Claimed(ClaimedLogin), + /// A login for the check is already running, under this run id. + AlreadyRunning { run_id: String }, +} + +/// Why a command aimed at one run of a check's login could not reach it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StaleLoginRun { + /// Nothing is running for the check: the run has ended. + Ended, + /// The run has ended and a newer one has since taken the check's slot. + Superseded, +} + +impl StaleLoginRun { + /// The refusal a client shows for a code typed into a login that is gone. + fn code_refusal(self, check_id: &str) -> String { + match self { + Self::Ended => format!( + "The login this code was typed for has ended; no login is running for {check_id}" + ), + Self::Superseded => format!( + "The login this code was typed for has ended; a newer login is running for \ + {check_id} and the code was not delivered to it" + ), + } + } +} + +/// The entry for `check_id` in `logins`, provided it is the run `run_id` the +/// caller is attached to. The map is keyed by check id, so this is where a +/// command aimed at a finished run is told apart from one aimed at the run +/// that replaced it. +fn find_login_run<'a>( + logins: &'a HashMap, + check_id: &str, + run_id: &str, +) -> Result<&'a ActiveLogin, StaleLoginRun> { + match logins.get(check_id) { + None => Err(StaleLoginRun::Ended), + Some(login) if login.run_id != run_id => Err(StaleLoginRun::Superseded), + Some(login) => Ok(login), + } +} + +/// A fresh run id — see [`ActiveLogin::run_id`]. +fn new_login_run_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Reserve the login slot for `check_id` under a fresh run id, returning what +/// the run needs — or the id of the login already running for the check. The +/// entry parked in [`ACTIVE_LOGINS`] is also the "a login is running" flag: +/// doctor's `FixStdin` is single-use, so a second concurrent login for one +/// check is refused here rather than spawning a CLI nothing can type into. +pub(super) fn claim_login(check_id: &str) -> Result { + doctor::agents::lookup_fix_command(check_id, &FixType::Auth) + .ok_or_else(|| format!("No login fix available for {check_id}"))?; + let run_id = new_login_run_id(); + let (writer, stdin) = FixStdin::pipe(); + // A fresh token per run: a cancelled one stays cancelled and would refuse + // the retry before it spawned. + let (cancel, cancellation) = FixCancellation::token(); + let output = Arc::new(Mutex::new(LoginOutputTail::default())); + let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(running) = logins.get(check_id) { + return Ok(LoginClaim::AlreadyRunning { + run_id: running.run_id.clone(), + }); + } + logins.insert( + check_id.to_string(), + ActiveLogin { + run_id: run_id.clone(), + writer, + cancel: cancel.clone(), + output: output.clone(), + }, + ); + Ok(LoginClaim::Claimed(ClaimedLogin { + run_id: run_id.clone(), + stdin, + cancellation, + cancel, + output, + slot: LoginSlot { + check_id: check_id.to_string(), + run_id, + }, + })) +} + +/// The final `doctor-login-output` event for a run. +/// +/// A cancelled run comes back from doctor's runner as an `Err`; `cancelled` is +/// what lets a client tell it from a failure, and the runner's message is kept +/// out of `error` so no client renders it as one. A cancel that landed after +/// the fix had already finished changes nothing — the fix's own result stands, +/// as the runner documents — so `cancelled` is only reported on a run that +/// actually ended early. +fn login_done_event( + check_id: String, + run_id: String, + result: &Result<(), String>, + cancel_requested: bool, + lines_emitted: u64, +) -> DoctorLoginOutput { + let cancelled = result.is_err() && cancel_requested; + DoctorLoginOutput { + check_id, + run_id, + line: None, + seq: lines_emitted, + done: true, + error: if cancelled { + None + } else { + result.as_ref().err().cloned() + }, + cancelled, + } +} + +/// Run a claimed login fix to completion on a piped stdin, streaming every +/// output line to the frontend as a `doctor-login-output` event and releasing +/// the slot afterwards. +/// +/// The final `done` event carries the outcome *and* the outcome is returned, so +/// this serves both entry points: [`start_doctor_login`], which spawns it and +/// watches the stream, and [`run_doctor_fix`], which awaits it. +pub(super) async fn run_login_fix( + app_handle: tauri::AppHandle, + check_id: String, + login: ClaimedLogin, +) -> Result<(), String> { + let ClaimedLogin { + run_id, + stdin, + cancellation, + cancel, + output, + slot, + } = login; + let env_vars = doctor_env_vars().await; + let event_check_id = check_id.clone(); + let event_run_id = run_id.clone(); + let event_app = app_handle.clone(); + let tail = output.clone(); + let result = doctor::execute_fix_streaming_with_env_options( + check_id.clone(), + FixType::Auth, + ExecuteFixOptions::default() + .with_env_snapshot(env_vars) + .with_stdin(stdin) + .with_cancellation(cancellation), + move |line| { + // Recorded before it is emitted, so a `doctor_login_status` + // snapshot taken between the two already covers the line whose + // event is about to follow it — the client's `seq` comparison then + // drops the event rather than showing the line twice. + let seq = tail.lock().unwrap_or_else(|e| e.into_inner()).push(line); + crate::web_server::emit_to_all( + &event_app, + "doctor-login-output", + DoctorLoginOutput { + check_id: event_check_id.clone(), + run_id: event_run_id.clone(), + line: Some(line.to_string()), + seq, + done: false, + error: None, + cancelled: false, + }, + ); + }, + ) + .await; + + let lines_emitted = output.lock().unwrap_or_else(|e| e.into_inner()).next_seq; + let done = login_done_event( + check_id.clone(), + run_id, + &result, + cancel.is_cancelled(), + lines_emitted, + ); + if done.cancelled { + if let Err(message) = &result { + log::info!("[doctor login {check_id}] cancelled: {message}"); + } + } + // Released *before* the `done` goes out. A client attaches by registering + // its listener and then asking `doctor_login_status`, so this order gives + // it a guarantee: a snapshot that says `running` was taken before the + // `done` was emitted, and the `done` is still ahead of the listener. The + // other order would let a snapshot report a run whose `done` had already + // passed, leaving that client waiting for an end it can never see. + // + // The price of this order is that a fresh start for the same check can be + // claimed between the release and the emit, and its client then sees this + // run's `done`. That is what `run_id` on the event is for: the client + // follows the run id its start returned and drops this one. + drop(slot); + crate::web_server::emit_to_all(&app_handle, "doctor-login-output", done); + result +} + +/// Start an interactive login fix and stream its output to the frontend. +/// +/// Returns as soon as the fix is claimed and spawned, with the run id its +/// events will carry; the caller learns the outcome from the `done` event, +/// which lets it feed a code through [`send_doctor_login_code`] while the fix +/// is still running. A login already running for the check is reported as +/// [`LoginStart::AlreadyRunning`] with *its* run id rather than as an error: +/// it is the same subprocess the caller wanted, and it can attach to it (see +/// [`doctor_login_status`]). +#[tauri::command] +pub async fn start_doctor_login( + app_handle: tauri::AppHandle, + check_id: String, +) -> Result { + let login = match claim_login(&check_id)? { + LoginClaim::Claimed(login) => login, + LoginClaim::AlreadyRunning { run_id } => return Ok(LoginStart::AlreadyRunning { run_id }), + }; + let run_id = login.run_id.clone(); + tokio::spawn(async move { + // A failure is reported to the frontend by the final `done` event; this + // handle has no caller to return it to. + let _ = run_login_fix(app_handle, check_id, login).await; + }); + Ok(LoginStart::Started { run_id }) +} + +/// Ask run `run_id` of `check_id`'s login to stop, reporting whether it was +/// found. Idempotent, and harmless when nothing is running — or when the run +/// has ended and a newer one holds the check's slot, which is left alone: it +/// is someone else's login, and `false` tells the caller its own is gone. +/// +/// The stop goes through doctor's cancellation token, which makes the runner — +/// the owner of the child — kill the fix's process tree, and the run then ends +/// with a `done` event carrying `cancelled: true`. It is deliberately *not* +/// done by dropping the stdin writer: the Claude CLI ignores EOF on stdin once +/// it has printed its URL and keeps waiting on its browser callback (the +/// stdin-vs-TTY experiments ran it with `< /dev/null` and killed it 25s later, +/// still waiting), so a closed pipe would leave the slot held until the fix +/// timeout with nothing to show for it. +#[tauri::command] +pub async fn cancel_doctor_login(check_id: String, run_id: String) -> bool { + let cancel = find_login_run( + &active_logins().lock().unwrap_or_else(|e| e.into_inner()), + &check_id, + &run_id, + ) + .map(|login| login.cancel.clone()); + match cancel { + Ok(cancel) => { + cancel.cancel(); + true + } + Err(_) => false, + } +} + +/// Whether a login is running for `check_id`, under which run id, and what it +/// has printed so far — for a client that lost its own record of the login (a +/// web refresh, a second client, a reloaded webview) and needs the sign-in URL +/// and code entry back. +/// +/// Register the `doctor-login-output` listener *before* calling this, then +/// follow the run id it names and merge that run's lines by `seq`: lines below +/// [`DoctorLoginStatus::next_seq`] are in the snapshot, lines at or above it +/// arrived after it. +#[tauri::command] +pub async fn doctor_login_status(check_id: String) -> DoctorLoginStatus { + // The map lock and the tail lock are never held together: the `on_line` + // callback takes only the tail's, the slot release only the map's. + let running = active_logins() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&check_id) + .map(|login| (login.run_id.clone(), login.output.clone())); + match running { + Some((run_id, output)) => output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .status(&run_id), + None => DoctorLoginStatus::idle(), + } +} + +/// Deliver a line — in practice the authentication code the agent CLI asked +/// for — to run `run_id` of a login started by [`start_doctor_login`] or +/// [`run_doctor_fix`]. A code typed for a run that has since ended is refused, +/// with the refusal saying so, rather than delivered to whatever login now +/// holds the check's slot: that one printed a different URL, and a code for +/// the old one would only be rejected by it — or, worse, accepted. +/// +/// `async` deliberately: `send_line` writes into the fix's stdin pipe inline +/// and can block if the fix isn't reading, and under Tauri 2 a non-`async` +/// command body runs on the main thread — the worst possible place to discover +/// a full pipe. The write itself goes to a blocking thread for the same reason. +#[tauri::command] +pub async fn send_doctor_login_code( + check_id: String, + run_id: String, + code: String, +) -> Result<(), String> { + let writer = find_login_run( + &active_logins().lock().unwrap_or_else(|e| e.into_inner()), + &check_id, + &run_id, + ) + .map(|login| login.writer.clone()) + .map_err(|stale| stale.code_refusal(&check_id))?; + tokio::task::spawn_blocking(move || writer.send_line(code)) + .await + .map_err(|e| format!("Failed to deliver the login code for {check_id}: {e}"))? +} diff --git a/apps/staged/src-tauri/src/doctor/managed_bridges.rs b/apps/staged/src-tauri/src/doctor/managed_bridges.rs new file mode 100644 index 000000000..bd3c07882 --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/managed_bridges.rs @@ -0,0 +1,569 @@ +//! Managed bridge readouts — what the Claude/Codex rows say about the copies +//! Staged installs: versions, update/install fixes, and why a managed copy is +//! missing. + +use std::path::Path; + +use crate::managed_acp_tools::{ManagedTool, ReconcileRecord}; + +use super::{ + managed_tool_id_for_check, AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, FixType, + InstallSource, +}; + +#[cfg(test)] +#[path = "tests/managed_bridges.rs"] +mod tests; + +// ============================================================================= +// Managed bridge readouts — the versions of the ACP bridges Staged installs +// ============================================================================= + +/// What Staged knows about one managed bridge's versions at report time. +/// +/// Gathered outside the doctor crate: the crate labels the managed shims +/// bundled and, on that policy, probes only the vendored agent CLI's version +/// (`main`) and offers no update. Staged updates these packages itself at +/// launch and daily, so its rows additionally show — and can update — the ACP +/// package, as a second readout on the same executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ManagedBridgeVersions { + tool: ManagedTool, + /// The ACP package version installed in `~/.staged/packages`, from the + /// live package's `package.json` (`state.json` as fallback). + installed: Option, + /// The newest published version of the *same* package. `None` outside the + /// freshness pass and whenever the lookup failed — unknown, not current. + latest: Option, +} + +/// The versions of every bridge this build manages, gathered concurrently. +/// Empty when nothing is managed (dev override, `no-managed-acp-tools`, +/// unsupported target), which is also what keeps those builds from ever +/// growing a managed update action. The registry is asked only when +/// `check_freshness` is set. +pub(super) async fn managed_bridge_versions( + check_freshness: bool, + env_vars: &[(String, String)], +) -> Vec { + let tools = crate::managed_acp_tools::managed_tools(); + if tools.is_empty() { + return Vec::new(); + } + let Some(packages_root) = crate::paths::packages_dir() else { + return Vec::new(); + }; + let handles: Vec<_> = tools + .into_iter() + .map(|tool| { + let env_vars = env_vars.to_vec(); + let packages_root = packages_root.clone(); + tokio::spawn(async move { + let installed = + crate::managed_acp_tools::installed_tool_version(&packages_root, &tool); + let latest = if check_freshness { + crate::managed_acp_tools::latest_published_version(&tool, &env_vars).await + } else { + None + }; + ManagedBridgeVersions { + tool, + installed, + latest, + } + }) + }) + .collect(); + let mut versions = Vec::with_capacity(handles.len()); + for handle in handles { + if let Ok(version) = handle.await { + versions.push(version); + } + } + versions +} + +/// What Doctor says about the managed installer on a Claude/Codex row whose +/// executable is *not* the managed shim: the last reconcile's outcome from +/// `state.json` — the only record a failed launch install leaves behind — and +/// the npm registry a managed install resolves on this machine. Gathered once +/// per report; nothing here is persisted. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct ManagedInstallDiagnostics { + /// `state.json`'s `lastReconcile`, `None` before the first reconcile has + /// finished (or with no `state.json` at all). + last_reconcile: Option, + /// The registry label for the row's raw output: the `STAGED_NPM_REGISTRY` + /// override, what `npm config get registry` answered under the doctor + /// env snapshot, or "npm default" when neither could be asked. `None` + /// when the probe was skipped because every managed shim was present + /// when the report started (see [`any_managed_shim_missing`]). + npm_registry: Option, + /// Wall-clock milliseconds the reconcile age is rendered against. + now_ms: u64, +} + +impl ManagedInstallDiagnostics { + /// The last reconcile's error for `tool_id`, without the `: ` prefix + /// the reconciler records it under. + fn reconcile_error_for(&self, tool_id: &str) -> Option<&str> { + reconcile_error_for(self.last_reconcile.as_ref()?, tool_id) + } + + /// One raw-output line on the last managed install run. + fn last_reconcile_line(&self) -> String { + match &self.last_reconcile { + None => "last managed install: not run yet (runs at launch and daily)".to_string(), + Some(record) => format!( + "last managed install: {} {}", + if record.ok { "succeeded" } else { "failed" }, + format_age(record.at_ms, self.now_ms) + ), + } + } + + /// One raw-output line naming the npm registry Doctor detected. + fn npm_registry_line(&self) -> String { + format!( + "npm registry: {}", + self.npm_registry.as_deref().unwrap_or("not probed") + ) + } +} + +/// The reconciler records each failure as `: `; the error on +/// its own, for the tool asked about. +fn reconcile_error_for<'a>(record: &'a ReconcileRecord, tool_id: &str) -> Option<&'a str> { + record + .errors + .iter() + .find_map(|error| error.strip_prefix(tool_id)?.strip_prefix(": ")) +} + +/// `just now`, `N minutes ago`, `N hours ago`, `N days ago` — enough to tell a +/// stale record from the launch reconcile that just ran. +pub(super) fn format_age(at_ms: u64, now_ms: u64) -> String { + let seconds = now_ms.saturating_sub(at_ms) / 1000; + let ago = |count: u64, unit: &str| { + let plural = if count == 1 { "" } else { "s" }; + format!("{count} {unit}{plural} ago") + }; + match seconds { + 0..=59 => "just now".to_string(), + 60..=3599 => ago(seconds / 60, "minute"), + 3600..=86_399 => ago(seconds / 3600, "hour"), + _ => ago(seconds / 86_400, "day"), + } +} + +/// Gather [`ManagedInstallDiagnostics`] for a report from `state.json` and +/// the registry label the report's probes produced (see +/// [`detect_npm_registry_label_if`]). +pub(super) fn managed_install_diagnostics( + npm_registry: Option, +) -> ManagedInstallDiagnostics { + ManagedInstallDiagnostics { + last_reconcile: last_reconcile_record(), + npm_registry, + now_ms: crate::managed_acp_tools::now_ms(), + } +} + +/// Whether any managed bridge's shim is absent from `shim_bin_dir`. The shim +/// dir is first on the probe PATH, so this is the only way a Claude/Codex row +/// can resolve to a copy Staged does not manage — and so the only time the +/// npm registry probe, which spawns `npm`, has a row to show its answer on. +/// Decided before the probes run so that probe can join them rather than +/// wait on the report. +pub(super) fn any_managed_shim_missing(shim_bin_dir: &Path) -> bool { + crate::managed_acp_tools::managed_tools() + .iter() + .any(|tool| !shim_bin_dir.join(tool.binary).is_file()) +} + +/// [`detect_npm_registry_label`] when `probe` is set; `None` otherwise. +pub(super) async fn detect_npm_registry_label_if( + probe: bool, + env_vars: &[(String, String)], +) -> Option { + if probe { + Some(detect_npm_registry_label(env_vars).await) + } else { + None + } +} + +/// `state.json`'s last reconcile record, if any. +pub(super) fn last_reconcile_record() -> Option { + let packages_root = crate::paths::packages_dir()?; + crate::managed_acp_tools::read_state(&packages_root).last_reconcile +} + +/// The npm registry a managed install resolves, labeled with where that +/// answer came from — so a row can say which mirror (or the public registry) +/// the failed install actually asked. The underlying `npm config get +/// registry` is bounded to 5 s and cached per env snapshot for a minute, so +/// a Doctor refresh does not spawn npm again. +async fn detect_npm_registry_label(env_vars: &[(String, String)]) -> String { + if let Some(registry) = crate::managed_acp_tools::npm_registry_override() { + return format!("{registry} (STAGED_NPM_REGISTRY)"); + } + match crate::managed_acp_tools::configured_npm_registry(env_vars).await { + Some(registry) => format!("{registry} (per npm config)"), + None => "npm default (per npm config; no npm on PATH to ask)".to_string(), + } +} + +/// Layer the managed ACP readout onto every check whose executable is one of +/// the managed shims in `shim_bin_dir` (see [`apply_managed_bridge_readout`]). +/// Every other Claude/Codex row is a copy Staged does not manage: it is +/// flagged as such, with why the managed copy is missing and the managed +/// install as its fix (see [`flag_missing_managed_copy`]), and its update is +/// pointed at the managed installer too (see +/// [`redirect_unmanaged_update_to_managed_install`]) — on a managing build it +/// is the only writer for these two packages. +pub(super) fn apply_managed_bridge_readouts( + report: &mut DoctorReport, + versions: &[ManagedBridgeVersions], + shim_bin_dir: &Path, + check_freshness: bool, + diagnostics: &ManagedInstallDiagnostics, +) { + for check in &mut report.checks { + let Some(tool_id) = managed_tool_id_for_check(&check.id) else { + continue; + }; + if let Some(versions) = versions.iter().find(|v| v.tool.id == tool_id) { + if !apply_managed_bridge_readout(check, versions, shim_bin_dir, check_freshness) { + flag_missing_managed_copy(check, versions, shim_bin_dir, diagnostics); + redirect_unmanaged_update_to_managed_install(check, versions, shim_bin_dir); + } + } + } +} + +/// Note on the Claude/Codex rows that this build does not manage bridge +/// installs, and why — the `STAGED_ACP_TOOLS_DIR` dev override, the +/// `no-managed-acp-tools` feature, an unsupported target. A copy resolved from +/// Homebrew (or the override dir) is then by design, and the row must not read +/// like a failed install. Status is kept; only the message and raw output +/// gain the note. +pub(super) fn note_managed_installs_disabled(report: &mut DoctorReport, reason: &str) { + for check in &mut report.checks { + if managed_tool_id_for_check(&check.id).is_none() { + continue; + } + let note = format!("managed installs disabled: {reason}"); + check.message = format!("{} ({note})", check.message); + append_raw_output(check, ¬e); + } +} + +/// Add a block to a check's raw output, below whatever the crate recorded. +fn append_raw_output(check: &mut DoctorCheck, block: &str) { + check.raw_output = Some(match check.raw_output.take() { + Some(existing) if !existing.trim().is_empty() => { + format!("{}\n\n{block}", existing.trim_end()) + } + _ => block.to_string(), + }); +} + +/// Flag a Claude/Codex row whose executable is a copy Staged does not manage +/// — Homebrew, the user's npm prefix, nvm — and report whether it did. +/// +/// On a managing build the managed shim dir is first on the probe PATH, so a +/// copy from anywhere else can only win while the managed copy is *absent*: +/// the launch reconcile has not finished yet, or it failed (no network to the +/// registry, a gateway block page, a 404 from a mirror, a validation failure). +/// Left as the crate built it, the row is a green "Installed" pointing at +/// `/opt/homebrew/bin`, indistinguishable from a healthy setup and with no +/// way to get the managed copy. So: +/// +/// - `status` drops to `Warn` (a row already warning or failing keeps its +/// status), and `message` says which copy is in use and, before any +/// reconcile has finished, that the managed install "hasn't completed yet" +/// — a healthy first launch, not a failure; after one, that the managed +/// copy is missing, with — when the last reconcile recorded an error for +/// this tool — the short of that error (see [`short_error_fragment`]). +/// - `raw_output` gains the expected shim path, the last reconcile's outcome +/// and age, the full recorded error with a classified hint on what to +/// change (`install_failure_hint`), and the npm registry Doctor detected. +/// - The row's fix becomes the managed install (`Bridge`, which +/// `run_doctor_fix` routes to `install_managed_tool_logged` for these two +/// checks), described by [`managed_install_description`] — the same text +/// [`redirect_unmanaged_update_to_managed_install`] gives the row's update +/// when a newer release is known, so the two buttons never disagree. The +/// fix is offered whether or not an update is available: the point is to +/// get the managed copy, not to chase a version. +/// +/// `path`, `main` and the version fields stay the crate's: the row still shows +/// the copy that is actually on PATH and how old it is. +fn flag_missing_managed_copy( + check: &mut DoctorCheck, + versions: &ManagedBridgeVersions, + shim_bin_dir: &Path, + diagnostics: &ManagedInstallDiagnostics, +) -> bool { + let Some(path) = check.path.clone() else { + return false; + }; + let tool = &versions.tool; + if is_managed_shim_path(&path, shim_bin_dir, tool.binary) { + return false; + } + let reconcile_error = diagnostics.reconcile_error_for(tool.id); + let situation = match (&diagnostics.last_reconcile, reconcile_error) { + (None, _) => "Staged's managed install hasn't completed yet".to_string(), + (Some(_), None) => "Staged's managed copy is missing".to_string(), + (Some(_), Some(error)) => format!( + "Staged's managed copy is missing: {}", + short_error_fragment(error) + ), + }; + if check.status == CheckStatus::Pass { + check.status = CheckStatus::Warn; + check.message = format!("Using {path} — {situation}"); + } else { + check.message = format!("{}; using {path} — {situation}", check.message); + } + + let mut detail = vec![ + format!( + "Staged-managed copy: missing (expected {})", + shim_bin_dir.join(tool.binary).display() + ), + diagnostics.last_reconcile_line(), + ]; + if let Some(error) = reconcile_error { + detail.push(format!("{}: {error}", tool.id)); + if let Some(hint) = crate::managed_acp_tools::install_failure_hint(error, tool.package) { + detail.push(format!("hint: {hint}")); + } + } + detail.push(diagnostics.npm_registry_line()); + append_raw_output(check, &detail.join("\n")); + + let latest = versions.latest.as_deref().or_else(|| { + check + .main + .as_ref() + .and_then(|main| main.latest_version.as_deref()) + }); + check.fix_type = Some(FixType::Bridge); + check.fix_command = Some(managed_install_description(tool, latest, &path)); + true +} + +/// Most a recorded install error contributes to a row's one-line message: +/// npm's own `npm error code ` when the error carries one (what +/// `npm_error_summary` leads with), else the error's first +/// [`SHORT_ERROR_CHARS`] characters, ellipsized. The full text, its hint and +/// the registry stay in the raw output for **Copy details**. +fn short_error_fragment(error: &str) -> String { + const NPM_CODE: &str = "npm error code "; + if let Some((_, rest)) = error.split_once(NPM_CODE) { + let code = rest + .split(|c: char| c == ';' || c.is_whitespace()) + .next() + .unwrap_or_default(); + if !code.is_empty() { + return format!("{NPM_CODE}{code}"); + } + } + match error.char_indices().nth(SHORT_ERROR_CHARS) { + Some((cut, _)) => format!("{}…", error[..cut].trim_end()), + None => error.to_string(), + } +} + +/// How much of a recorded error without an npm code fits on the row. +const SHORT_ERROR_CHARS: usize = 120; + +/// Give a managed Claude/Codex row the two readouts its one executable +/// stands for, and report whether it did. +/// +/// - `main` is left as the doctor crate built it: the vendored agent CLI's +/// version, probed through the bridge passthrough, with the crate's bundled +/// suppression (no update) intact — the agent is updated as part of the +/// package, never on its own. +/// - `bridge` is added: the ACP package's installed version, and on the +/// freshness pass the latest published version of that same package, +/// `update_available` from comparing the two, and — only when newer — the +/// one actionable update, typed `UpdateBridge`, which `run_doctor_update` +/// routes to the managed installer. The command text describes updating +/// Staged's managed copy; nothing runs it as a shell command. +/// - `bridge_path` is set to the same executable so the row's path gate +/// renders the readout, on both the initial and the freshness report — the +/// frontend's freshness merge copies `bridge` but not `bridge_path`, so a +/// readout that appeared only on the second pass would stay hidden. +/// - The flat version fields mirror the ACP readout, as the crate does for +/// any check with a bridge readout. +/// +/// Nothing is touched unless the check's executable is the managed shim for +/// this tool and the crate labeled it bundled: a copy that resolved elsewhere +/// keeps its source-aware readout, is flagged as missing the managed copy +/// (see [`flag_missing_managed_copy`]), and has its update redirected to the +/// managed install (see [`redirect_unmanaged_update_to_managed_install`]). An +/// unknown installed or latest version leaves `update_available` unknown +/// rather than false. +fn apply_managed_bridge_readout( + check: &mut DoctorCheck, + versions: &ManagedBridgeVersions, + shim_bin_dir: &Path, + check_freshness: bool, +) -> bool { + let Some(path) = check.path.clone() else { + return false; + }; + if !is_managed_shim_path(&path, shim_bin_dir, versions.tool.binary) { + return false; + } + if check.main.as_ref().and_then(|main| main.bundled) != Some(true) { + return false; + } + + let update_available = if check_freshness { + newer_version_available(versions.installed.as_deref(), versions.latest.as_deref()) + } else { + None + }; + let update_command = match (&update_available, &versions.latest) { + (Some(true), Some(latest)) => Some(managed_update_description(&versions.tool, latest)), + _ => None, + }; + let bridge = AgentVersionInfo { + install_source: Some(InstallSource::Bundled), + bundled: Some(true), + installed_version: versions.installed.clone(), + latest_version: versions.latest.clone(), + update_available, + // Only the freshness pass says anything about updates, matching the + // crate; and what it says is "not self-updating": the update is the + // user's to action here, unlike the crate's bundled default. + self_updating: check_freshness.then_some(false), + update_fix_type: update_command.as_ref().map(|_| FixType::UpdateBridge), + update_command, + }; + check.installed_version = bridge.installed_version.clone(); + check.latest_version = bridge.latest_version.clone(); + check.update_available = bridge.update_available; + check.self_updating = bridge.self_updating; + check.bridge_path = Some(path); + check.bridge = Some(bridge); + true +} + +/// Point a non-managed copy's update at the managed installer, and report +/// whether it did. +/// +/// Doctor can resolve one of these two checks to a copy of the bridge the user +/// installed themselves (Homebrew, their own npm prefix, nvm) — before the +/// launch reconcile has written the managed shim, or for as long as it keeps +/// failing. On a managing build the crate's source-aware `UpdateMain` must not +/// run: `npm install -g @latest` under the fix env's `NPM_CONFIG_PREFIX` +/// would install a third copy into `~/.staged/packages/npm-prefix` that +/// nothing owns, and would not upgrade the user's copy the confirmation named. +/// +/// So the action becomes the managed install, described as such, and only the +/// action: `path`, `install_source`, both versions and `update_available` stay +/// as the crate reported them, so the row keeps showing the copy that is +/// actually on PATH, where it is, and how old it is. The fix type is set even +/// when the crate derived no command (an `Unknown` source, say) — Staged can +/// install its own copy where the crate had nothing to offer. +/// +/// The version the description names is the one the installer will fetch: +/// `versions.latest`, Staged's own `npm view version` against the +/// configured registry, gathered alongside the managed readout. The crate's +/// `latest_version` is derived per install source — `brew info` for a Homebrew +/// copy — and can name a release the configured registry does not (yet) carry, +/// or lag one it does; it is only the fallback for when the managed probe +/// failed, so the text at least names the release the row is flagged stale +/// against. +/// +/// Only ever fires on the freshness pass, since `update_available` is unset +/// until then, and never on a management-off build, where +/// [`managed_bridge_versions`] returns nothing. +fn redirect_unmanaged_update_to_managed_install( + check: &mut DoctorCheck, + versions: &ManagedBridgeVersions, + shim_bin_dir: &Path, +) -> bool { + let Some(path) = check.path.clone() else { + return false; + }; + if is_managed_shim_path(&path, shim_bin_dir, versions.tool.binary) { + return false; + } + let Some(main) = check.main.as_mut() else { + return false; + }; + if main.update_available != Some(true) { + return false; + } + let latest = versions + .latest + .as_deref() + .or(main.latest_version.as_deref()); + main.update_command = Some(managed_install_description(&versions.tool, latest, &path)); + main.update_fix_type = Some(FixType::UpdateMain); + true +} + +/// Whether `path` is the shim Staged writes for `binary` in `shim_bin_dir` — +/// the executable of a managed install, as opposed to a same-named copy that +/// resolved from anywhere else on PATH. +fn is_managed_shim_path(path: &str, shim_bin_dir: &Path, binary: &str) -> bool { + let path = Path::new(path); + path.starts_with(shim_bin_dir) && path.file_name().is_some_and(|name| name == binary) +} + +/// What the update confirmation shows for a managed bridge. Not a shell +/// command: `run_doctor_update` dispatches this readout to the managed +/// installer, which installs `@latest` into `~/.staged/packages` — so +/// the text says that, rather than offering a global `npm install -g` the +/// user might run by hand and end up with a second copy. +fn managed_update_description(tool: &ManagedTool, latest: &str) -> String { + format!( + "update Staged's managed {} to {latest} in ~/.staged/packages", + tool.package + ) +} + +/// What the update confirmation shows when the row is a copy Staged does not +/// manage: the managed copy is absent or stale, so the installer *installs* +/// rather than updates, and the text is explicit that `replaced` — the copy on +/// PATH, the one the version beside it was read from — is not touched. +fn managed_install_description(tool: &ManagedTool, latest: Option<&str>, replaced: &str) -> String { + let version = latest + .map(|latest| format!(" {latest}")) + .unwrap_or_default(); + format!( + "install Staged's managed {}{version} in ~/.staged/packages \ + (the copy at {replaced} is left as is)", + tool.package + ) +} + +/// `Some(true)` when `latest` is a newer release than `installed`, comparing +/// the two versions of one package as semver triples. `None` — unknown, not +/// "current" — when either side is missing or does not parse. +fn newer_version_available(installed: Option<&str>, latest: Option<&str>) -> Option { + let installed = parse_version_triple(installed?)?; + let latest = parse_version_triple(latest?)?; + Some(latest > installed) +} + +/// `X.Y.Z[-pre][+build]`, with an optional leading `v`, as `(X, Y, Z)`. +fn parse_version_triple(version: &str) -> Option<(u64, u64, u64)> { + let core = version + .trim() + .trim_start_matches('v') + .split(['-', '+', ' ']) + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) +} diff --git a/apps/staged/src-tauri/src/doctor/mod.rs b/apps/staged/src-tauri/src/doctor/mod.rs new file mode 100644 index 000000000..3d79157d2 --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/mod.rs @@ -0,0 +1,456 @@ +//! Tauri command wrappers for the doctor health-check system. + +use std::path::PathBuf; + +pub use doctor::types::{AuthStatus, InstallSource}; +pub use doctor::{ + AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, ExecuteFixOptions, FixCancelHandle, + FixCancellation, FixStdin, FixStdinWriter, FixType, RunChecksOptions, +}; + +pub mod login; +mod managed_bridges; +mod runtime_checks; + +#[cfg(test)] +#[path = "tests/support.rs"] +pub(crate) mod test_support; +#[cfg(test)] +#[path = "tests/commands.rs"] +mod tests; + +pub use login::{ + cancel_doctor_login, doctor_login_status, send_doctor_login_code, start_doctor_login, + DoctorLoginOutput, DoctorLoginStatus, LoginStart, +}; +use login::{claim_login, run_login_fix, LoginClaim}; +use managed_bridges::{ + any_managed_shim_missing, apply_managed_bridge_readouts, detect_npm_registry_label_if, + managed_bridge_versions, managed_install_diagnostics, note_managed_installs_disabled, +}; +use runtime_checks::{run_node_runtime_check, session_queue_doctor_check, NODE_RUNTIME_CHECK_ID}; + +/// Environment snapshot for doctor checks and fixes — and for the managed +/// bridge installs themselves (see +/// [`crate::managed_acp_tools::install_managed_tool`]), so the registry npm +/// resolves is the same for a check, its fix, and the reconciler. Shaped +/// through `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. +pub(crate) 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_managed_tools_env(&mut env_vars); + crate::managed_acp_tools::apply_managed_npm_env( + &mut env_vars, + &crate::managed_acp_tools::managed_npm_env(), + ); + env_vars +} + +fn run_checks_options( + check_freshness: bool, + env_vars: Vec<(String, String)>, + bundled_dir: Option, +) -> RunChecksOptions { + RunChecksOptions { + check_freshness, + offline: false, + // npm-backed checks and fixes pass a `--registry` only when the user + // set `STAGED_NPM_REGISTRY`; otherwise npm resolves its own config + // (see the registry policy note in `managed_acp_tools`). + npm_registry: crate::managed_acp_tools::npm_registry_override(), + env: None, + // Doctor labels binaries resolved from this dir as bundled (install + // source + readout flag) and suppresses registry update fixes for + // 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) +} + +fn execute_fix_options( + command_override: Option, + env_vars: Vec<(String, String)>, +) -> ExecuteFixOptions { + execute_fix_options_through( + command_override, + crate::managed_acp_tools::npm_registry_override(), + env_vars, + ) +} + +/// [`execute_fix_options`] with the registry chosen by the caller — the +/// gateway-blocked retry passes the compiled-in fallback here. +fn execute_fix_options_through( + command_override: Option, + npm_registry: Option, + env_vars: Vec<(String, String)>, +) -> ExecuteFixOptions { + // Everything else stays at doctor's defaults: the fixes that reach this + // builder are the non-interactive ones (installs and updates), so nothing + // here feeds a prompt and the child keeps inheriting stdin rather than + // getting a piped one; the standard fix timeout is far above any install + // this runs. Interactive logins do *not* come through here — every + // `FixType::Auth` run goes through [`run_login_fix`], which pipes stdin so + // the code the CLI asks for can actually be delivered. Spelled with + // `..Default::default()` so a new doctor option doesn't break this + // workspace-excluded crate, which `cargo check` under `crates/` never + // compiles but `staged-ci.yml` does. + ExecuteFixOptions { + command_override, + npm_registry, + ..Default::default() + } + .with_env_snapshot(env_vars) +} + +/// Run a doctor fix through the crate's executor with the registry precedence +/// the managed installer applies: npm's own config (or the explicit +/// `STAGED_NPM_REGISTRY`) first, and — only for an npm-backed command that +/// failed as a gateway block of `registry.npmjs.org`, with no override set +/// and a fallback compiled in — one retry with `--registry `. The +/// executor surfaces the failed command's stderr as its error string, which +/// is exactly what the classifier reads; when the retry fails too, the +/// surfaced error keeps the original diagnosis in front of the retry's. +async fn execute_fix_with_gateway_fallback( + check_id: String, + fix_type: FixType, + command_override: Option, + npm_backed: bool, + env_vars: Vec<(String, String)>, +) -> Result<(), String> { + let first = doctor::execute_fix_with_env_options( + check_id.clone(), + fix_type.clone(), + execute_fix_options(command_override.clone(), env_vars.clone()), + ) + .await; + let Err(error) = first else { + return Ok(()); + }; + if !npm_backed { + return Err(error); + } + let Some(fallback) = crate::managed_acp_tools::gateway_fallback_registry( + crate::managed_acp_tools::npm_registry_override().as_deref(), + crate::managed_acp_tools::fallback_npm_registry(), + &error, + ) else { + return Err(error); + }; + log::info!( + "[doctor fix {check_id}] npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" + ); + doctor::execute_fix_with_env_options( + check_id, + fix_type, + execute_fix_options_through(command_override, Some(fallback.to_string()), env_vars), + ) + .await + .map_err(|retry_error| { + crate::managed_acp_tools::gateway_retry_failed_message(fallback, &retry_error) + }) +} + +/// Run all health checks and return the report. +/// +/// This is the cheap, no-network path: it resolves binaries and reports +/// install/auth status but does not probe registries for version freshness. +/// The frontend calls this first for an instant paint, then follows up with +/// [`run_doctor_freshness`] to fill in version/update information. +/// +/// Takes the app handle rather than `State` borrows so the command keeps its +/// plain `DoctorReport` return: the session-queue check reads store and +/// registry state synchronously before the first await. +#[tauri::command] +pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { + let queue = session_queue_doctor_check(&app_handle); + run_doctor_report(false, queue).await +} + +/// Run all health checks with version freshness enabled. +/// +/// This is the slower second pass: it probes each readout's installed version +/// and looks up the latest version from the relevant registry (npm, brew, +/// crates.io, GitHub releases), populating `installedVersion`, `latestVersion`, +/// `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 { + let queue = session_queue_doctor_check(&app_handle); + run_doctor_report(true, queue).await +} + +/// Run the doctor crate's checks plus Staged-local ones over one shared env +/// snapshot: the managed Node.js runtime check, the session-queue check the +/// caller already took (`queue_check`; both passes carry it, since the +/// frontend merges the freshness report into the first by check id), and the +/// managed-bridge version readouts layered onto the Claude/Codex rows (see +/// [`apply_managed_bridge_readouts`]). Bundled readouts are labeled by the +/// doctor crate itself via `RunChecksOptions::bundled_tools_dir`. +/// +/// The managed versions are gathered concurrently with the crate's run: the +/// installed version is a local `package.json` read on both passes, and the +/// registry lookup for the latest version runs only when `check_freshness` +/// is set, so the first paint never waits on the network. The npm registry +/// probe for a missing-managed-copy row (see [`managed_install_diagnostics`]) +/// joins them too, decided from the shim dir up front, so it never adds to +/// the report's wall-clock after the crate's checks are in. +async fn run_doctor_report( + check_freshness: bool, + queue_check: Option, +) -> DoctorReport { + let env_vars = doctor_env_vars().await; + let shim_bin_dir = crate::managed_acp_tools::managed_shim_bin_dir(); + let probe_registry = shim_bin_dir + .as_deref() + .is_some_and(any_managed_shim_missing); + let (mut report, node_runtime, managed_versions, npm_registry) = tokio::join!( + doctor::run_checks_with_options(run_checks_options( + check_freshness, + env_vars.clone(), + crate::acp_tools::primary_tools_dir(), + )), + run_node_runtime_check(), + managed_bridge_versions(check_freshness, &env_vars), + detect_npm_registry_label_if(probe_registry, &env_vars), + ); + match shim_bin_dir { + Some(shim_bin_dir) => { + let diagnostics = managed_install_diagnostics(npm_registry); + apply_managed_bridge_readouts( + &mut report, + &managed_versions, + &shim_bin_dir, + check_freshness, + &diagnostics, + ); + } + None => { + if let Some(reason) = crate::managed_acp_tools::managed_tools_disabled_reason() { + note_managed_installs_disabled(&mut report, &reason); + } + } + } + if let Some(check) = node_runtime { + report.checks.push(check); + } + report.checks.extend(queue_check); + report +} + +/// Reserve the login slot for `check_id` under a fresh run id, returning what +/// the run needs — or the id of the login already running for the check. The +/// entry parked in [`ACTIVE_LOGINS`] is also the "a login is running" flag: +/// doctor's `FixStdin` is single-use, so a second concurrent login for one +/// check is refused here rather than spawning a CLI nothing can type into. + +#[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; + } + // An auth fix is the one interactive fix: it prints a verification URL and + // then blocks reading a code from stdin. Route it through the same piped, + // streamed path `start_doctor_login` uses so both entry points behave the + // same. On inherited stdin — `/dev/null` in the GUI — this printed its URL + // to a log nobody reads and could only ever finish through the CLI's own + // browser callback, otherwise dying at the fix timeout with no way to enter + // the code (block/berd#99). + if matches!(fix_type, FixType::Auth) { + // This entry point awaits the fix to its end, so there is no run to + // hand an "already running" answer to — the caller asked for a fix and + // there is one it can't have. + let login = match claim_login(&check_id)? { + LoginClaim::Claimed(login) => login, + LoginClaim::AlreadyRunning { .. } => { + return Err(format!("A login is already running for {check_id}")); + } + }; + return run_login_fix(app_handle, check_id, login).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 env_vars = doctor_env_vars().await; + let npm_backed = doctor::agents::lookup_fix_command(&check_id, &fix_type) + .as_deref() + .is_some_and(crate::managed_acp_tools::is_npm_backed_command); + if npm_backed { + ensure_managed_node_runtime_for_fix().await?; + } + execute_fix_with_gateway_fallback(check_id, fix_type, None, npm_backed, env_vars).await +} + +/// The install id of the ACP bridge a doctor check fronts, for the two checks +/// whose bridge Staged can manage — whether or not this build does. +fn managed_tool_id_for_check(check_id: &str) -> Option<&'static str> { + match check_id { + "ai-agent-claude" => Some("claude-acp"), + "ai-agent-codex" => Some("codex-acp"), + _ => None, + } +} + +/// 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> { + crate::managed_acp_tools::managed_tool(managed_tool_id_for_check(check_id)?).map(|tool| tool.id) +} + +/// The managed bridge a doctor *update* targets: on a build that manages these +/// two packages, the managed installer is the only writer for them, so *every* +/// update of the Claude and Codex checks goes to it — `UpdateBridge` from a +/// managed row's ACP readout, and `UpdateMain` from a copy that resolved +/// elsewhere (a user install found on PATH before the first reconcile lands, +/// or while it keeps failing). Letting that `UpdateMain` take the +/// command-validated path instead would run `npm install -g @latest` +/// under the fix env's `NPM_CONFIG_PREFIX`, landing a third, unowned copy in +/// `~/.staged/packages/npm-prefix` that nothing prunes, updates or runs — and +/// leaving the user's copy stale anyway. +/// +/// There is still exactly one install per row, because the two update fix +/// types never both carry an action on the same check: +/// [`apply_managed_bridge_readout`] gives the managed row its `bridge` action +/// while `main` stays bundled and update-free, and an unmanaged row has only +/// the `main` action that +/// [`redirect_unmanaged_update_to_managed_install`] rewrites — the crate emits +/// no `bridge` readout for the single-binary Claude and Codex checks. +/// +/// Non-update fix types, and every check when management is off (dev override, +/// `no-managed-acp-tools`, unsupported target), fall through to the +/// command-validated path. +fn managed_update_target(check_id: &str, fix_type: &FixType) -> Option<&'static str> { + if !matches!(fix_type, FixType::UpdateMain | FixType::UpdateBridge) { + return None; + } + managed_tool_for_check(check_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}]"); + let env_vars = doctor_env_vars().await; + crate::managed_acp_tools::install_managed_tool(tool_id, &env_vars, &|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. +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 +/// derived per-readout at freshness time rather than living in the static check +/// table, so the executor needs the command passed in as an override. +/// +/// **Trust boundary:** we do not execute the frontend-supplied `command` +/// blindly. We re-run freshness, re-derive the expected `updateCommand` for +/// `(check_id, fix_type)` backend-side, and only proceed if the two match. This +/// keeps `run_doctor_update` from becoming an arbitrary-shell-exec hole — the +/// `command` argument is effectively a confirmation of what the UI displayed, +/// validated against the authoritative backend derivation. +#[tauri::command] +pub async fn run_doctor_update( + check_id: String, + fix_type: FixType, + command: String, +) -> Result<(), String> { + // On a build that manages these two bridges, every update of their checks + // is the floating installer itself: `@latest` staged, verified and + // swapped into `~/.staged/packages` on the managed runtime. That covers + // both the managed row's ACP readout (see `apply_managed_bridge_readout`) + // and a copy that resolved elsewhere, whose source-aware `UpdateMain` is + // rewritten into a managed-install description by + // `redirect_unmanaged_update_to_managed_install` — its own `npm install -g` + // would write into the private prefix and orphan a copy there. No shell + // command runs, so the frontend-supplied `command` — the description the + // confirmation showed — needs no validation; what is validated is the + // target: a check whose bridge this build manages, and an update fix type. + if let Some(tool_id) = managed_update_target(&check_id, &fix_type) { + return install_managed_tool_logged(tool_id, &check_id).await; + } + let env_vars = doctor_env_vars().await; + let expected = expected_update_command( + &check_id, + &fix_type, + env_vars.clone(), + crate::acp_tools::primary_tools_dir(), + ) + .await?; + if expected != command { + return Err(format!( + "Update command mismatch for {check_id}: refusing to run a command \ + 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. + let npm_backed = crate::managed_acp_tools::is_npm_backed_command(&expected); + if npm_backed { + 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 + // the equality check surviving future edits. + execute_fix_with_gateway_fallback(check_id, fix_type, Some(expected), npm_backed, env_vars) + .await +} + +/// Re-run freshness and return the authoritative update command for the given +/// check + slot, or an error if no actionable update is derivable. Passes the +/// bundled dir through so bundled readouts derive no update command here, +/// exactly as in the report the UI rendered. +async fn expected_update_command( + check_id: &str, + fix_type: &FixType, + env_vars: Vec<(String, String)>, + bundled_dir: Option, +) -> Result { + let report = + doctor::run_checks_with_options(run_checks_options(true, env_vars, bundled_dir)).await; + + let check = report + .checks + .iter() + .find(|c| c.id == check_id) + .ok_or_else(|| format!("No such check: {check_id}"))?; + + // The fix type selects which readout's update applies. + let readout = match fix_type { + FixType::UpdateMain => check.main.as_ref(), + FixType::UpdateBridge => check.bridge.as_ref(), + _ => return Err(format!("{fix_type:?} is not an update fix type")), + }; + + readout + .and_then(|r| r.update_command.clone()) + .ok_or_else(|| format!("No actionable update available for {check_id}")) +} diff --git a/apps/staged/src-tauri/src/doctor/runtime_checks.rs b/apps/staged/src-tauri/src/doctor/runtime_checks.rs new file mode 100644 index 000000000..b9036dd99 --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/runtime_checks.rs @@ -0,0 +1,342 @@ +//! Staged's own doctor rows: the managed Node.js runtime and the session +//! queue. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use tauri::Manager; + +use crate::managed_acp_tools::ReconcileRecord; +use crate::session_queue::sweep::{self, QueueHealth}; +use crate::session_runner::SessionRegistry; +use crate::store::Store; + +use super::managed_bridges::{format_age, last_reconcile_record}; +use super::{CheckStatus, DoctorCheck, FixType}; + +#[cfg(test)] +#[path = "tests/runtime_checks.rs"] +mod tests; + +// ============================================================================= +// Managed Node.js runtime check +// ============================================================================= + +pub(super) const NODE_RUNTIME_CHECK_ID: &str = "node-runtime"; +const NODE_RUNTIME_CHECK_LABEL: &str = "Node.js Runtime"; +const SESSION_QUEUE_CHECK_ID: &str = "session-queue"; +const SESSION_QUEUE_CHECK_LABEL: &str = "Session Queue"; + +/// The session-queue readout: what the stranded-session sweep sees right now +/// and how often it has had to act. `None` until the store is open — the +/// needs-reset flow runs the Doctor with no store managed. +pub(super) fn session_queue_doctor_check( + app_handle: &tauri::AppHandle, +) -> Option { + let store = crate::get_store(&app_handle.try_state::>>>()?).ok()?; + let registry = app_handle.try_state::>()?; + match sweep::queue_health(&store, ®istry) { + Ok(health) => Some(build_session_queue_check(&health)), + Err(e) => { + log::warn!("[doctor] session-queue check failed to read the store: {e}"); + None + } + } +} + +/// `Warn` while any row would be swept right now; `Pass` otherwise, naming +/// the lifetime sweep count when it is non-zero so a recurrence stays +/// visible after the sweep has already cleaned up. +fn build_session_queue_check(health: &QueueHealth) -> DoctorCheck { + let stranded = health.stranded.len(); + let (status, message) = if stranded > 0 { + ( + CheckStatus::Warn, + format!( + "{} {} running with nothing in this process owning {}; the stranded-session sweep ends {} within a minute", + stranded, + if stranded == 1 { "session is" } else { "sessions are" }, + if stranded == 1 { "it" } else { "them" }, + if stranded == 1 { "it" } else { "them" }, + ), + ) + } else if health.swept_total > 0 { + ( + CheckStatus::Pass, + format!( + "No stranded sessions; the sweep has ended {} so far", + if health.swept_total == 1 { + "1 session".to_string() + } else { + format!("{} sessions", health.swept_total) + } + ), + ) + } else { + (CheckStatus::Pass, "No stranded sessions".to_string()) + }; + + let mut detail = vec![ + "checked: running session rows owned by this process".to_string(), + format!("owned running rows: {}", health.owned_running), + format!( + "of which owned by a registered runner or a liveness hold: {}", + health.live + ), + format!("stranded now: {stranded}"), + ]; + detail.extend(health.stranded.iter().map(|row| { + format!( + "- session {} (branch {})", + row.session_id, + row.branch_id.as_deref().unwrap_or("none"), + ) + })); + detail.push(format!("ended by the sweep so far: {}", health.swept_total)); + + DoctorCheck { + id: SESSION_QUEUE_CHECK_ID.to_string(), + label: SESSION_QUEUE_CHECK_LABEL.to_string(), + status, + message, + fix_url: None, + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(detail.join("\n")), + auth_status: None, + login_command: None, + installed_version: None, + latest_version: None, + update_available: None, + install_source: None, + self_updating: None, + main: None, + bridge: None, + } +} + +/// 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, +} + +/// 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. +pub(super) 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 + }; + // 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, + last_reconcile_record().as_ref(), + ) +} + +/// 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(); + }; + entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| !name.starts_with('.')) + .collect() +} + +/// Build the runtime row. `last_reconcile` is `state.json`'s record of the +/// last managed install run: a runtime that is `Missing` after a reconcile +/// that *failed* is not "never needed", it is the install that could not +/// happen — every managed bridge install starts by ensuring the runtime — so +/// that case warns with the recorded error even when nothing is installed +/// yet, instead of staying silent. With no record (first launch still +/// reconciling, or a profile that never ran one) a missing runtime is +/// reported only when installed tools need it, as before. +fn build_node_runtime_check( + state: ManagedNodeRuntimeState, + install_dir: &Path, + npm_tools: &[String], + last_reconcile: Option<&ReconcileRecord>, +) -> Option { + let version = &crate::managed_node::node_runtime_lock().version; + let failed_reconcile = last_reconcile.filter(|record| !record.ok); + let (status, message) = match (&state, failed_reconcile) { + (ManagedNodeRuntimeState::Ready, _) => ( + CheckStatus::Pass, + 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, Some(record)) => ( + CheckStatus::Warn, + format!( + "Staged-managed Node.js {version} could not be installed: {}", + reconcile_headline(record) + ), + ), + (ManagedNodeRuntimeState::Missing, None) if npm_tools.is_empty() => return None, + (ManagedNodeRuntimeState::Missing, None) => ( + CheckStatus::Warn, + format!( + "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: Staged-managed Node.js runtime".to_string(), + format!("pinned version: {version}"), + format!("install dir: {}", install_dir.display()), + format!("state: {state_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}"))); + } + if let Some(record) = failed_reconcile { + detail.push(format!( + "last managed install: failed {} with {} error(s):", + format_age(record.at_ms, crate::managed_acp_tools::now_ms()), + record.errors.len() + )); + detail.extend(record.errors.iter().map(|error| format!("- {error}"))); + // Only a runtime download error is this row's to advise on: a bridge's + // npm 404 as the headline must not read as "no registry carries + // Node.js". + let headline = reconcile_headline(record); + if let Some(hint) = headline + .starts_with(NODE_DOWNLOAD_ERROR_PREFIX) + .then(|| crate::managed_acp_tools::install_failure_hint(&headline, "Node.js")) + .flatten() + { + detail.push(format!("hint: {hint}")); + } + } + + 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, + )) +} + +/// How `ManagedNodeError`'s download failures (network, HTTP status) render, +/// so the runtime row can tell its own download failure from a bridge's. +const NODE_DOWNLOAD_ERROR_PREFIX: &str = "Node.js runtime download failed"; + +/// The one error a failed reconcile is summarised by on the runtime row: the +/// first that names the Node.js runtime download — the failure every bridge +/// shares when the runtime could not be fetched — else the first recorded, +/// in both cases without the reconciler's `: ` prefix. +fn reconcile_headline(record: &ReconcileRecord) -> String { + record + .errors + .iter() + .find(|error| error.contains("Node.js runtime")) + .or_else(|| record.errors.first()) + .map(|error| strip_tool_id_prefix(error).to_string()) + .unwrap_or_else(|| "no error recorded".to_string()) +} + +/// `: ` → ``, for the ids this build knows; anything +/// else is returned whole. +fn strip_tool_id_prefix(error: &str) -> &str { + error + .split_once(": ") + .filter(|(id, _)| { + crate::managed_acp_tools::MANAGED_TOOLS + .iter() + .any(|tool| tool.id == *id) + }) + .map(|(_, rest)| rest) + .unwrap_or(error) +} + +pub(super) fn node_runtime_doctor_check( + status: CheckStatus, + 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, + fix_url: None, + fix_command, + fix_type, + path, + bridge_path: None, + raw_output, + auth_status: None, + login_command: None, + installed_version: None, + latest_version: None, + update_available: None, + install_source: None, + self_updating: None, + main: None, + bridge: None, + } +} diff --git a/apps/staged/src-tauri/src/doctor/tests/commands.rs b/apps/staged/src-tauri/src/doctor/tests/commands.rs new file mode 100644 index 000000000..938ccce4b --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/tests/commands.rs @@ -0,0 +1,89 @@ +use super::*; + +/// 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("/data/packages/bin"); + let opts = run_checks_options(false, Vec::new(), Some(dir.clone())); + assert_eq!(opts.bundled_tools_dir, Some(dir)); + + let opts = run_checks_options(false, Vec::new(), None); + 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); +} + +/// Both update fix types on a managed check dispatch to the managed +/// installer — `UpdateBridge` from the managed row's ACP readout, and the +/// `UpdateMain` a copy that resolved outside the managed dir carries, whose +/// own `npm install -g` would orphan a copy in the private prefix. Non-update +/// fix types take the command-validated path, and nothing is managed when +/// bridge management is off (dev override, `no-managed-acp-tools`, +/// unsupported target). +#[test] +fn both_update_fix_types_dispatch_to_the_managed_installer() { + let managed = crate::managed_acp_tools::managed_tools_enabled(); + for fix_type in [FixType::UpdateMain, FixType::UpdateBridge] { + assert_eq!( + managed_update_target("ai-agent-claude", &fix_type), + managed.then_some("claude-acp"), + "{fix_type:?}" + ); + assert_eq!( + managed_update_target("ai-agent-codex", &fix_type), + managed.then_some("codex-acp"), + "{fix_type:?}" + ); + // Checks whose bridge Staged never manages keep the crate's own + // fixes for both update types. + assert_eq!( + managed_update_target("ai-agent-amp", &fix_type), + None, + "{fix_type:?}" + ); + assert_eq!( + managed_update_target("ai-agent-copilot", &fix_type), + None, + "{fix_type:?}" + ); + } + for fix_type in [FixType::Command, FixType::Bridge, FixType::Auth] { + assert_eq!( + managed_update_target("ai-agent-claude", &fix_type), + None, + "{fix_type:?}" + ); + } +} + +/// Checks and fixes must agree on the registry: both option builders take +/// it from the same `managed_acp_tools::npm_registry_override()` — the +/// explicit `STAGED_NPM_REGISTRY`, or `None` so npm resolves its own +/// config. +#[test] +fn doctor_options_route_npm_through_the_registry_override() { + let expected = crate::managed_acp_tools::npm_registry_override(); + assert_eq!( + run_checks_options(false, Vec::new(), None).npm_registry, + expected + ); + assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected); +} diff --git a/apps/staged/src-tauri/src/doctor/tests/login.rs b/apps/staged/src-tauri/src/doctor/tests/login.rs new file mode 100644 index 000000000..a66e979ad --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/tests/login.rs @@ -0,0 +1,333 @@ +use super::*; + +/// The tail numbers every line from zero and keeps only the newest +/// `LOGIN_OUTPUT_TAIL_LINES`, and its snapshot states both — a client merges +/// live lines against `next_seq`, so a snapshot whose `output` did not sit +/// exactly below it would show lines twice or lose them. +#[test] +fn login_output_tail_numbers_lines_and_keeps_the_newest() { + let mut tail = LoginOutputTail::default(); + assert_eq!(tail.push("Opening browser to sign in…"), 0); + assert_eq!(tail.push("If the browser didn't open, visit: https://x"), 1); + assert_eq!( + tail.status("run-1"), + DoctorLoginStatus { + running: true, + run_id: Some("run-1".to_string()), + output: vec![ + "Opening browser to sign in…".to_string(), + "If the browser didn't open, visit: https://x".to_string(), + ], + next_seq: 2, + } + ); + + for i in 2..(LOGIN_OUTPUT_TAIL_LINES as u64 + 10) { + assert_eq!(tail.push(&format!("line {i}")), i); + } + let status = tail.status("run-1"); + assert_eq!(status.output.len(), LOGIN_OUTPUT_TAIL_LINES); + assert_eq!(status.next_seq, LOGIN_OUTPUT_TAIL_LINES as u64 + 10); + // `output[i]` carries seq `next_seq - output.len() + i`. + let first_seq = status.next_seq - status.output.len() as u64; + assert_eq!(status.output.first().unwrap(), &format!("line {first_seq}")); + assert_eq!( + status.output.last().unwrap(), + &format!("line {}", status.next_seq - 1) + ); +} + +/// A cancelled run is reported as cancelled and not as a failure; a run +/// that failed on its own keeps its error; and a cancel that only landed +/// after the fix had finished changes nothing about its result. +#[test] +fn login_done_event_tells_a_cancellation_from_a_failure() { + let cancelled_by_runner = + Err("Fix cancelled before finishing: claude-agent-acp --cli auth login".to_string()); + + let done = login_done_event( + "ai-agent-claude".into(), + "run-1".into(), + &cancelled_by_runner, + true, + 3, + ); + assert_eq!( + done, + DoctorLoginOutput { + check_id: "ai-agent-claude".into(), + run_id: "run-1".into(), + line: None, + seq: 3, + done: true, + error: None, + cancelled: true, + } + ); + + let failed = Err("Fix timed out after 10m without finishing: …".to_string()); + let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &failed, false, 3); + assert!(!done.cancelled); + assert_eq!( + done.error.as_deref(), + Some("Fix timed out after 10m without finishing: …") + ); + + let done = login_done_event("ai-agent-claude".into(), "run-1".into(), &Ok(()), true, 0); + assert!(done.done && !done.cancelled && done.error.is_none()); +} + +/// The start answer is what a client keys its whole record on, so its wire +/// shape is pinned: one tag field for the outcome and the run id beside it +/// in camelCase, for both variants. +#[test] +fn login_start_serializes_as_a_tagged_object_with_the_run_id() { + assert_eq!( + serde_json::to_value(LoginStart::Started { + run_id: "run-1".into() + }) + .unwrap(), + serde_json::json!({ "outcome": "started", "runId": "run-1" }) + ); + assert_eq!( + serde_json::to_value(LoginStart::AlreadyRunning { + run_id: "run-1".into() + }) + .unwrap(), + serde_json::json!({ "outcome": "alreadyRunning", "runId": "run-1" }) + ); + // The status and the events spell it the same way. + let status = serde_json::to_value(LoginOutputTail::default().status("run-1")).unwrap(); + assert_eq!(status["runId"], "run-1"); + assert_eq!(status["nextSeq"], 0); + let idle = serde_json::to_value(DoctorLoginStatus::idle()).unwrap(); + assert_eq!(idle["runId"], serde_json::Value::Null); + let done = serde_json::to_value(login_done_event( + "ai-agent-claude".into(), + "run-1".into(), + &Ok(()), + false, + 0, + )) + .unwrap(); + assert_eq!(done["runId"], "run-1"); +} + +/// Build an entry the way `claim_login` does, without touching the global +/// map — for tests of the lookup logic alone. +fn active_login(run_id: &str) -> ActiveLogin { + let (writer, _stdin) = FixStdin::pipe(); + let (cancel, _cancellation) = FixCancellation::token(); + ActiveLogin { + run_id: run_id.to_string(), + writer, + cancel, + output: Arc::new(Mutex::new(LoginOutputTail::default())), + } +} + +/// A command names a run, and reaches it only while that run holds the +/// check's slot. Nothing running is one kind of stale, a *different* run +/// holding the slot is another, and the refusal for a code says which — +/// and, for the second, that the code did not go to the newer login. +#[test] +fn find_login_run_tells_a_finished_run_from_the_one_that_replaced_it() { + let mut logins = HashMap::new(); + assert_eq!( + find_login_run(&logins, "ai-agent-claude", "run-1").err(), + Some(StaleLoginRun::Ended) + ); + + logins.insert("ai-agent-claude".to_string(), active_login("run-2")); + let found = find_login_run(&logins, "ai-agent-claude", "run-2").expect("the live run"); + assert_eq!(found.run_id, "run-2"); + assert_eq!( + find_login_run(&logins, "ai-agent-claude", "run-1").err(), + Some(StaleLoginRun::Superseded) + ); + // The slot is per check: another check's run id never matches. + assert_eq!( + find_login_run(&logins, "ai-agent-codex", "run-2").err(), + Some(StaleLoginRun::Ended) + ); + + let ended = StaleLoginRun::Ended.code_refusal("ai-agent-claude"); + assert!(ended.contains("has ended"), "{ended}"); + assert!( + ended.contains("no login is running for ai-agent-claude"), + "{ended}" + ); + let superseded = StaleLoginRun::Superseded.code_refusal("ai-agent-claude"); + assert!(superseded.contains("has ended"), "{superseded}"); + assert!( + superseded.contains("a newer login is running for ai-agent-claude"), + "{superseded}" + ); + assert!(superseded.contains("was not delivered"), "{superseded}"); +} + +/// Every claim mints its own id, and ids are not recycled: two runs of one +/// check, and two checks' runs, never share one. +#[test] +fn login_run_ids_are_unique() { + let ids: std::collections::HashSet = (0..64).map(|_| new_login_run_id()).collect(); + assert_eq!(ids.len(), 64); + assert!(ids.iter().all(|id| !id.is_empty())); +} + +/// A slot releases only the run it was claimed for. Its `Drop` cannot +/// normally run while a later run holds the entry — the later claim needs +/// the entry gone first — but the release is keyed to the run id so that +/// stays true by construction rather than by call order. +/// +/// Uses `ai-agent-cursor`: `ACTIVE_LOGINS` is process-global and the other +/// login tests in this module claim other checks. +#[test] +fn login_slot_releases_only_its_own_run() { + let check_id = "ai-agent-cursor"; + let LoginClaim::Claimed(login) = claim_login(check_id).expect("cursor has a login fix") else { + panic!("the slot is free"); + }; + let live_run_id = login.run_id.clone(); + + // A stale slot for a run that no longer holds the entry. + drop(LoginSlot { + check_id: check_id.to_string(), + run_id: "an-earlier-run".to_string(), + }); + assert_eq!( + active_logins() + .lock() + .unwrap() + .get(check_id) + .map(|login| login.run_id.clone()), + Some(live_run_id), + "a stale slot's drop leaves the live run's entry alone" + ); + + drop(login); + assert!( + !active_logins().lock().unwrap().contains_key(check_id), + "the run's own slot releases it" + ); +} + +/// The slot lifecycle end to end: a claim holds the check under a run id, a +/// second claim reports that id rather than an error, the status names it, +/// the cancel and code commands reach the run only under it, dropping the +/// claim releases the slot, and the next claim gets a new id and a token the +/// earlier cancel does not refuse. +/// +/// Uses `ai-agent-codex` because `ACTIVE_LOGINS` is process-global and the +/// other login tests in this module claim other checks. +#[tokio::test] +async fn login_slot_is_released_on_drop_and_the_cancel_reaches_the_run() { + let check_id = "ai-agent-codex"; + let LoginClaim::Claimed(login) = claim_login(check_id).expect("codex has a login fix") else { + panic!("the first claim takes the slot"); + }; + let run_id = login.run_id.clone(); + assert_eq!( + login.slot.run_id, run_id, + "the slot releases the run it was claimed for" + ); + match claim_login(check_id).expect("still a valid check") { + LoginClaim::AlreadyRunning { run_id: running } => assert_eq!( + running, run_id, + "a second claim names the run that holds the slot" + ), + LoginClaim::Claimed(_) => panic!("the slot is held"), + } + + assert_eq!( + doctor_login_status(check_id.into()).await, + DoctorLoginStatus { + running: true, + run_id: Some(run_id.clone()), + output: Vec::new(), + next_seq: 0, + } + ); + // What the run's `on_line` records is what a late client is shown. + login + .output + .lock() + .unwrap() + .push("Opening browser to sign in…"); + assert_eq!( + doctor_login_status(check_id.into()).await.output, + vec!["Opening browser to sign in…".to_string()] + ); + + // A code for a run that isn't the one holding the slot is refused — + // and never reaches this run's pipe. The right id queues it (the fix + // has not spawned here, so `send_line` buffers it for the replay). + let refused = send_doctor_login_code(check_id.into(), "an-earlier-run".into(), "abc".into()) + .await + .expect_err("a code for another run is refused"); + assert!( + refused.contains("a newer login is running for ai-agent-codex"), + "{refused}" + ); + send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) + .await + .expect("a code for the live run reaches its pipe"); + + assert!(!login.cancel.is_cancelled()); + assert!( + !cancel_doctor_login(check_id.into(), "an-earlier-run".into()).await, + "a cancel for another run is not this run's" + ); + assert!(!login.cancel.is_cancelled()); + assert!(cancel_doctor_login(check_id.into(), run_id.clone()).await); + assert!( + login.cancel.is_cancelled(), + "the command reaches the token the run was given" + ); + assert!( + cancel_doctor_login(check_id.into(), run_id.clone()).await, + "repeating it is a no-op that still reports the running login" + ); + + drop(login); + assert_eq!( + doctor_login_status(check_id.into()).await, + DoctorLoginStatus::idle() + ); + assert!( + !cancel_doctor_login(check_id.into(), run_id.clone()).await, + "nothing running: nothing to cancel" + ); + let refused = send_doctor_login_code(check_id.into(), run_id.clone(), "abc".into()) + .await + .expect_err("a code for a run that has ended is refused"); + assert!( + refused.contains("no login is running for ai-agent-codex"), + "{refused}" + ); + + let LoginClaim::Claimed(again) = claim_login(check_id).expect("still a valid check") else { + panic!("a released slot can be claimed again"); + }; + assert_ne!( + again.run_id, run_id, + "each run gets its own id, so the earlier run's events and commands can't be taken for this one's" + ); + assert!( + !again.cancel.is_cancelled(), + "each run gets a fresh token, so the earlier cancel can't refuse the retry" + ); +} + +#[test] +fn claim_login_refuses_a_check_with_no_login_fix() { + let err = claim_login("ai-agent-goose").expect_err("goose has no auth command"); + assert!( + err.contains("No login fix available for ai-agent-goose"), + "{err}" + ); + assert!(!active_logins() + .lock() + .unwrap() + .contains_key("ai-agent-goose")); +} diff --git a/apps/staged/src-tauri/src/doctor/tests/managed_bridges.rs b/apps/staged/src-tauri/src/doctor/tests/managed_bridges.rs new file mode 100644 index 000000000..1c96ce78d --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/tests/managed_bridges.rs @@ -0,0 +1,972 @@ +use super::*; +use crate::doctor::runtime_checks::node_runtime_doctor_check; +use crate::doctor::test_support::*; +use crate::doctor::*; + +#[test] +fn format_age_rounds_to_the_largest_whole_unit() { + assert_eq!(format_age(1_000, 1_000), "just now"); + assert_eq!(format_age(0, 59_000), "just now"); + assert_eq!(format_age(0, 60_000), "1 minute ago"); + assert_eq!(format_age(0, 2 * 3_600_000 + 5), "2 hours ago"); + assert_eq!(format_age(0, 3 * 86_400_000), "3 days ago"); + // A clock that went backwards is not a panic. + assert_eq!(format_age(5_000, 1_000), "just now"); +} + +fn claude_tool() -> ManagedTool { + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + } +} + +const SHIM_BIN_DIR: &str = "/data/.staged/packages/bin"; + +/// The vendored Claude Code version the crate's freshness pass probes +/// through `claude-agent-acp --cli --version` — a different identity from +/// the ACP package's own version. +const VENDORED_CLI_VERSION: &str = "2.1.205"; + +/// A Claude row as the doctor crate reports the managed shim: bundled +/// `main`, no `bridge`. With `freshness`, the vendored CLI's probed version +/// and the crate's bundled suppression (`self_updating`, no update). +fn managed_claude_check(path: &str, freshness: bool) -> DoctorCheck { + DoctorCheck { + id: "ai-agent-claude".to_string(), + label: "Claude Code".to_string(), + status: CheckStatus::Pass, + message: "Installed".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: Some(path.to_string()), + bridge_path: None, + raw_output: None, + auth_status: Some(AuthStatus::Authenticated), + login_command: Some("claude-agent-acp --cli auth login".to_string()), + installed_version: freshness.then(|| VENDORED_CLI_VERSION.to_string()), + latest_version: None, + update_available: None, + install_source: Some(InstallSource::Bundled), + self_updating: freshness.then_some(true), + main: Some(AgentVersionInfo { + install_source: Some(InstallSource::Bundled), + bundled: Some(true), + installed_version: freshness.then(|| VENDORED_CLI_VERSION.to_string()), + self_updating: freshness.then_some(true), + ..AgentVersionInfo::default() + }), + bridge: None, + } +} + +fn managed_shim_path() -> String { + Path::new(SHIM_BIN_DIR) + .join("claude-agent-acp") + .display() + .to_string() +} + +/// The ACP package version a copy the user installed themselves reports. +const USER_COPY_VERSION: &str = "0.16.2"; + +/// A Claude row as the doctor crate reports a copy the *user* installed — +/// found on PATH before the launch reconcile has written the managed shim, +/// or for as long as that reconcile keeps failing. Labeled by source rather +/// than bundled, with the crate's freshness fields: `latest` as the registry +/// answered it, and `command` the source-aware update the crate derived +/// (`None` for a source it cannot update, such as `Unknown`). +fn user_copy_claude_check( + path: &str, + source: &InstallSource, + latest: Option<&str>, + command: Option<&str>, +) -> DoctorCheck { + let main = AgentVersionInfo { + install_source: Some(source.clone()), + bundled: None, + installed_version: Some(USER_COPY_VERSION.to_string()), + latest_version: latest.map(str::to_string), + update_available: latest.map(|latest| latest != USER_COPY_VERSION), + self_updating: Some(false), + update_command: command.map(str::to_string), + update_fix_type: command.map(|_| FixType::UpdateMain), + }; + DoctorCheck { + installed_version: main.installed_version.clone(), + latest_version: main.latest_version.clone(), + update_available: main.update_available, + install_source: Some(source.clone()), + self_updating: Some(false), + main: Some(main), + ..managed_claude_check(path, true) + } +} + +/// Run the report-wide pass over a single check, so the tests exercise the +/// managed-readout and redirect branches through the same dispatch the +/// report does. +fn apply_to( + check: DoctorCheck, + versions: &ManagedBridgeVersions, + check_freshness: bool, +) -> DoctorCheck { + apply_with(check, versions, check_freshness, &clean_diagnostics()) +} + +/// A reconcile that succeeded a minute ago and left nothing to report: +/// the managed copy is then simply missing, with no error to name. +fn clean_diagnostics() -> ManagedInstallDiagnostics { + ManagedInstallDiagnostics { + last_reconcile: Some(reconcile_record(true, vec![])), + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), + now_ms: 61_000, + } +} + +fn apply_with( + check: DoctorCheck, + versions: &ManagedBridgeVersions, + check_freshness: bool, + diagnostics: &ManagedInstallDiagnostics, +) -> DoctorCheck { + let mut report = DoctorReport { + checks: vec![check], + }; + apply_managed_bridge_readouts( + &mut report, + std::slice::from_ref(versions), + Path::new(SHIM_BIN_DIR), + check_freshness, + diagnostics, + ); + report.checks.pop().unwrap() +} + +/// Undo what [`flag_missing_managed_copy`] sets on a user-copy row, so a +/// test can assert that *nothing else* changed. +fn without_missing_copy_flag(mut check: DoctorCheck) -> DoctorCheck { + check.status = CheckStatus::Pass; + check.message = "Installed".to_string(); + check.fix_type = None; + check.fix_command = None; + check.raw_output = None; + check +} + +fn versions(installed: Option<&str>, latest: Option<&str>) -> ManagedBridgeVersions { + ManagedBridgeVersions { + tool: claude_tool(), + installed: installed.map(str::to_string), + latest: latest.map(str::to_string), + } +} + +fn json(check: &DoctorCheck) -> serde_json::Value { + serde_json::to_value(check).unwrap() +} + +/// The initial (no-network) report already carries both readouts: the +/// crate's `main`, untouched, and the ACP package's installed version +/// under `bridge`, with `bridge_path` set so the row renders it — the +/// same structure the freshness report has, so the frontend's merge (which +/// copies `bridge` but not `bridge_path`) never hides a readout. +#[test] +fn initial_report_adds_the_acp_readout_without_update_fields() { + let path = managed_shim_path(); + let mut check = managed_claude_check(&path, false); + let main_before = check.main.clone(); + + assert!(apply_managed_bridge_readout( + &mut check, + &versions(Some("0.16.2"), None), + Path::new(SHIM_BIN_DIR), + false, + )); + + assert_eq!( + check.main, main_before, + "the vendored-agent readout is preserved" + ); + assert_eq!(check.bridge_path.as_deref(), Some(path.as_str())); + let bridge = check.bridge.as_ref().expect("the ACP readout is added"); + assert_eq!(bridge.install_source, Some(InstallSource::Bundled)); + assert_eq!(bridge.bundled, Some(true)); + assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); + assert_eq!(bridge.latest_version, None); + assert_eq!(bridge.update_available, None); + assert_eq!(bridge.self_updating, None); + assert_eq!(bridge.update_command, None); + assert_eq!(bridge.update_fix_type, None); + // The flat fields mirror the ACP readout, as the crate does for any + // check with a bridge. + assert_eq!(check.installed_version.as_deref(), Some("0.16.2")); + assert_eq!(check.update_available, None); +} + +/// The freshness report shows the two different versions side by side and +/// attaches exactly one update — to the ACP readout — when the registry has +/// a newer release of the same package. The text describes updating +/// Staged's managed copy; it is not an npm command. +#[test] +fn freshness_with_a_newer_release_offers_one_update_on_the_acp_readout() { + let path = managed_shim_path(); + let mut check = managed_claude_check(&path, true); + + assert!(apply_managed_bridge_readout( + &mut check, + &versions(Some("0.16.2"), Some("0.17.0")), + Path::new(SHIM_BIN_DIR), + true, + )); + + let main = check.main.as_ref().unwrap(); + let bridge = check.bridge.as_ref().unwrap(); + assert_eq!( + main.installed_version.as_deref(), + Some(VENDORED_CLI_VERSION) + ); + assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); + assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); + assert_eq!(bridge.update_available, Some(true)); + assert_eq!(bridge.self_updating, Some(false)); + assert_eq!(bridge.update_fix_type, Some(FixType::UpdateBridge)); + let command = bridge.update_command.as_deref().expect("actionable update"); + assert!( + command.contains("@agentclientprotocol/claude-agent-acp"), + "{command}" + ); + assert!(command.contains("0.17.0"), "{command}"); + assert!(command.contains("~/.staged/packages"), "{command}"); + assert!(!command.contains("npm install"), "{command}"); + // Exactly one actionable readout: the agent vendored inside the same + // package is never offered a second install of its own. + assert_eq!(main.update_available, None); + assert_eq!(main.update_command, None); + assert_eq!(main.update_fix_type, None); + assert_eq!(main.self_updating, Some(true)); + // The flat headline follows the ACP readout. + assert_eq!(check.installed_version.as_deref(), Some("0.16.2")); + assert_eq!(check.latest_version.as_deref(), Some("0.17.0")); + assert_eq!(check.update_available, Some(true)); + assert_eq!(check.self_updating, Some(false)); +} + +#[test] +fn freshness_with_the_current_release_reports_no_update() { + let path = managed_shim_path(); + let mut check = managed_claude_check(&path, true); + + assert!(apply_managed_bridge_readout( + &mut check, + &versions(Some("0.17.0"), Some("0.17.0")), + Path::new(SHIM_BIN_DIR), + true, + )); + + let bridge = check.bridge.as_ref().unwrap(); + assert_eq!(bridge.installed_version.as_deref(), Some("0.17.0")); + assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); + assert_eq!(bridge.update_available, Some(false)); + assert_eq!(bridge.update_command, None); + assert_eq!(bridge.update_fix_type, None); + assert_eq!(check.update_available, Some(false)); + + // A registry that is *behind* the install (a just-published version + // not yet on the mirror) is likewise not an update. + let mut check = managed_claude_check(&path, true); + apply_managed_bridge_readout( + &mut check, + &versions(Some("0.17.1"), Some("0.17.0")), + Path::new(SHIM_BIN_DIR), + true, + ); + assert_eq!(check.bridge.as_ref().unwrap().update_available, Some(false)); +} + +/// A failed or absent registry answer — and an unreadable installed +/// version — leave `update_available` unknown, never "up to date", while +/// whatever *was* read is still shown. +#[test] +fn unknown_installed_or_latest_versions_stay_unknown() { + let path = managed_shim_path(); + + let mut check = managed_claude_check(&path, true); + assert!(apply_managed_bridge_readout( + &mut check, + &versions(Some("0.16.2"), None), + Path::new(SHIM_BIN_DIR), + true, + )); + let bridge = check.bridge.as_ref().unwrap(); + assert_eq!(bridge.installed_version.as_deref(), Some("0.16.2")); + assert_eq!(bridge.latest_version, None); + assert_eq!(bridge.update_available, None); + assert_eq!(bridge.update_command, None); + assert_eq!(check.update_available, None); + + let mut check = managed_claude_check(&path, true); + assert!(apply_managed_bridge_readout( + &mut check, + &versions(None, Some("0.17.0")), + Path::new(SHIM_BIN_DIR), + true, + )); + let bridge = check.bridge.as_ref().unwrap(); + assert_eq!(bridge.installed_version, None); + assert_eq!(bridge.latest_version.as_deref(), Some("0.17.0")); + assert_eq!(bridge.update_available, None); + assert_eq!(bridge.update_command, None); + + // Unparseable output on either side is not compared as a string. + let mut check = managed_claude_check(&path, true); + apply_managed_bridge_readout( + &mut check, + &versions(Some("0.16.2"), Some("latest")), + Path::new(SHIM_BIN_DIR), + true, + ); + assert_eq!(check.bridge.as_ref().unwrap().update_available, None); +} + +/// A copy the user installed themselves never gets the managed readout. +/// It is flagged — the managed copy is missing, and the managed install is +/// its fix — but nothing else changes: its own location, source and +/// version are the honest ones for what is on PATH, and with nothing to +/// update no update action is invented. +#[test] +fn a_user_copy_without_an_update_is_flagged_and_otherwise_left_alone() { + let installed = versions(Some("0.16.2"), Some("0.17.0")); + let path = "/opt/homebrew/bin/claude-agent-acp"; + + // The registry answered, and the user copy is current. + let check = user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let before = json(&check); + let after = apply_to(check, &installed, true); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + "Using /opt/homebrew/bin/claude-agent-acp — Staged's managed copy is missing" + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!( + after.fix_command.as_deref(), + Some( + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ) + ); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains( + "Staged-managed copy: missing (expected /data/.staged/packages/bin/claude-agent-acp)" + ), + "{detail}" + ); + assert!( + detail.contains("last managed install: succeeded 1 minute ago"), + "{detail}" + ); + assert!(!detail.contains("hint:"), "{detail}"); + assert_eq!(json(&without_missing_copy_flag(after)), before); + + // The registry lookup failed: unknown, so no action is invented. + let check = user_copy_claude_check(path, &InstallSource::Brew, None, None); + let before = json(&check); + let after = apply_to(check, &installed, true); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!(json(&without_missing_copy_flag(after)), before); + + // The initial (no-network) pass sets no update fields at all, so there + // is nothing to redirect there either — but the flag and fix do not + // wait for the network: the base report already carries them. + let mut check = managed_claude_check(path, false); + check.main.as_mut().unwrap().bundled = None; + check.main.as_mut().unwrap().install_source = Some(InstallSource::Npm); + let before = json(&check); + let after = apply_to(check, &installed, false); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!(json(&without_missing_copy_flag(after)), before); +} + +/// Before the first reconcile has finished — a fresh profile whose launch +/// install is still downloading — a Homebrew copy on PATH is expected, so +/// the row must not read as a failure: it says the managed install hasn't +/// completed yet, the detail says when it runs, and the Fix stays +/// available for whoever would rather not wait. An already-warning row +/// keeps its own message in front. +#[test] +fn a_user_copy_before_the_first_reconcile_is_not_called_a_failure() { + let installed = versions(None, Some("0.17.0")); + let path = "/opt/homebrew/bin/claude-agent-acp"; + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: None, + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), + now_ms: 0, + }; + + let check = user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!("Using {path} — Staged's managed install hasn't completed yet") + ); + assert!(!after.message.contains("missing"), "{}", after.message); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + assert_eq!( + after.fix_command.as_deref(), + Some( + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ) + ); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains("last managed install: not run yet (runs at launch and daily)"), + "{detail}" + ); + assert!(!detail.contains("hint:"), "{detail}"); + assert!( + detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), + "{detail}" + ); + + let mut check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + check.status = CheckStatus::Warn; + check.message = "Installed, not authenticated".to_string(); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!( + after.message, + format!( + "Installed, not authenticated; using {path} — Staged's managed install hasn't completed yet" + ) + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); +} + +/// With a failed reconcile on record the row says why the managed copy is +/// missing — only the `npm error code` of the recorded error for *this* +/// tool, the rest of which (naming the registry it used) goes to the +/// detail with the classified hint and the registry Doctor detected. The +/// other tool's error stays off this row. +#[test] +fn a_user_copy_names_the_recorded_install_error_and_its_hint() { + let installed = versions(None, None); + let path = "/opt/homebrew/bin/claude-agent-acp"; + let claude_error = "npm install failed: npm install exited with exit status: 1 \ + (registry: npm default (per npm config)): npm error code FETCH_ERROR; \ + invalid json response body at \ + https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: \ + Unexpected token '<'"; + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: Some(ReconcileRecord { + at_ms: 0, + ok: false, + errors: vec![ + format!("claude-acp: {claude_error}"), + "codex-acp: npm install failed: npm install exited with exit status: 1 \ + (registry: npm default (per npm config)): npm error code E404" + .to_string(), + ], + }), + npm_registry: Some("https://registry.npmjs.org/ (per npm config)".to_string()), + now_ms: 5 * 60_000, + }; + + let check = user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!("Using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR") + ); + assert!( + !after.message.contains("invalid json"), + "the full error stays out of the one-line message: {}", + after.message + ); + assert_eq!(after.fix_type, Some(FixType::Bridge)); + let detail = after.raw_output.as_deref().expect("detail"); + assert!( + detail.contains("last managed install: failed 5 minutes ago"), + "{detail}" + ); + assert!( + detail.contains(&format!("claude-acp: {claude_error}")), + "{detail}" + ); + assert!(!detail.contains("codex-acp"), "{detail}"); + assert!( + detail.contains( + "hint: this network blocks registry.npmjs.org and npm has no registry configured" + ), + "{detail}" + ); + assert!( + detail.contains("npm registry: https://registry.npmjs.org/ (per npm config)"), + "{detail}" + ); + + // A row that already warns (installed, not signed in) keeps its own + // message in front and its status. + let mut check = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + check.status = CheckStatus::Warn; + check.message = "Installed, not authenticated".to_string(); + check.raw_output = Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); + let after = apply_with(check, &installed, true, &diagnostics); + assert_eq!(after.status, CheckStatus::Warn); + assert_eq!( + after.message, + format!( + "Installed, not authenticated; using {path} — Staged's managed copy is missing: npm error code FETCH_ERROR" + ) + ); + let detail = after.raw_output.as_deref().unwrap(); + assert!( + detail.starts_with("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\nStaged-managed copy: missing"), + "the crate's trace stays on top: {detail}" + ); + assert!( + detail.contains(&format!("claude-acp: {claude_error}")), + "{detail}" + ); + + // An error with no npm code is ellipsized on the row and whole in the + // detail; the registry line says when the probe did not run. + let long_error = format!( + "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: {}", + "validation probe exited with 1 and printed a very long explanation ".repeat(3) + ); + let diagnostics = ManagedInstallDiagnostics { + last_reconcile: Some(reconcile_record( + false, + vec![&format!("claude-acp: {long_error}")], + )), + npm_registry: None, + now_ms: 0, + }; + let check = user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + let after = apply_with(check, &installed, true, &diagnostics); + let expected_fragment: String = long_error.chars().take(SHORT_ERROR_CHARS).collect(); + assert_eq!( + after.message, + format!( + "Using {path} — Staged's managed copy is missing: {}…", + expected_fragment.trim_end() + ) + ); + let detail = after.raw_output.as_deref().unwrap(); + assert!( + detail.contains(&format!("claude-acp: {long_error}")), + "{detail}" + ); + assert!(detail.contains("npm registry: not probed"), "{detail}"); +} + +#[test] +fn short_error_fragment_prefers_the_npm_code_then_ellipsizes() { + assert_eq!( + short_error_fragment( + "npm install failed: npm install exited with exit status: 1 (registry: npm default \ + (per npm config)): npm error code FETCH_ERROR; invalid json response body at x" + ), + "npm error code FETCH_ERROR" + ); + assert_eq!( + short_error_fragment( + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ + network gateway; fallback https://f.example.test/ also failed: npm install exited \ + with exit status: 1 (registry: https://f.example.test/): npm error code E404" + ), + "npm error code E404" + ); + assert_eq!(short_error_fragment("short"), "short"); + let exact: String = "é".repeat(SHORT_ERROR_CHARS); + assert_eq!( + short_error_fragment(&exact), + exact, + "no ellipsis at the limit" + ); + let over = format!("{exact}x"); + assert_eq!(short_error_fragment(&over), format!("{exact}…")); + assert_eq!( + short_error_fragment(&format!( + "{} {}", + "a".repeat(SHORT_ERROR_CHARS - 1), + "b".repeat(5) + )), + format!("{}…", "a".repeat(SHORT_ERROR_CHARS - 1)), + "trailing whitespace before the cut is dropped" + ); +} + +/// The registry probe is decided from the shim dir alone: any managed +/// shim absent means a Claude/Codex row may resolve elsewhere. +#[test] +fn registry_probe_runs_only_while_a_managed_shim_is_absent() { + let tools = crate::managed_acp_tools::managed_tools(); + if tools.is_empty() { + return; + } + let dir = tempfile::tempdir().unwrap(); + assert!(any_managed_shim_missing(dir.path())); + for tool in &tools { + std::fs::write(dir.path().join(tool.binary), "#!/bin/sh\n").unwrap(); + } + assert!(!any_managed_shim_missing(dir.path())); + std::fs::remove_file(dir.path().join(tools[0].binary)).unwrap(); + assert!(any_managed_shim_missing(dir.path())); +} + +/// When this build does not manage bridges at all, a Homebrew copy is by +/// design: the rows say so and keep their status, and no fix is added. +/// Rows for other agents are untouched. +#[test] +fn managed_installs_disabled_is_noted_on_the_bridge_rows_without_a_downgrade() { + let path = "/opt/homebrew/bin/claude-agent-acp"; + let mut claude = + user_copy_claude_check(path, &InstallSource::Brew, Some(USER_COPY_VERSION), None); + claude.raw_output = Some("strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp".to_string()); + let mut codex = user_copy_claude_check( + "/opt/homebrew/bin/codex-acp", + &InstallSource::Brew, + None, + None, + ); + codex.id = "ai-agent-codex".to_string(); + codex.status = CheckStatus::Fail; + codex.message = "Not installed (optional)".to_string(); + codex.path = None; + let amp = DoctorCheck { + id: "ai-agent-amp".to_string(), + ..claude.clone() + }; + let amp_before = json(&); + let mut report = DoctorReport { + checks: vec![claude, codex, amp], + }; + + note_managed_installs_disabled(&mut report, "STAGED_ACP_TOOLS_DIR is set to /dev/acp"); + + let claude = &report.checks[0]; + assert_eq!(claude.status, CheckStatus::Pass); + assert_eq!( + claude.message, + "Installed (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" + ); + assert!(claude.fix_type.is_none()); + assert_eq!( + claude.raw_output.as_deref(), + Some( + "strategy 0: PATH => /opt/homebrew/bin/claude-agent-acp\n\n\ + managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp" + ) + ); + let codex = &report.checks[1]; + assert_eq!(codex.status, CheckStatus::Fail); + assert_eq!( + codex.message, + "Not installed (optional) (managed installs disabled: STAGED_ACP_TOOLS_DIR is set to /dev/acp)" + ); + assert_eq!(json(&report.checks[2]), amp_before); +} + +/// A user copy with a newer release keeps its readout but has its action +/// redirected: on a managing build the managed installer is the only writer +/// for these two packages, so the crate's `npm install -g` (which would +/// orphan a copy in the private prefix) and `brew upgrade` (which Staged +/// does not run for a copy it never uses) are replaced by a description of +/// the managed install. A source the crate cannot update gets the action it +/// had none for. +/// +/// The version the description names is the managed probe's — what the +/// installer will actually fetch from the configured registry — not the +/// crate's per-source answer (`brew info` for a Homebrew copy), which the +/// readout itself keeps showing. +#[test] +fn a_user_copy_with_a_newer_release_is_redirected_to_the_managed_install() { + // The configured registry is one release ahead of what the crate's + // source-aware lookup found for the user's copy. + let installed = versions(Some("0.16.2"), Some("0.17.1")); + let cases = [ + ( + "/Users/u/.nvm/versions/node/v22.14.0/bin/claude-agent-acp", + InstallSource::Npm, + Some("npm install -g @agentclientprotocol/claude-agent-acp@latest"), + ), + ( + "/opt/homebrew/bin/claude-agent-acp", + InstallSource::Brew, + Some("brew upgrade claude-agent-acp"), + ), + ( + "/usr/local/bin/claude-agent-acp", + InstallSource::Unknown, + None, + ), + ]; + + for (path, source, command) in &cases { + let check = user_copy_claude_check(path, source, Some("0.17.0"), *command); + let before = json(&check); + let mut after = apply_to(check, &installed, true); + + assert!(after.bridge.is_none(), "{source:?}"); + assert!(after.bridge_path.is_none(), "{source:?}"); + let main = after.main.as_ref().unwrap(); + assert_eq!( + main.update_fix_type, + Some(FixType::UpdateMain), + "{source:?}" + ); + let description = main.update_command.as_deref().expect("an action"); + assert!( + description.contains("@agentclientprotocol/claude-agent-acp"), + "{description}" + ); + assert!( + description.contains("0.17.1"), + "names the registry release the installer fetches: {description}" + ); + assert!( + !description.contains("0.17.0"), + "not the crate's per-source answer: {description}" + ); + assert!(description.contains("~/.staged/packages"), "{description}"); + assert!(description.contains(path), "{description}"); + assert!(!description.contains("npm install"), "{description}"); + assert!(!description.contains("brew upgrade"), "{description}"); + + // The row's fix is the same managed install, word for word, so the + // Update and Fix buttons never describe two different things. + assert_eq!(after.fix_type, Some(FixType::Bridge), "{source:?}"); + assert_eq!( + after.fix_command.as_deref(), + Some(description), + "{source:?}" + ); + + // The action (and the missing-copy flag) are the *only* changes: + // path, source, both versions and `updateAvailable` still describe + // the copy that is on PATH — the readout's `latestVersion` stays + // the crate's, even though the description names the registry's. + let main = after.main.as_mut().unwrap(); + main.update_command = command.map(str::to_string); + main.update_fix_type = command.map(|_| FixType::UpdateMain); + assert_eq!( + json(&without_missing_copy_flag(after)), + before, + "{source:?}" + ); + } +} + +/// When the managed probe failed — registry unreachable, garbage, timed +/// out — the description falls back to the version the crate flagged the +/// copy stale against, rather than naming nothing: the row is still +/// redirected, since the installer's `@latest` needs no version up front. +#[test] +fn a_redirected_description_falls_back_to_the_crates_latest_when_the_probe_failed() { + let probe_failed = versions(Some("0.16.2"), None); + let path = "/opt/homebrew/bin/claude-agent-acp"; + let check = user_copy_claude_check( + path, + &InstallSource::Brew, + Some("0.17.0"), + Some("brew upgrade claude-agent-acp"), + ); + + let after = apply_to(check, &probe_failed, true); + + let main = after.main.as_ref().unwrap(); + assert_eq!(main.update_fix_type, Some(FixType::UpdateMain)); + assert_eq!( + main.update_command.as_deref(), + Some( + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ) + ); +} + +/// Only the managed shim gets the managed readout. A row whose path sits +/// under the shim dir under another name, a row with no executable, a row +/// the crate did not label bundled, another tool's versions, and a build +/// with nothing managed all leave the check exactly as the crate produced +/// it. +#[test] +fn unmanaged_copies_and_other_checks_are_left_alone() { + let shim_bin_dir = Path::new(SHIM_BIN_DIR); + let installed = versions(Some("0.16.2"), Some("0.17.0")); + + // Under the shim dir but a different binary name. + let other = Path::new(SHIM_BIN_DIR) + .join("codex-acp") + .display() + .to_string(); + let mut check = managed_claude_check(&other, true); + let before = json(&check); + assert!(!apply_managed_bridge_readout( + &mut check, + &installed, + shim_bin_dir, + true + )); + assert_eq!(json(&check), before); + + // Not installed at all. + let mut check = managed_claude_check(&managed_shim_path(), false); + check.path = None; + check.main = None; + check.status = CheckStatus::Warn; + let before = json(&check); + assert!(!apply_managed_bridge_readout( + &mut check, + &installed, + shim_bin_dir, + false + )); + assert_eq!(json(&check), before); + + // The right path, but the crate did not call it bundled (a dev override + // dir that happens to sit at the shim path is not a managed install). + let mut check = managed_claude_check(&managed_shim_path(), true); + check.main.as_mut().unwrap().bundled = None; + let before = json(&check); + assert!(!apply_managed_bridge_readout( + &mut check, + &installed, + shim_bin_dir, + true + )); + assert_eq!(json(&check), before); + + // Report-wide: nothing managed (dev override, disabled feature, + // unsupported target) means no versions and no readout anywhere. + let mut report = DoctorReport { + checks: vec![ + managed_claude_check(&managed_shim_path(), true), + node_runtime_doctor_check(CheckStatus::Pass, "ok".to_string(), None, None, None), + ], + }; + let before: Vec<_> = report.checks.iter().map(json).collect(); + apply_managed_bridge_readouts( + &mut report, + &[], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); + let after: Vec<_> = report.checks.iter().map(json).collect(); + assert_eq!(after, before); + + // Report-wide: versions for one tool reach only its own check. + let codex_versions = ManagedBridgeVersions { + tool: ManagedTool { + id: "codex-acp", + binary: "codex-acp", + package: "@agentclientprotocol/codex-acp", + }, + installed: Some("1.1.2".to_string()), + latest: Some("1.2.0".to_string()), + }; + apply_managed_bridge_readouts( + &mut report, + &[codex_versions], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); + let after: Vec<_> = report.checks.iter().map(json).collect(); + assert_eq!(after, before); + apply_managed_bridge_readouts( + &mut report, + &[installed], + shim_bin_dir, + true, + &ManagedInstallDiagnostics::default(), + ); + assert!(report.checks[0].bridge.is_some()); + assert!(report.checks[1].bridge.is_none()); +} + +/// The redirected action's text names the package, the version, where the +/// managed copy goes, and the copy it leaves alone — an unknown latest +/// version drops the version rather than printing a placeholder. +#[test] +fn managed_install_description_names_the_package_version_and_replaced_copy() { + let description = managed_install_description( + &claude_tool(), + Some("0.17.0"), + "/opt/homebrew/bin/claude-agent-acp", + ); + assert_eq!( + description, + "install Staged's managed @agentclientprotocol/claude-agent-acp 0.17.0 in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ); + + let description = + managed_install_description(&claude_tool(), None, "/opt/homebrew/bin/claude-agent-acp"); + assert_eq!( + description, + "install Staged's managed @agentclientprotocol/claude-agent-acp in \ + ~/.staged/packages (the copy at /opt/homebrew/bin/claude-agent-acp is left as is)" + ); +} + +#[test] +fn newer_version_available_compares_semver_triples() { + assert_eq!( + newer_version_available(Some("0.16.2"), Some("0.17.0")), + Some(true) + ); + assert_eq!( + newer_version_available(Some("0.17.0"), Some("0.17.0")), + Some(false) + ); + assert_eq!( + newer_version_available(Some("1.0.0"), Some("0.99.99")), + Some(false) + ); + assert_eq!( + newer_version_available(Some("v1.2.3"), Some("1.2.4-rc.1")), + Some(true) + ); + assert_eq!(newer_version_available(None, Some("1.0.0")), None); + assert_eq!(newer_version_available(Some("1.0.0"), None), None); + assert_eq!(newer_version_available(Some("1.0"), Some("1.0.1")), None); + assert_eq!(newer_version_available(Some("1.0.0"), Some("latest")), None); +} + +#[test] +fn is_managed_shim_path_requires_the_shim_dir_and_binary_name() { + let dir = Path::new(SHIM_BIN_DIR); + assert!(is_managed_shim_path( + "/data/.staged/packages/bin/claude-agent-acp", + dir, + "claude-agent-acp" + )); + assert!(!is_managed_shim_path( + "/data/.staged/packages/bin/codex-acp", + dir, + "claude-agent-acp" + )); + assert!(!is_managed_shim_path( + "/data/.staged/packages/npm-prefix/bin/claude-agent-acp", + dir, + "claude-agent-acp" + )); + assert!(!is_managed_shim_path( + "/usr/local/bin/claude-agent-acp", + dir, + "claude-agent-acp" + )); +} diff --git a/apps/staged/src-tauri/src/doctor/tests/runtime_checks.rs b/apps/staged/src-tauri/src/doctor/tests/runtime_checks.rs new file mode 100644 index 000000000..6f76da2ab --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/tests/runtime_checks.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::doctor::test_support::*; + +fn queue_health(stranded: Vec, swept_total: usize) -> QueueHealth { + QueueHealth { + owned_running: stranded.len() + 1, + live: 1, + stranded, + swept_total, + } +} + +#[test] +fn session_queue_check_passes_when_nothing_is_stranded() { + let check = build_session_queue_check(&queue_health(Vec::new(), 0)); + + assert_eq!(check.id, SESSION_QUEUE_CHECK_ID); + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!(check.message, "No stranded sessions"); + assert!(check.fix_type.is_none()); + let detail = check.raw_output.expect("detail is attached"); + assert!(detail.contains("owned running rows: 1"), "{detail}"); + assert!(detail.contains("stranded now: 0"), "{detail}"); + assert!(detail.contains("ended by the sweep so far: 0"), "{detail}"); +} + +/// A sweep that has already acted leaves nothing stranded, so the status +/// stays `Pass` — but the count is what makes a recurrence visible. +#[test] +fn session_queue_check_reports_past_sweeps_without_warning() { + let check = build_session_queue_check(&queue_health(Vec::new(), 2)); + + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!( + check.message, + "No stranded sessions; the sweep has ended 2 sessions so far" + ); +} + +#[test] +fn session_queue_check_warns_while_a_row_is_stranded() { + let row = sweep::StrandedRow { + session_id: "s-1".to_string(), + branch_id: Some("b-1".to_string()), + }; + let check = build_session_queue_check(&queue_health(vec![row], 0)); + + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!( + check.message, + "1 session is running with nothing in this process owning it; the stranded-session sweep ends it within a minute" + ); + let detail = check.raw_output.expect("detail is attached"); + assert!(detail.contains("- session s-1 (branch b-1)"), "{detail}"); + assert!( + detail.contains("of which owned by a registered runner or a liveness hold: 1"), + "{detail}" + ); +} + +#[test] +fn ready_runtime_passes_without_a_fix() { + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Ready, + Path::new("/data/packages/node/v9.9.9/plat"), + &["copilot".to_string()], + None, + ) + .expect("ready runtime is reported"); + + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!( + check.message, + format!("Staged-managed Node.js {} is installed", pinned_version()) + ); + assert_eq!( + check.path.as_deref(), + Some("/data/packages/node/v9.9.9/plat/bin/node") + ); + 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("state: ready")); + assert!(output.contains("- copilot")); +} + +#[test] +fn damaged_runtime_warns_with_a_native_reinstall_fix() { + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Broken, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + None, + ) + .expect("damaged runtime is reported"); + + assert_eq!(check.status, CheckStatus::Warn); + assert!(check.message.contains("is damaged")); + assert!(check.path.is_none()); + 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 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( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &["amp-acp".to_string(), "copilot".to_string()], + None, + ) + .expect("needed-but-missing runtime is reported"); + assert_eq!(check.status, CheckStatus::Warn); + 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")); + assert!(!output.contains("last managed install"), "{output}"); + + // Nothing installed that needs it, and no reconcile on record (or a + // clean one): stay silent. + for record in [None, Some(reconcile_record(true, Vec::new()))] { + assert!(build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + record.as_ref(), + ) + .is_none()); + } +} + +/// A runtime that is missing *because* the last reconcile failed is the +/// install that could not happen, not one that was never needed: the row +/// warns with the recorded error even with nothing installed yet, lists +/// every error (and the URL they name) in its detail, and keeps the +/// reinstall fix. +#[test] +fn missing_runtime_after_a_failed_reconcile_warns_with_the_recorded_errors() { + let node_error = "claude-acp: Node.js runtime download failed: \ + https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ + error sending request for url"; + let record = reconcile_record( + false, + vec![ + "codex-acp: npm install failed: npm install exited with exit status: 1", + node_error, + ], + ); + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&record), + ) + .expect("a failed install is reported"); + + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!( + check.message, + format!( + "Staged-managed Node.js {} could not be installed: Node.js runtime download \ + failed: https://nodejs.org/dist/v9.9.9/node-v9.9.9-darwin-arm64.tar.gz: \ + error sending request for url", + pinned_version() + ), + "leads with the runtime download error, minus the tool id" + ); + assert_eq!(check.fix_type, Some(FixType::Command)); + assert!(check.fix_command.is_some()); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("state: missing"), "{output}"); + assert!(output.contains("last managed install: failed"), "{output}"); + assert!(output.contains("with 2 error(s):"), "{output}"); + assert!(output.contains(&format!("- {node_error}")), "{output}"); + assert!( + output.contains("- codex-acp: npm install failed"), + "{output}" + ); + assert!( + output.contains("hint: the configured registry/host did not respond"), + "{output}" + ); + + // With no runtime-specific error the first recorded one is the + // headline, still without its tool id. + let record = reconcile_record( + false, + vec!["claude-acp: installed ACP bridge is incomplete: probe exited with 1"], + ); + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&record), + ) + .unwrap(); + assert!( + check.message.ends_with( + "could not be installed: installed ACP bridge is incomplete: probe exited with 1" + ), + "{}", + check.message + ); + assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); +} + +/// The hint is the runtime row's own only when the headline is a runtime +/// download failure: a bridge's npm 404 is still reported, but never as +/// "the configured registry does not carry Node.js". A 4xx from the dist +/// host points at the download URL. +#[test] +fn runtime_row_hints_only_on_its_own_download_failure() { + let bridge_404 = "codex-acp: npm install failed: npm install exited with exit status: 1 \ + (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ + GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp"; + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record(false, vec![bridge_404])), + ) + .unwrap(); + assert_eq!(check.status, CheckStatus::Warn); + assert!( + check.message.ends_with( + "could not be installed: npm install failed: npm install exited with exit status: 1 \ + (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - \ + GET https://mirror.example.test/npm/@agentclientprotocol%2fcodex-acp" + ), + "{}", + check.message + ); + let output = check.raw_output.as_deref().unwrap(); + assert!(output.contains(&format!("- {bridge_404}")), "{output}"); + assert!(!output.contains("hint:"), "{output}"); + assert!(!output.contains("Node.js\n"), "{output}"); + + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record( + false, + vec![ + bridge_404, + "claude-acp: Node.js runtime download failed: HTTP 404", + ], + )), + ) + .unwrap(); + assert!( + check + .message + .ends_with("could not be installed: Node.js runtime download failed: HTTP 404"), + "{}", + check.message + ); + let output = check.raw_output.as_deref().unwrap(); + assert!( + output.contains( + "hint: the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL" + ), + "{output}" + ); + + // A checksum mismatch is the headline but not a download failure Doctor + // has advice for. + let check = build_node_runtime_check( + ManagedNodeRuntimeState::Missing, + Path::new("/data/packages/node/v9.9.9/plat"), + &[], + Some(&reconcile_record( + false, + vec!["claude-acp: Node.js runtime archive SHA-256 mismatch: expected a, got b"], + )), + ) + .unwrap(); + assert!( + check + .message + .ends_with("Node.js runtime archive SHA-256 mismatch: expected a, got b"), + "{}", + check.message + ); + assert!(!check.raw_output.as_deref().unwrap().contains("hint:")); +} + +#[test] +fn installed_npm_tool_names_lists_visible_entries_only() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("copilot"), "").unwrap(); + std::fs::write(dir.path().join(".copilot.tmp"), "").unwrap(); + + let names = installed_npm_tool_names(dir.path()); + assert_eq!(names, vec!["copilot".to_string()]); + + // An absent dir reads as no tools, not an error. + assert!(installed_npm_tool_names(&dir.path().join("absent")).is_empty()); +} diff --git a/apps/staged/src-tauri/src/doctor/tests/support.rs b/apps/staged/src-tauri/src/doctor/tests/support.rs new file mode 100644 index 000000000..f35d30d44 --- /dev/null +++ b/apps/staged/src-tauri/src/doctor/tests/support.rs @@ -0,0 +1,15 @@ +//! Fixtures shared by the doctor test modules. + +use crate::managed_acp_tools::ReconcileRecord; + +pub(super) fn pinned_version() -> String { + crate::managed_node::node_runtime_lock().version.clone() +} + +pub(super) fn reconcile_record(ok: bool, errors: Vec<&str>) -> ReconcileRecord { + ReconcileRecord { + at_ms: 1_000, + ok, + errors: errors.into_iter().map(str::to_string).collect(), + } +} diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index f03ec79d9..ca4552607 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2374,10 +2374,10 @@ pub fn run() { doctor::run_doctor, doctor::run_doctor_freshness, doctor::run_doctor_fix, - doctor::start_doctor_login, - doctor::cancel_doctor_login, - doctor::doctor_login_status, - doctor::send_doctor_login_code, + doctor::login::start_doctor_login, + doctor::login::cancel_doctor_login, + doctor::login::doctor_login_status, + doctor::login::send_doctor_login_code, doctor::run_doctor_update, ]) .build(tauri::generate_context!()) diff --git a/apps/staged/src-tauri/src/managed_acp_tools.rs b/apps/staged/src-tauri/src/managed_acp_tools.rs deleted file mode 100644 index d968fd0a5..000000000 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ /dev/null @@ -1,3287 +0,0 @@ -//! 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. 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, validates -//! the staged native CLI before promotion, 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`; 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. -//! - `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 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, HashMap}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -use tokio::io::AsyncBufReadExt; - -use crate::managed_node; - -mod validation; - -/// 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"; - -// ----------------------------------------------------------------------------- -// npm registry policy -// -// Staged passes no `--registry` of its own: npm resolves the registry from -// its normal config chain (`NPM_CONFIG_REGISTRY`, `~/.npmrc` including -// `@scope:registry` entries, then `registry.npmjs.org`). That keeps a -// corporate laptop whose `~/.npmrc` already points at an internal mirror — -// with whatever auth token it needs — on that mirror, and gives everyone -// else their own registry; a hardcoded URL served exactly one network and -// could not even override a scoped `~/.npmrc` entry. -// -// Two escape hatches sit around that default. `STAGED_NPM_REGISTRY` is an -// explicit override passed as `--registry` to every Staged-run npm command. -// `STAGED_FALLBACK_NPM_REGISTRY` is a *build-time* registry (compiled in via -// `option_env!`, never written into source) for the one population npm's own -// config fails: Block laptops with no `~/.npmrc` behind the Cloudflare -// Gateway, where `registry.npmjs.org` answers with a redirect to an HTML -// block page and npm reports `FETCH_ERROR`/invalid JSON. Only a failure that -// reads as that block page is retried through the fallback — a 404 from a -// mirror, a dead host, or an unresolvable version is not a registry problem a -// different registry can fix, and an explicit override is never second- -// guessed. -// ----------------------------------------------------------------------------- - -/// Explicit npm registry override, passed as `--registry` when set. -pub const NPM_REGISTRY_ENV: &str = "STAGED_NPM_REGISTRY"; - -/// The `STAGED_NPM_REGISTRY` override, trimmed, when set and non-empty. -/// `None` means npm decides from its own config. -pub fn npm_registry_override() -> Option { - registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) -} - -fn registry_from_env_value(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -/// The registry compiled in through `STAGED_FALLBACK_NPM_REGISTRY` at build -/// time, retried once when npm's configured registry turns out to be -/// gateway-blocked (see the policy note above). `None` for builds that set -/// no such variable. -pub fn fallback_npm_registry() -> Option<&'static str> { - option_env!("STAGED_FALLBACK_NPM_REGISTRY") - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -/// How an npm command names its registry in errors and progress lines. -fn registry_label(registry: Option<&str>) -> String { - registry - .map(str::to_string) - .unwrap_or_else(|| "npm default (per npm config)".to_string()) -} - -/// What a failed npm command's stderr says about why it failed, as far as -/// the registry retry needs to know. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum NpmFailure { - /// A network gateway answered for `registry.npmjs.org` with a block page - /// instead of registry JSON — Cloudflare Gateway on managed Block laptops - /// redirects to `blocked.teams.cloudflare.com`, and npm surfaces the HTML - /// as `FETCH_ERROR` or a JSON parse error for a request it made to the - /// public registry. - GatewayBlocked, - Other, -} - -/// How npm names `registry.npmjs.org` as the host it *requested* — minipass- -/// fetch's `request to ` / `invalid json response body at ` and -/// npm's `registry: ` detail line. A bare `registry.npmjs.org` anywhere -/// in stderr is not enough: a mirror's own error body mentions its upstream -/// ("failed to fetch from registry.npmjs.org") when *it* could not reach it, -/// and that is the mirror failing, not a gateway blocking the public registry. -const NPMJS_REQUEST_MARKERS: [&str; 3] = [ - "request to https://registry.npmjs.org/", - "response body at https://registry.npmjs.org/", - "registry: https://registry.npmjs.org", -]; - -/// Gateway-blocked iff stderr names the block page itself, or a non-JSON / -/// fetch-level failure (`FETCH_ERROR`, `EJSONPARSE`, "invalid json", -/// "Unexpected token") happened on a request npm made to -/// `registry.npmjs.org`. `FETCH_ERROR` is minipass-fetch's catch-all for a -/// fetch that died without a system errno, so a truncated body from the -/// public registry classifies as blocked too — that costs one cheap extra -/// attempt through the fallback, which then reports its own error. -pub(crate) fn classify_npm_failure(stderr: &str) -> NpmFailure { - if stderr.contains("blocked.teams.cloudflare.com") { - return NpmFailure::GatewayBlocked; - } - let requested_npmjs = NPMJS_REQUEST_MARKERS - .iter() - .any(|marker| stderr.contains(marker)); - if requested_npmjs { - let lower = stderr.to_ascii_lowercase(); - if stderr.contains("FETCH_ERROR") - || stderr.contains("EJSONPARSE") - || lower.contains("unexpected token") - || lower.contains("invalid json") - { - return NpmFailure::GatewayBlocked; - } - } - NpmFailure::Other -} - -/// The registry to retry a failed npm command through, if any: only when no -/// explicit override was given, a build-time fallback exists, and `stderr` -/// reads as a gateway block of `registry.npmjs.org`. -pub(crate) fn gateway_fallback_registry<'a>( - override_registry: Option<&str>, - fallback: Option<&'a str>, - stderr: &str, -) -> Option<&'a str> { - if override_registry.is_some() { - return None; - } - let fallback = fallback?; - (classify_npm_failure(stderr) == NpmFailure::GatewayBlocked).then_some(fallback) -} - -/// The error to surface when the retry through `fallback` failed as well: -/// keeps the original diagnosis (npm's default registry was gateway-blocked) -/// in front of the retry's own error, so the surfaced message explains why a -/// registry the user never configured was contacted at all. -pub(crate) fn gateway_retry_failed_message(fallback: &str, retry_error: &str) -> String { - format!( - "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ - fallback {fallback} also failed: {retry_error}" - ) -} - -/// What a recorded managed-install error says the user can change, as far -/// as the error text tells. Coarser than [`NpmFailure`] on purpose: this -/// classifies the *recorded* error (`state.json`'s `lastReconcile.errors`, -/// the Node download error), which carries npm's `error code` block via -/// [`npm_error_summary`] rather than the full stderr, and it also covers the -/// Node.js runtime download. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum InstallFailureHint { - /// `registry.npmjs.org` answered with a network gateway's block page and - /// npm had no other registry configured. - GatewayBlocked, - /// The registry npm used answered 404 for the package. - PackageMissing, - /// The registry or download host could not be reached at all: DNS, - /// refused/reset connections, or a timeout on the request. - HostUnreachable, - /// The Node.js dist host answered the runtime download with a 4xx — the - /// URL (`STAGED_NODE_DIST_URL`) points at the wrong path or host. - NodeDownloadRejected(u16), -} - -/// Classify a recorded install error (see [`InstallFailureHint`]). `None` -/// when the text names no network or registry cause Doctor has advice for — -/// a validation failure, a missing entrypoint, a disk error. -pub(crate) fn classify_install_failure(error: &str) -> Option { - // Checked first: `HTTP 404` from the dist host is a download URL problem, - // not a registry without the package. A 5xx is the host's, not the - // user's, so it gets no hint. - if let Some(status) = node_download_http_status(error) { - return (400..500) - .contains(&status) - .then_some(InstallFailureHint::NodeDownloadRejected(status)); - } - if error.contains("blocked by a network gateway") - || classify_npm_failure(error) == NpmFailure::GatewayBlocked - { - return Some(InstallFailureHint::GatewayBlocked); - } - if error.contains("E404") || error.contains("404 Not Found") { - return Some(InstallFailureHint::PackageMissing); - } - const UNREACHABLE_MARKERS: [&str; 11] = [ - "ENOTFOUND", - "ECONNREFUSED", - "ECONNRESET", - "ETIMEDOUT", - "EAI_AGAIN", - "EHOSTUNREACH", - "ENETUNREACH", - // reqwest's wording for the Node.js runtime download. - "error sending request", - "dns error", - "connection refused", - "operation timed out", - ]; - if UNREACHABLE_MARKERS - .iter() - .any(|marker| error.contains(marker)) - { - return Some(InstallFailureHint::HostUnreachable); - } - // The managed `npm install` itself timing out names its registry; a - // validation probe timing out does not, and is not a network problem. - if error.contains("timed out after") && error.contains("(registry:") { - return Some(InstallFailureHint::HostUnreachable); - } - None -} - -/// The status in a `Node.js runtime download failed: HTTP ` error -/// (`ManagedNodeError::HttpStatus`'s wording), if that is what `error` is. -fn node_download_http_status(error: &str) -> Option { - let (_, rest) = error.split_once(NODE_DOWNLOAD_HTTP_STATUS_PREFIX)?; - rest.split(|c: char| !c.is_ascii_digit()) - .next()? - .parse() - .ok() -} - -/// How `ManagedNodeError::HttpStatus` renders, up to the status code. -const NODE_DOWNLOAD_HTTP_STATUS_PREFIX: &str = "Node.js runtime download failed: HTTP "; - -/// The one-line hint Doctor shows under a classified install failure, naming -/// what to change: `package` is the npm package the install was for. -pub(crate) fn install_failure_hint(error: &str, package: &str) -> Option { - Some(match classify_install_failure(error)? { - InstallFailureHint::GatewayBlocked => { - "this network blocks registry.npmjs.org and npm has no registry configured — \ - add `registry=` to ~/.npmrc or set STAGED_NPM_REGISTRY" - .to_string() - } - InstallFailureHint::PackageMissing => { - format!("the configured registry does not carry {package}") - } - InstallFailureHint::HostUnreachable => { - "the configured registry/host did not respond — check `registry=` in ~/.npmrc \ - or STAGED_NODE_DIST_URL" - .to_string() - } - InstallFailureHint::NodeDownloadRejected(status) => { - format!("the Node.js download URL returned HTTP {status} — check STAGED_NODE_DIST_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, 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, - managed_tools_disabled: bool, - supported_target: bool, -) -> bool { - !override_active && !managed_tools_disabled && supported_target -} - -/// Why this build does not manage bridge installs, for Doctor to say so on -/// the Claude/Codex rows — a copy resolved from Homebrew is then by design, -/// not a failed install. `None` when management is on. -pub(crate) fn managed_tools_disabled_reason() -> Option { - managed_tools_disabled_reason_from_parts( - dev_tools_override_dir().as_deref(), - cfg!(feature = "no-managed-acp-tools"), - managed_node::current_target_triple().is_some(), - ) -} - -fn managed_tools_disabled_reason_from_parts( - override_dir: Option<&Path>, - managed_tools_disabled: bool, - supported_target: bool, -) -> Option { - if let Some(dir) = override_dir { - return Some(format!("{ACP_TOOLS_DIR_ENV} is set to {}", dir.display())); - } - if managed_tools_disabled { - return Some("this build was compiled without managed ACP tools".to_string()); - } - if !supported_target { - return Some("no managed Node.js runtime is available for this platform".to_string()); - } - None -} - -/// 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 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 { - 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 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") -} - -// ============================================================================= -// 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`]). -#[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 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, -} - -/// 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. -/// Each entry must have a runtime probe in [`validation::validate_staged_tool`]. -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), - /// npm exited cleanly but the staged bridge failed install 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` 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. -/// -/// `env_vars` is the sanitized login-shell snapshot doctor's checks and the -/// freshness probe run under ([`crate::doctor::doctor_env_vars`]); npm runs -/// under exactly that, so the registry config it resolves (`~/.npmrc`, -/// `NPM_CONFIG_REGISTRY` from the shell rc) is the one the probe saw. -pub async fn install_managed_tool( - id: &str, - env_vars: &[(String, String)], - 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, - env_vars, - npm_registry_override().as_deref(), - fallback_npm_registry(), - validation::VALIDATION_TIMEOUT, - 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. npm runs under the `env_vars` snapshot; `registry` is the -/// explicit override (or `None` for npm's own config) and `fallback_registry` -/// the build-time fallback tried once if the first attempt fails as -/// gateway-blocked (see the registry policy note). -#[allow(clippy::too_many_arguments)] -async fn install_npm_tool( - packages_root: &Path, - node_install_dir: &Path, - node_version: &str, - tool: &ManagedTool, - env_vars: &[(String, String)], - registry: Option<&str>, - fallback_registry: Option<&str>, - validation_timeout: Duration, - on_line: &InstallLineFn<'_>, -) -> Result<(), ManagedToolError> { - let install_dir = tool_install_dir(packages_root, tool.id); - // Stage the floating install into a scratch prefix and swap it into the - // live tree only after entrypoint and runtime validation pass. 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 - )); - if let Err(failure) = run_floating_npm_install( - packages_root, - node_install_dir, - &staging_dir, - tool, - env_vars, - registry, - on_line, - ) - .await - { - let Some(fallback) = - gateway_fallback_registry(registry, fallback_registry, &failure.stderr) - else { - let _ = std::fs::remove_dir_all(&staging_dir); - return Err(failure.error); - }; - on_line(&format!( - "npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" - )); - // A fresh scratch prefix: whatever the blocked attempt reified must - // not seed the retry. - if let Err(error) = reset_dir(&staging_dir) { - let _ = std::fs::remove_dir_all(&staging_dir); - return Err(ManagedToolError::Io(format!( - "prepare staging dir for registry retry: {error}" - ))); - } - if let Err(failure) = run_floating_npm_install( - packages_root, - node_install_dir, - &staging_dir, - tool, - env_vars, - Some(fallback), - on_line, - ) - .await - { - let _ = std::fs::remove_dir_all(&staging_dir); - // The retry's own error is an `NpmInstall` too; its bare message - // goes inside so the prefix is not doubled. - let retry_error = match &failure.error { - ManagedToolError::NpmInstall(message) => message.clone(), - other => other.to_string(), - }; - return Err(ManagedToolError::NpmInstall(gateway_retry_failed_message( - fallback, - &retry_error, - ))); - } - } - - 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, - staged_entrypoint.display() - ))); - } - on_line(&format!("Validating {} before promotion", tool.package)); - if let Err(error) = - validation::validate_staged_tool(node_install_dir, &staging_dir, tool, validation_timeout) - .await - { - let _ = std::fs::remove_dir_all(&staging_dir); - return Err(error); - } - 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, - &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(()) -} - -/// 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 { - 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) -} - -/// A failed floating install: the error to surface plus what npm wrote to -/// stderr, kept so the caller can classify the failure for a registry retry. -struct NpmInstallFailure { - error: ManagedToolError, - stderr: String, -} - -/// How much of npm's stderr is kept for the retry classifier. npm writes its -/// `npm error code …` block *last*, after any deprecation and peer warnings, -/// so the capture keeps the newest lines (see [`push_captured_line`]) — a -/// warning-heavy install must not push the error block out of view. -const NPM_STDERR_CAPTURE_LIMIT: usize = 64 * 1024; - -/// Append `line` to the capture, dropping whole lines from the *front* once -/// it outgrows [`NPM_STDERR_CAPTURE_LIMIT`] (a single oversized line keeps -/// its tail). -fn push_captured_line(capture: &mut String, line: &str) { - capture.push_str(line); - capture.push('\n'); - while capture.len() > NPM_STDERR_CAPTURE_LIMIT { - match capture.find('\n') { - Some(end) if end + 1 < capture.len() => { - capture.drain(..=end); - } - _ => { - let mut cut = capture.len() - NPM_STDERR_CAPTURE_LIMIT; - while !capture.is_char_boundary(cut) { - cut += 1; - } - capture.drain(..cut); - } - } - } -} - -/// How many `npm error` detail lines after the `npm error code` line are -/// kept in the recorded error, and how long each may be. -const NPM_ERROR_SUMMARY_LINES: usize = 3; -const NPM_ERROR_SUMMARY_LINE_LIMIT: usize = 240; - -/// npm's own account of why it failed, from its captured stderr: the last -/// `npm error code ` line and the first few `npm error` detail lines -/// after it (minus `errno`, which repeats the code, and the log-file pointer). -/// Appended to the recorded install error so `state.json` and Doctor can say -/// *why* — the exit status alone says nothing about a block page, a 404, or -/// an unreachable mirror. `None` when npm printed no error block. -fn npm_error_summary(stderr: &str) -> Option { - let lines: Vec<&str> = stderr.lines().map(str::trim_end).collect(); - let code_index = lines.iter().rposition(|line| { - line.starts_with("npm error code ") || line.starts_with("npm ERR! code ") - })?; - let mut summary = vec![clip_line(lines[code_index])]; - let details = lines[code_index + 1..] - .iter() - .filter(|line| line.starts_with("npm error") || line.starts_with("npm ERR!")) - .map(|line| { - line.trim_start_matches("npm error") - .trim_start_matches("npm ERR!") - .trim() - }) - .filter(|detail| { - !detail.is_empty() - && !detail.starts_with("errno ") - && !detail.starts_with("A complete log") - }) - .take(NPM_ERROR_SUMMARY_LINES) - .map(clip_line); - summary.extend(details); - Some(summary.join("; ")) -} - -fn clip_line(line: &str) -> String { - if line.len() <= NPM_ERROR_SUMMARY_LINE_LIMIT { - return line.to_string(); - } - let mut cut = NPM_ERROR_SUMMARY_LINE_LIMIT; - while !line.is_char_boundary(cut) { - cut -= 1; - } - format!("{}…", &line[..cut]) -} - -#[allow(clippy::too_many_arguments)] -async fn run_floating_npm_install( - packages_root: &Path, - node_install_dir: &Path, - install_dir: &Path, - tool: &ManagedTool, - env_vars: &[(String, String)], - registry: Option<&str>, - on_line: &InstallLineFn<'_>, -) -> Result<(), NpmInstallFailure> { - let mut stderr_capture = String::new(); - run_floating_npm_install_capturing( - packages_root, - node_install_dir, - install_dir, - tool, - env_vars, - registry, - on_line, - &mut stderr_capture, - ) - .await - .map_err(|error| NpmInstallFailure { - error, - stderr: stderr_capture, - }) -} - -#[allow(clippy::too_many_arguments)] -async fn run_floating_npm_install_capturing( - packages_root: &Path, - node_install_dir: &Path, - install_dir: &Path, - tool: &ManagedTool, - env_vars: &[(String, String)], - registry: Option<&str>, - on_line: &InstallLineFn<'_>, - stderr_capture: &mut String, -) -> 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)); - - // Exactly the snapshot, not the Tauri process env: launched from Finder - // the process carries none of the user's shell rc, so an - // `NPM_CONFIG_REGISTRY`/`npm_config_registry` exported there would steer - // the freshness probe and doctor's fixes but not this install. The - // snapshot is what all of those run under. - command.env_clear(); - for (key, value) in env_vars { - command.env(key, value); - } - // npm's own `#!/usr/bin/env node` shebang must resolve the managed node: - // its bin dir goes first on the snapshot's PATH (the process PATH when - // the snapshot carries none). - let inherited_path = snapshot_var(env_vars, "PATH") - .map(std::ffi::OsString::from) - .or_else(|| std::env::var_os("PATH")) - .unwrap_or_default(); - let mut paths = vec![node_bin_dir.clone()]; - paths.extend(std::env::split_paths(&inherited_path)); - 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); - push_captured_line(stderr_capture, &line); - } - } - }; - let label = registry_label(registry); - 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 (registry: {label})", - NPM_INSTALL_TIMEOUT.as_secs() - ))) - } - } - }; - let (status, (), ()) = tokio::join!(wait, forward_out, forward_err); - let status = status?; - if status.success() { - Ok(()) - } else { - let summary = npm_error_summary(stderr_capture) - .map(|summary| format!(": {summary}")) - .unwrap_or_default(); - Err(ManagedToolError::NpmInstall(format!( - "npm install exited with {status} (registry: {label}){summary}" - ))) - } -} - -/// 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", - 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) -} - -// ============================================================================= -// Doctor readouts — installed and latest versions of a managed bridge -// ============================================================================= - -/// The ACP package version installed in the live managed prefix for `tool`, -/// read from the package's own `package.json` — the tree the shim actually -/// execs. Falls back to the version `state.json` recorded at install time when -/// that tree is unreadable; state is supporting information, not the -/// authority, since it can be missing (a state write that failed after the -/// swap) or stale (a tree swapped in by another Staged instance). -pub fn installed_tool_version(packages_root: &Path, tool: &ManagedTool) -> Option { - installed_version(&tool_install_dir(packages_root, tool.id), tool.package).or_else(|| { - read_state(packages_root) - .tools - .get(tool.id) - .map(|pin| pin.version.clone()) - .filter(|version| !version.is_empty()) - }) -} - -/// How long a registry answer is reused before the registry is asked again. -/// Mirrors the doctor crate's freshness cache: a Doctor re-run within the -/// hour costs no network, and a real release still shows up within one. -const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(60 * 60); - -/// A registry lookup that hangs must not hold the freshness pass open — the -/// same bound the doctor crate puts on its own `npm view` probes. -const LATEST_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(15); - -/// Latest published versions by package, with the time each was fetched. Only -/// answers are cached: a failed lookup is retried on the next pass rather than -/// pinned as "unknown" for an hour. -#[derive(Debug, Default)] -struct LatestVersionCache { - entries: HashMap, -} - -impl LatestVersionCache { - fn get_fresh(&self, package: &str, now: Instant) -> Option { - let (version, fetched_at) = self.entries.get(package)?; - (now.saturating_duration_since(*fetched_at) <= LATEST_VERSION_CACHE_TTL) - .then(|| version.clone()) - } - - fn insert(&mut self, package: &str, version: String, now: Instant) { - self.entries.insert(package.to_string(), (version, now)); - } -} - -fn latest_version_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LatestVersionCache::default())) -} - -/// The newest published version of `tool`'s npm package on the configured -/// registry, or `None` when it cannot be determined — offline, registry -/// unreachable, no `npm` to run, or the probe timed out. The caller must keep -/// `None` as *unknown*, never read it as "up to date". -/// -/// Runs `npm view version` under the same env snapshot doctor's own -/// probes and the managed install itself run under (see -/// [`install_managed_tool`]), so the managed runtime's `npm` is preferred and -/// the lookup resolves the registry the install will — `~/.npmrc` and any -/// `NPM_CONFIG_REGISTRY` the user's shell rc exports alike — with the same -/// override → npm config → gateway-blocked fallback precedence. Bounded by -/// [`LATEST_VERSION_PROBE_TIMEOUT`] and cached for [`LATEST_VERSION_CACHE_TTL`]. -pub async fn latest_published_version( - tool: &ManagedTool, - env_vars: &[(String, String)], -) -> Option { - let now = Instant::now(); - let cached = latest_version_cache() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get_fresh(tool.package, now); - if cached.is_some() { - return cached; - } - let npm = find_on_env_path(env_vars, "npm")?; - let version = probe_latest_version( - &npm, - tool.package, - npm_registry_override().as_deref(), - fallback_npm_registry(), - env_vars, - LATEST_VERSION_PROBE_TIMEOUT, - ) - .await?; - latest_version_cache() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(tool.package, version.clone(), now); - Some(version) -} - -/// One uncached latest-version lookup with the registry precedence applied: -/// `override_registry` if given, else npm's own config, and — only when that -/// fails as a gateway block of `registry.npmjs.org` and no override was -/// given — one retry through `fallback_registry` within whatever is left of -/// `timeout`. -async fn probe_latest_version( - npm_path: &Path, - package: &str, - override_registry: Option<&str>, - fallback_registry: Option<&str>, - env_vars: &[(String, String)], - timeout: Duration, -) -> Option { - let started = Instant::now(); - let failure = - match npm_view_version(npm_path, package, override_registry, env_vars, timeout).await { - Ok(version) => return Some(version), - Err(failure) => failure, - }; - let NpmViewFailure::Exit { stderr } = failure else { - return None; - }; - let fallback = gateway_fallback_registry(override_registry, fallback_registry, &stderr)?; - let remaining = timeout - .checked_sub(started.elapsed()) - .filter(|remaining| !remaining.is_zero())?; - log::info!( - "npm view {package} version: registry.npmjs.org is gateway-blocked; retrying through {fallback}" - ); - npm_view_version(npm_path, package, Some(fallback), env_vars, remaining) - .await - .ok() -} - -/// The first `/` that is a file along the snapshot's `PATH` — -/// resolved by hand because `Command::new("npm")` would search the *parent* -/// process's PATH, not the snapshot's, and the snapshot is what puts the -/// managed runtime's bin dir first. -pub(crate) fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option { - std::env::split_paths(snapshot_var(env_vars, "PATH")?) - .map(|dir| dir.join(name)) - .find(|candidate| candidate.is_file()) -} - -/// The last value of `key` in an env snapshot — the one a `Command` applying -/// the snapshot in order would end up with. -fn snapshot_var<'a>(env_vars: &'a [(String, String)], key: &str) -> Option<&'a str> { - env_vars - .iter() - .rev() - .find(|(existing, _)| existing == key) - .map(|(_, value)| value.as_str()) -} - -/// Why an `npm view` probe produced no version. -#[derive(Debug)] -enum NpmViewFailure { - /// npm ran and exited non-zero; its stderr is kept for classification. - Exit { stderr: String }, - /// npm could not be started, timed out, or printed something that is - /// not a version. - Other, -} - -/// `npm view version [--registry ]` with `npm` at `npm_path`, -/// under exactly the `env_vars` snapshot, killed at `timeout`. `Ok` only for -/// a clean exit whose stdout is a version-shaped token. -/// -/// npm's own retry/timeout defaults (`fetch-retries=2`, backoff up to 60 s) -/// would let a dead registry host run ~70 s; a probe is not worth that, so -/// retries are off and npm's fetch timeout is pinned to the probe budget. -/// Installs keep npm's defaults. -async fn npm_view_version( - npm_path: &Path, - package: &str, - registry: Option<&str>, - env_vars: &[(String, String)], - timeout: Duration, -) -> Result { - let mut command = tokio::process::Command::new(npm_path); - command.args(["view", package, "version", "--fetch-retries=0"]); - command.arg(format!("--fetch-timeout={}", timeout.as_millis())); - if let Some(registry) = registry { - command.args(["--registry", registry]); - } - command.env_clear(); - for (key, value) in env_vars { - command.env(key, value); - } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - let output = match tokio::time::timeout(timeout, command.output()).await { - Ok(Ok(output)) => output, - Ok(Err(error)) => { - log::debug!("npm view {package} version failed to run: {error}"); - return Err(NpmViewFailure::Other); - } - Err(_) => { - log::debug!( - "npm view {package} version timed out after {}s (registry: {})", - timeout.as_secs(), - registry_label(registry) - ); - return Err(NpmViewFailure::Other); - } - }; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - log::debug!( - "npm view {package} version exited with {} (registry: {}): {stderr}", - output.status, - registry_label(registry) - ); - return Err(NpmViewFailure::Exit { stderr }); - } - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - version - .chars() - .next() - .is_some_and(|first| first.is_ascii_digit()) - .then_some(version) - .ok_or(NpmViewFailure::Other) -} - -/// How long Doctor waits on `npm config get registry`. -const NPM_CONFIG_PROBE_TIMEOUT: Duration = Duration::from_secs(5); - -/// How long a `npm config get registry` answer is reused for the same env -/// snapshot. Doctor asks on every report while a managed bridge is missing — -/// two passes per open, plus refreshes — and each ask spawns npm; one spawn a -/// minute keeps that off the report's critical path without hiding a -/// `~/.npmrc` edit for long. Unlike [`LatestVersionCache`] a `None` answer -/// is cached too: it is what a hung or absent npm costs, and re-asking every -/// pass is exactly the stall this avoids. -const NPM_REGISTRY_CACHE_TTL: Duration = Duration::from_secs(60); - -/// The env snapshot a registry answer was asked under: the cache key, since -/// the answer depends on the snapshot's `npm`, `~/.npmrc` and exports. -type EnvSnapshot = Vec<(String, String)>; - -/// `npm config get registry` answers by the env snapshot they were asked -/// under, with the time each was fetched. -#[derive(Debug, Default)] -struct NpmRegistryCache { - entries: HashMap, Instant)>, -} - -impl NpmRegistryCache { - /// `Some(answer)` when a fresh answer (itself possibly `None`) is cached. - fn get_fresh(&self, env_vars: &[(String, String)], now: Instant) -> Option> { - let (registry, fetched_at) = self.entries.get(env_vars)?; - (now.saturating_duration_since(*fetched_at) <= NPM_REGISTRY_CACHE_TTL) - .then(|| registry.clone()) - } - - fn insert(&mut self, env_vars: &[(String, String)], registry: Option, now: Instant) { - self.entries.insert(env_vars.to_vec(), (registry, now)); - } -} - -fn npm_registry_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(NpmRegistryCache::default())) -} - -/// The registry npm would use under the `env_vars` snapshot with no -/// `--registry` — `npm config get registry` asked of the snapshot's own -/// `npm` (the managed runtime's, when installed), under exactly that env, so -/// the answer is the one a managed install resolves. `None` when the -/// snapshot has no `npm`, the probe fails or times out, or the answer is not -/// a URL; Doctor then says "npm default" rather than guessing. Cached per -/// snapshot for [`NPM_REGISTRY_CACHE_TTL`]. -pub(crate) async fn configured_npm_registry(env_vars: &[(String, String)]) -> Option { - let now = Instant::now(); - let cached = npm_registry_cache() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .get_fresh(env_vars, now); - if let Some(registry) = cached { - return registry; - } - let registry = probe_configured_npm_registry(env_vars).await; - npm_registry_cache() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(env_vars, registry.clone(), now); - registry -} - -/// One uncached `npm config get registry` (see [`configured_npm_registry`]). -async fn probe_configured_npm_registry(env_vars: &[(String, String)]) -> Option { - let npm_path = find_on_env_path(env_vars, "npm")?; - let mut command = tokio::process::Command::new(npm_path); - command.args(["config", "get", "registry"]); - command.env_clear(); - for (key, value) in env_vars { - command.env(key, value); - } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .kill_on_drop(true); - let output = tokio::time::timeout(NPM_CONFIG_PROBE_TIMEOUT, command.output()) - .await - .ok()? - .ok()?; - if !output.status.success() { - return None; - } - let registry = String::from_utf8_lossy(&output.stdout).trim().to_string(); - registry.starts_with("http").then_some(registry) -} - -// ============================================================================= -// 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}"); - } -} - -pub(crate) 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 { - mod validation; - use super::*; - - // -- registry policy ----------------------------------------------------- - - /// `STAGED_NPM_REGISTRY` is honored only when it carries a value; unset, - /// empty, or whitespace all mean "npm decides". - #[test] - fn npm_registry_override_is_the_trimmed_non_empty_env_value() { - assert_eq!(registry_from_env_value(None), None); - assert_eq!(registry_from_env_value(Some("")), None); - assert_eq!(registry_from_env_value(Some(" \n")), None); - assert_eq!( - registry_from_env_value(Some(" https://registry.example.test/npm/ ")), - Some("https://registry.example.test/npm/".to_string()) - ); - assert_eq!( - npm_registry_override(), - registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) - ); - } - - /// The fallback is whatever the build injected — no URL lives in source, - /// so a from-source build has none and a value is never blank. - #[test] - fn fallback_npm_registry_is_build_time_only() { - let expected = option_env!("STAGED_FALLBACK_NPM_REGISTRY") - .map(str::trim) - .filter(|value| !value.is_empty()); - assert_eq!(fallback_npm_registry(), expected); - if let Some(fallback) = fallback_npm_registry() { - assert!(!fallback.is_empty()); - assert_eq!(fallback, fallback.trim()); - } - } - - #[test] - fn registry_label_names_npm_config_when_no_registry_is_passed() { - assert_eq!( - registry_label(Some("https://registry.example.test/npm/")), - "https://registry.example.test/npm/" - ); - assert_eq!(registry_label(None), "npm default (per npm config)"); - } - - /// Gateway-blocked only for the block page itself, or a fetch/JSON failure - /// on a request npm made *to* `registry.npmjs.org`. The public registry's - /// hostname appearing elsewhere in stderr proves nothing — mirrors name - /// their upstream in their own error bodies. - #[test] - fn classify_npm_failure_spots_gateway_block_pages_only() { - let blocked = [ - // The real thing: Cloudflare Gateway redirects the packument fetch - // to its block page, and npm reports the redirect target. - "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error request to https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp failed: https://blocked.teams.cloudflare.com?block_reason=...", - // The block page served inline as the response body. - "npm warn deprecated foo@1.0.0: use bar\nnpm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<', \"Failed to fetch from registry.npmjs.org (https://registry.npmjs.org/foo)\"... is not valid JSON", - // The public registry itself unreachable (offline): a network - // error, not a block page. - "npm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/foo failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", - ]; - for stderr in other { - assert_eq!(classify_npm_failure(stderr), NpmFailure::Other, "{stderr}"); - } - } - - #[test] - fn gateway_retry_failed_message_keeps_the_original_diagnosis() { - let message = gateway_retry_failed_message( - "https://fallback.example.test/npm/", - "npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", - ); - assert_eq!( - message, - "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ - fallback https://fallback.example.test/npm/ also failed: npm install exited with \ - exit status: 1 (registry: https://fallback.example.test/npm/)" - ); - } - - /// The Doctor hint classifies the *recorded* error: the block page (with - /// or without a fallback having been tried), a 404 from whichever - /// registry, and an unreachable registry or download host. A validation - /// failure, a missing entrypoint, and a probe timeout get no hint — there - /// is nothing network-side to change. - #[test] - fn classify_install_failure_names_the_three_fixable_causes() { - let cases = [ - ( - "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code FETCH_ERROR; invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<'", - Some(InstallFailureHint::GatewayBlocked), - ), - ( - "npm install failed: npm default registry (registry.npmjs.org) was blocked by a network gateway; fallback https://fallback.example.test/npm/ also failed: npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", - Some(InstallFailureHint::GatewayBlocked), - ), - ( - "npm install failed: npm install exited with exit status: 1 (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", - Some(InstallFailureHint::PackageMissing), - ), - ( - "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code ENOTFOUND; network request to https://mirror.example.test/npm/x failed, reason: getaddrinfo ENOTFOUND mirror.example.test", - Some(InstallFailureHint::HostUnreachable), - ), - ( - "Node.js runtime download failed: https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz: error sending request for url", - Some(InstallFailureHint::HostUnreachable), - ), - ( - "npm install failed: timed out after 900 seconds (registry: npm default (per npm config))", - Some(InstallFailureHint::HostUnreachable), - ), - ( - "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: validation probe timed out after 20 seconds", - None, - ), - ( - "installed ACP bridge is incomplete: bridge entrypoint /x/index.js is missing after install", - None, - ), - ( - "Node.js runtime download failed: HTTP 404", - Some(InstallFailureHint::NodeDownloadRejected(404)), - ), - ( - "Node.js runtime download failed: HTTP 403", - Some(InstallFailureHint::NodeDownloadRejected(403)), - ), - ("Node.js runtime download failed: HTTP 503", None), - ("", None), - ]; - for (error, expected) in cases { - assert_eq!(classify_install_failure(error), expected, "{error}"); - } - - assert_eq!( - install_failure_hint("Node.js runtime download failed: HTTP 404", "Node.js").as_deref(), - Some("the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL"), - "a 404 from the dist host is a URL problem, never a missing package" - ); - - let hint = install_failure_hint( - "npm install failed: … npm error code E404; 404 Not Found", - "@agentclientprotocol/codex-acp", - ) - .unwrap(); - assert_eq!( - hint, - "the configured registry does not carry @agentclientprotocol/codex-acp" - ); - assert!(install_failure_hint( - "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed", - "x" - ) - .unwrap() - .contains("STAGED_NPM_REGISTRY")); - assert!(install_failure_hint("getaddrinfo ENOTFOUND mirror", "x") - .unwrap() - .contains("STAGED_NODE_DIST_URL")); - assert!(install_failure_hint("disk full", "x").is_none()); - } - - /// The recorded install error carries npm's own account: the last - /// `npm error code` line and the detail lines after it, minus the `errno` - /// repeat and the log-file pointer, each clipped. Without an error block - /// nothing is appended. - #[test] - fn npm_error_summary_keeps_the_code_and_the_first_details() { - let stderr = "npm warn deprecated foo@1.0.0: use bar\n\ - npm error code FETCH_ERROR\n\ - npm error errno FETCH_ERROR\n\ - npm error invalid json response body at https://registry.npmjs.org/x reason: Unexpected token '<'\n\ - npm error\n\ - npm error A complete log of this run can be found in: /tmp/x.log\n"; - assert_eq!( - npm_error_summary(stderr).as_deref(), - Some( - "npm error code FETCH_ERROR; invalid json response body at \ - https://registry.npmjs.org/x reason: Unexpected token '<'" - ) - ); - assert_eq!( - classify_npm_failure(&npm_error_summary(stderr).unwrap()), - NpmFailure::GatewayBlocked - ); - - // Older npm spelling, and more details than are kept. - let stderr = "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://r/x\nnpm ERR! 404\nnpm ERR! 404 'x@latest' is not in this registry.\nnpm ERR! 404 note\nnpm ERR! 404 more\n"; - assert_eq!( - npm_error_summary(stderr).as_deref(), - Some("npm ERR! code E404; 404 Not Found - GET https://r/x; 404; 404 'x@latest' is not in this registry.") - ); - - assert_eq!(npm_error_summary("npm warn only\n"), None); - assert_eq!(npm_error_summary(""), None); - - let long = format!("npm error code E1\nnpm error {}\n", "y".repeat(1000)); - let summary = npm_error_summary(&long).unwrap(); - assert!(summary.len() < 300, "{}", summary.len()); - assert!(summary.ends_with('…')); - } - - #[test] - fn managed_tools_disabled_reason_names_the_cause() { - assert_eq!( - managed_tools_disabled_reason_from_parts(None, false, true), - None - ); - assert_eq!( - managed_tools_disabled_reason_from_parts(Some(Path::new("/dev/acp")), false, true) - .as_deref(), - Some("STAGED_ACP_TOOLS_DIR is set to /dev/acp") - ); - assert_eq!( - managed_tools_disabled_reason_from_parts(None, true, true).as_deref(), - Some("this build was compiled without managed ACP tools") - ); - assert_eq!( - managed_tools_disabled_reason_from_parts(None, false, false).as_deref(), - Some("no managed Node.js runtime is available for this platform") - ); - // The override is the reason even on a build that is disabled anyway. - assert!( - managed_tools_disabled_reason_from_parts(Some(Path::new("/d")), true, false) - .unwrap() - .starts_with("STAGED_ACP_TOOLS_DIR") - ); - } - - /// The registry probe asks the snapshot's own `npm`, under the snapshot's - /// env, and reports only a URL-shaped answer. - #[tokio::test] - async fn configured_npm_registry_asks_the_snapshots_npm() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - write_executable( - &bin.join("npm"), - "#!/bin/sh\n[ \"$1 $2 $3\" = \"config get registry\" ] || exit 2\nprintf '%s\\n' \"${FAKE_REGISTRY:-https://mirror.example.test/npm/}\"\n", - ); - let env = vec![("PATH".to_string(), bin.display().to_string())]; - assert_eq!( - configured_npm_registry(&env).await.as_deref(), - Some("https://mirror.example.test/npm/") - ); - - // The env is the snapshot's, not the process's. - let mut env_with_registry = env.clone(); - env_with_registry.push(( - "FAKE_REGISTRY".to_string(), - "https://other.example.test/".to_string(), - )); - assert_eq!( - configured_npm_registry(&env_with_registry).await.as_deref(), - Some("https://other.example.test/") - ); - - // Not a URL: no answer. No npm on the snapshot PATH: no answer. - let mut env_garbage = env.clone(); - env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); - assert_eq!(configured_npm_registry(&env_garbage).await, None); - let empty = dir.path().join("empty"); - std::fs::create_dir_all(&empty).unwrap(); - assert_eq!( - configured_npm_registry(&[("PATH".to_string(), empty.display().to_string())]).await, - None - ); - } - - /// Asking twice under one snapshot spawns npm once: the answer — a URL or - /// `None` alike — is reused within the TTL, so a Doctor refresh never - /// re-pays the probe. A different snapshot is a different question. - #[tokio::test] - async fn configured_npm_registry_is_cached_per_env_snapshot() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let calls = dir.path().join("calls"); - write_executable( - &bin.join("npm"), - &format!( - "#!/bin/sh\nprintf x >> '{}'\nprintf '%s\\n' \"${{FAKE_REGISTRY:-https://mirror.example.test/npm/}}\"\n", - calls.display() - ), - ); - let spawns = || { - std::fs::read_to_string(&calls) - .map(|s| s.len()) - .unwrap_or(0) - }; - let env = vec![("PATH".to_string(), bin.display().to_string())]; - - assert_eq!( - configured_npm_registry(&env).await.as_deref(), - Some("https://mirror.example.test/npm/") - ); - assert_eq!( - configured_npm_registry(&env).await.as_deref(), - Some("https://mirror.example.test/npm/") - ); - assert_eq!(spawns(), 1, "the second ask is answered from the cache"); - - // A `None` answer is cached as well. - let mut env_garbage = env.clone(); - env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); - assert_eq!(configured_npm_registry(&env_garbage).await, None); - assert_eq!(configured_npm_registry(&env_garbage).await, None); - assert_eq!(spawns(), 2); - } - - #[test] - fn npm_registry_cache_expires_after_the_ttl() { - let mut cache = NpmRegistryCache::default(); - let now = Instant::now(); - let env = vec![("PATH".to_string(), "/x".to_string())]; - let other = vec![("PATH".to_string(), "/y".to_string())]; - assert_eq!(cache.get_fresh(&env, now), None); - - cache.insert(&env, Some("https://r.example.test/".to_string()), now); - cache.insert(&other, None, now); - assert_eq!( - cache.get_fresh(&env, now + Duration::from_secs(30)), - Some(Some("https://r.example.test/".to_string())) - ); - assert_eq!( - cache.get_fresh(&other, now), - Some(None), - "a cached `None` is an answer, not a miss" - ); - assert_eq!( - cache.get_fresh(&env, now + NPM_REGISTRY_CACHE_TTL + Duration::from_secs(1)), - None - ); - } - - /// The capture keeps the *newest* lines: npm's `npm error code …` block - /// comes last, after however many warnings preceded it. - #[test] - fn stderr_capture_keeps_the_tail() { - let mut capture = String::new(); - let warning = format!("npm warn deprecated {}", "x".repeat(1000)); - for _ in 0..(NPM_STDERR_CAPTURE_LIMIT / warning.len() + 5) { - push_captured_line(&mut capture, &warning); - } - push_captured_line(&mut capture, "npm error code FETCH_ERROR"); - push_captured_line( - &mut capture, - "npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com", - ); - assert!(capture.len() <= NPM_STDERR_CAPTURE_LIMIT); - assert!(capture.starts_with("npm warn"), "drops whole lines only"); - assert!(capture.ends_with("blocked.teams.cloudflare.com\n")); - assert_eq!(classify_npm_failure(&capture), NpmFailure::GatewayBlocked); - - // A single line larger than the whole budget keeps its tail, cut on a - // char boundary. - let mut capture = String::new(); - push_captured_line( - &mut capture, - &format!("{}é end", "a".repeat(NPM_STDERR_CAPTURE_LIMIT)), - ); - assert_eq!(capture.len(), NPM_STDERR_CAPTURE_LIMIT); - assert!(capture.ends_with("é end\n")); - } - - /// The fallback is consulted only with no override, a compiled-in - /// fallback, and a gateway-shaped failure — all three at once. - #[test] - fn gateway_fallback_registry_requires_no_override_a_fallback_and_a_block_page() { - let blocked = "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed"; - let fallback = Some("https://fallback.example.test/npm/"); - assert_eq!( - gateway_fallback_registry(None, fallback, blocked), - fallback, - "no override + fallback + block page retries" - ); - assert_eq!( - gateway_fallback_registry(Some("https://mine.example.test/"), fallback, blocked), - None, - "an explicit override is never second-guessed" - ); - assert_eq!( - gateway_fallback_registry(None, None, blocked), - None, - "nothing to retry through" - ); - assert_eq!( - gateway_fallback_registry(None, fallback, "npm error code E404"), - None, - "a 404 is not a gateway block" - ); - } - - #[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_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] - 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}"); - } - } - - #[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"; - - /// The env snapshot the fixture installs run under: enough of the test - /// process env for a `#!/bin/sh` fake npm to find `cp` and its home. - fn install_env() -> Vec<(String, String)> { - ["PATH", "HOME"] - .into_iter() - .filter_map(|key| Some((key.to_string(), std::env::var(key).ok()?))) - .collect() - } - - 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(); - } - } - - /// 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(); - 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, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &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, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .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, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .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()); - } - - #[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, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .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()); - // And the version Doctor reads is still the working install's. - assert_eq!( - installed_tool_version(&packages_root, &tool).as_deref(), - Some("1.2.3") - ); - } - - /// A managed update writes only under the packages root: nothing lands in - /// a global prefix, and the fixture's neighbours are left alone. - #[tokio::test] - async fn install_touches_only_the_packages_root() { - 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.4"); - write_fake_node_with_npm(&node_install_dir, &template, 0); - let outside = dir.path().join("global-bin"); - std::fs::create_dir_all(&outside).unwrap(); - - install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap(); - - let mut entries: Vec = std::fs::read_dir(dir.path()) - .unwrap() - .flatten() - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .collect(); - entries.sort(); - assert_eq!(entries, vec!["global-bin", "packages", "template"]); - assert!(std::fs::read_dir(&outside).unwrap().next().is_none()); - assert_eq!( - installed_tool_version(&packages_root, &tool).as_deref(), - Some("1.2.4") - ); - } - - // -- install flow: registry fallback ------------------------------------- - - /// A fake managed-node `npm` standing in for a laptop whose network - /// gateway blocks `registry.npmjs.org`: without `--registry` it prints - /// npm's block-page failure and exits 1; with one it performs the install - /// from `template`. Every invocation's argv is appended to `argv_log`. - fn write_fake_node_with_gateway_blocked_npm( - node_install_dir: &Path, - template: &Path, - argv_log: &Path, - stderr_without_registry: &str, - ) { - 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\necho \"$*\" >> '{log}'\nprefix=\"\"\nregistry=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n if [ \"$prev\" = \"--registry\" ]; then registry=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -z \"$registry\" ]; then\n printf '%s\\n' '{stderr}' >&2\n exit 1\nfi\ncp -R '{template}/.' \"$prefix/\"\necho \"added 3 packages\"\nexit 0\n", - log = argv_log.display(), - stderr = stderr_without_registry.replace('\'', "'\\''"), - template = 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(); - } - } - - const GATEWAY_BLOCK_STDERR: &str = "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token < in JSON at position 0"; - - fn logged_argv(argv_log: &Path) -> Vec { - std::fs::read_to_string(argv_log) - .unwrap_or_default() - .lines() - .map(str::to_string) - .collect() - } - - /// No override, a compiled-in fallback, and npm failing on a gateway - /// block page: the install is retried once through the fallback from a - /// fresh staging prefix, and succeeds. - #[tokio::test] - async fn gateway_blocked_install_retries_once_through_the_fallback_registry() { - 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.5"); - let argv_log = dir.path().join("npm-argv.log"); - write_fake_node_with_gateway_blocked_npm( - &node_install_dir, - &template, - &argv_log, - GATEWAY_BLOCK_STDERR, - ); - - 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, - &install_env(), - None, - Some("https://fallback.example.test/npm/"), - super::validation::VALIDATION_TIMEOUT, - &on_line, - ) - .await - .unwrap(); - - let argv = logged_argv(&argv_log); - assert_eq!(argv.len(), 2, "{argv:?}"); - assert!(!argv[0].contains("--registry"), "{}", argv[0]); - assert!( - argv[1].contains("--registry https://fallback.example.test/npm/"), - "{}", - argv[1] - ); - assert_eq!( - installed_tool_version(&packages_root, &tool).as_deref(), - Some("1.2.5") - ); - let recorded = lines.lock().unwrap().clone(); - assert!( - recorded - .iter() - .any(|line| line.contains("retrying through https://fallback.example.test/npm/")), - "{recorded:?}" - ); - assert!(!staging_install_dir(&packages_root, tool.id).exists()); - } - - /// When the fallback attempt fails too, the surfaced error still says why - /// the fallback was contacted: the default registry was gateway-blocked. - #[tokio::test] - async fn gateway_blocked_install_names_both_failures_when_the_fallback_fails() { - 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(); - // Fails whatever registry it is given: a block page without one, a - // plain exit 7 with one. - let bin = node_install_dir.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - write_executable(&bin.join("node"), "#!/bin/sh\necho v9.9.9\n"); - write_executable( - &bin.join("npm"), - &format!( - "#!/bin/sh\ncase \" $* \" in\n *\" --registry \"*) echo 'npm error code E500' >&2; exit 7 ;;\nesac\nprintf '%s\\n' '{}' >&2\nexit 1\n", - GATEWAY_BLOCK_STDERR.replace('\'', "'\\''") - ), - ); - - let error = install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &install_env(), - None, - Some("https://fallback.example.test/npm/"), - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap_err(); - - assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); - let message = error.to_string(); - // The retry's own `npm error code` line rides along, so the recorded - // error says what the fallback answered, not just that it failed. - assert_eq!( - message, - "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ - network gateway; fallback https://fallback.example.test/npm/ also failed: \ - npm install exited with exit status: 7 (registry: https://fallback.example.test/npm/): \ - npm error code E500" - ); - assert!( - message.contains( - "fallback https://fallback.example.test/npm/ also failed: npm install exited with" - ), - "{message}" - ); - assert!( - message.contains("(registry: https://fallback.example.test/npm/)"), - "{message}" - ); - assert!(!staging_install_dir(&packages_root, tool.id).exists()); - } - - /// npm runs under the env snapshot, not the Tauri process env: a registry - /// the user's shell rc exports reaches the install exactly as it reaches - /// the freshness probe, while the managed node bin leads PATH and the - /// private cache outranks whatever cache the snapshot named. - #[tokio::test] - async fn install_runs_npm_under_the_env_snapshot() { - 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); - // The fake npm also dumps the env it saw. - let env_log = dir.path().join("npm-env.log"); - let npm = node_install_dir.join("bin").join("npm"); - let script = std::fs::read_to_string(&npm).unwrap(); - write_executable( - &npm, - &script.replacen( - "#!/bin/sh\n", - &format!("#!/bin/sh\nenv > '{}'\n", env_log.display()), - 1, - ), - ); - - let mut env_vars = install_env(); - env_vars.push(( - "NPM_CONFIG_REGISTRY".to_string(), - "https://mine.example.test/npm/".to_string(), - )); - env_vars.push(( - "npm_config_registry".to_string(), - "https://mine.example.test/npm/".to_string(), - )); - env_vars.push(( - "NPM_CONFIG_CACHE".to_string(), - "/somewhere/else/cache".to_string(), - )); - env_vars.push(("STAGED_TEST_SNAPSHOT_ONLY".to_string(), "1".to_string())); - install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &env_vars, - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap(); - - let seen: std::collections::HashMap = std::fs::read_to_string(&env_log) - .unwrap() - .lines() - .filter_map(|line| line.split_once('=')) - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect(); - assert_eq!( - seen.get("NPM_CONFIG_REGISTRY").map(String::as_str), - Some("https://mine.example.test/npm/") - ); - assert_eq!( - seen.get("npm_config_registry").map(String::as_str), - Some("https://mine.example.test/npm/") - ); - assert_eq!( - seen.get("STAGED_TEST_SNAPSHOT_ONLY").map(String::as_str), - Some("1"), - "snapshot-only vars reach npm" - ); - let expected_cache = packages_root.join("npm-prefix").join("cache"); - assert_eq!( - seen.get("NPM_CONFIG_CACHE").map(Path::new), - Some(expected_cache.as_path()), - "the managed cache overlay wins over the snapshot's" - ); - let first_on_path = std::env::split_paths(seen.get("PATH").unwrap()).next(); - assert_eq!( - first_on_path.as_deref(), - Some(node_install_dir.join("bin").as_path()) - ); - } - - /// An explicit `STAGED_NPM_REGISTRY` is never second-guessed, and a - /// failure that is not a gateway block page is not retried either; both - /// surface the first failure, naming the registry npm used. - #[tokio::test] - async fn install_does_not_retry_with_an_override_or_for_other_failures() { - 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.5"); - let fallback = Some("https://fallback.example.test/npm/"); - - // A gateway block page, but no fallback compiled in: plain failure. - let argv_log = dir.path().join("no-fallback.log"); - write_fake_node_with_gateway_blocked_npm( - &node_install_dir, - &template, - &argv_log, - GATEWAY_BLOCK_STDERR, - ); - let error = install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &install_env(), - None, - None, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap_err(); - assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); - assert!( - error - .to_string() - .contains("(registry: npm default (per npm config))"), - "{error}" - ); - assert_eq!(logged_argv(&argv_log).len(), 1); - - // A 404 from whichever registry npm used: a different registry cannot - // fix that, so no retry even with a fallback available. - let argv_log = dir.path().join("not-found.log"); - write_fake_node_with_gateway_blocked_npm( - &node_install_dir, - &template, - &argv_log, - "npm error code E404\nnpm error 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", - ); - let error = install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &install_env(), - None, - fallback, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap_err(); - assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); - assert_eq!(logged_argv(&argv_log).len(), 1); - - // An explicit override: the one and only call carries it (the fake - // succeeds on any `--registry`), and the fallback is never consulted. - let argv_log = dir.path().join("override.log"); - write_fake_node_with_gateway_blocked_npm( - &node_install_dir, - &template, - &argv_log, - GATEWAY_BLOCK_STDERR, - ); - install_npm_tool( - &packages_root, - &node_install_dir, - TEST_NODE_VERSION, - &tool, - &install_env(), - Some("https://mine.example.test/npm/"), - fallback, - super::validation::VALIDATION_TIMEOUT, - &|_| {}, - ) - .await - .unwrap(); - let argv = logged_argv(&argv_log); - assert_eq!(argv.len(), 1, "{argv:?}"); - assert!( - argv[0].contains("--registry https://mine.example.test/npm/"), - "{}", - argv[0] - ); - } - - // -- doctor readouts ------------------------------------------------------ - - /// The live tree's `package.json` is the authority for the installed ACP - /// version; `state.json` only stands in when that tree cannot be read. - #[test] - fn installed_tool_version_prefers_the_live_package_over_state() { - let dir = tempfile::tempdir().unwrap(); - let packages_root = dir.path(); - let tool = test_tool(); - - // Nothing installed, no state: unknown. - assert_eq!(installed_tool_version(packages_root, &tool), None); - - // State alone (tree missing or unreadable): the recorded version. - let mut state = ManagedToolsState::default(); - 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(); - assert_eq!( - installed_tool_version(packages_root, &tool).as_deref(), - Some("1.2.3") - ); - - // A live tree at another version wins over stale state. - write_fixture_install(&tool_install_dir(packages_root, tool.id), &tool, "1.3.0"); - assert_eq!( - installed_tool_version(packages_root, &tool).as_deref(), - Some("1.3.0") - ); - - // An empty recorded version (unreadable package.json at install - // time) is not a version. - std::fs::remove_dir_all(tool_install_dir(packages_root, tool.id)).unwrap(); - state.tools.get_mut(tool.id).unwrap().version.clear(); - write_state(packages_root, &state).unwrap(); - assert_eq!(installed_tool_version(packages_root, &tool), None); - } - - /// A fake `npm` on a PATH dir: prints `version` for `npm view - /// version`, exits `exit_code`, and refuses unless `--registry` is passed - /// exactly when `expect_registry` says so. - fn write_fake_npm(bin_dir: &Path, version: &str, exit_code: i32, expect_registry: bool) { - std::fs::create_dir_all(bin_dir).unwrap(); - let registry_check = if expect_registry { - "case \" $* \" in *\" --registry \"*) ;; *) echo 'missing --registry' >&2; exit 43;; esac\n" - } else { - "case \" $* \" in *\" --registry \"*) echo 'unexpected --registry' >&2; exit 43;; esac\n" - }; - write_executable( - &bin_dir.join("npm"), - &format!( - "#!/bin/sh\ntest \"$1\" = view || exit 44\ntest \"$3\" = version || exit 45\n{registry_check}echo '{version}'\nexit {exit_code}\n" - ), - ); - } - - /// Writes `contents` to `path` as a 0755 script and returns only once it - /// can actually be exec'd (see [`wait_until_executable`]). - fn write_executable(path: &Path, contents: &str) { - std::fs::write(path, contents).unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); - wait_until_executable(path); - } - - /// Returns only once the freshly written script at `path` can actually be - /// exec'd. On Linux, a test on another thread that forks while our write - /// handle is open inherits it until its own exec, and exec of a file - /// anyone still holds open for writing fails with `ETXTBSY` — which the - /// code under test would faithfully report and fail the test. Probe-spawn - /// (and immediately kill) the script until that window has passed, so the - /// script must be harmless to run with no arguments. - fn wait_until_executable(path: &Path) { - for _ in 0..1000 { - match std::process::Command::new(path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - Ok(mut child) => { - let _ = child.kill(); - let _ = child.wait(); - return; - } - Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { - std::thread::sleep(Duration::from_millis(1)); - } - Err(error) => panic!("{} is not executable: {error}", path.display()), - } - } - panic!("{} stayed text-busy", path.display()); - } - - fn path_env(bin_dir: &Path) -> Vec<(String, String)> { - vec![( - "PATH".to_string(), - format!("{}:/usr/bin:/bin", bin_dir.display()), - )] - } - - #[tokio::test] - async fn npm_view_version_reads_the_registry_answer() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - write_fake_npm(&bin, "4.5.6", 0, true); - let env = path_env(&bin); - let npm = find_on_env_path(&env, "npm").expect("npm resolves from the snapshot PATH"); - assert_eq!(npm, bin.join("npm")); - - let version = npm_view_version( - &npm, - "@agentclientprotocol/claude-agent-acp", - Some("https://registry.example.test/npm/"), - &env, - Duration::from_secs(10), - ) - .await - .ok(); - assert_eq!(version.as_deref(), Some("4.5.6")); - } - - #[tokio::test] - async fn npm_view_version_omits_the_registry_flag_when_none_is_configured() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - write_fake_npm(&bin, "4.5.6", 0, false); - let env = path_env(&bin); - - let version = npm_view_version( - &bin.join("npm"), - "@agentclientprotocol/codex-acp", - None, - &env, - Duration::from_secs(10), - ) - .await - .ok(); - assert_eq!(version.as_deref(), Some("4.5.6")); - } - - /// Probes must not inherit npm's ~70 s dead-host retry schedule: retries - /// are off and npm's own fetch timeout is pinned to the probe budget. - #[tokio::test] - async fn npm_view_version_bounds_npm_fetch_retries_and_timeout() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let argv_log = dir.path().join("argv.log"); - write_executable( - &bin.join("npm"), - &format!( - "#!/bin/sh\necho \"$*\" >> '{}'\necho 1.0.0\n", - argv_log.display() - ), - ); - let env = path_env(&bin); - npm_view_version( - &bin.join("npm"), - "@agentclientprotocol/codex-acp", - None, - &env, - Duration::from_secs(15), - ) - .await - .unwrap(); - let argv = std::fs::read_to_string(&argv_log).unwrap(); - assert!(argv.contains(" --fetch-retries=0"), "{argv}"); - assert!(argv.contains(" --fetch-timeout=15000"), "{argv}"); - } - - /// A fake `npm view` that reports a gateway block page unless `--registry` - /// is passed, logging each invocation's argv. - fn write_fake_gateway_blocked_npm_view(bin_dir: &Path, argv_log: &Path) { - std::fs::create_dir_all(bin_dir).unwrap(); - write_executable( - &bin_dir.join("npm"), - &format!( - "#!/bin/sh\necho \"$*\" >> '{}'\ncase \" $* \" in *\" --registry \"*) echo 7.8.9; exit 0;; esac\necho 'npm error code FETCH_ERROR' >&2\necho 'npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com?block_reason=x' >&2\nexit 1\n", - argv_log.display() - ), - ); - } - - /// The freshness probe follows the install's precedence: npm's own config - /// first, then — for a gateway block with no override — one retry through - /// the fallback. - #[tokio::test] - async fn probe_latest_version_retries_a_gateway_block_through_the_fallback() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - let argv_log = dir.path().join("argv.log"); - write_fake_gateway_blocked_npm_view(&bin, &argv_log); - let env = path_env(&bin); - let package = "@agentclientprotocol/claude-agent-acp"; - let fallback = Some("https://fallback.example.test/npm/"); - - let version = probe_latest_version( - &bin.join("npm"), - package, - None, - fallback, - &env, - Duration::from_secs(15), - ) - .await; - assert_eq!(version.as_deref(), Some("7.8.9")); - let argv: Vec = std::fs::read_to_string(&argv_log) - .unwrap() - .lines() - .map(str::to_string) - .collect(); - assert_eq!(argv.len(), 2, "{argv:?}"); - assert!(!argv[0].contains("--registry"), "{}", argv[0]); - assert!( - argv[1].contains("--registry https://fallback.example.test/npm/"), - "{}", - argv[1] - ); - - // No fallback compiled in: the block page is simply unknown. - std::fs::remove_file(&argv_log).unwrap(); - assert_eq!( - probe_latest_version( - &bin.join("npm"), - package, - None, - None, - &env, - Duration::from_secs(15) - ) - .await, - None - ); - assert_eq!( - std::fs::read_to_string(&argv_log).unwrap().lines().count(), - 1 - ); - - // An override is used as-is and never falls back. - std::fs::remove_file(&argv_log).unwrap(); - assert_eq!( - probe_latest_version( - &bin.join("npm"), - package, - Some("https://mine.example.test/npm/"), - fallback, - &env, - Duration::from_secs(15) - ) - .await - .as_deref(), - Some("7.8.9") - ); - let argv = std::fs::read_to_string(&argv_log).unwrap(); - assert_eq!(argv.lines().count(), 1, "{argv}"); - assert!(argv.contains("--registry https://mine.example.test/npm/")); - } - - /// Every failure mode reads as unknown — never as a version. - #[tokio::test] - async fn npm_view_version_is_unknown_on_failure_garbage_or_timeout() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join("bin"); - let env = path_env(&bin); - let package = "@agentclientprotocol/claude-agent-acp"; - - // A registry error: npm exits non-zero. - write_fake_npm(&bin, "4.5.6", 1, false); - assert_eq!( - npm_view_version( - &bin.join("npm"), - package, - None, - &env, - Duration::from_secs(10) - ) - .await - .ok(), - None - ); - - // A clean exit with non-version output (an npm notice, an HTML page). - write_fake_npm(&bin, "npm notice: try again later", 0, false); - assert_eq!( - npm_view_version( - &bin.join("npm"), - package, - None, - &env, - Duration::from_secs(10) - ) - .await - .ok(), - None - ); - - // A hung lookup is cut off at the bound. - write_executable(&bin.join("npm"), "#!/bin/sh\nsleep 30\necho 9.9.9\n"); - let started = Instant::now(); - assert_eq!( - npm_view_version( - &bin.join("npm"), - package, - None, - &env, - Duration::from_millis(200) - ) - .await - .ok(), - None - ); - assert!(started.elapsed() < Duration::from_secs(10)); - - // No npm anywhere on the snapshot PATH: nothing to ask. - let empty = vec![("PATH".to_string(), "/nonexistent/dir".to_string())]; - assert_eq!(find_on_env_path(&empty, "npm"), None); - assert_eq!(find_on_env_path(&[], "npm"), None); - } - - #[test] - fn latest_version_cache_expires_after_the_ttl() { - let mut cache = LatestVersionCache::default(); - let now = Instant::now(); - assert_eq!(cache.get_fresh("pkg", now), None); - - cache.insert("pkg", "1.2.3".to_string(), now); - assert_eq!( - cache - .get_fresh("pkg", now + Duration::from_secs(60)) - .as_deref(), - Some("1.2.3") - ); - assert_eq!(cache.get_fresh("other", now), None); - assert_eq!( - cache.get_fresh( - "pkg", - now + LATEST_VERSION_CACHE_TTL + Duration::from_secs(1) - ), - None - ); - } - - // -- 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_acp_tools/install.rs b/apps/staged/src-tauri/src/managed_acp_tools/install.rs new file mode 100644 index 000000000..0d727a246 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/install.rs @@ -0,0 +1,602 @@ +//! `install_managed_tool` — the floating npm install of one managed bridge: +//! stage, validate, swap into place, write the shim, record the version. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::io::AsyncBufReadExt; + +use crate::managed_node; + +use super::probe::snapshot_var; +use super::registry::{ + fallback_npm_registry, gateway_fallback_registry, gateway_retry_failed_message, + npm_registry_args, npm_registry_override, registry_label, +}; +use super::{ + managed_tool, read_state, shim_bin_dir, staging_install_dir, tool_install_dir, validation, + write_state, InstalledToolPin, ManagedTool, +}; + +#[cfg(test)] +#[path = "tests/install.rs"] +mod tests; + +// ============================================================================= +// 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), + /// npm exited cleanly but the staged bridge failed install 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; + +pub(super) 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` 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. +/// +/// `env_vars` is the sanitized login-shell snapshot doctor's checks and the +/// freshness probe run under ([`crate::doctor::doctor_env_vars`]); npm runs +/// under exactly that, so the registry config it resolves (`~/.npmrc`, +/// `NPM_CONFIG_REGISTRY` from the shell rc) is the one the probe saw. +pub async fn install_managed_tool( + id: &str, + env_vars: &[(String, String)], + 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, + env_vars, + npm_registry_override().as_deref(), + fallback_npm_registry(), + validation::VALIDATION_TIMEOUT, + 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. npm runs under the `env_vars` snapshot; `registry` is the +/// explicit override (or `None` for npm's own config) and `fallback_registry` +/// the build-time fallback tried once if the first attempt fails as +/// gateway-blocked (see the registry policy note). +#[allow(clippy::too_many_arguments)] +pub(super) async fn install_npm_tool( + packages_root: &Path, + node_install_dir: &Path, + node_version: &str, + tool: &ManagedTool, + env_vars: &[(String, String)], + registry: Option<&str>, + fallback_registry: Option<&str>, + validation_timeout: Duration, + on_line: &InstallLineFn<'_>, +) -> Result<(), ManagedToolError> { + let install_dir = tool_install_dir(packages_root, tool.id); + // Stage the floating install into a scratch prefix and swap it into the + // live tree only after entrypoint and runtime validation pass. 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 + )); + if let Err(failure) = run_floating_npm_install( + packages_root, + node_install_dir, + &staging_dir, + tool, + env_vars, + registry, + on_line, + ) + .await + { + let Some(fallback) = + gateway_fallback_registry(registry, fallback_registry, &failure.stderr) + else { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(failure.error); + }; + on_line(&format!( + "npm could not reach registry.npmjs.org (network gateway block page); retrying through {fallback}" + )); + // A fresh scratch prefix: whatever the blocked attempt reified must + // not seed the retry. + if let Err(error) = reset_dir(&staging_dir) { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(ManagedToolError::Io(format!( + "prepare staging dir for registry retry: {error}" + ))); + } + if let Err(failure) = run_floating_npm_install( + packages_root, + node_install_dir, + &staging_dir, + tool, + env_vars, + Some(fallback), + on_line, + ) + .await + { + let _ = std::fs::remove_dir_all(&staging_dir); + // The retry's own error is an `NpmInstall` too; its bare message + // goes inside so the prefix is not doubled. + let retry_error = match &failure.error { + ManagedToolError::NpmInstall(message) => message.clone(), + other => other.to_string(), + }; + return Err(ManagedToolError::NpmInstall(gateway_retry_failed_message( + fallback, + &retry_error, + ))); + } + } + + 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, + staged_entrypoint.display() + ))); + } + on_line(&format!("Validating {} before promotion", tool.package)); + if let Err(error) = + validation::validate_staged_tool(node_install_dir, &staging_dir, tool, validation_timeout) + .await + { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(error); + } + 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, + &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(()) +} + +/// 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. +pub(super) fn npm_entrypoint(install_dir: &Path, package: &str) -> PathBuf { + package_dir(install_dir, package) + .join("dist") + .join("index.js") +} + +pub(super) fn package_dir(install_dir: &Path, package: &str) -> PathBuf { + package + .split('/') + .fold(install_dir.join("node_modules"), |dir, part| dir.join(part)) +} + +pub(super) 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. +pub(super) 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) +} + +/// A failed floating install: the error to surface plus what npm wrote to +/// stderr, kept so the caller can classify the failure for a registry retry. +struct NpmInstallFailure { + error: ManagedToolError, + stderr: String, +} + +/// How much of npm's stderr is kept for the retry classifier. npm writes its +/// `npm error code …` block *last*, after any deprecation and peer warnings, +/// so the capture keeps the newest lines (see [`push_captured_line`]) — a +/// warning-heavy install must not push the error block out of view. +const NPM_STDERR_CAPTURE_LIMIT: usize = 64 * 1024; + +/// Append `line` to the capture, dropping whole lines from the *front* once +/// it outgrows [`NPM_STDERR_CAPTURE_LIMIT`] (a single oversized line keeps +/// its tail). +fn push_captured_line(capture: &mut String, line: &str) { + capture.push_str(line); + capture.push('\n'); + while capture.len() > NPM_STDERR_CAPTURE_LIMIT { + match capture.find('\n') { + Some(end) if end + 1 < capture.len() => { + capture.drain(..=end); + } + _ => { + let mut cut = capture.len() - NPM_STDERR_CAPTURE_LIMIT; + while !capture.is_char_boundary(cut) { + cut += 1; + } + capture.drain(..cut); + } + } + } +} + +/// How many `npm error` detail lines after the `npm error code` line are +/// kept in the recorded error, and how long each may be. +const NPM_ERROR_SUMMARY_LINES: usize = 3; +const NPM_ERROR_SUMMARY_LINE_LIMIT: usize = 240; + +/// npm's own account of why it failed, from its captured stderr: the last +/// `npm error code ` line and the first few `npm error` detail lines +/// after it (minus `errno`, which repeats the code, and the log-file pointer). +/// Appended to the recorded install error so `state.json` and Doctor can say +/// *why* — the exit status alone says nothing about a block page, a 404, or +/// an unreachable mirror. `None` when npm printed no error block. +fn npm_error_summary(stderr: &str) -> Option { + let lines: Vec<&str> = stderr.lines().map(str::trim_end).collect(); + let code_index = lines.iter().rposition(|line| { + line.starts_with("npm error code ") || line.starts_with("npm ERR! code ") + })?; + let mut summary = vec![clip_line(lines[code_index])]; + let details = lines[code_index + 1..] + .iter() + .filter(|line| line.starts_with("npm error") || line.starts_with("npm ERR!")) + .map(|line| { + line.trim_start_matches("npm error") + .trim_start_matches("npm ERR!") + .trim() + }) + .filter(|detail| { + !detail.is_empty() + && !detail.starts_with("errno ") + && !detail.starts_with("A complete log") + }) + .take(NPM_ERROR_SUMMARY_LINES) + .map(clip_line); + summary.extend(details); + Some(summary.join("; ")) +} + +fn clip_line(line: &str) -> String { + if line.len() <= NPM_ERROR_SUMMARY_LINE_LIMIT { + return line.to_string(); + } + let mut cut = NPM_ERROR_SUMMARY_LINE_LIMIT; + while !line.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &line[..cut]) +} + +#[allow(clippy::too_many_arguments)] +async fn run_floating_npm_install( + packages_root: &Path, + node_install_dir: &Path, + install_dir: &Path, + tool: &ManagedTool, + env_vars: &[(String, String)], + registry: Option<&str>, + on_line: &InstallLineFn<'_>, +) -> Result<(), NpmInstallFailure> { + let mut stderr_capture = String::new(); + run_floating_npm_install_capturing( + packages_root, + node_install_dir, + install_dir, + tool, + env_vars, + registry, + on_line, + &mut stderr_capture, + ) + .await + .map_err(|error| NpmInstallFailure { + error, + stderr: stderr_capture, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_floating_npm_install_capturing( + packages_root: &Path, + node_install_dir: &Path, + install_dir: &Path, + tool: &ManagedTool, + env_vars: &[(String, String)], + registry: Option<&str>, + on_line: &InstallLineFn<'_>, + stderr_capture: &mut String, +) -> 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.args(npm_registry_args(tool.package, 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)); + + // Exactly the snapshot, not the Tauri process env: launched from Finder + // the process carries none of the user's shell rc, so an + // `NPM_CONFIG_REGISTRY`/`npm_config_registry` exported there would steer + // the freshness probe and doctor's fixes but not this install. The + // snapshot is what all of those run under. + command.env_clear(); + for (key, value) in env_vars { + command.env(key, value); + } + // npm's own `#!/usr/bin/env node` shebang must resolve the managed node: + // its bin dir goes first on the snapshot's PATH (the process PATH when + // the snapshot carries none). + let inherited_path = snapshot_var(env_vars, "PATH") + .map(std::ffi::OsString::from) + .or_else(|| std::env::var_os("PATH")) + .unwrap_or_default(); + let mut paths = vec![node_bin_dir.clone()]; + paths.extend(std::env::split_paths(&inherited_path)); + 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); + push_captured_line(stderr_capture, &line); + } + } + }; + let label = registry_label(registry); + 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 (registry: {label})", + NPM_INSTALL_TIMEOUT.as_secs() + ))) + } + } + }; + let (status, (), ()) = tokio::join!(wait, forward_out, forward_err); + let status = status?; + if status.success() { + Ok(()) + } else { + let summary = npm_error_summary(stderr_capture) + .map(|summary| format!(": {summary}")) + .unwrap_or_default(); + Err(ManagedToolError::NpmInstall(format!( + "npm install exited with {status} (registry: {label}){summary}" + ))) + } +} + +/// Shim body for a managed bridge. Both paths are absolute, so the shim runs +/// with no `node` on PATH at all. +pub(super) 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"'\''")) +} + +pub(super) 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) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/mod.rs b/apps/staged/src-tauri/src/managed_acp_tools/mod.rs new file mode 100644 index 000000000..1b1d1fae5 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/mod.rs @@ -0,0 +1,356 @@ +//! 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. 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, validates +//! the staged native CLI before promotion, 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`; 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. +//! - `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 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 crate::managed_node; + +mod install; +mod probe; +mod reconcile; +mod registry; +mod validation; + +#[cfg(test)] +#[path = "tests/support.rs"] +pub(crate) mod test_support; +#[cfg(test)] +#[path = "tests/layout.rs"] +mod tests; + +pub use install::{install_managed_tool, InstallLineFn, ManagedToolError}; +pub(crate) use probe::configured_npm_registry; +pub use probe::{installed_tool_version, latest_published_version}; +pub(crate) use reconcile::{finish_reconcile, now_ms}; +pub use registry::{fallback_npm_registry, npm_registry_override, NPM_REGISTRY_ENV}; +pub(crate) use registry::{ + gateway_fallback_registry, gateway_retry_failed_message, install_failure_hint, +}; + +/// 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"; + +/// 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, 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, + managed_tools_disabled: bool, + supported_target: bool, +) -> bool { + !override_active && !managed_tools_disabled && supported_target +} + +/// Why this build does not manage bridge installs, for Doctor to say so on +/// the Claude/Codex rows — a copy resolved from Homebrew is then by design, +/// not a failed install. `None` when management is on. +pub(crate) fn managed_tools_disabled_reason() -> Option { + managed_tools_disabled_reason_from_parts( + dev_tools_override_dir().as_deref(), + cfg!(feature = "no-managed-acp-tools"), + managed_node::current_target_triple().is_some(), + ) +} + +fn managed_tools_disabled_reason_from_parts( + override_dir: Option<&Path>, + managed_tools_disabled: bool, + supported_target: bool, +) -> Option { + if let Some(dir) = override_dir { + return Some(format!("{ACP_TOOLS_DIR_ENV} is set to {}", dir.display())); + } + if managed_tools_disabled { + return Some("this build was compiled without managed ACP tools".to_string()); + } + if !supported_target { + return Some("no managed Node.js runtime is available for this platform".to_string()); + } + None +} + +/// 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)) +} + +pub(super) 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 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. +pub(super) 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 { + 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 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") +} + +// ============================================================================= +// 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`]). +#[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 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, +} + +/// 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. +/// Each entry must have a runtime probe in [`validation::validate_staged_tool`]. +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() +} + +pub(super) 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) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/probe.rs b/apps/staged/src-tauri/src/managed_acp_tools/probe.rs new file mode 100644 index 000000000..141ed9cc1 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/probe.rs @@ -0,0 +1,392 @@ +//! Registry probes behind Doctor's readouts: the latest published version of +//! a managed bridge (`npm view`) and the registry npm would resolve with no +//! `--registry` (`npm config get registry`), each cached. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use super::install::installed_version; +use super::registry::{ + fallback_npm_registry, gateway_fallback_registry, npm_registry_args, npm_registry_override, + registry_label, +}; +use super::{read_state, tool_install_dir, ManagedTool}; + +#[cfg(test)] +#[path = "tests/probe.rs"] +mod tests; + +// ============================================================================= +// Doctor readouts — installed and latest versions of a managed bridge +// ============================================================================= + +/// The ACP package version installed in the live managed prefix for `tool`, +/// read from the package's own `package.json` — the tree the shim actually +/// execs. Falls back to the version `state.json` recorded at install time when +/// that tree is unreadable; state is supporting information, not the +/// authority, since it can be missing (a state write that failed after the +/// swap) or stale (a tree swapped in by another Staged instance). +pub fn installed_tool_version(packages_root: &Path, tool: &ManagedTool) -> Option { + installed_version(&tool_install_dir(packages_root, tool.id), tool.package).or_else(|| { + read_state(packages_root) + .tools + .get(tool.id) + .map(|pin| pin.version.clone()) + .filter(|version| !version.is_empty()) + }) +} + +/// How long a registry answer is reused before the registry is asked again. +/// Mirrors the doctor crate's freshness cache: a Doctor re-run within the +/// hour costs no network, and a real release still shows up within one. +const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(60 * 60); + +/// A registry lookup that hangs must not hold the freshness pass open — the +/// same bound the doctor crate puts on its own `npm view` probes. +const LATEST_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(15); + +/// What, beyond the package name, decides which registry `npm view` answers +/// from: the explicit override, the env snapshot (its `npm`, `HOME`, and any +/// `NPM_CONFIG_*`/`npm_config_*` exports), and the identity of the user npmrc +/// npm reads under it. Keying the cache on all of it means an edited +/// `~/.npmrc` or a changed `STAGED_NPM_REGISTRY` starts a fresh lookup instead +/// of Doctor showing one registry's version for an hour while the installer +/// fetches `@latest` from another. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RegistryFingerprint { + override_registry: Option, + env: EnvSnapshot, + /// The user npmrc's path, size and mtime; `None` when there is none. + user_npmrc: Option<(PathBuf, u64, Option)>, +} + +impl RegistryFingerprint { + fn current(env_vars: &[(String, String)]) -> Self { + Self { + override_registry: npm_registry_override(), + env: env_vars.to_vec(), + user_npmrc: user_npmrc_path(env_vars).and_then(|path| { + let meta = std::fs::metadata(&path).ok()?; + Some((path, meta.len(), meta.modified().ok())) + }), + } + } +} + +/// The user-level npmrc npm reads under `env_vars`: `NPM_CONFIG_USERCONFIG` +/// (either spelling), else `$HOME/.npmrc`. +fn user_npmrc_path(env_vars: &[(String, String)]) -> Option { + snapshot_var(env_vars, "NPM_CONFIG_USERCONFIG") + .or_else(|| snapshot_var(env_vars, "npm_config_userconfig")) + .map(PathBuf::from) + .or_else(|| snapshot_var(env_vars, "HOME").map(|home| Path::new(home).join(".npmrc"))) +} + +/// One latest-version cache entry: a package under one registry configuration. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct LatestVersionKey { + package: String, + registry: RegistryFingerprint, +} + +impl LatestVersionKey { + fn new(package: &str, env_vars: &[(String, String)]) -> Self { + Self { + package: package.to_string(), + registry: RegistryFingerprint::current(env_vars), + } + } +} + +/// Latest published versions by package and registry configuration, with +/// the time each was fetched. Only answers are cached: a failed lookup is +/// retried on the next pass rather than pinned as "unknown" for an hour. +#[derive(Debug, Default)] +struct LatestVersionCache { + entries: HashMap, +} + +impl LatestVersionCache { + fn get_fresh(&self, key: &LatestVersionKey, now: Instant) -> Option { + let (version, fetched_at) = self.entries.get(key)?; + (now.saturating_duration_since(*fetched_at) <= LATEST_VERSION_CACHE_TTL) + .then(|| version.clone()) + } + + /// Records an answer, dropping entries the TTL has already expired — a + /// superseded npmrc or override would otherwise linger as a dead key. + fn insert(&mut self, key: LatestVersionKey, version: String, now: Instant) { + self.entries.retain(|_, (_, fetched_at)| { + now.saturating_duration_since(*fetched_at) <= LATEST_VERSION_CACHE_TTL + }); + self.entries.insert(key, (version, now)); + } +} + +fn latest_version_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(LatestVersionCache::default())) +} + +/// The newest published version of `tool`'s npm package on the configured +/// registry, or `None` when it cannot be determined — offline, registry +/// unreachable, no `npm` to run, or the probe timed out. The caller must keep +/// `None` as *unknown*, never read it as "up to date". +/// +/// Runs `npm view version` under the same env snapshot doctor's own +/// probes and the managed install itself run under (see +/// [`install_managed_tool`]), so the managed runtime's `npm` is preferred and +/// the lookup resolves the registry the install will — `~/.npmrc` and any +/// `NPM_CONFIG_REGISTRY` the user's shell rc exports alike — with the same +/// override → npm config → gateway-blocked fallback precedence. Bounded by +/// [`LATEST_VERSION_PROBE_TIMEOUT`] and cached for [`LATEST_VERSION_CACHE_TTL`] +/// per registry configuration ([`RegistryFingerprint`]): an edited `~/.npmrc` +/// or a changed override is asked afresh, not served the old registry's answer. +pub async fn latest_published_version( + tool: &ManagedTool, + env_vars: &[(String, String)], +) -> Option { + let now = Instant::now(); + let key = LatestVersionKey::new(tool.package, env_vars); + let cached = latest_version_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_fresh(&key, now); + if cached.is_some() { + return cached; + } + let npm = find_on_env_path(env_vars, "npm")?; + let version = probe_latest_version( + &npm, + tool.package, + npm_registry_override().as_deref(), + fallback_npm_registry(), + env_vars, + LATEST_VERSION_PROBE_TIMEOUT, + ) + .await?; + latest_version_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, version.clone(), now); + Some(version) +} + +/// One uncached latest-version lookup with the registry precedence applied: +/// `override_registry` if given, else npm's own config, and — only when that +/// fails as a gateway block of `registry.npmjs.org` and no override was +/// given — one retry through `fallback_registry` within whatever is left of +/// `timeout`. +async fn probe_latest_version( + npm_path: &Path, + package: &str, + override_registry: Option<&str>, + fallback_registry: Option<&str>, + env_vars: &[(String, String)], + timeout: Duration, +) -> Option { + let started = Instant::now(); + let failure = + match npm_view_version(npm_path, package, override_registry, env_vars, timeout).await { + Ok(version) => return Some(version), + Err(failure) => failure, + }; + let NpmViewFailure::Exit { stderr } = failure else { + return None; + }; + let fallback = gateway_fallback_registry(override_registry, fallback_registry, &stderr)?; + let remaining = timeout + .checked_sub(started.elapsed()) + .filter(|remaining| !remaining.is_zero())?; + log::info!( + "npm view {package} version: registry.npmjs.org is gateway-blocked; retrying through {fallback}" + ); + npm_view_version(npm_path, package, Some(fallback), env_vars, remaining) + .await + .ok() +} + +/// The first `/` that is a file along the snapshot's `PATH` — +/// resolved by hand because `Command::new("npm")` would search the *parent* +/// process's PATH, not the snapshot's, and the snapshot is what puts the +/// managed runtime's bin dir first. +pub(crate) fn find_on_env_path(env_vars: &[(String, String)], name: &str) -> Option { + std::env::split_paths(snapshot_var(env_vars, "PATH")?) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +/// The last value of `key` in an env snapshot — the one a `Command` applying +/// the snapshot in order would end up with. +pub(super) fn snapshot_var<'a>(env_vars: &'a [(String, String)], key: &str) -> Option<&'a str> { + env_vars + .iter() + .rev() + .find(|(existing, _)| existing == key) + .map(|(_, value)| value.as_str()) +} + +/// Why an `npm view` probe produced no version. +#[derive(Debug)] +enum NpmViewFailure { + /// npm ran and exited non-zero; its stderr is kept for classification. + Exit { stderr: String }, + /// npm could not be started, timed out, or printed something that is + /// not a version. + Other, +} + +/// `npm view version [--registry ]` with `npm` at `npm_path`, +/// under exactly the `env_vars` snapshot, killed at `timeout`. `Ok` only for +/// a clean exit whose stdout is a version-shaped token. +/// +/// npm's own retry/timeout defaults (`fetch-retries=2`, backoff up to 60 s) +/// would let a dead registry host run ~70 s; a probe is not worth that, so +/// retries are off and npm's fetch timeout is pinned to the probe budget. +/// Installs keep npm's defaults. +async fn npm_view_version( + npm_path: &Path, + package: &str, + registry: Option<&str>, + env_vars: &[(String, String)], + timeout: Duration, +) -> Result { + let mut command = tokio::process::Command::new(npm_path); + command.args(["view", package, "version", "--fetch-retries=0"]); + command.arg(format!("--fetch-timeout={}", timeout.as_millis())); + if let Some(registry) = registry { + command.args(npm_registry_args(package, registry)); + } + command.env_clear(); + for (key, value) in env_vars { + command.env(key, value); + } + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let output = match tokio::time::timeout(timeout, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(error)) => { + log::debug!("npm view {package} version failed to run: {error}"); + return Err(NpmViewFailure::Other); + } + Err(_) => { + log::debug!( + "npm view {package} version timed out after {}s (registry: {})", + timeout.as_secs(), + registry_label(registry) + ); + return Err(NpmViewFailure::Other); + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + log::debug!( + "npm view {package} version exited with {} (registry: {}): {stderr}", + output.status, + registry_label(registry) + ); + return Err(NpmViewFailure::Exit { stderr }); + } + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + version + .chars() + .next() + .is_some_and(|first| first.is_ascii_digit()) + .then_some(version) + .ok_or(NpmViewFailure::Other) +} + +/// How long Doctor waits on `npm config get registry`. +const NPM_CONFIG_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long a `npm config get registry` answer is reused for the same env +/// snapshot. Doctor asks on every report while a managed bridge is missing — +/// two passes per open, plus refreshes — and each ask spawns npm; one spawn a +/// minute keeps that off the report's critical path without hiding a +/// `~/.npmrc` edit for long. Unlike [`LatestVersionCache`] a `None` answer +/// is cached too: it is what a hung or absent npm costs, and re-asking every +/// pass is exactly the stall this avoids. +const NPM_REGISTRY_CACHE_TTL: Duration = Duration::from_secs(60); + +/// The env snapshot a registry answer was asked under: the cache key, since +/// the answer depends on the snapshot's `npm`, `~/.npmrc` and exports. +type EnvSnapshot = Vec<(String, String)>; + +/// `npm config get registry` answers by the env snapshot they were asked +/// under, with the time each was fetched. +#[derive(Debug, Default)] +struct NpmRegistryCache { + entries: HashMap, Instant)>, +} + +impl NpmRegistryCache { + /// `Some(answer)` when a fresh answer (itself possibly `None`) is cached. + fn get_fresh(&self, env_vars: &[(String, String)], now: Instant) -> Option> { + let (registry, fetched_at) = self.entries.get(env_vars)?; + (now.saturating_duration_since(*fetched_at) <= NPM_REGISTRY_CACHE_TTL) + .then(|| registry.clone()) + } + + fn insert(&mut self, env_vars: &[(String, String)], registry: Option, now: Instant) { + self.entries.insert(env_vars.to_vec(), (registry, now)); + } +} + +fn npm_registry_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(NpmRegistryCache::default())) +} + +/// The registry npm would use under the `env_vars` snapshot with no +/// `--registry` — `npm config get registry` asked of the snapshot's own +/// `npm` (the managed runtime's, when installed), under exactly that env, so +/// the answer is the one a managed install resolves. `None` when the +/// snapshot has no `npm`, the probe fails or times out, or the answer is not +/// a URL; Doctor then says "npm default" rather than guessing. Cached per +/// snapshot for [`NPM_REGISTRY_CACHE_TTL`]. +pub(crate) async fn configured_npm_registry(env_vars: &[(String, String)]) -> Option { + let now = Instant::now(); + let cached = npm_registry_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_fresh(env_vars, now); + if let Some(registry) = cached { + return registry; + } + let registry = probe_configured_npm_registry(env_vars).await; + npm_registry_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(env_vars, registry.clone(), now); + registry +} + +/// One uncached `npm config get registry` (see [`configured_npm_registry`]). +async fn probe_configured_npm_registry(env_vars: &[(String, String)]) -> Option { + let npm_path = find_on_env_path(env_vars, "npm")?; + let mut command = tokio::process::Command::new(npm_path); + command.args(["config", "get", "registry"]); + command.env_clear(); + for (key, value) in env_vars { + command.env(key, value); + } + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(NPM_CONFIG_PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let registry = String::from_utf8_lossy(&output.stdout).trim().to_string(); + registry.starts_with("http").then_some(registry) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/reconcile.rs b/apps/staged/src-tauri/src/managed_acp_tools/reconcile.rs new file mode 100644 index 000000000..95ac3b0e5 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/reconcile.rs @@ -0,0 +1,121 @@ +//! Reconcile epilogue — prune stale ids, record the outcome, gate the Node +//! prune. + +use std::path::Path; + +use crate::managed_node; + +use super::install::tool_install_lock; +use super::{read_state, shim_bin_dir, tools_root, write_state, ManagedTool, ReconcileRecord}; + +#[cfg(test)] +#[path = "tests/reconcile.rs"] +mod tests; + +// ============================================================================= +// 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; +} + +pub(super) 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}"); + } +} + +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/registry.rs b/apps/staged/src-tauri/src/managed_acp_tools/registry.rs new file mode 100644 index 000000000..33d4c00c6 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/registry.rs @@ -0,0 +1,268 @@ +//! npm registry policy. +//! +//! Staged passes no `--registry` of its own: npm resolves the registry from +//! its normal config chain (`NPM_CONFIG_REGISTRY`, `~/.npmrc` including +//! `@scope:registry` entries, then `registry.npmjs.org`). That keeps a +//! corporate laptop whose `~/.npmrc` already points at an internal mirror — +//! with whatever auth token it needs — on that mirror, and gives everyone +//! else their own registry; a hardcoded URL served exactly one network and +//! could not even override a scoped `~/.npmrc` entry. +//! +//! Two escape hatches sit around that default. `STAGED_NPM_REGISTRY` is an +//! explicit override passed to every Staged-run npm command as `--registry` +//! *and* as the package's `--@scope:registry` ([`npm_registry_args`]): npm +//! picks a scoped package's registry from its scope entry before the default, +//! so `--registry` alone could never pull `@agentclientprotocol/*` off a +//! scoped `~/.npmrc` mirror. The fallback below is passed the same way. +//! `STAGED_FALLBACK_NPM_REGISTRY` is a *build-time* registry (compiled in via +//! `option_env!`, never written into source) for the one population npm's own +//! config fails: Block laptops with no `~/.npmrc` behind the Cloudflare +//! Gateway, where `registry.npmjs.org` answers with a redirect to an HTML +//! block page and npm reports `FETCH_ERROR`/invalid JSON. Only a failure that +//! reads as that block page is retried through the fallback — a 404 from a +//! mirror, a dead host, or an unresolvable version is not a registry problem a +//! different registry can fix, and an explicit override is never second- +//! guessed. + +#[cfg(test)] +#[path = "tests/registry.rs"] +mod tests; + +/// Explicit npm registry override, passed as `--registry` (and the managed +/// package's `@scope:registry`, see [`npm_registry_args`]) when set. +pub const NPM_REGISTRY_ENV: &str = "STAGED_NPM_REGISTRY"; + +/// The `STAGED_NPM_REGISTRY` override, trimmed, when set and non-empty. +/// `None` means npm decides from its own config. +pub fn npm_registry_override() -> Option { + registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) +} + +fn registry_from_env_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// The registry compiled in through `STAGED_FALLBACK_NPM_REGISTRY` at build +/// time, retried once when npm's configured registry turns out to be +/// gateway-blocked (see the policy note above). `None` for builds that set +/// no such variable. +pub fn fallback_npm_registry() -> Option<&'static str> { + option_env!("STAGED_FALLBACK_NPM_REGISTRY") + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// How an npm command names its registry in errors and progress lines. +pub(super) fn registry_label(registry: Option<&str>) -> String { + registry + .map(str::to_string) + .unwrap_or_else(|| "npm default (per npm config)".to_string()) +} + +/// The flags that force a Staged-run npm command for `package` onto +/// `registry`. `--registry` alone only sets npm's *default* registry: a +/// scoped package resolves through its `@scope:registry` config first, so a +/// `~/.npmrc` carrying `@agentclientprotocol:registry=` would +/// keep winning over `STAGED_NPM_REGISTRY` and the gateway fallback alike. +/// For a scoped package the scope's own key is overridden too. +pub(super) fn npm_registry_args(package: &str, registry: &str) -> Vec { + let mut args = vec!["--registry".to_string(), registry.to_string()]; + if let Some(scope) = npm_package_scope(package) { + args.push(format!("--{scope}:registry={registry}")); + } + args +} + +/// The `@scope` of a scoped package name (`@scope/name`), `@` included. +fn npm_package_scope(package: &str) -> Option<&str> { + if !package.starts_with('@') { + return None; + } + package.split_once('/').map(|(scope, _)| scope) +} + +/// What a failed npm command's stderr says about why it failed, as far as +/// the registry retry needs to know. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NpmFailure { + /// A network gateway answered for `registry.npmjs.org` with a block page + /// instead of registry JSON — Cloudflare Gateway on managed Block laptops + /// redirects to `blocked.teams.cloudflare.com`, and npm surfaces the HTML + /// as `FETCH_ERROR` or a JSON parse error for a request it made to the + /// public registry. + GatewayBlocked, + Other, +} + +/// How npm names `registry.npmjs.org` as the host it *requested* — minipass- +/// fetch's `request to ` / `invalid json response body at ` and +/// npm's `registry: ` detail line. A bare `registry.npmjs.org` anywhere +/// in stderr is not enough: a mirror's own error body mentions its upstream +/// ("failed to fetch from registry.npmjs.org") when *it* could not reach it, +/// and that is the mirror failing, not a gateway blocking the public registry. +const NPMJS_REQUEST_MARKERS: [&str; 3] = [ + "request to https://registry.npmjs.org/", + "response body at https://registry.npmjs.org/", + "registry: https://registry.npmjs.org", +]; + +/// Gateway-blocked iff stderr names the block page itself, or a non-JSON / +/// fetch-level failure (`FETCH_ERROR`, `EJSONPARSE`, "invalid json", +/// "Unexpected token") happened on a request npm made to +/// `registry.npmjs.org`. `FETCH_ERROR` is minipass-fetch's catch-all for a +/// fetch that died without a system errno, so a truncated body from the +/// public registry classifies as blocked too — that costs one cheap extra +/// attempt through the fallback, which then reports its own error. +pub(crate) fn classify_npm_failure(stderr: &str) -> NpmFailure { + if stderr.contains("blocked.teams.cloudflare.com") { + return NpmFailure::GatewayBlocked; + } + let requested_npmjs = NPMJS_REQUEST_MARKERS + .iter() + .any(|marker| stderr.contains(marker)); + if requested_npmjs { + let lower = stderr.to_ascii_lowercase(); + if stderr.contains("FETCH_ERROR") + || stderr.contains("EJSONPARSE") + || lower.contains("unexpected token") + || lower.contains("invalid json") + { + return NpmFailure::GatewayBlocked; + } + } + NpmFailure::Other +} + +/// The registry to retry a failed npm command through, if any: only when no +/// explicit override was given, a build-time fallback exists, and `stderr` +/// reads as a gateway block of `registry.npmjs.org`. +pub(crate) fn gateway_fallback_registry<'a>( + override_registry: Option<&str>, + fallback: Option<&'a str>, + stderr: &str, +) -> Option<&'a str> { + if override_registry.is_some() { + return None; + } + let fallback = fallback?; + (classify_npm_failure(stderr) == NpmFailure::GatewayBlocked).then_some(fallback) +} + +/// The error to surface when the retry through `fallback` failed as well: +/// keeps the original diagnosis (npm's default registry was gateway-blocked) +/// in front of the retry's own error, so the surfaced message explains why a +/// registry the user never configured was contacted at all. +pub(crate) fn gateway_retry_failed_message(fallback: &str, retry_error: &str) -> String { + format!( + "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ + fallback {fallback} also failed: {retry_error}" + ) +} + +/// What a recorded managed-install error says the user can change, as far +/// as the error text tells. Coarser than [`NpmFailure`] on purpose: this +/// classifies the *recorded* error (`state.json`'s `lastReconcile.errors`, +/// the Node download error), which carries npm's `error code` block via +/// [`npm_error_summary`] rather than the full stderr, and it also covers the +/// Node.js runtime download. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallFailureHint { + /// `registry.npmjs.org` answered with a network gateway's block page and + /// npm had no other registry configured. + GatewayBlocked, + /// The registry npm used answered 404 for the package. + PackageMissing, + /// The registry or download host could not be reached at all: DNS, + /// refused/reset connections, or a timeout on the request. + HostUnreachable, + /// The Node.js dist host answered the runtime download with a 4xx — the + /// URL (`STAGED_NODE_DIST_URL`) points at the wrong path or host. + NodeDownloadRejected(u16), +} + +/// Classify a recorded install error (see [`InstallFailureHint`]). `None` +/// when the text names no network or registry cause Doctor has advice for — +/// a validation failure, a missing entrypoint, a disk error. +pub(crate) fn classify_install_failure(error: &str) -> Option { + // Checked first: `HTTP 404` from the dist host is a download URL problem, + // not a registry without the package. A 5xx is the host's, not the + // user's, so it gets no hint. + if let Some(status) = node_download_http_status(error) { + return (400..500) + .contains(&status) + .then_some(InstallFailureHint::NodeDownloadRejected(status)); + } + if error.contains("blocked by a network gateway") + || classify_npm_failure(error) == NpmFailure::GatewayBlocked + { + return Some(InstallFailureHint::GatewayBlocked); + } + if error.contains("E404") || error.contains("404 Not Found") { + return Some(InstallFailureHint::PackageMissing); + } + const UNREACHABLE_MARKERS: [&str; 11] = [ + "ENOTFOUND", + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + // reqwest's wording for the Node.js runtime download. + "error sending request", + "dns error", + "connection refused", + "operation timed out", + ]; + if UNREACHABLE_MARKERS + .iter() + .any(|marker| error.contains(marker)) + { + return Some(InstallFailureHint::HostUnreachable); + } + // The managed `npm install` itself timing out names its registry; a + // validation probe timing out does not, and is not a network problem. + if error.contains("timed out after") && error.contains("(registry:") { + return Some(InstallFailureHint::HostUnreachable); + } + None +} + +/// The status in a `Node.js runtime download failed: HTTP ` error +/// (`ManagedNodeError::HttpStatus`'s wording), if that is what `error` is. +fn node_download_http_status(error: &str) -> Option { + let (_, rest) = error.split_once(NODE_DOWNLOAD_HTTP_STATUS_PREFIX)?; + rest.split(|c: char| !c.is_ascii_digit()) + .next()? + .parse() + .ok() +} + +/// How `ManagedNodeError::HttpStatus` renders, up to the status code. +const NODE_DOWNLOAD_HTTP_STATUS_PREFIX: &str = "Node.js runtime download failed: HTTP "; + +/// The one-line hint Doctor shows under a classified install failure, naming +/// what to change: `package` is the npm package the install was for. +pub(crate) fn install_failure_hint(error: &str, package: &str) -> Option { + Some(match classify_install_failure(error)? { + InstallFailureHint::GatewayBlocked => { + "this network blocks registry.npmjs.org and npm has no registry configured — \ + add `registry=` to ~/.npmrc or set STAGED_NPM_REGISTRY" + .to_string() + } + InstallFailureHint::PackageMissing => { + format!("the configured registry does not carry {package}") + } + InstallFailureHint::HostUnreachable => { + "the configured registry/host did not respond — check `registry=` in ~/.npmrc \ + or STAGED_NODE_DIST_URL" + .to_string() + } + InstallFailureHint::NodeDownloadRejected(status) => { + format!("the Node.js download URL returned HTTP {status} — check STAGED_NODE_DIST_URL") + } + }) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/install.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/install.rs new file mode 100644 index 000000000..be49068c0 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/install.rs @@ -0,0 +1,721 @@ +use super::*; +use crate::managed_acp_tools::registry::{classify_npm_failure, NpmFailure}; +use crate::managed_acp_tools::test_support::*; +use crate::managed_acp_tools::*; + +/// The recorded install error carries npm's own account: the last +/// `npm error code` line and the detail lines after it, minus the `errno` +/// repeat and the log-file pointer, each clipped. Without an error block +/// nothing is appended. +#[test] +fn npm_error_summary_keeps_the_code_and_the_first_details() { + let stderr = "npm warn deprecated foo@1.0.0: use bar\n\ + npm error code FETCH_ERROR\n\ + npm error errno FETCH_ERROR\n\ + npm error invalid json response body at https://registry.npmjs.org/x reason: Unexpected token '<'\n\ + npm error\n\ + npm error A complete log of this run can be found in: /tmp/x.log\n"; + assert_eq!( + npm_error_summary(stderr).as_deref(), + Some( + "npm error code FETCH_ERROR; invalid json response body at \ + https://registry.npmjs.org/x reason: Unexpected token '<'" + ) + ); + assert_eq!( + classify_npm_failure(&npm_error_summary(stderr).unwrap()), + NpmFailure::GatewayBlocked + ); + + // Older npm spelling, and more details than are kept. + let stderr = "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://r/x\nnpm ERR! 404\nnpm ERR! 404 'x@latest' is not in this registry.\nnpm ERR! 404 note\nnpm ERR! 404 more\n"; + assert_eq!( + npm_error_summary(stderr).as_deref(), + Some("npm ERR! code E404; 404 Not Found - GET https://r/x; 404; 404 'x@latest' is not in this registry.") + ); + + assert_eq!(npm_error_summary("npm warn only\n"), None); + assert_eq!(npm_error_summary(""), None); + + let long = format!("npm error code E1\nnpm error {}\n", "y".repeat(1000)); + let summary = npm_error_summary(&long).unwrap(); + assert!(summary.len() < 300, "{}", summary.len()); + assert!(summary.ends_with('…')); +} + +/// The capture keeps the *newest* lines: npm's `npm error code …` block +/// comes last, after however many warnings preceded it. +#[test] +fn stderr_capture_keeps_the_tail() { + let mut capture = String::new(); + let warning = format!("npm warn deprecated {}", "x".repeat(1000)); + for _ in 0..(NPM_STDERR_CAPTURE_LIMIT / warning.len() + 5) { + push_captured_line(&mut capture, &warning); + } + push_captured_line(&mut capture, "npm error code FETCH_ERROR"); + push_captured_line( + &mut capture, + "npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com", + ); + assert!(capture.len() <= NPM_STDERR_CAPTURE_LIMIT); + assert!(capture.starts_with("npm warn"), "drops whole lines only"); + assert!(capture.ends_with("blocked.teams.cloudflare.com\n")); + assert_eq!(classify_npm_failure(&capture), NpmFailure::GatewayBlocked); + + // A single line larger than the whole budget keeps its tail, cut on a + // char boundary. + let mut capture = String::new(); + push_captured_line( + &mut capture, + &format!("{}é end", "a".repeat(NPM_STDERR_CAPTURE_LIMIT)), + ); + assert_eq!(capture.len(), NPM_STDERR_CAPTURE_LIMIT); + assert!(capture.ends_with("é end\n")); +} + +#[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" + ); +} + +/// 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(); + 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, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &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, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .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, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .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()); +} + +#[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, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .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()); + // And the version Doctor reads is still the working install's. + assert_eq!( + installed_tool_version(&packages_root, &tool).as_deref(), + Some("1.2.3") + ); +} + +/// A managed update writes only under the packages root: nothing lands in +/// a global prefix, and the fixture's neighbours are left alone. +#[tokio::test] +async fn install_touches_only_the_packages_root() { + 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.4"); + write_fake_node_with_npm(&node_install_dir, &template, 0); + let outside = dir.path().join("global-bin"); + std::fs::create_dir_all(&outside).unwrap(); + + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap(); + + let mut entries: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + entries.sort(); + assert_eq!(entries, vec!["global-bin", "packages", "template"]); + assert!(std::fs::read_dir(&outside).unwrap().next().is_none()); + assert_eq!( + installed_tool_version(&packages_root, &tool).as_deref(), + Some("1.2.4") + ); +} + +/// A fake managed-node `npm` standing in for a laptop whose network +/// gateway blocks `registry.npmjs.org`: without `--registry` it prints +/// npm's block-page failure and exits 1; with one it performs the install +/// from `template`. Every invocation's argv is appended to `argv_log`. +fn write_fake_node_with_gateway_blocked_npm( + node_install_dir: &Path, + template: &Path, + argv_log: &Path, + stderr_without_registry: &str, +) { + 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\necho \"$*\" >> '{log}'\nprefix=\"\"\nregistry=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"--prefix\" ]; then prefix=\"$arg\"; fi\n if [ \"$prev\" = \"--registry\" ]; then registry=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -z \"$registry\" ]; then\n printf '%s\\n' '{stderr}' >&2\n exit 1\nfi\ncp -R '{template}/.' \"$prefix/\"\necho \"added 3 packages\"\nexit 0\n", + log = argv_log.display(), + stderr = stderr_without_registry.replace('\'', "'\\''"), + template = 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(); + } +} + +const GATEWAY_BLOCK_STDERR: &str = "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token < in JSON at position 0"; + +fn logged_argv(argv_log: &Path) -> Vec { + std::fs::read_to_string(argv_log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() +} + +/// No override, a compiled-in fallback, and npm failing on a gateway +/// block page: the install is retried once through the fallback from a +/// fresh staging prefix, and succeeds. +#[tokio::test] +async fn gateway_blocked_install_retries_once_through_the_fallback_registry() { + 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.5"); + let argv_log = dir.path().join("npm-argv.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + + 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, + &install_env(), + None, + Some("https://fallback.example.test/npm/"), + super::validation::VALIDATION_TIMEOUT, + &on_line, + ) + .await + .unwrap(); + + let argv = logged_argv(&argv_log); + assert_eq!(argv.len(), 2, "{argv:?}"); + assert!(!argv[0].contains("--registry"), "{}", argv[0]); + assert!( + argv[1].contains("--registry https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + assert!( + argv[1].contains("--@agentclientprotocol:registry=https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + assert_eq!( + installed_tool_version(&packages_root, &tool).as_deref(), + Some("1.2.5") + ); + let recorded = lines.lock().unwrap().clone(); + assert!( + recorded + .iter() + .any(|line| line.contains("retrying through https://fallback.example.test/npm/")), + "{recorded:?}" + ); + assert!(!staging_install_dir(&packages_root, tool.id).exists()); +} + +/// When the fallback attempt fails too, the surfaced error still says why +/// the fallback was contacted: the default registry was gateway-blocked. +#[tokio::test] +async fn gateway_blocked_install_names_both_failures_when_the_fallback_fails() { + 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(); + // Fails whatever registry it is given: a block page without one, a + // plain exit 7 with one. + let bin = node_install_dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + write_executable(&bin.join("node"), "#!/bin/sh\necho v9.9.9\n"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\ncase \" $* \" in\n *\" --registry \"*) echo 'npm error code E500' >&2; exit 7 ;;\nesac\nprintf '%s\\n' '{}' >&2\nexit 1\n", + GATEWAY_BLOCK_STDERR.replace('\'', "'\\''") + ), + ); + + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + None, + Some("https://fallback.example.test/npm/"), + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + let message = error.to_string(); + // The retry's own `npm error code` line rides along, so the recorded + // error says what the fallback answered, not just that it failed. + assert_eq!( + message, + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a \ + network gateway; fallback https://fallback.example.test/npm/ also failed: \ + npm install exited with exit status: 7 (registry: https://fallback.example.test/npm/): \ + npm error code E500" + ); + assert!( + message.contains( + "fallback https://fallback.example.test/npm/ also failed: npm install exited with" + ), + "{message}" + ); + assert!( + message.contains("(registry: https://fallback.example.test/npm/)"), + "{message}" + ); + assert!(!staging_install_dir(&packages_root, tool.id).exists()); +} + +/// npm runs under the env snapshot, not the Tauri process env: a registry +/// the user's shell rc exports reaches the install exactly as it reaches +/// the freshness probe, while the managed node bin leads PATH and the +/// private cache outranks whatever cache the snapshot named. +#[tokio::test] +async fn install_runs_npm_under_the_env_snapshot() { + 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); + // The fake npm also dumps the env it saw. + let env_log = dir.path().join("npm-env.log"); + let npm = node_install_dir.join("bin").join("npm"); + let script = std::fs::read_to_string(&npm).unwrap(); + write_executable( + &npm, + &script.replacen( + "#!/bin/sh\n", + &format!("#!/bin/sh\nenv > '{}'\n", env_log.display()), + 1, + ), + ); + + let mut env_vars = install_env(); + env_vars.push(( + "NPM_CONFIG_REGISTRY".to_string(), + "https://mine.example.test/npm/".to_string(), + )); + env_vars.push(( + "npm_config_registry".to_string(), + "https://mine.example.test/npm/".to_string(), + )); + env_vars.push(( + "NPM_CONFIG_CACHE".to_string(), + "/somewhere/else/cache".to_string(), + )); + env_vars.push(("STAGED_TEST_SNAPSHOT_ONLY".to_string(), "1".to_string())); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &env_vars, + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap(); + + let seen: std::collections::HashMap = std::fs::read_to_string(&env_log) + .unwrap() + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + assert_eq!( + seen.get("NPM_CONFIG_REGISTRY").map(String::as_str), + Some("https://mine.example.test/npm/") + ); + assert_eq!( + seen.get("npm_config_registry").map(String::as_str), + Some("https://mine.example.test/npm/") + ); + assert_eq!( + seen.get("STAGED_TEST_SNAPSHOT_ONLY").map(String::as_str), + Some("1"), + "snapshot-only vars reach npm" + ); + let expected_cache = packages_root.join("npm-prefix").join("cache"); + assert_eq!( + seen.get("NPM_CONFIG_CACHE").map(Path::new), + Some(expected_cache.as_path()), + "the managed cache overlay wins over the snapshot's" + ); + let first_on_path = std::env::split_paths(seen.get("PATH").unwrap()).next(); + assert_eq!( + first_on_path.as_deref(), + Some(node_install_dir.join("bin").as_path()) + ); +} + +/// An explicit `STAGED_NPM_REGISTRY` is never second-guessed, and a +/// failure that is not a gateway block page is not retried either; both +/// surface the first failure, naming the registry npm used. +#[tokio::test] +async fn install_does_not_retry_with_an_override_or_for_other_failures() { + 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.5"); + let fallback = Some("https://fallback.example.test/npm/"); + + // A gateway block page, but no fallback compiled in: plain failure. + let argv_log = dir.path().join("no-fallback.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + None, + None, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert!( + error + .to_string() + .contains("(registry: npm default (per npm config))"), + "{error}" + ); + assert_eq!(logged_argv(&argv_log).len(), 1); + + // A 404 from whichever registry npm used: a different registry cannot + // fix that, so no retry even with a fallback available. + let argv_log = dir.path().join("not-found.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + "npm error code E404\nnpm error 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", + ); + let error = install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + None, + fallback, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap_err(); + assert!(matches!(error, ManagedToolError::NpmInstall(_)), "{error}"); + assert_eq!(logged_argv(&argv_log).len(), 1); + + // An explicit override: the one and only call carries it (the fake + // succeeds on any `--registry`), and the fallback is never consulted. + let argv_log = dir.path().join("override.log"); + write_fake_node_with_gateway_blocked_npm( + &node_install_dir, + &template, + &argv_log, + GATEWAY_BLOCK_STDERR, + ); + install_npm_tool( + &packages_root, + &node_install_dir, + TEST_NODE_VERSION, + &tool, + &install_env(), + Some("https://mine.example.test/npm/"), + fallback, + super::validation::VALIDATION_TIMEOUT, + &|_| {}, + ) + .await + .unwrap(); + let argv = logged_argv(&argv_log); + assert_eq!(argv.len(), 1, "{argv:?}"); + assert!( + argv[0].contains("--registry https://mine.example.test/npm/"), + "{}", + argv[0] + ); + assert!( + argv[0].contains("--@agentclientprotocol:registry=https://mine.example.test/npm/"), + "{}", + argv[0] + ); +} + +/// The live tree's `package.json` is the authority for the installed ACP +/// version; `state.json` only stands in when that tree cannot be read. +#[test] +fn installed_tool_version_prefers_the_live_package_over_state() { + let dir = tempfile::tempdir().unwrap(); + let packages_root = dir.path(); + let tool = test_tool(); + + // Nothing installed, no state: unknown. + assert_eq!(installed_tool_version(packages_root, &tool), None); + + // State alone (tree missing or unreadable): the recorded version. + let mut state = ManagedToolsState::default(); + 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(); + assert_eq!( + installed_tool_version(packages_root, &tool).as_deref(), + Some("1.2.3") + ); + + // A live tree at another version wins over stale state. + write_fixture_install(&tool_install_dir(packages_root, tool.id), &tool, "1.3.0"); + assert_eq!( + installed_tool_version(packages_root, &tool).as_deref(), + Some("1.3.0") + ); + + // An empty recorded version (unreadable package.json at install + // time) is not a version. + std::fs::remove_dir_all(tool_install_dir(packages_root, tool.id)).unwrap(); + state.tools.get_mut(tool.id).unwrap().version.clear(); + write_state(packages_root, &state).unwrap(); + assert_eq!(installed_tool_version(packages_root, &tool), None); +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/layout.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/layout.rs new file mode 100644 index 000000000..fb1ae7028 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/layout.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::managed_acp_tools::test_support::*; + +#[test] +fn managed_tools_disabled_reason_names_the_cause() { + assert_eq!( + managed_tools_disabled_reason_from_parts(None, false, true), + None + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(Some(Path::new("/dev/acp")), false, true) + .as_deref(), + Some("STAGED_ACP_TOOLS_DIR is set to /dev/acp") + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(None, true, true).as_deref(), + Some("this build was compiled without managed ACP tools") + ); + assert_eq!( + managed_tools_disabled_reason_from_parts(None, false, false).as_deref(), + Some("no managed Node.js runtime is available for this platform") + ); + // The override is the reason even on a build that is disabled anyway. + assert!( + managed_tools_disabled_reason_from_parts(Some(Path::new("/d")), true, false) + .unwrap() + .starts_with("STAGED_ACP_TOOLS_DIR") + ); +} + +#[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_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] +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}"); + } +} + +#[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); + } +} + +#[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()); +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs new file mode 100644 index 000000000..60bcbfe4b --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs @@ -0,0 +1,489 @@ +use super::*; +use crate::managed_acp_tools::test_support::*; +use crate::managed_acp_tools::*; + +/// The registry probe asks the snapshot's own `npm`, under the snapshot's +/// env, and reports only a URL-shaped answer. +#[tokio::test] +async fn configured_npm_registry_asks_the_snapshots_npm() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + write_executable( + &bin.join("npm"), + "#!/bin/sh\n[ \"$1 $2 $3\" = \"config get registry\" ] || exit 2\nprintf '%s\\n' \"${FAKE_REGISTRY:-https://mirror.example.test/npm/}\"\n", + ); + let env = vec![("PATH".to_string(), bin.display().to_string())]; + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + + // The env is the snapshot's, not the process's. + let mut env_with_registry = env.clone(); + env_with_registry.push(( + "FAKE_REGISTRY".to_string(), + "https://other.example.test/".to_string(), + )); + assert_eq!( + configured_npm_registry(&env_with_registry).await.as_deref(), + Some("https://other.example.test/") + ); + + // Not a URL: no answer. No npm on the snapshot PATH: no answer. + let mut env_garbage = env.clone(); + env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + let empty = dir.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + assert_eq!( + configured_npm_registry(&[("PATH".to_string(), empty.display().to_string())]).await, + None + ); +} + +/// Asking twice under one snapshot spawns npm once: the answer — a URL or +/// `None` alike — is reused within the TTL, so a Doctor refresh never +/// re-pays the probe. A different snapshot is a different question. +#[tokio::test] +async fn configured_npm_registry_is_cached_per_env_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let calls = dir.path().join("calls"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\nprintf x >> '{}'\nprintf '%s\\n' \"${{FAKE_REGISTRY:-https://mirror.example.test/npm/}}\"\n", + calls.display() + ), + ); + let spawns = || { + std::fs::read_to_string(&calls) + .map(|s| s.len()) + .unwrap_or(0) + }; + let env = vec![("PATH".to_string(), bin.display().to_string())]; + + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + assert_eq!( + configured_npm_registry(&env).await.as_deref(), + Some("https://mirror.example.test/npm/") + ); + assert_eq!(spawns(), 1, "the second ask is answered from the cache"); + + // A `None` answer is cached as well. + let mut env_garbage = env.clone(); + env_garbage.push(("FAKE_REGISTRY".to_string(), "undefined".to_string())); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + assert_eq!(configured_npm_registry(&env_garbage).await, None); + assert_eq!(spawns(), 2); +} + +#[test] +fn npm_registry_cache_expires_after_the_ttl() { + let mut cache = NpmRegistryCache::default(); + let now = Instant::now(); + let env = vec![("PATH".to_string(), "/x".to_string())]; + let other = vec![("PATH".to_string(), "/y".to_string())]; + assert_eq!(cache.get_fresh(&env, now), None); + + cache.insert(&env, Some("https://r.example.test/".to_string()), now); + cache.insert(&other, None, now); + assert_eq!( + cache.get_fresh(&env, now + Duration::from_secs(30)), + Some(Some("https://r.example.test/".to_string())) + ); + assert_eq!( + cache.get_fresh(&other, now), + Some(None), + "a cached `None` is an answer, not a miss" + ); + assert_eq!( + cache.get_fresh(&env, now + NPM_REGISTRY_CACHE_TTL + Duration::from_secs(1)), + None + ); +} + +/// A fake `npm` on a PATH dir: prints `version` for `npm view +/// version`, exits `exit_code`, and refuses unless `--registry` is passed +/// exactly when `expect_registry` says so. +fn write_fake_npm(bin_dir: &Path, version: &str, exit_code: i32, expect_registry: bool) { + std::fs::create_dir_all(bin_dir).unwrap(); + let registry_check = if expect_registry { + "case \" $* \" in *\" --registry \"*) ;; *) echo 'missing --registry' >&2; exit 43;; esac\n" + } else { + "case \" $* \" in *\" --registry \"*) echo 'unexpected --registry' >&2; exit 43;; esac\n" + }; + write_executable( + &bin_dir.join("npm"), + &format!( + "#!/bin/sh\ntest \"$1\" = view || exit 44\ntest \"$3\" = version || exit 45\n{registry_check}echo '{version}'\nexit {exit_code}\n" + ), + ); +} + +fn path_env(bin_dir: &Path) -> Vec<(String, String)> { + vec![( + "PATH".to_string(), + format!("{}:/usr/bin:/bin", bin_dir.display()), + )] +} + +#[tokio::test] +async fn npm_view_version_reads_the_registry_answer() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + write_fake_npm(&bin, "4.5.6", 0, true); + let env = path_env(&bin); + let npm = find_on_env_path(&env, "npm").expect("npm resolves from the snapshot PATH"); + assert_eq!(npm, bin.join("npm")); + + let version = npm_view_version( + &npm, + "@agentclientprotocol/claude-agent-acp", + Some("https://registry.example.test/npm/"), + &env, + Duration::from_secs(10), + ) + .await + .ok(); + assert_eq!(version.as_deref(), Some("4.5.6")); +} + +#[tokio::test] +async fn npm_view_version_omits_the_registry_flag_when_none_is_configured() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + write_fake_npm(&bin, "4.5.6", 0, false); + let env = path_env(&bin); + + let version = npm_view_version( + &bin.join("npm"), + "@agentclientprotocol/codex-acp", + None, + &env, + Duration::from_secs(10), + ) + .await + .ok(); + assert_eq!(version.as_deref(), Some("4.5.6")); +} + +/// Probes must not inherit npm's ~70 s dead-host retry schedule: retries +/// are off and npm's own fetch timeout is pinned to the probe budget. +#[tokio::test] +async fn npm_view_version_bounds_npm_fetch_retries_and_timeout() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let argv_log = dir.path().join("argv.log"); + write_executable( + &bin.join("npm"), + &format!( + "#!/bin/sh\necho \"$*\" >> '{}'\necho 1.0.0\n", + argv_log.display() + ), + ); + let env = path_env(&bin); + npm_view_version( + &bin.join("npm"), + "@agentclientprotocol/codex-acp", + None, + &env, + Duration::from_secs(15), + ) + .await + .unwrap(); + let argv = std::fs::read_to_string(&argv_log).unwrap(); + assert!(argv.contains(" --fetch-retries=0"), "{argv}"); + assert!(argv.contains(" --fetch-timeout=15000"), "{argv}"); +} + +/// A fake `npm view` that reports a gateway block page unless `--registry` +/// is passed, logging each invocation's argv. +fn write_fake_gateway_blocked_npm_view(bin_dir: &Path, argv_log: &Path) { + std::fs::create_dir_all(bin_dir).unwrap(); + write_executable( + &bin_dir.join("npm"), + &format!( + "#!/bin/sh\necho \"$*\" >> '{}'\ncase \" $* \" in *\" --registry \"*) echo 7.8.9; exit 0;; esac\necho 'npm error code FETCH_ERROR' >&2\necho 'npm error request to https://registry.npmjs.org/x failed: https://blocked.teams.cloudflare.com?block_reason=x' >&2\nexit 1\n", + argv_log.display() + ), + ); +} + +/// The freshness probe follows the install's precedence: npm's own config +/// first, then — for a gateway block with no override — one retry through +/// the fallback. +#[tokio::test] +async fn probe_latest_version_retries_a_gateway_block_through_the_fallback() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + let argv_log = dir.path().join("argv.log"); + write_fake_gateway_blocked_npm_view(&bin, &argv_log); + let env = path_env(&bin); + let package = "@agentclientprotocol/claude-agent-acp"; + let fallback = Some("https://fallback.example.test/npm/"); + + let version = probe_latest_version( + &bin.join("npm"), + package, + None, + fallback, + &env, + Duration::from_secs(15), + ) + .await; + assert_eq!(version.as_deref(), Some("7.8.9")); + let argv: Vec = std::fs::read_to_string(&argv_log) + .unwrap() + .lines() + .map(str::to_string) + .collect(); + assert_eq!(argv.len(), 2, "{argv:?}"); + assert!(!argv[0].contains("--registry"), "{}", argv[0]); + assert!( + argv[1].contains("--registry https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + assert!( + argv[1].contains("--@agentclientprotocol:registry=https://fallback.example.test/npm/"), + "{}", + argv[1] + ); + + // No fallback compiled in: the block page is simply unknown. + std::fs::remove_file(&argv_log).unwrap(); + assert_eq!( + probe_latest_version( + &bin.join("npm"), + package, + None, + None, + &env, + Duration::from_secs(15) + ) + .await, + None + ); + assert_eq!( + std::fs::read_to_string(&argv_log).unwrap().lines().count(), + 1 + ); + + // An override is used as-is and never falls back. + std::fs::remove_file(&argv_log).unwrap(); + assert_eq!( + probe_latest_version( + &bin.join("npm"), + package, + Some("https://mine.example.test/npm/"), + fallback, + &env, + Duration::from_secs(15) + ) + .await + .as_deref(), + Some("7.8.9") + ); + let argv = std::fs::read_to_string(&argv_log).unwrap(); + assert_eq!(argv.lines().count(), 1, "{argv}"); + assert!(argv.contains("--registry https://mine.example.test/npm/")); + assert!(argv.contains("--@agentclientprotocol:registry=https://mine.example.test/npm/")); +} + +/// Every failure mode reads as unknown — never as a version. +#[tokio::test] +async fn npm_view_version_is_unknown_on_failure_garbage_or_timeout() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + let env = path_env(&bin); + let package = "@agentclientprotocol/claude-agent-acp"; + + // A registry error: npm exits non-zero. + write_fake_npm(&bin, "4.5.6", 1, false); + assert_eq!( + npm_view_version( + &bin.join("npm"), + package, + None, + &env, + Duration::from_secs(10) + ) + .await + .ok(), + None + ); + + // A clean exit with non-version output (an npm notice, an HTML page). + write_fake_npm(&bin, "npm notice: try again later", 0, false); + assert_eq!( + npm_view_version( + &bin.join("npm"), + package, + None, + &env, + Duration::from_secs(10) + ) + .await + .ok(), + None + ); + + // A hung lookup is cut off at the bound. + write_executable(&bin.join("npm"), "#!/bin/sh\nsleep 30\necho 9.9.9\n"); + let started = Instant::now(); + assert_eq!( + npm_view_version( + &bin.join("npm"), + package, + None, + &env, + Duration::from_millis(200) + ) + .await + .ok(), + None + ); + assert!(started.elapsed() < Duration::from_secs(10)); + + // No npm anywhere on the snapshot PATH: nothing to ask. + let empty = vec![("PATH".to_string(), "/nonexistent/dir".to_string())]; + assert_eq!(find_on_env_path(&empty, "npm"), None); + assert_eq!(find_on_env_path(&[], "npm"), None); +} + +#[test] +fn latest_version_cache_expires_after_the_ttl() { + let env = vec![("HOME".to_string(), "/nonexistent".to_string())]; + let key = LatestVersionKey::new("pkg", &env); + let other = LatestVersionKey::new("other", &env); + let mut cache = LatestVersionCache::default(); + let now = Instant::now(); + assert_eq!(cache.get_fresh(&key, now), None); + + cache.insert(key.clone(), "1.2.3".to_string(), now); + assert_eq!( + cache + .get_fresh(&key, now + Duration::from_secs(60)) + .as_deref(), + Some("1.2.3") + ); + assert_eq!(cache.get_fresh(&other, now), None); + let expired = now + LATEST_VERSION_CACHE_TTL + Duration::from_secs(1); + assert_eq!(cache.get_fresh(&key, expired), None); + + // An insert past the TTL also drops the expired entry. + cache.insert(other.clone(), "2.0.0".to_string(), expired); + assert!(!cache.entries.contains_key(&key)); + assert_eq!(cache.get_fresh(&other, expired).as_deref(), Some("2.0.0")); +} + +/// The cache is keyed by what decides the registry, not just the package: a +/// different override, env snapshot or user npmrc is a different entry. +#[test] +fn latest_version_cache_is_keyed_by_registry_configuration() { + let env = vec![("HOME".to_string(), "/nonexistent".to_string())]; + let key = LatestVersionKey::new("pkg", &env); + let mut cache = LatestVersionCache::default(); + let now = Instant::now(); + cache.insert(key.clone(), "1.2.3".to_string(), now); + assert_eq!(cache.get_fresh(&key, now).as_deref(), Some("1.2.3")); + + let mut other_override = key.clone(); + other_override.registry.override_registry = Some("https://mine.example.test/npm/".to_string()); + assert_eq!(cache.get_fresh(&other_override, now), None); + + let mut env_with_registry = env.clone(); + env_with_registry.push(( + "NPM_CONFIG_REGISTRY".to_string(), + "https://mirror.example.test/npm/".to_string(), + )); + let other_env = LatestVersionKey::new("pkg", &env_with_registry); + assert_eq!(cache.get_fresh(&other_env, now), None); + + let mut other_npmrc = key.clone(); + other_npmrc.registry.user_npmrc = Some((PathBuf::from("/nonexistent/.npmrc"), 12, None)); + assert_eq!(cache.get_fresh(&other_npmrc, now), None); +} + +/// The user npmrc is the one npm reads under the snapshot: the userconfig +/// override in either spelling, else `$HOME/.npmrc`. +#[test] +fn user_npmrc_path_follows_the_snapshots_npm_config() { + let home = vec![("HOME".to_string(), "/home/u".to_string())]; + assert_eq!( + user_npmrc_path(&home), + Some(PathBuf::from("/home/u/.npmrc")) + ); + let mut lower = home.clone(); + lower.push(( + "npm_config_userconfig".to_string(), + "/etc/alt-npmrc".to_string(), + )); + assert_eq!( + user_npmrc_path(&lower), + Some(PathBuf::from("/etc/alt-npmrc")) + ); + let mut upper = lower.clone(); + upper.push(( + "NPM_CONFIG_USERCONFIG".to_string(), + "/etc/upper-npmrc".to_string(), + )); + assert_eq!( + user_npmrc_path(&upper), + Some(PathBuf::from("/etc/upper-npmrc")) + ); + assert_eq!(user_npmrc_path(&[]), None); +} + +/// Editing the user npmrc (here the one `NPM_CONFIG_USERCONFIG` names) makes +/// the next ask probe again within the TTL; an untouched one is served from +/// the cache even once npm is gone. +#[tokio::test] +async fn latest_published_version_reprobes_when_the_user_npmrc_changes() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + let npmrc = dir.path().join("npmrc"); + std::fs::create_dir_all(&bin).unwrap(); + // The fake answers with the npmrc's contents, so the answer itself tells + // whether npm was asked again. + write_executable( + &bin.join("npm"), + "#!/bin/sh\ntest \"$1\" = view || exit 44\ncat \"$NPM_CONFIG_USERCONFIG\"\n", + ); + wait_until_executable(&bin.join("npm")); + let tool = ManagedTool { + id: "cache-key-probe", + binary: "cache-key-probe", + package: "@staged-test/cache-key-probe", + }; + let mut env = path_env(&bin); + env.push(( + "NPM_CONFIG_USERCONFIG".to_string(), + npmrc.display().to_string(), + )); + + std::fs::write(&npmrc, "1.0.0\n").unwrap(); + assert_eq!( + latest_published_version(&tool, &env).await.as_deref(), + Some("1.0.0") + ); + // A different length, so the fingerprint changes even within one mtime tick. + std::fs::write(&npmrc, "2.0.0-next\n").unwrap(); + assert_eq!( + latest_published_version(&tool, &env).await.as_deref(), + Some("2.0.0-next") + ); + std::fs::remove_file(bin.join("npm")).unwrap(); + assert_eq!( + latest_published_version(&tool, &env).await.as_deref(), + Some("2.0.0-next"), + "an unchanged npmrc is a cache hit" + ); +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/reconcile.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/reconcile.rs new file mode 100644 index 000000000..d8cefb95c --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/reconcile.rs @@ -0,0 +1,83 @@ +use super::*; +use crate::managed_acp_tools::test_support::*; +use crate::managed_acp_tools::*; + +#[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()); +} + +#[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_acp_tools/tests/registry.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/registry.rs new file mode 100644 index 000000000..43b666c76 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/registry.rs @@ -0,0 +1,235 @@ +use super::*; + +/// `STAGED_NPM_REGISTRY` is honored only when it carries a value; unset, +/// empty, or whitespace all mean "npm decides". +#[test] +fn npm_registry_override_is_the_trimmed_non_empty_env_value() { + assert_eq!(registry_from_env_value(None), None); + assert_eq!(registry_from_env_value(Some("")), None); + assert_eq!(registry_from_env_value(Some(" \n")), None); + assert_eq!( + registry_from_env_value(Some(" https://registry.example.test/npm/ ")), + Some("https://registry.example.test/npm/".to_string()) + ); + assert_eq!( + npm_registry_override(), + registry_from_env_value(std::env::var(NPM_REGISTRY_ENV).ok().as_deref()) + ); +} + +/// The fallback is whatever the build injected — no URL lives in source, +/// so a from-source build has none and a value is never blank. +#[test] +fn fallback_npm_registry_is_build_time_only() { + let expected = option_env!("STAGED_FALLBACK_NPM_REGISTRY") + .map(str::trim) + .filter(|value| !value.is_empty()); + assert_eq!(fallback_npm_registry(), expected); + if let Some(fallback) = fallback_npm_registry() { + assert!(!fallback.is_empty()); + assert_eq!(fallback, fallback.trim()); + } +} + +#[test] +fn registry_label_names_npm_config_when_no_registry_is_passed() { + assert_eq!( + registry_label(Some("https://registry.example.test/npm/")), + "https://registry.example.test/npm/" + ); + assert_eq!(registry_label(None), "npm default (per npm config)"); +} + +/// Gateway-blocked only for the block page itself, or a fetch/JSON failure +/// on a request npm made *to* `registry.npmjs.org`. The public registry's +/// hostname appearing elsewhere in stderr proves nothing — mirrors name +/// their upstream in their own error bodies. +#[test] +fn classify_npm_failure_spots_gateway_block_pages_only() { + let blocked = [ + // The real thing: Cloudflare Gateway redirects the packument fetch + // to its block page, and npm reports the redirect target. + "npm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error request to https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp failed: https://blocked.teams.cloudflare.com?block_reason=...", + // The block page served inline as the response body. + "npm warn deprecated foo@1.0.0: use bar\nnpm error code FETCH_ERROR\nnpm error errno FETCH_ERROR\nnpm error invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<', \"Failed to fetch from registry.npmjs.org (https://registry.npmjs.org/foo)\"... is not valid JSON", + // The public registry itself unreachable (offline): a network + // error, not a block page. + "npm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/foo failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org", + ]; + for stderr in other { + assert_eq!(classify_npm_failure(stderr), NpmFailure::Other, "{stderr}"); + } +} + +#[test] +fn gateway_retry_failed_message_keeps_the_original_diagnosis() { + let message = gateway_retry_failed_message( + "https://fallback.example.test/npm/", + "npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", + ); + assert_eq!( + message, + "npm default registry (registry.npmjs.org) was blocked by a network gateway; \ + fallback https://fallback.example.test/npm/ also failed: npm install exited with \ + exit status: 1 (registry: https://fallback.example.test/npm/)" + ); +} + +/// The Doctor hint classifies the *recorded* error: the block page (with +/// or without a fallback having been tried), a 404 from whichever +/// registry, and an unreachable registry or download host. A validation +/// failure, a missing entrypoint, and a probe timeout get no hint — there +/// is nothing network-side to change. +#[test] +fn classify_install_failure_names_the_three_fixable_causes() { + let cases = [ + ( + "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code FETCH_ERROR; invalid json response body at https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp reason: Unexpected token '<'", + Some(InstallFailureHint::GatewayBlocked), + ), + ( + "npm install failed: npm default registry (registry.npmjs.org) was blocked by a network gateway; fallback https://fallback.example.test/npm/ also failed: npm install exited with exit status: 1 (registry: https://fallback.example.test/npm/)", + Some(InstallFailureHint::GatewayBlocked), + ), + ( + "npm install failed: npm install exited with exit status: 1 (registry: https://mirror.example.test/npm/): npm error code E404; 404 Not Found - GET https://mirror.example.test/npm/@agentclientprotocol%2fclaude-agent-acp", + Some(InstallFailureHint::PackageMissing), + ), + ( + "npm install failed: npm install exited with exit status: 1 (registry: npm default (per npm config)): npm error code ENOTFOUND; network request to https://mirror.example.test/npm/x failed, reason: getaddrinfo ENOTFOUND mirror.example.test", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "Node.js runtime download failed: https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz: error sending request for url", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "npm install failed: timed out after 900 seconds (registry: npm default (per npm config))", + Some(InstallFailureHint::HostUnreachable), + ), + ( + "installed ACP bridge is incomplete: @agentclientprotocol/claude-agent-acp: validation probe timed out after 20 seconds", + None, + ), + ( + "installed ACP bridge is incomplete: bridge entrypoint /x/index.js is missing after install", + None, + ), + ( + "Node.js runtime download failed: HTTP 404", + Some(InstallFailureHint::NodeDownloadRejected(404)), + ), + ( + "Node.js runtime download failed: HTTP 403", + Some(InstallFailureHint::NodeDownloadRejected(403)), + ), + ("Node.js runtime download failed: HTTP 503", None), + ("", None), + ]; + for (error, expected) in cases { + assert_eq!(classify_install_failure(error), expected, "{error}"); + } + + assert_eq!( + install_failure_hint("Node.js runtime download failed: HTTP 404", "Node.js").as_deref(), + Some("the Node.js download URL returned HTTP 404 — check STAGED_NODE_DIST_URL"), + "a 404 from the dist host is a URL problem, never a missing package" + ); + + let hint = install_failure_hint( + "npm install failed: … npm error code E404; 404 Not Found", + "@agentclientprotocol/codex-acp", + ) + .unwrap(); + assert_eq!( + hint, + "the configured registry does not carry @agentclientprotocol/codex-acp" + ); + assert!(install_failure_hint( + "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed", + "x" + ) + .unwrap() + .contains("STAGED_NPM_REGISTRY")); + assert!(install_failure_hint("getaddrinfo ENOTFOUND mirror", "x") + .unwrap() + .contains("STAGED_NODE_DIST_URL")); + assert!(install_failure_hint("disk full", "x").is_none()); +} + +/// The fallback is consulted only with no override, a compiled-in +/// fallback, and a gateway-shaped failure — all three at once. +#[test] +fn gateway_fallback_registry_requires_no_override_a_fallback_and_a_block_page() { + let blocked = "npm error code FETCH_ERROR request to https://registry.npmjs.org/x failed"; + let fallback = Some("https://fallback.example.test/npm/"); + assert_eq!( + gateway_fallback_registry(None, fallback, blocked), + fallback, + "no override + fallback + block page retries" + ); + assert_eq!( + gateway_fallback_registry(Some("https://mine.example.test/"), fallback, blocked), + None, + "an explicit override is never second-guessed" + ); + assert_eq!( + gateway_fallback_registry(None, None, blocked), + None, + "nothing to retry through" + ); + assert_eq!( + gateway_fallback_registry(None, fallback, "npm error code E404"), + None, + "a 404 is not a gateway block" + ); +} + +/// `--registry` sets npm's default registry only; a scoped package follows +/// its `@scope:registry` entry first, so that key is forced alongside. +#[test] +fn npm_registry_args_override_the_packages_scope_too() { + assert_eq!( + npm_registry_args( + "@agentclientprotocol/claude-agent-acp", + "https://mine.example.test/npm/" + ), + vec![ + "--registry", + "https://mine.example.test/npm/", + "--@agentclientprotocol:registry=https://mine.example.test/npm/", + ] + ); + assert_eq!( + npm_registry_args("amp-acp", "https://mine.example.test/npm/"), + vec!["--registry", "https://mine.example.test/npm/"] + ); + // A bare `@name` without a slash is not a scope. + assert_eq!(npm_package_scope("@weird"), None); + assert_eq!(npm_package_scope("@scope/name"), Some("@scope")); +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs new file mode 100644 index 000000000..513e14d41 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs @@ -0,0 +1,147 @@ +//! Fixtures shared by the managed-tool test modules: a fake managed Node +//! runtime whose `npm` copies a template install into `--prefix`, fixture +//! package trees, and the env snapshot installs run under. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use super::install::{node_binary, npm_entrypoint, package_dir, shim_contents, write_shim}; +use super::*; + +pub(super) 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) +} + +pub(super) fn test_tool() -> ManagedTool { + ManagedTool { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + } +} + +pub(super) const TEST_NODE_VERSION: &str = "v9.9.9"; + +/// The env snapshot the fixture installs run under: enough of the test +/// process env for a `#!/bin/sh` fake npm to find `cp` and its home. +pub(super) fn install_env() -> Vec<(String, String)> { + ["PATH", "HOME"] + .into_iter() + .filter_map(|key| Some((key.to_string(), std::env::var(key).ok()?))) + .collect() +} + +pub(super) 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. +pub(super) 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(); +} + +/// 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. +pub(super) 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(); + } +} + +/// Writes `contents` to `path` as a 0755 script and returns only once it +/// can actually be exec'd (see [`wait_until_executable`]). +pub(super) fn write_executable(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + wait_until_executable(path); +} + +/// Returns only once the freshly written script at `path` can actually be +/// exec'd. On Linux, a test on another thread that forks while our write +/// handle is open inherits it until its own exec, and exec of a file +/// anyone still holds open for writing fails with `ETXTBSY` — which the +/// code under test would faithfully report and fail the test. Probe-spawn +/// (and immediately kill) the script until that window has passed, so the +/// script must be harmless to run with no arguments. +pub(super) fn wait_until_executable(path: &Path) { + for _ in 0..1000 { + match std::process::Command::new(path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.kill(); + let _ = child.wait(); + return; + } + Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => panic!("{} is not executable: {error}", path.display()), + } + } + panic!("{} stayed text-busy", path.display()); +} + +/// Lay down a complete healthy install (tree + shim + state) for `tool`. +pub(super) 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(); +} + +/// 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)`. +pub(super) 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) +} diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs index 44782648b..5f88951d5 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs @@ -1,6 +1,13 @@ +use std::sync::Mutex; +use std::time::Instant; + use super::*; +use crate::managed_acp_tools::install::{install_npm_tool, shim_contents, write_shim}; +use crate::managed_acp_tools::reconcile::finish_reconcile_at; +use crate::managed_acp_tools::test_support::*; +use crate::managed_acp_tools::*; -const TIMEOUT: Duration = super::super::validation::VALIDATION_TIMEOUT; +const TIMEOUT: Duration = VALIDATION_TIMEOUT; const MISSING_NATIVE: &str = "echo 'native binary not found for this platform' >&2\nexit 1\n"; /// Installs `contents` as the fake `node` under `bin_dir`, through the @@ -219,7 +226,7 @@ async fn personal_overrides_cannot_mask_missing_native_binary() { const MARKER: &str = "STAGED_TEST_ACP_VALIDATION"; if std::env::var_os(MARKER).is_none() { let output = tokio::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "managed_acp_tools::tests::validation::personal_overrides_cannot_mask_missing_native_binary", "--nocapture"]) + .args(["--exact", "managed_acp_tools::validation::tests::personal_overrides_cannot_mask_missing_native_binary", "--nocapture"]) .env(MARKER, "1") .env("CLAUDE_CODE_EXECUTABLE", "/bin/true") .env("CODEX_PATH", "/bin/true") @@ -342,9 +349,10 @@ async fn cancellation_kills_bridge_and_native_child() { let node = root.join("node"); let staging = staging_install_dir(&root, tool.id); write_probe(&node, &staging, &tool, &format!("{HANGING_NATIVE}wait\n")); - let mut probe = tokio::spawn(async move { - super::super::validation::validate_staged_tool(&node, &staging, &tool, TIMEOUT).await - }); + let mut probe = + tokio::spawn( + async move { validate_staged_tool(&node, &staging, &tool, TIMEOUT).await }, + ); // Validation should hang until aborted. If it returns first (say, the // node shim failed to start), report why instead of a bare timeout. let pids = tokio::select! { diff --git a/apps/staged/src-tauri/src/managed_acp_tools/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/validation.rs index 3f1e9a709..5418f2dce 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/validation.rs @@ -7,7 +7,12 @@ use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::sync::CancellationToken; -use super::{node_binary, npm_entrypoint, ManagedTool, ManagedToolError}; +use super::install::{node_binary, npm_entrypoint}; +use super::{ManagedTool, ManagedToolError}; + +#[cfg(test)] +#[path = "tests/validation.rs"] +mod tests; pub(super) const VALIDATION_TIMEOUT: Duration = Duration::from_secs(30); const OUTPUT_LIMIT: usize = 2048; diff --git a/crates/doctor/src/agents.rs b/crates/doctor/src/agents.rs index e791e5163..aff3502f9 100644 --- a/crates/doctor/src/agents.rs +++ b/crates/doctor/src/agents.rs @@ -213,19 +213,49 @@ pub fn derive_update_command( } } -/// Append `--registry=` to `command` when a registry override is supplied -/// and the command is npm-backed. Non-npm commands (curl-pipe installers, auth -/// commands, …) and the `None` registry case return the command unchanged. +/// Append `--registry=` — and, for a scoped package, its +/// `--@scope:registry=` — to `command` when a registry override is +/// supplied and the command is npm-backed. Non-npm commands (curl-pipe +/// installers, auth commands, …) and the `None` registry case return the +/// command unchanged. /// /// The registry URL is always caller-supplied — the crate never bakes in a /// Block-specific (or any other) registry. pub(crate) fn apply_npm_registry(command: &str, registry: Option<&str>) -> String { match registry { - Some(url) if is_npm_command(command) => format!("{command} --registry={url}"), + Some(url) if is_npm_command(command) => { + let package = command + .split_whitespace() + .find(|token| npm_package_scope(token).is_some()); + let flags = npm_registry_args(package.unwrap_or_default(), url); + format!("{command} {}", flags.join(" ")) + } _ => command.to_string(), } } +/// The flags that force an npm command for `package` onto `registry`. +/// `--registry` alone only sets npm's *default* registry: a scoped package +/// resolves through its `@scope:registry` config first, so a user npmrc +/// carrying a scope entry would keep winning over the override. For a scoped +/// package the scope's own key is forced too. +pub(crate) fn npm_registry_args(package: &str, registry: &str) -> Vec { + let mut args = vec![format!("--registry={registry}")]; + if let Some(scope) = npm_package_scope(package) { + args.push(format!("--{scope}:registry={registry}")); + } + args +} + +/// The `@scope` of a scoped package name (`@scope/name[@version]`), `@` +/// included. +fn npm_package_scope(package: &str) -> Option<&str> { + if !package.starts_with('@') { + return None; + } + package.split_once('/').map(|(scope, _)| scope) +} + /// Heuristic for whether `command` shells out to npm. Matches a leading `npm ` /// invocation as well as the `npm install` / `npm view` forms that may be /// preceded by other tokens. @@ -1287,6 +1317,30 @@ mod tests { ); } + #[test] + fn apply_npm_registry_overrides_a_scoped_packages_scope_registry_too() { + assert_eq!( + apply_npm_registry( + "npm install -g @agentclientprotocol/claude-agent-acp@latest", + Some("https://artifactory/npm"), + ), + "npm install -g @agentclientprotocol/claude-agent-acp@latest \ + --registry=https://artifactory/npm \ + --@agentclientprotocol:registry=https://artifactory/npm", + ); + assert_eq!( + npm_registry_args("@agentclientprotocol/codex-acp", "https://r/"), + vec![ + "--registry=https://r/", + "--@agentclientprotocol:registry=https://r/" + ] + ); + assert_eq!( + npm_registry_args("amp-acp", "https://r/"), + vec!["--registry=https://r/"] + ); + } + #[test] fn apply_npm_registry_leaves_non_npm_commands_unchanged() { let curl = "curl -fsSL https://cursor.com/install | bash"; diff --git a/crates/doctor/src/freshness.rs b/crates/doctor/src/freshness.rs index 7cea45147..5736665ec 100644 --- a/crates/doctor/src/freshness.rs +++ b/crates/doctor/src/freshness.rs @@ -439,15 +439,17 @@ fn latest_npm( ) -> ProbeResult { let mut cmd = Command::new("npm"); cmd.args(["view", package_id, "version"]); + let mut display_command = format!("npm view {package_id} version"); if let Some(registry) = npm_registry { - cmd.args(["--registry", registry]); + // `--registry` plus the package's scope key, so a scoped `~/.npmrc` + // entry cannot keep the probe on another registry than the install. + for arg in crate::agents::npm_registry_args(package_id, registry) { + display_command.push(' '); + display_command.push_str(&arg); + cmd.arg(arg); + } } apply_doctor_env(&mut cmd, env); - let display_command = if let Some(registry) = npm_registry { - format!("npm view {package_id} version --registry {registry}") - } else { - format!("npm view {package_id} version") - }; let output = match run_command_with_timeout(cmd, display_command, FRESHNESS_PROBE_TIMEOUT) { Ok(output) => output, Err(CommandError::Timeout { command, timeout }) => { From 0630ad6e8ee594143aff205e0824b4b47634b1d0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 14:55:39 +1100 Subject: [PATCH 6/7] test(staged): make the ETXTBSY exec probe a guaranteed no-op for fixture scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staged CI on PR #998 failed nondeterministically in `install_runs_npm_under_the_env_snapshot` (env dump lacked the snapshot's NPM_CONFIG_REGISTRY, or carried the runner's hermit NPM_CONFIG_CACHE) and `configured_npm_registry_is_cached_per_env_snapshot` (npm "spawned" twice). Cause: `wait_until_executable`, the Linux ETXTBSY workaround behind `write_executable`, spawned each fresh fixture script with no arguments under the inherited test-process env and then killed it. `spawn` returns after `sh` has exec'd, so on Linux the kill can land after the script's first lines ran — and both fixtures put their side effect there (`env > log`, `printf x >> calls`). A stray run overwrote the real env dump with the CI env, or appended an extra call marker. macOS never loses the race, which is why it was green locally. Fix, in tests/support.rs: - `with_exec_probe_guard` inserts `[ -z "$STAGED_EXEC_PROBE" ] || exit 0` after the shebang; `write_executable` applies it to every script, and the validation tests' `write_node_shim` applies it before the production `write_shim`. - `wait_until_executable` runs the script with `env_clear()` plus `STAGED_EXEC_PROBE=1` and waits for it (asserting a clean exit) instead of kill-racing it, and asserts the script carries the guard so an unguarded fixture fails loudly rather than racing. - Dropped a redundant `wait_until_executable` after a `write_executable` in tests/probe.rs. Test-only; no product code changes. Verified: cargo fmt --check; cargo clippy --lib --tests (only the three pre-existing session_queue/session_runner lints); cargo test --lib -- managed_acp_tools doctor: 94 passed, run alternately against a HEAD worktree 3x each at load ~96 (all green, same timings). One run of each of the two 1 s-timeout validation tests failed under that load on this change, but neither repeated in the A/B runs. Linux is the real check (macOS never reproduced the race). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../src/managed_acp_tools/tests/probe.rs | 1 - .../src/managed_acp_tools/tests/support.rs | 49 ++++++++++++++++--- .../src/managed_acp_tools/tests/validation.rs | 2 +- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs index 60bcbfe4b..0128f7eb8 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs @@ -457,7 +457,6 @@ async fn latest_published_version_reprobes_when_the_user_npmrc_changes() { &bin.join("npm"), "#!/bin/sh\ntest \"$1\" = view || exit 44\ncat \"$NPM_CONFIG_USERCONFIG\"\n", ); - wait_until_executable(&bin.join("npm")); let tool = ManagedTool { id: "cache-key-probe", binary: "cache-key-probe", diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs index 513e14d41..a0c22cdff 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs @@ -68,10 +68,27 @@ pub(super) fn write_fake_node_with_npm(node_install_dir: &Path, template: &Path, } } -/// Writes `contents` to `path` as a 0755 script and returns only once it -/// can actually be exec'd (see [`wait_until_executable`]). +/// Env var [`wait_until_executable`] sets on its probe run. +const EXEC_PROBE_ENV: &str = "STAGED_EXEC_PROBE"; +/// Line [`with_exec_probe_guard`] puts right after the shebang: a probe run +/// exits before reaching any of the fixture's own lines. +const EXEC_PROBE_GUARD: &str = "[ -z \"$STAGED_EXEC_PROBE\" ] || exit 0\n"; + +/// `contents` with [`EXEC_PROBE_GUARD`] inserted after its shebang line, so +/// [`wait_until_executable`] can exec the script without running its body. +pub(super) fn with_exec_probe_guard(contents: &str) -> String { + let (shebang, body) = contents + .split_once('\n') + .filter(|(shebang, _)| shebang.starts_with("#!")) + .expect("fixture script must start with a shebang line"); + format!("{shebang}\n{EXEC_PROBE_GUARD}{body}") +} + +/// Writes `contents` (behind [`with_exec_probe_guard`]) to `path` as a 0755 +/// script and returns only once it can actually be exec'd (see +/// [`wait_until_executable`]). pub(super) fn write_executable(path: &Path, contents: &str) { - std::fs::write(path, contents).unwrap(); + std::fs::write(path, with_exec_probe_guard(contents)).unwrap(); use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); wait_until_executable(path); @@ -81,20 +98,36 @@ pub(super) fn write_executable(path: &Path, contents: &str) { /// exec'd. On Linux, a test on another thread that forks while our write /// handle is open inherits it until its own exec, and exec of a file /// anyone still holds open for writing fails with `ETXTBSY` — which the -/// code under test would faithfully report and fail the test. Probe-spawn -/// (and immediately kill) the script until that window has passed, so the -/// script must be harmless to run with no arguments. +/// code under test would faithfully report and fail the test. Probe-run +/// the script until that window has passed. The script must carry +/// [`EXEC_PROBE_GUARD`]: the probe runs it with a cleared env plus +/// `STAGED_EXEC_PROBE=1` and waits for it, so a probe run is a guaranteed +/// no-op. (Killing an unguarded script instead races: on Linux `sh` can +/// already be running its first lines — e.g. a fixture's `env > log` — +/// under the test process env by the time the kill lands.) pub(super) fn wait_until_executable(path: &Path) { + let contents = std::fs::read_to_string(path).unwrap(); + assert!( + contents.contains(EXEC_PROBE_GUARD), + "{} lacks the exec-probe guard; write it via with_exec_probe_guard", + path.display() + ); for _ in 0..1000 { match std::process::Command::new(path) + .env_clear() + .env(EXEC_PROBE_ENV, "1") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { Ok(mut child) => { - let _ = child.kill(); - let _ = child.wait(); + let status = child.wait().unwrap(); + assert!( + status.success(), + "exec probe of {} exited {status}", + path.display() + ); return; } Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs index 5f88951d5..3d2127e12 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs @@ -15,7 +15,7 @@ const MISSING_NATIVE: &str = "echo 'native binary not found for this platform' > /// validation probe execs it. A busy exec would otherwise fail validation /// with `could not start: Text file busy`. fn write_node_shim(bin_dir: &Path, contents: &str) { - write_shim(bin_dir, "node", contents).unwrap(); + write_shim(bin_dir, "node", &with_exec_probe_guard(contents)).unwrap(); wait_until_executable(&bin_dir.join("node")); } From 71d4b5540b06e4349b7f03565f715b75f7b4469c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 27 Oct 2026 17:00:26 +1100 Subject: [PATCH 7/7] test(staged): give the validation timeout tests a 5 s probe budget `timeout_kills_bridge_and_native_child_and_preserves_previous_install` and `timeout_kills_native_child_after_bridge_exits_with_open_pipes` failed the pre-push hook in two of three full-suite runs at load ~30-45 with "probe did not spawn its native child: ... `--cli --version` timed out after 1 seconds". The 1 s budget starts at spawn and has to cover exec'ing the node shim and `sh` before the fixture even forks its native child; under parallel-suite load the timeout fired first (no "waiting for native CLI" in the excerpt). The tests only need the timeout to fire after the native child exists, so widen it to 5 s and the elapsed bound to 15 s. Test-only. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../src-tauri/src/managed_acp_tools/tests/validation.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs index 3d2127e12..2a37d3d0e 100644 --- a/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/validation.rs @@ -315,19 +315,22 @@ async fn assert_probe_tree_stopped(pids: &[i32]) { async fn timeout_preserves_install_and_kills_tree(bridge_exit: &str) { for tool in MANAGED_TOOLS { let started = Instant::now(); + // The timeout only has to fire after the fixture has spawned its + // native child. Under parallel-suite load, exec'ing the node shim and + // `sh` alone can eat a 1 s budget before the script runs a line. let (dir, error) = rejected_upgrade( tool, &format!("{HANGING_NATIVE}{bridge_exit}\n"), - Duration::from_secs(1), + Duration::from_secs(5), ) .await; let pids = probe_pids(&dir.path().join("packages")) .await .unwrap_or_else(|| panic!("probe did not spawn its native child: {error}")); assert_probe_tree_stopped(&pids).await; - assert!(error.contains("timed out after 1 seconds"), "{error}"); + assert!(error.contains("timed out after 5 seconds"), "{error}"); assert!(error.contains("waiting for native CLI"), "{error}"); - assert!(started.elapsed() < Duration::from_secs(10)); + assert!(started.elapsed() < Duration::from_secs(15)); } }