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..993afee0c 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -74,11 +74,59 @@ 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. +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 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); 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 + (`//`) 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 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`. + ## 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/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 deleted file mode 100644 index e496120c1..000000000 --- a/apps/staged/src-tauri/src/doctor.rs +++ /dev/null @@ -1,2569 +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; -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. 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)> { - 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 route through Block's Artifactory - // square-npm proxy (registry.npmjs.org is blocked on managed - // devices); `no-block-npm-registry` builds fall back to npm's - // default public registry. - npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string), - env: None, - // Doctor labels binaries resolved from this dir as bundled (install - // source + readout flag) and suppresses registry update fixes for - // 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 { - // 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: crate::managed_acp_tools::npm_registry().map(str::to_string), - ..Default::default() - } - .with_env_snapshot(env_vars) -} - -/// 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. -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!( - 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), - ); - 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, - ); - } - 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; - if doctor::agents::lookup_fix_command(&check_id, &fix_type) - .as_deref() - .is_some_and(crate::managed_acp_tools::is_npm_backed_command) - { - ensure_managed_node_runtime_for_fix().await?; - } - doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) - .await -} - -/// 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}]"); - crate::managed_acp_tools::install_managed_tool(tool_id, &|line| { - log::info!("{log_prefix} {line}"); - }) - .await - .map_err(|error| error.to_string()) -} - -/// Install (or repair) the managed Node.js runtime ahead of a fix that needs -/// it. Progress goes to the log — doctor fixes have no streamed-output -/// channel, only a button spinner. -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. - if crate::managed_acp_tools::is_npm_backed_command(&expected) { - ensure_managed_node_runtime_for_fix().await?; - } - // Run the backend-derived `expected`, not the frontend-supplied `command`. - // They are equal past the guard above, but executing `expected` makes the - // command that runs provably the one the backend derived — no dependence on - // the equality check surviving future edits. - doctor::execute_fix_with_env_options( - check_id, - fix_type, - execute_fix_options(Some(expected), 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 -} - -/// 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. -fn apply_managed_bridge_readouts( - report: &mut DoctorReport, - versions: &[ManagedBridgeVersions], - shim_bin_dir: &Path, - check_freshness: bool, -) { - 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) { - redirect_unmanaged_update_to_managed_install(check, versions, shim_bin_dir); - } - } - } -} - -/// 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; 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. -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) -} - -/// 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() -} - -fn build_node_runtime_check( - state: ManagedNodeRuntimeState, - install_dir: &Path, - npm_tools: &[String], -) -> Option { - let version = &crate::managed_node::node_runtime_lock().version; - let (status, message) = match state { - ManagedNodeRuntimeState::Ready => ( - CheckStatus::Pass, - format!("Staged-managed Node.js {version} is installed"), - ), - ManagedNodeRuntimeState::Broken => ( - CheckStatus::Warn, - format!("Staged-managed Node.js {version} is damaged; run the fix to reinstall it"), - ), - ManagedNodeRuntimeState::Missing if npm_tools.is_empty() => return None, - ManagedNodeRuntimeState::Missing => ( - CheckStatus::Warn, - format!( - "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}"))); - } - - let node_path = (state == ManagedNodeRuntimeState::Ready) - .then(|| install_dir.join("bin").join("node").display().to_string()); - // Native fix: `run_doctor_fix` routes this check id to - // `ensure_managed_node_runtime`. The command string is what the fix - // confirmation dialog displays, not a shell command. - let fix = (status != CheckStatus::Pass).then(|| { - ( - FixType::Command, - format!("download and install Node.js {version} into ~/.staged/packages"), - ) - }); - Some(node_runtime_doctor_check( - status, - message, - node_path, - Some(detail.join("\n")), - fix, - )) -} - -fn node_runtime_doctor_check( - 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()], - ) - .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"), - &[], - ) - .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()], - ) - .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")); - - // Nothing installed that needs it: stay silent. - assert!(build_node_runtime_check( - ManagedNodeRuntimeState::Missing, - Path::new("/data/packages/node/v9.9.9/plat"), - &[], - ) - .is_none()); - } - - #[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 { - 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, - ); - report.checks.pop().unwrap() - } - - 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, and - /// with nothing to update is left byte-identical: its own location, source - /// and version are the honest ones for what is on PATH. - #[test] - fn a_user_copy_without_an_update_is_left_untouched() { - 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); - assert_eq!(json(&apply_to(check, &installed, true)), 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); - - // The initial (no-network) pass sets no update fields at all, so there - // is nothing to redirect there either. - 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); - } - - /// 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 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. - 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:?}"); - } - } - - /// 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); - 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); - let after: Vec<_> = report.checks.iter().map(json).collect(); - assert_eq!(after, before); - apply_managed_bridge_readouts(&mut report, &[installed], shim_bin_dir, true); - 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()` gate. - #[test] - fn doctor_options_route_npm_through_the_managed_registry() { - let expected = crate::managed_acp_tools::npm_registry().map(str::to_string); - assert_eq!( - run_checks_options(false, Vec::new(), None).npm_registry, - expected - ); - assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected); - if !cfg!(feature = "no-block-npm-registry") { - assert!(expected.is_some()); - } - } - - /// 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 73b2221ee..000000000 --- a/apps/staged/src-tauri/src/managed_acp_tools.rs +++ /dev/null @@ -1,1806 +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"; - -/// Block's internal Artifactory npm registry. Direct access to -/// `registry.npmjs.org` is blocked by Cloudflare WARP on managed devices, so -/// npm-backed agent installs must route through this proxy. The doctor crate -/// exposes an optional `npm_registry` param but bakes in no registry of its -/// own, so Staged supplies this URL at every fix/run call site. -pub const BLOCK_NPM_REGISTRY_URL: &str = - "https://global.block-artifacts.com/artifactory/api/npm/square-npm/"; - -/// The npm registry every Staged-run npm command routes through, or `None` -/// (npm's default public registry) for `no-block-npm-registry` builds. -pub fn npm_registry() -> Option<&'static str> { - if cfg!(feature = "no-block-npm-registry") { - None - } else { - Some(BLOCK_NPM_REGISTRY_URL) - } -} - -/// The `STAGED_ACP_TOOLS_DIR` override dir, when set and non-empty. -pub fn dev_tools_override_dir() -> Option { - std::env::var_os(ACP_TOOLS_DIR_ENV) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) -} - -fn dev_tools_override_active() -> bool { - dev_tools_override_dir().is_some() -} - -/// Whether this build manages ACP bridge installs (and therefore exposes the -/// managed shim dir): the `STAGED_ACP_TOOLS_DIR` dev override supplies -/// bridges from its own dir instead, 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 -} - -/// 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. -pub async fn install_managed_tool( - id: &str, - on_line: &InstallLineFn<'_>, -) -> Result<(), ManagedToolError> { - let tool = managed_tool(id).ok_or_else(|| { - ManagedToolError::NotManaged(format!("'{id}' is not a Staged-managed ACP bridge")) - })?; - let packages_root = crate::paths::packages_dir() - .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; - let node_root = managed_node::managed_node_root() - .ok_or_else(|| ManagedToolError::DataDir("home directory is unavailable".to_string()))?; - let node_install_dir = managed_node::pinned_install_dir(&node_root).ok_or_else(|| { - ManagedToolError::NotManaged("no managed Node.js runtime pin for this target".to_string()) - })?; - - let _guard = tool_install_lock().lock().await; - managed_node::ensure_managed_node_runtime() - .await - .map_err(ManagedToolError::Node)?; - // The cross-process lock is taken only after the runtime ensure has - // released the same flock — nesting the two would deadlock (see - // `lock_packages_dir`). In the unlocked window another process can at - // most install or prune, and both leave the pinned runtime in place. - let _packages_lock = managed_node::lock_packages_dir(&packages_root) - .await - .map_err(ManagedToolError::Node)?; - install_npm_tool( - &packages_root, - &node_install_dir, - &managed_node::node_runtime_lock().version, - &tool, - npm_registry(), - 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. -async fn install_npm_tool( - packages_root: &Path, - node_install_dir: &Path, - node_version: &str, - tool: &ManagedTool, - 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(error) = run_floating_npm_install( - packages_root, - node_install_dir, - &staging_dir, - tool, - registry, - on_line, - ) - .await - { - let _ = std::fs::remove_dir_all(&staging_dir); - return Err(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) -} - -async fn run_floating_npm_install( - packages_root: &Path, - node_install_dir: &Path, - install_dir: &Path, - tool: &ManagedTool, - registry: Option<&str>, - on_line: &InstallLineFn<'_>, -) -> Result<(), ManagedToolError> { - let node_bin_dir = node_install_dir.join("bin"); - let mut command = tokio::process::Command::new(node_bin_dir.join("npm")); - command - .arg("install") - .arg("--prefix") - .arg(install_dir) - .args([ - "--omit=dev", - "--include=optional", - "--ignore-scripts", - "--no-audit", - "--no-fund", - ]); - if let Some(registry) = registry { - command.arg("--registry").arg(registry); - } - // `@latest` floats to the newest published version; npm on the managed - // runtime resolves the platform-native optional dependency for the - // running machine on its own, so no `--os`/`--cpu` pinning is needed. - command.arg(format!("{}@latest", tool.package)); - - // npm's own `#!/usr/bin/env node` shebang must resolve the managed node. - let mut paths = vec![node_bin_dir.clone()]; - paths.extend(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - )); - if let Ok(path_value) = std::env::join_paths(paths) { - command.env("PATH", path_value); - } - // Share the private prefix's download cache; `--prefix` on the command - // line outranks any inherited prefix config. - let cache = packages_root.join("npm-prefix").join("cache"); - command.env("NPM_CONFIG_CACHE", &cache); - command.env("npm_config_cache", &cache); - command - .current_dir(install_dir) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = command - .spawn() - .map_err(|error| ManagedToolError::NpmInstall(format!("spawn managed npm: {error}")))?; - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let forward_out = async { - if let Some(stream) = stdout { - let mut lines = tokio::io::BufReader::new(stream).lines(); - while let Ok(Some(line)) = lines.next_line().await { - on_line(&line); - } - } - }; - let forward_err = async { - if let Some(stream) = stderr { - let mut lines = tokio::io::BufReader::new(stream).lines(); - while let Ok(Some(line)) = lines.next_line().await { - on_line(&line); - } - } - }; - let wait = async { - match tokio::time::timeout(NPM_INSTALL_TIMEOUT, child.wait()).await { - Ok(result) => result - .map_err(|error| ManagedToolError::NpmInstall(format!("wait on npm: {error}"))), - Err(_) => { - let _ = child.kill().await; - Err(ManagedToolError::NpmInstall(format!( - "timed out after {} seconds", - NPM_INSTALL_TIMEOUT.as_secs() - ))) - } - } - }; - let (status, (), ()) = tokio::join!(wait, forward_out, forward_err); - let status = status?; - if status.success() { - Ok(()) - } else { - Err(ManagedToolError::NpmInstall(format!( - "npm install exited with {status}" - ))) - } -} - -/// Shim body for a managed bridge. Both paths are absolute, so the shim 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` 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 -/// [`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 = npm_view_version( - &npm, - tool.package, - 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) -} - -/// 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. -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) - .map(|dir| dir.join(name)) - .find(|candidate| candidate.is_file()) -} - -/// `npm view version [--registry ]` with `npm` at `npm_path`, -/// under exactly the `env_vars` snapshot, killed at `timeout`. `Some` only for -/// a clean exit whose stdout is a version-shaped token. -async fn npm_view_version( - npm_path: &Path, - package: &str, - registry: Option<&str>, - env_vars: &[(String, String)], - timeout: Duration, -) -> Option { - let mut command = tokio::process::Command::new(npm_path); - command.args(["view", package, "version"]); - 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 None; - } - Err(_) => { - log::debug!( - "npm view {package} version timed out after {}s", - timeout.as_secs() - ); - return None; - } - }; - if !output.status.success() { - log::debug!( - "npm view {package} version exited with {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ); - return None; - } - 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) -} - -// ============================================================================= -// Reconcile epilogue — prune stale ids, record the outcome, gate the Node prune -// ============================================================================= - -/// Reconcile epilogue: drop installs for ids no longer in the managed set -/// (their shims, tool dirs, and state entries), record the run's outcome in -/// `state.json`, and — only when every managed bridge installed cleanly — -/// prune superseded managed Node runtimes. Takes the install mutex and the -/// cross-process flock so it cannot race an in-flight install in this or any -/// other Staged process. -pub(crate) async fn finish_reconcile(managed: &[ManagedTool], errors: Vec) { - let Some(packages_root) = crate::paths::packages_dir() else { - return; - }; - finish_reconcile_at(&packages_root, managed, errors).await; -} - -async fn finish_reconcile_at(packages_root: &Path, managed: &[ManagedTool], errors: Vec) { - let all_installed = errors.is_empty(); - { - let _guard = tool_install_lock().lock().await; - let _packages_lock = match managed_node::lock_packages_dir(packages_root).await { - Ok(lock) => lock, - Err(error) => { - log::warn!("skipping ACP tools reconcile epilogue: {error}"); - return; - } - }; - prune_stale_managed_tools(packages_root, managed); - record_reconcile(packages_root, errors); - } - // Success-gated Node prune: `errors` empty means every managed bridge - // reinstalled this run, so every shim now embeds the pinned runtime's - // path and superseded runtimes are unreferenced. On partial failure the - // old runtime is kept — the failed bridge's un-rewritten shim still - // resolves a real Node, so an offline launch never breaks a working - // bridge. Runs outside the scope above: the prune takes the same flock - // itself, and nesting would deadlock (see `lock_packages_dir`). - if all_installed { - managed_node::prune_superseded_node_runtimes(packages_root).await; - } -} - -pub(crate) fn prune_stale_managed_tools(packages_root: &Path, managed: &[ManagedTool]) { - let managed_ids: Vec<&str> = managed.iter().map(|tool| tool.id).collect(); - let managed_binaries: Vec<&str> = managed.iter().map(|tool| tool.binary).collect(); - - let mut state = read_state(packages_root); - let stale: Vec = state - .tools - .keys() - .filter(|id| !managed_ids.contains(&id.as_str())) - .cloned() - .collect(); - for id in &stale { - if let Some(pin) = state.tools.remove(id) { - let _ = std::fs::remove_file(shim_bin_dir(packages_root).join(&pin.binary)); - } - } - if !stale.is_empty() { - if let Err(error) = write_state(packages_root, &state) { - log::warn!("failed to write ACP tools state after prune: {error}"); - } - } - - // Tool dirs with no state entry (crashed installs) and shims for binaries - // no longer managed. `/bin` holds only Staged-written shims, so - // pruning by name is safe. - if let Ok(entries) = std::fs::read_dir(tools_root(packages_root)) { - for entry in entries.flatten() { - if !managed_ids.contains(&entry.file_name().to_string_lossy().as_ref()) { - let _ = std::fs::remove_dir_all(entry.path()); - } - } - } - if let Ok(entries) = std::fs::read_dir(shim_bin_dir(packages_root)) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if !name.starts_with('.') && !managed_binaries.contains(&name.as_str()) { - let _ = std::fs::remove_file(entry.path()); - } - } - } -} - -pub(crate) fn record_reconcile(packages_root: &Path, errors: Vec) { - let mut state = read_state(packages_root); - state.last_reconcile = Some(ReconcileRecord { - at_ms: now_ms(), - ok: errors.is_empty(), - errors, - }); - if let Err(error) = write_state(packages_root, &state) { - log::warn!("failed to record ACP tools reconcile result: {error}"); - } -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_millis() as u64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - mod validation; - use super::*; - - #[test] - fn npm_registry_follows_registry_feature() { - if cfg!(feature = "no-block-npm-registry") { - assert_eq!(npm_registry(), None); - } else { - assert_eq!(npm_registry(), Some(BLOCK_NPM_REGISTRY_URL)); - } - } - - #[test] - fn path_helpers_lay_out_the_packages_tree() { - let root = Path::new("/home/.staged/packages"); - assert_eq!(shim_bin_dir(root), root.join("bin")); - assert_eq!(tools_root(root), root.join("tools")); - assert_eq!( - tool_install_dir(root, "claude-acp"), - root.join("tools").join("claude-acp") - ); - assert_eq!(state_path(root), root.join("state.json")); - } - - #[test] - fn managed_tools_require_no_override_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"; - - 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, - 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, - 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, - 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, - 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, - 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") - ); - } - - // -- 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; - 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; - assert_eq!(version.as_deref(), Some("4.5.6")); - } - - /// 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, - 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, - 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, - 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..0128f7eb8 --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/probe.rs @@ -0,0 +1,488 @@ +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", + ); + 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..a0c22cdff --- /dev/null +++ b/apps/staged/src-tauri/src/managed_acp_tools/tests/support.rs @@ -0,0 +1,180 @@ +//! 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(); + } +} + +/// 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, 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); +} + +/// 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-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 status = child.wait().unwrap(); + assert!( + status.success(), + "exec probe of {} exited {status}", + path.display() + ); + 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 f2683a381..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 @@ -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 @@ -8,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")); } @@ -51,6 +58,8 @@ async fn every_managed_tool_promotes_the_validated_staging_tree() { &node, TEST_NODE_VERSION, tool, + &install_env(), + None, None, TIMEOUT, &|_| {}, @@ -92,6 +101,8 @@ async fn incomplete_first_install_creates_no_live_tree_shim_or_state() { &node, TEST_NODE_VERSION, tool, + &install_env(), + None, None, TIMEOUT, &|_| {}, @@ -133,6 +144,8 @@ async fn rejected_upgrade( &node, TEST_NODE_VERSION, tool, + &install_env(), + None, None, timeout, &|line| lines.lock().unwrap().push(line.to_string()), @@ -177,6 +190,8 @@ async fn missing_native_binary_preserves_live_install_and_partial_reconcile_runt &node, TEST_NODE_VERSION, other, + &install_env(), + None, None, TIMEOUT, &|_| {}, @@ -211,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") @@ -300,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)); } } @@ -334,9 +352,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! { @@ -386,6 +405,8 @@ 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_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/apps/staged/src-tauri/src/managed_node.rs b/apps/staged/src-tauri/src/managed_node.rs index d2e0a9ac1..ec712a534 100644 --- a/apps/staged/src-tauri/src/managed_node.rs +++ b/apps/staged/src-tauri/src/managed_node.rs @@ -4,9 +4,13 @@ //! 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 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), @@ -24,9 +28,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 +93,45 @@ 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 +/// 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 { + override_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .unwrap_or(UPSTREAM_NODE_DIST_BASE_URL) + .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. +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 +240,48 @@ 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( + let override_url = node_dist_override(); + ensure_managed_node_runtime_with_fallback( &packages_root, - 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, ) .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 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, + 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 +567,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())); @@ -517,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 { @@ -728,6 +801,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 +887,56 @@ 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_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 + /// 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 +1028,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(); 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 }) => {