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