Skip to main content

agentmux_srv/backend/
cli_install.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Where provider CLIs (claude, codex, …) are installed, shared by every
5//! resolver: `resolvecli`, `install.start` / `install.check`, `agent.open`'s
6//! launch path and the picker's read-only lookup.
7//!
8//! **Shared, keyed by pinned CLI version:**
9//! `<shared_dir>/cli/<provider>/<pinned_version>/`. The install used to live
10//! in `<home>/instances/v<AGENTMUX_VERSION>/cli/<provider>/`, i.e. inside the
11//! AgentMux *version* dir, so every upgrade, portable or dev build started
12//! with no CLI and the first open of each provider ran a blocking
13//! `npm install` of the very same pinned CLI (~3 s, 16 times in a week of
14//! logs — SPEC_AGENT_OPEN_LATENCY_2026_09_27.md F1). The CLI's identity is its
15//! pinned version, not AgentMux's, so that is the key now.
16//!
17//! **Legacy fallback (read-only):** an existing per-version install still
18//! resolves, so nothing breaks for a version that already has one; new
19//! installs always go to the shared dir.
20//!
21//! **Cross-instance safety:** the shared dir is written by every AgentMux
22//! instance on the machine, so installs into it serialize on an exclusive
23//! lock file (`<provider>/<pinned_version>.lock`, blocking, both platforms —
24//! the same primitive the agent registry uses) and re-check for a finished
25//! install after acquiring it, so a second instance waits and then reuses
26//! the first one's result instead of running npm into the same directory.
27//! A shared dir only counts as installed once [`COMPLETE_MARKER`] is written,
28//! after npm succeeded and the shim was verified; an unmarked dir (a failed or
29//! in-progress install), or a marked one whose shim is gone, is cleared —
30//! marker and all — before the next attempt.
31//! `install.start` waits for the lock with [`try_lock_install`] so the wait
32//! stays cancellable.
33
34use std::path::{Path, PathBuf};
35
36use agentmux_common::DataPaths;
37
38const AGENTMUX_VERSION: &str = env!("CARGO_PKG_VERSION");
39
40/// A pinned version string is used as a path component, so it must be a
41/// plain version: letters, digits and `.`, `-`, `_`, `+`, no `..`, bounded.
42/// Anything else (including empty — an unpinned provider) has no shared dir
43/// and falls back to the legacy per-version location.
44pub fn is_safe_version_component(v: &str) -> bool {
45    !v.is_empty()
46        && v.len() <= 64
47        && !v.contains("..")
48        && v.chars()
49            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
50}
51
52/// Same rule for the provider id, which is also a path component.
53pub(crate) fn is_safe_provider_component(p: &str) -> bool {
54    !p.is_empty()
55        && p.len() <= 64
56        && p.chars()
57            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
58}
59
60/// `<shared_dir>/cli/<provider>/<pinned_version>/`, or None when either
61/// component isn't safe to use as a path segment.
62pub fn shared_cli_dir(
63    paths: &DataPaths,
64    provider_id: &str,
65    pinned_version: &str,
66) -> Option<PathBuf> {
67    if !is_safe_provider_component(provider_id) || !is_safe_version_component(pinned_version) {
68        return None;
69    }
70    Some(
71        paths
72            .shared_dir
73            .join("cli")
74            .join(provider_id)
75            .join(pinned_version),
76    )
77}
78
79/// The pre-2026-09-27 per-AgentMux-version location, kept for read-only
80/// fallback and for unpinned providers.
81pub fn legacy_cli_dir(paths: &DataPaths, provider_id: &str) -> PathBuf {
82    paths
83        .home_dir
84        .join("instances")
85        .join(format!("v{AGENTMUX_VERSION}"))
86        .join("cli")
87        .join(provider_id)
88}
89
90/// The directory new installs go to: the shared dir when the provider is
91/// pinned, else the legacy one.
92pub fn install_dir(paths: &DataPaths, provider_id: &str, pinned_version: &str) -> PathBuf {
93    shared_cli_dir(paths, provider_id, pinned_version)
94        .unwrap_or_else(|| legacy_cli_dir(paths, provider_id))
95}
96
97/// `<dir>/node_modules/.bin/<cli>` — `.cmd` on Windows, where npm writes a
98/// batch shim.
99pub fn npm_bin(dir: &Path, cli_command: &str) -> PathBuf {
100    let bin = dir.join("node_modules").join(".bin");
101    if cfg!(windows) {
102        bin.join(format!("{cli_command}.cmd"))
103    } else {
104        bin.join(cli_command)
105    }
106}
107
108/// Written into a shared install dir only after npm succeeded and the shim was
109/// verified, under the install lock. npm creates `.bin` shims before its
110/// lifecycle scripts and the rest of the install finish, so a shim alone
111/// doesn't mean "installed" — a reader could otherwise run a CLI another
112/// instance is still installing, and a failed install's leftover shim would
113/// poison the shared cache for every later AgentMux version (Codex P2 on #3927).
114pub const COMPLETE_MARKER: &str = ".agentmux-install-complete";
115
116/// Whether `dir` holds a finished, verified install.
117pub fn is_complete(dir: &Path) -> bool {
118    dir.join(COMPLETE_MARKER).is_file()
119}
120
121/// Mark `dir` complete (write-then-rename, so a reader never sees a torn
122/// marker). Call under the install lock, after verifying the shim.
123pub fn mark_complete(dir: &Path, version: &str) -> std::io::Result<()> {
124    let tmp = dir.join(format!("{COMPLETE_MARKER}.tmp"));
125    std::fs::write(&tmp, version)?;
126    std::fs::rename(&tmp, dir.join(COMPLETE_MARKER))
127}
128
129/// Whether `dir` holds a usable install: marked complete AND its shim is
130/// still there. A marker alone isn't enough — a finished install can lose its
131/// shim later (corruption, antivirus cleanup).
132pub fn is_valid_install(dir: &Path, cli_command: &str) -> bool {
133    is_complete(dir) && npm_bin(dir, cli_command).is_file()
134}
135
136/// Under the install lock, before (re)installing into `dir`: unless it holds
137/// a valid install, remove it entirely — leftovers of a failed or interrupted
138/// install, and a stale completion marker whose shim is gone. Removing the
139/// marker is the point: a repair must not run with the old marker visible, or
140/// readers would accept the half-repaired install as soon as npm recreates the
141/// shim, and a failed repair would leave marker + shim accepted indefinitely
142/// (Codex P2 on #3927, second pass). No-op for a missing or valid dir.
143pub fn clear_unless_valid(dir: &Path, cli_command: &str) -> std::io::Result<()> {
144    if dir.exists() && !is_valid_install(dir, cli_command) {
145        std::fs::remove_dir_all(dir)?;
146    }
147    Ok(())
148}
149
150/// The installed CLI binary, if any: the shared dir (only once marked
151/// complete), then the legacy per-version dir (which predates the marker, so
152/// its shim is enough).
153pub fn find_installed(
154    paths: &DataPaths,
155    provider_id: &str,
156    pinned_version: &str,
157    cli_command: &str,
158) -> Option<PathBuf> {
159    let shared = shared_cli_dir(paths, provider_id, pinned_version)
160        .filter(|d| is_complete(d))
161        .inspect(|d| crate::backend::cli_prune::record_use(d, std::time::SystemTime::now()))
162        .map(|d| npm_bin(&d, cli_command))
163        .filter(|b| b.is_file());
164    shared.or_else(|| {
165        let legacy = npm_bin(&legacy_cli_dir(paths, provider_id), cli_command);
166        legacy.is_file().then_some(legacy)
167    })
168}
169
170/// Same, with the pinned version taken from the backend provider registry
171/// (the source of truth) rather than a caller-supplied string.
172pub fn find_installed_for_provider(paths: &DataPaths, provider_id: &str) -> Option<PathBuf> {
173    let provider = crate::backend::providers::get_provider(provider_id)?;
174    find_installed(
175        paths,
176        provider.id,
177        provider.pinned_version,
178        provider.cli_command,
179    )
180}
181
182/// Held for the duration of an install into a shared dir; dropping it
183/// releases the lock. `None` inside for the legacy (per-instance) dir,
184/// which needs no cross-instance lock.
185pub struct InstallGuard {
186    _file: Option<std::fs::File>,
187}
188
189/// Take the cross-instance install lock for `dir` when it is a shared dir.
190/// Blocks until any other instance's install into the same dir finishes —
191/// call it from a blocking context (`spawn_blocking`). The lock file sits
192/// next to the dir and is never deleted (deleting a locked file races
193/// waiters onto a different inode; see `registry::leases::CriticalSection`).
194pub fn lock_install(
195    paths: &DataPaths,
196    provider_id: &str,
197    pinned_version: &str,
198) -> std::io::Result<InstallGuard> {
199    let Some(file) = open_lock_file(paths, provider_id, pinned_version)? else {
200        return Ok(InstallGuard { _file: None });
201    };
202    crate::registry::lock_exclusive(&file)?;
203    Ok(InstallGuard { _file: Some(file) })
204}
205
206/// Non-blocking [`lock_install`]: `Ok(None)` while another instance holds
207/// the lock. For waiters that must stay cancellable (`install.start` polls
208/// this and races `install.cancel`, Codex P2 on #3927).
209pub fn try_lock_install(
210    paths: &DataPaths,
211    provider_id: &str,
212    pinned_version: &str,
213) -> std::io::Result<Option<InstallGuard>> {
214    let Some(file) = open_lock_file(paths, provider_id, pinned_version)? else {
215        return Ok(Some(InstallGuard { _file: None }));
216    };
217    if crate::registry::try_lock_exclusive(&file)? {
218        Ok(Some(InstallGuard { _file: Some(file) }))
219    } else {
220        Ok(None)
221    }
222}
223
224/// The shared dir's lock file, opened (created if missing); None for a
225/// provider/pin with no shared dir.
226fn open_lock_file(
227    paths: &DataPaths,
228    provider_id: &str,
229    pinned_version: &str,
230) -> std::io::Result<Option<std::fs::File>> {
231    let Some(dir) = shared_cli_dir(paths, provider_id, pinned_version) else {
232        return Ok(None);
233    };
234    let parent = dir.parent().unwrap_or(&dir).to_path_buf();
235    std::fs::create_dir_all(&parent)?;
236    let lock_path = parent.join(format!("{pinned_version}.lock"));
237    let file = std::fs::OpenOptions::new()
238        .create(true)
239        .write(true)
240        .truncate(false)
241        .open(&lock_path)?;
242    Ok(Some(file))
243}
244
245// ─── installing ─────────────────────────────────────────────────────────────
246
247/// What to install: `npm_package@pinned_version`, whose shim is `cli_command`.
248#[derive(Debug, Clone)]
249pub struct NpmInstallRequest {
250    pub provider_id: String,
251    pub npm_package: String,
252    pub pinned_version: String,
253    pub cli_command: String,
254    /// Run npm below normal priority (the startup warm-up, not an open
255    /// someone is waiting on).
256    pub background: bool,
257}
258
259/// What `npm install` produced.
260#[derive(Debug)]
261pub struct NpmRun {
262    pub success: bool,
263    pub code: Option<i32>,
264    pub stdout: Vec<u8>,
265    pub stderr: Vec<u8>,
266}
267
268#[derive(Debug)]
269pub enum InstallOutcome {
270    /// This call ran npm; the shim is here.
271    Installed(PathBuf),
272    /// A finished install was already there — typically another instance's,
273    /// completed while this one waited for the lock.
274    AlreadyInstalled(PathBuf),
275}
276
277#[derive(Debug)]
278pub enum InstallError {
279    Lock(std::io::Error),
280    Clear(PathBuf, std::io::Error),
281    Spawn(std::io::Error),
282    NpmFailed { code: Option<i32> },
283    ShimMissing { expected: PathBuf },
284    Mark(PathBuf, std::io::Error),
285}
286
287impl std::fmt::Display for InstallError {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            InstallError::Lock(e) => write!(f, "cannot take the CLI install lock: {e}"),
291            InstallError::Clear(d, e) => write!(
292                f,
293                "cannot clear an incomplete CLI install at {}: {e}",
294                d.display()
295            ),
296            InstallError::Spawn(e) => write!(f, "failed to run npm install: {e}"),
297            InstallError::NpmFailed { code } => {
298                write!(f, "npm install exited {}", code.unwrap_or(-1))
299            }
300            InstallError::ShimMissing { expected } => {
301                write!(f, "npm install left no CLI shim at {}", expected.display())
302            }
303            InstallError::Mark(d, e) => write!(
304                f,
305                "cannot mark the CLI install complete at {}: {e}",
306                d.display()
307            ),
308        }
309    }
310}
311
312/// Install `req` into its install dir, **blocking** — call it from
313/// `spawn_blocking` or a plain thread. The one install routine for
314/// `resolvecli` and the startup warm-up ([`warm_used_providers`]):
315///
316/// 1. Take the cross-instance lock, and hold it until this returns — so npm
317///    never outlives the lock (Codex P2 on #3927: a timed-out RPC used to
318///    drop its guard while npm kept writing).
319/// 2. A valid install already there (another instance finished while this
320///    one waited): reuse it. Otherwise clear leftovers, marker and all.
321/// 3. `npm install --prefix <dir> <pkg>@<pin>`; every output line goes to
322///    `on_line(stream, line)`.
323/// 4. Verify the shim, then publish with [`COMPLETE_MARKER`].
324pub fn install_pinned_cli(
325    paths: &DataPaths,
326    req: &NpmInstallRequest,
327    on_line: &dyn Fn(&'static str, &str),
328) -> Result<InstallOutcome, InstallError> {
329    install_pinned_cli_with(paths, req, on_line, run_npm_install)
330}
331
332/// [`install_pinned_cli`] with the npm runner injected (tests).
333pub fn install_pinned_cli_with(
334    paths: &DataPaths,
335    req: &NpmInstallRequest,
336    on_line: &dyn Fn(&'static str, &str),
337    run_npm: impl FnOnce(&Path, &str, bool) -> std::io::Result<NpmRun>,
338) -> Result<InstallOutcome, InstallError> {
339    let dir = install_dir(paths, &req.provider_id, &req.pinned_version);
340    let bin = npm_bin(&dir, &req.cli_command);
341    let _guard =
342        lock_install(paths, &req.provider_id, &req.pinned_version).map_err(InstallError::Lock)?;
343    let shared = shared_cli_dir(paths, &req.provider_id, &req.pinned_version);
344    if let Some(d) = shared.as_deref() {
345        if is_valid_install(d, &req.cli_command) {
346            return Ok(InstallOutcome::AlreadyInstalled(bin));
347        }
348        clear_unless_valid(d, &req.cli_command)
349            .map_err(|e| InstallError::Clear(d.to_path_buf(), e))?;
350    }
351    // The pinned package plus any companions (pi-acp's `pi`), in one npm run
352    // under the same lock and completion marker. Specs never hold whitespace.
353    let package = match crate::backend::providers::get_provider(&req.provider_id) {
354        Some(p) if p.npm_package == req.npm_package => p.npm_install_specs(&req.pinned_version).join(" "),
355        _ => format!("{}@{}", req.npm_package, req.pinned_version),
356    };
357    tracing::info!(package = %package, prefix = %dir.display(), background = req.background, "running npm install");
358    let run = run_npm(&dir, &package, req.background).map_err(InstallError::Spawn)?;
359    tracing::info!(
360        exit_code = run.code.unwrap_or(-1),
361        stdout_bytes = run.stdout.len(),
362        stderr_bytes = run.stderr.len(),
363        "npm install output collected"
364    );
365    // stderr first: npm writes progress and errors there.
366    for (stream, bytes) in [("stderr", &run.stderr), ("stdout", &run.stdout)] {
367        for line in String::from_utf8_lossy(bytes).lines() {
368            if !line.trim().is_empty() {
369                on_line(stream, line);
370            }
371        }
372    }
373    if !run.success {
374        return Err(InstallError::NpmFailed { code: run.code });
375    }
376    if !bin.is_file() {
377        return Err(InstallError::ShimMissing { expected: bin });
378    }
379    if let Some(d) = shared.as_deref() {
380        mark_complete(d, &req.pinned_version)
381            .map_err(|e| InstallError::Mark(d.to_path_buf(), e))?;
382    }
383    Ok(InstallOutcome::Installed(bin))
384}
385
386/// Registry flags for installing `package`. AgentMux's own packages
387/// (`@agentmuxai/*`) are published to the public npm registry, but a machine
388/// can route that scope elsewhere in its `.npmrc` (a
389/// `@agentmuxai:registry=https://npm.pkg.github.com` line, for internal
390/// packages), and the install then 404s. The flag pins the scope for this one
391/// command only; other packages use the user's config as before.
392pub(crate) fn npm_registry_args(package: &str) -> Vec<String> {
393    if package.starts_with("@agentmuxai/") {
394        vec!["--@agentmuxai:registry=https://registry.npmjs.org/".to_string()]
395    } else {
396        Vec::new()
397    }
398}
399
400/// Run `npm install --prefix <dir> <package>` to completion. `package` is one
401/// or more space-separated `name@version` specs.
402///
403/// Output is collected after exit (`.output()`): pipe-based streaming
404/// (async IOCP and sync blocking alike) receives nothing from `cmd.exe /C`
405/// batch children on Windows until the process exits.
406fn run_npm_install(dir: &Path, package: &str, background: bool) -> std::io::Result<NpmRun> {
407    let out = {
408        #[cfg(windows)]
409        {
410            // npm on Windows is a .cmd batch script — it must run via
411            // `cmd /C`. `raw_arg` passes the command string verbatim: with
412            // `.args(["/C", s])` Rust quotes `s` and escapes inner quotes as
413            // `\"`, which cmd.exe takes literally, corrupting the path
414            // (CWD + \"C:\path\" → ENOENT).
415            use std::os::windows::process::CommandExt;
416            // No console flash from a GUI process (CREATE_NO_WINDOW); the
417            // warm-up also runs below normal priority.
418            const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000;
419            let mut flags = agentmux_common::win32::CREATE_NO_WINDOW;
420            if background {
421                flags |= BELOW_NORMAL_PRIORITY_CLASS;
422            }
423            let prefix = dir.to_string_lossy().replace('/', "\\");
424            let registry = npm_registry_args(package).join(" ");
425            let cmdline = format!(
426                "npm install --loglevel=http --no-audit --no-fund --no-progress {registry} --prefix \"{prefix}\" {package}"
427            );
428            let mut c = std::process::Command::new("cmd");
429            // `npm install` runs arbitrary postinstall scripts — no instance
430            // identity for those.
431            crate::backend::pane_env::sanitize_external_std_command(&mut c);
432            c.arg("/C")
433                .raw_arg(&cmdline)
434                .creation_flags(flags)
435                .env("CI", "true")
436                .env("FORCE_COLOR", "0")
437                .output()?
438        }
439        #[cfg(not(windows))]
440        {
441            let mut c = std::process::Command::new("npm");
442            crate::backend::pane_env::sanitize_external_std_command(&mut c);
443            c.args([
444                "install",
445                "--loglevel=http",
446                "--no-audit",
447                "--no-fund",
448                "--no-progress",
449            ])
450            .args(npm_registry_args(package))
451            .arg("--prefix")
452            .arg(dir)
453            .args(package.split_whitespace())
454            .env("CI", "true")
455            .env("FORCE_COLOR", "0");
456            if background {
457                use std::os::unix::process::CommandExt;
458                // SAFETY: setpriority is async-signal-safe; nothing else runs
459                // between fork and exec here.
460                unsafe {
461                    c.pre_exec(|| {
462                        let _ = libc::setpriority(libc::PRIO_PROCESS, 0, 10);
463                        Ok(())
464                    });
465                }
466            }
467            c.output()?
468        }
469    };
470    Ok(NpmRun {
471        success: out.status.success(),
472        code: out.status.code(),
473        stdout: out.stdout,
474        stderr: out.stderr,
475    })
476}
477
478// ─── install on demand ──────────────────────────────────────────────────────
479
480/// Why [`ensure_installed`] did not hand back a CLI.
481#[derive(Debug)]
482pub enum EnsureError {
483    /// The install ran and failed (no npm, offline, npm exited non-zero, ...).
484    Failed(String),
485    /// It is still running after the wait. The install is NOT cancelled: it keeps
486    /// the cross-instance lock until npm returns, and a later call waits on that
487    /// lock and then reuses the result.
488    StillInstalling,
489}
490
491/// The pinned CLI for a provider whose install is missing: install it, waiting
492/// up to `wait`. `install` is injected so tests don't need npm.
493///
494/// This is what the UI's launch flow does on a pin bump (`ResolveCli` installs
495/// and waits). `agent.open` used to refuse instead, with `CLI_NOT_AVAILABLE`,
496/// for the minute or two after an upgrade while the new pin downloaded.
497pub async fn ensure_installed_with<F>(
498    paths: DataPaths,
499    req: NpmInstallRequest,
500    wait: std::time::Duration,
501    install: F,
502) -> Result<PathBuf, EnsureError>
503where
504    F: FnOnce(&DataPaths, &NpmInstallRequest) -> Result<InstallOutcome, InstallError>
505        + Send
506        + 'static,
507{
508    let task = tokio::task::spawn_blocking(move || install(&paths, &req));
509    match tokio::time::timeout(wait, task).await {
510        Ok(Ok(Ok(InstallOutcome::Installed(bin) | InstallOutcome::AlreadyInstalled(bin)))) => {
511            Ok(bin)
512        }
513        Ok(Ok(Err(e))) => Err(EnsureError::Failed(e.to_string())),
514        Ok(Err(join)) => Err(EnsureError::Failed(format!(
515            "the install task failed: {join}"
516        ))),
517        Err(_elapsed) => Err(EnsureError::StillInstalling),
518    }
519}
520
521/// [`ensure_installed_with`] running the real npm install.
522pub async fn ensure_installed(
523    paths: DataPaths,
524    req: NpmInstallRequest,
525    wait: std::time::Duration,
526) -> Result<PathBuf, EnsureError> {
527    ensure_installed_with(paths, req, wait, |p, r| {
528        install_pinned_cli(p, r, &|_, _| {})
529    })
530    .await
531}
532
533// ─── startup warm-up ────────────────────────────────────────────────────────
534
535/// How long after srv start the warm-up begins: after the first IPC traffic,
536/// well before most users open an agent.
537pub const WARM_INSTALL_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
538
539/// The npm-installed providers among `provider_ids` whose pinned CLI has no
540/// install yet, deduplicated, in first-seen order. Pure over the registry and
541/// the filesystem.
542pub fn providers_needing_install(
543    paths: &DataPaths,
544    provider_ids: &[String],
545) -> Vec<NpmInstallRequest> {
546    let mut seen = std::collections::HashSet::new();
547    let mut out = Vec::new();
548    for id in provider_ids {
549        let Some(p) = crate::backend::providers::get_provider(id) else {
550            continue;
551        };
552        if p.npm_package.is_empty() || !seen.insert(p.id) {
553            continue;
554        }
555        if find_installed(paths, p.id, p.pinned_version, p.cli_command).is_some() {
556            continue;
557        }
558        out.push(NpmInstallRequest {
559            provider_id: p.id.to_string(),
560            npm_package: p.npm_package.to_string(),
561            pinned_version: p.pinned_version.to_string(),
562            cli_command: p.cli_command.to_string(),
563            background: true,
564        });
565    }
566    out
567}
568
569/// SPEC_AGENT_OPEN_LATENCY_2026_09_27.md §4.1: install the pinned CLI of every
570/// provider the user's agents use, in the background, so the first open
571/// after a pin bump doesn't wait on npm. Only providers in `provider_ids`
572/// (the user's own agent definitions — not every template's), only pins with
573/// no install yet, one at a time, npm below normal priority. An open that
574/// arrives mid-install waits on the same lock and then reuses the result.
575/// Failures (no npm, offline) are logged and left for the open to report.
576pub fn warm_used_providers(
577    paths: DataPaths,
578    provider_ids: Vec<String>,
579) -> std::thread::JoinHandle<()> {
580    std::thread::Builder::new()
581        .name("cli-install-warm".into())
582        .spawn(move || {
583            std::thread::sleep(WARM_INSTALL_DELAY);
584            let todo = providers_needing_install(&paths, &provider_ids);
585            if todo.is_empty() {
586                tracing::info!(
587                    providers = provider_ids.len(),
588                    "cli warm-up: every used provider's pinned CLI is installed"
589                );
590                return;
591            }
592            for req in todo {
593                let started = std::time::Instant::now();
594                let quiet = |_: &'static str, _: &str| {};
595                match install_pinned_cli(&paths, &req, &quiet) {
596                    Ok(outcome) => tracing::info!(
597                        provider = %req.provider_id,
598                        pinned_version = %req.pinned_version,
599                        duration_ms = started.elapsed().as_millis() as u64,
600                        outcome = ?outcome,
601                        "cli warm-up: installed"
602                    ),
603                    Err(e) => tracing::info!(
604                        provider = %req.provider_id,
605                        pinned_version = %req.pinned_version,
606                        error = %e,
607                        "cli warm-up: install failed; the first open will retry and report it"
608                    ),
609                }
610            }
611        })
612        .expect("spawn cli-install-warm thread")
613}
614
615#[cfg(test)]
616mod tests {
617
618    #[test]
619    fn agentmux_packages_install_from_the_public_registry_others_use_npmrc() {
620        // A `.npmrc` routing @agentmuxai to GitHub Packages made the muxcode
621        // install 404; the flag pins the scope for this command only.
622        assert_eq!(
623            super::npm_registry_args("@agentmuxai/muxcode@0.8.0"),
624            vec!["--@agentmuxai:registry=https://registry.npmjs.org/".to_string()]
625        );
626        assert!(super::npm_registry_args("@anthropic-ai/claude-code@2.1.288").is_empty());
627        assert!(super::npm_registry_args("openclaw").is_empty());
628    }
629
630    use super::*;
631
632    fn paths_in(root: &Path) -> DataPaths {
633        let version_dir = root
634            .join("channels")
635            .join("c")
636            .join("versions")
637            .join("1.0.0");
638        DataPaths {
639            home_dir: version_dir.join("data"),
640            instance_dir: root.join("channels").join("c"),
641            channel: "c".to_string(),
642            data_dir: version_dir.join("data"),
643            config_dir: root.join("channels").join("c").join("config"),
644            logs_dir: version_dir.join("logs"),
645            cef_cache_dir: version_dir.join("cef-cache"),
646            agents_dir: root.join("channels").join("c").join("agents"),
647            instance_runtime_dir: version_dir.join("runtime"),
648            shared_dir: root.join("shared"),
649            mode: agentmux_common::RuntimeMode::Portable,
650        }
651    }
652
653    #[test]
654    fn shared_dir_is_keyed_by_provider_and_pinned_version_not_agentmux_version() {
655        let tmp = tempfile::tempdir().unwrap();
656        let p = paths_in(tmp.path());
657        let d = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
658        assert_eq!(
659            d,
660            tmp.path()
661                .join("shared")
662                .join("cli")
663                .join("claude")
664                .join("2.1.280")
665        );
666        assert!(!d.to_string_lossy().contains(AGENTMUX_VERSION) || AGENTMUX_VERSION == "2.1.280");
667    }
668
669    #[test]
670    fn unsafe_components_have_no_shared_dir_and_fall_back_to_legacy() {
671        let tmp = tempfile::tempdir().unwrap();
672        let p = paths_in(tmp.path());
673        for bad in ["", "..", "../x", "1.0/2", "1.0\\2", "a b"] {
674            assert!(shared_cli_dir(&p, "claude", bad).is_none(), "{bad:?}");
675            assert_eq!(install_dir(&p, "claude", bad), legacy_cli_dir(&p, "claude"));
676        }
677        assert!(shared_cli_dir(&p, "../claude", "1.0.0").is_none());
678        assert!(is_safe_version_component("2.1.280"));
679        assert!(is_safe_version_component("1.0.0-beta.1+build_7"));
680    }
681
682    fn touch_bin(dir: &Path, cli: &str) -> PathBuf {
683        let b = npm_bin(dir, cli);
684        std::fs::create_dir_all(b.parent().unwrap()).unwrap();
685        std::fs::write(&b, "").unwrap();
686        b
687    }
688
689    #[test]
690    fn finds_the_shared_install_first_then_the_legacy_one() {
691        let tmp = tempfile::tempdir().unwrap();
692        let p = paths_in(tmp.path());
693        assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
694
695        let legacy = touch_bin(&legacy_cli_dir(&p, "claude"), "claude");
696        assert_eq!(
697            find_installed(&p, "claude", "2.1.280", "claude"),
698            Some(legacy.clone())
699        );
700
701        let shared_dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
702        let shared = touch_bin(&shared_dir, "claude");
703        // A shim alone isn't an install (npm writes it early): still legacy.
704        assert_eq!(
705            find_installed(&p, "claude", "2.1.280", "claude"),
706            Some(legacy.clone())
707        );
708        mark_complete(&shared_dir, "2.1.280").unwrap();
709        assert_eq!(
710            find_installed(&p, "claude", "2.1.280", "claude"),
711            Some(shared)
712        );
713
714        // A different pinned version doesn't see the shared install.
715        assert_eq!(
716            find_installed(&p, "claude", "2.1.281", "claude"),
717            Some(legacy)
718        );
719    }
720
721    #[test]
722    fn an_upgraded_agentmux_version_reuses_the_shared_install() {
723        let tmp = tempfile::tempdir().unwrap();
724        let old = paths_in(tmp.path());
725        let shared_dir = shared_cli_dir(&old, "claude", "2.1.280").unwrap();
726        let shared = touch_bin(&shared_dir, "claude");
727        mark_complete(&shared_dir, "2.1.280").unwrap();
728
729        // Same machine, a different AgentMux version dir, same pinned CLI.
730        let mut new = paths_in(tmp.path());
731        new.home_dir = tmp
732            .path()
733            .join("channels")
734            .join("c")
735            .join("versions")
736            .join("9.9.9")
737            .join("data");
738        assert_eq!(
739            find_installed(&new, "claude", "2.1.280", "claude"),
740            Some(shared)
741        );
742    }
743
744    #[test]
745    fn the_install_lock_serializes_two_holders() {
746        let tmp = tempfile::tempdir().unwrap();
747        let p = paths_in(tmp.path());
748        let first = lock_install(&p, "claude", "2.1.280").unwrap();
749        assert!(tmp
750            .path()
751            .join("shared")
752            .join("cli")
753            .join("claude")
754            .join("2.1.280.lock")
755            .is_file());
756
757        let p2 = paths_in(tmp.path());
758        let (tx, rx) = std::sync::mpsc::channel();
759        let waiter = std::thread::spawn(move || {
760            let _second = lock_install(&p2, "claude", "2.1.280").unwrap();
761            tx.send(()).unwrap();
762        });
763        // The second holder is still blocked while the first holds the lock.
764        assert!(rx
765            .recv_timeout(std::time::Duration::from_millis(300))
766            .is_err());
767        drop(first);
768        rx.recv_timeout(std::time::Duration::from_secs(5))
769            .expect("second holder acquires after release");
770        waiter.join().unwrap();
771    }
772
773    #[test]
774    fn the_legacy_dir_needs_no_lock() {
775        let tmp = tempfile::tempdir().unwrap();
776        let p = paths_in(tmp.path());
777        let g = lock_install(&p, "claude", "").unwrap();
778        assert!(g._file.is_none());
779    }
780
781    /// Codex P2 on #3927: a shared shim without the completion marker (an
782    /// install in progress in another instance, or one that failed) is never
783    /// handed out, and it doesn't hide an absent install either.
784    #[test]
785    fn a_shim_without_the_completion_marker_is_not_an_install() {
786        let tmp = tempfile::tempdir().unwrap();
787        let p = paths_in(tmp.path());
788        let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
789        touch_bin(&dir, "claude");
790        assert!(!is_complete(&dir));
791        assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
792    }
793
794    #[test]
795    fn clear_unless_valid_removes_leftovers_but_keeps_a_finished_install() {
796        let tmp = tempfile::tempdir().unwrap();
797        let p = paths_in(tmp.path());
798        let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
799        touch_bin(&dir, "claude");
800        clear_unless_valid(&dir, "claude").unwrap();
801        assert!(!dir.exists(), "a partial install is cleared");
802
803        touch_bin(&dir, "claude");
804        mark_complete(&dir, "2.1.280").unwrap();
805        clear_unless_valid(&dir, "claude").unwrap();
806        assert!(
807            is_valid_install(&dir, "claude"),
808            "a finished install is kept"
809        );
810        clear_unless_valid(&tmp.path().join("missing"), "claude").unwrap();
811    }
812
813    /// Codex P2 on #3927 (second pass): a completed install that lost its
814    /// shim must have its marker removed before the repair, so neither a
815    /// repair in progress nor a failed one is ever accepted.
816    #[test]
817    fn a_completed_install_that_lost_its_shim_is_cleared_marker_and_all() {
818        let tmp = tempfile::tempdir().unwrap();
819        let p = paths_in(tmp.path());
820        let dir = shared_cli_dir(&p, "claude", "2.1.280").unwrap();
821        let shim = touch_bin(&dir, "claude");
822        mark_complete(&dir, "2.1.280").unwrap();
823        std::fs::remove_file(&shim).unwrap();
824        assert!(is_complete(&dir) && !is_valid_install(&dir, "claude"));
825
826        clear_unless_valid(&dir, "claude").unwrap();
827        assert!(!dir.exists(), "stale marker and leftovers are gone");
828
829        // A repair that recreates only the shim (npm mid-install) is not accepted.
830        touch_bin(&dir, "claude");
831        assert!(find_installed(&p, "claude", "2.1.280", "claude").is_none());
832    }
833
834    #[test]
835    fn try_lock_install_reports_a_held_lock_instead_of_waiting() {
836        let tmp = tempfile::tempdir().unwrap();
837        let p = paths_in(tmp.path());
838        let held = lock_install(&p, "claude", "2.1.280").unwrap();
839        let p2 = paths_in(tmp.path());
840        // A second instance's attempt, from another thread (flock/LockFileEx
841        // are per-handle, so a second handle contends like another process).
842        let busy = std::thread::spawn(move || {
843            try_lock_install(&p2, "claude", "2.1.280")
844                .unwrap()
845                .is_none()
846        })
847        .join()
848        .unwrap();
849        assert!(
850            busy,
851            "try_lock_install must report the held lock, not block"
852        );
853        drop(held);
854        let p3 = paths_in(tmp.path());
855        let got = std::thread::spawn(move || {
856            try_lock_install(&p3, "claude", "2.1.280")
857                .unwrap()
858                .is_some()
859        })
860        .join()
861        .unwrap();
862        assert!(got, "acquired once released");
863        assert!(
864            try_lock_install(&p, "claude", "").unwrap().is_some(),
865            "no lock needed without a shared dir"
866        );
867    }
868
869    fn claude_req(background: bool) -> NpmInstallRequest {
870        NpmInstallRequest {
871            provider_id: "claude".into(),
872            npm_package: "@anthropic-ai/claude-code".into(),
873            pinned_version: "2.1.280".into(),
874            cli_command: "claude".into(),
875            background,
876        }
877    }
878
879    /// A fake npm: writes the shim unless told not to, and reports `success`.
880    fn fake_npm(
881        write_shim: bool,
882        success: bool,
883    ) -> impl FnOnce(&Path, &str, bool) -> std::io::Result<NpmRun> {
884        move |dir: &Path, _pkg: &str, _bg: bool| {
885            if write_shim {
886                let bin = npm_bin(dir, "claude");
887                std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
888                std::fs::write(&bin, "").unwrap();
889            }
890            Ok(NpmRun {
891                success,
892                code: Some(if success { 0 } else { 1 }),
893                stdout: b"added 3 packages\n".to_vec(),
894                stderr: b"npm http fetch\n\n".to_vec(),
895            })
896        }
897    }
898
899    #[test]
900    fn install_runs_npm_verifies_the_shim_and_marks_it_complete() {
901        let _tmp = tempfile::tempdir().unwrap();
902        let paths = paths_in(_tmp.path());
903        let lines = std::sync::Mutex::new(Vec::new());
904        let on_line = |s: &'static str, l: &str| lines.lock().unwrap().push(format!("{s}:{l}"));
905        let out =
906            install_pinned_cli_with(&paths, &claude_req(false), &on_line, fake_npm(true, true))
907                .unwrap();
908        let dir = shared_cli_dir(&paths, "claude", "2.1.280").unwrap();
909        assert!(matches!(out, InstallOutcome::Installed(ref b) if *b == npm_bin(&dir, "claude")));
910        assert!(is_valid_install(&dir, "claude"));
911        assert_eq!(
912            *lines.lock().unwrap(),
913            vec!["stderr:npm http fetch", "stdout:added 3 packages"]
914        );
915    }
916
917    #[test]
918    fn a_finished_install_is_reused_without_running_npm() {
919        let _tmp = tempfile::tempdir().unwrap();
920        let paths = paths_in(_tmp.path());
921        install_pinned_cli_with(&paths, &claude_req(true), &|_, _| {}, fake_npm(true, true))
922            .unwrap();
923        let out = install_pinned_cli_with(
924            &paths,
925            &claude_req(false),
926            &|_, _| {},
927            |_: &Path, _: &str, _: bool| -> std::io::Result<NpmRun> {
928                panic!("npm must not run again")
929            },
930        )
931        .unwrap();
932        assert!(matches!(out, InstallOutcome::AlreadyInstalled(_)));
933    }
934
935    #[test]
936    fn a_failed_npm_leaves_no_marker_and_the_next_attempt_starts_clean() {
937        let _tmp = tempfile::tempdir().unwrap();
938        let paths = paths_in(_tmp.path());
939        let dir = shared_cli_dir(&paths, "claude", "2.1.280").unwrap();
940        // npm wrote a shim but exited non-zero: not an install.
941        let err = install_pinned_cli_with(
942            &paths,
943            &claude_req(false),
944            &|_, _| {},
945            fake_npm(true, false),
946        )
947        .unwrap_err();
948        assert!(matches!(err, InstallError::NpmFailed { code: Some(1) }));
949        assert!(!is_complete(&dir));
950        assert!(find_installed(&paths, "claude", "2.1.280", "claude").is_none());
951        // The retry clears the leftover before npm runs.
952        let retry = install_pinned_cli_with(
953            &paths,
954            &claude_req(false),
955            &|_, _| {},
956            |d: &Path, p: &str, b: bool| {
957                assert!(
958                    !npm_bin(d, "claude").exists(),
959                    "leftover shim must be cleared first"
960                );
961                fake_npm(true, true)(d, p, b)
962            },
963        );
964        assert!(matches!(retry, Ok(InstallOutcome::Installed(_))));
965    }
966
967    #[test]
968    fn npm_success_without_a_shim_is_an_error_and_unmarked() {
969        let _tmp = tempfile::tempdir().unwrap();
970        let paths = paths_in(_tmp.path());
971        let err = install_pinned_cli_with(
972            &paths,
973            &claude_req(false),
974            &|_, _| {},
975            fake_npm(false, true),
976        )
977        .unwrap_err();
978        assert!(matches!(err, InstallError::ShimMissing { .. }));
979        assert!(!is_complete(
980            &shared_cli_dir(&paths, "claude", "2.1.280").unwrap()
981        ));
982    }
983
984    #[test]
985    fn the_install_holds_the_lock_until_npm_returns() {
986        let _tmp = tempfile::tempdir().unwrap();
987        let paths = paths_in(_tmp.path());
988        let p2 = paths.clone();
989        install_pinned_cli_with(
990            &paths,
991            &claude_req(false),
992            &|_, _| {},
993            move |d: &Path, pk: &str, b: bool| {
994                // While npm "runs", nobody else can take the lock.
995                assert!(try_lock_install(&p2, "claude", "2.1.280")
996                    .unwrap()
997                    .is_none());
998                fake_npm(true, true)(d, pk, b)
999            },
1000        )
1001        .unwrap();
1002        assert!(try_lock_install(&paths, "claude", "2.1.280")
1003            .unwrap()
1004            .is_some());
1005    }
1006
1007    #[test]
1008    fn warm_up_picks_used_npm_providers_with_no_install_once_each() {
1009        let _tmp = tempfile::tempdir().unwrap();
1010        let paths = paths_in(_tmp.path());
1011        let ids: Vec<String> = ["claude", "claude", "codex", "not-a-provider"]
1012            .iter()
1013            .map(|s| s.to_string())
1014            .collect();
1015        let todo: Vec<String> = providers_needing_install(&paths, &ids)
1016            .into_iter()
1017            .map(|r| r.provider_id)
1018            .collect();
1019        assert_eq!(todo, vec!["claude", "codex"]);
1020        assert!(providers_needing_install(&paths, &ids)
1021            .iter()
1022            .all(|r| r.background));
1023
1024        // Installed → no longer needed. `providers_needing_install` checks
1025        // against the LIVE registry pin (`providers::get_provider`), unlike
1026        // `claude_req`'s own hardcoded fixture version — install a request
1027        // built from that live pin, or this only keeps passing by coincidence
1028        // (it did, silently, until the 2.1.280 -> 2.1.285 bump broke it).
1029        let live_claude = crate::backend::providers::get_provider("claude").unwrap();
1030        let live_req = NpmInstallRequest {
1031            provider_id: "claude".into(),
1032            npm_package: live_claude.npm_package.into(),
1033            pinned_version: live_claude.pinned_version.into(),
1034            cli_command: live_claude.cli_command.into(),
1035            background: true,
1036        };
1037        install_pinned_cli_with(&paths, &live_req, &|_, _| {}, fake_npm(true, true)).unwrap();
1038        let todo: Vec<String> = providers_needing_install(&paths, &ids)
1039            .into_iter()
1040            .map(|r| r.provider_id)
1041            .collect();
1042        assert_eq!(todo, vec!["codex"]);
1043    }
1044
1045    // ── a pin bump, as a user meets it ─────────────────────────────────────
1046    //
1047    // The user upgraded AgentMux, the provider's pin moved, and they open an
1048    // existing agent. The previous pin is installed and complete; the new one is
1049    // not. These use the LIVE registry pin (never a hardcoded version — that is
1050    // what silently broke `warm_up_picks_used_npm_providers…` at 2.1.280 →
1051    // 2.1.285) and a fake npm.
1052
1053    /// A completed install of "some earlier pin" of claude, plus the paths.
1054    fn with_previous_pin_installed() -> (tempfile::TempDir, DataPaths, String, PathBuf) {
1055        let tmp = tempfile::tempdir().unwrap();
1056        let paths = paths_in(tmp.path());
1057        let live = crate::backend::providers::get_provider("claude").unwrap();
1058        let previous = "1.0.0-previous".to_string();
1059        assert_ne!(previous, live.pinned_version);
1060        let req = NpmInstallRequest {
1061            provider_id: "claude".into(),
1062            npm_package: live.npm_package.into(),
1063            pinned_version: previous.clone(),
1064            cli_command: live.cli_command.into(),
1065            background: true,
1066        };
1067        install_pinned_cli_with(&paths, &req, &|_, _| {}, fake_npm(true, true)).unwrap();
1068        let old_bin = npm_bin(
1069            &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1070            "claude",
1071        );
1072        assert!(old_bin.is_file());
1073        (tmp, paths, previous, old_bin)
1074    }
1075
1076    fn live_claude_req(background: bool) -> NpmInstallRequest {
1077        let live = crate::backend::providers::get_provider("claude").unwrap();
1078        NpmInstallRequest {
1079            provider_id: "claude".into(),
1080            npm_package: live.npm_package.into(),
1081            pinned_version: live.pinned_version.into(),
1082            cli_command: live.cli_command.into(),
1083            background,
1084        }
1085    }
1086
1087    #[test]
1088    fn after_a_pin_bump_the_previous_install_is_not_mistaken_for_the_new_pin() {
1089        let (_tmp, paths, _prev, _old_bin) = with_previous_pin_installed();
1090        assert_eq!(
1091            find_installed_for_provider(&paths, "claude"),
1092            None,
1093            "an open must resolve the NEW pin (installing it), not run whatever was there"
1094        );
1095        // …and the warm-up knows it has work to do, for exactly the live pin.
1096        let todo = providers_needing_install(&paths, &["claude".to_string()]);
1097        assert_eq!(todo.len(), 1);
1098        let live = crate::backend::providers::get_provider("claude").unwrap();
1099        assert_eq!(todo[0].pinned_version, live.pinned_version);
1100    }
1101
1102    #[test]
1103    fn installing_the_new_pin_leaves_the_previous_one_runnable_for_panes_that_hold_its_path() {
1104        // A pane restored with the old `cmd` path keeps working until its mount
1105        // flow rewrites it; nothing may delete the old install out from under it.
1106        let (_tmp, paths, previous, old_bin) = with_previous_pin_installed();
1107        install_pinned_cli_with(
1108            &paths,
1109            &live_claude_req(false),
1110            &|_, _| {},
1111            fake_npm(true, true),
1112        )
1113        .unwrap();
1114
1115        let new_bin = find_installed_for_provider(&paths, "claude").expect("new pin resolves");
1116        assert_ne!(new_bin, old_bin);
1117        assert!(is_valid_install(
1118            &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1119            "claude"
1120        ));
1121        assert!(old_bin.is_file(), "the previous pin's shim is untouched");
1122        // Resolving again is stable and does not reinstall.
1123        assert_eq!(find_installed_for_provider(&paths, "claude"), Some(new_bin));
1124    }
1125
1126    #[test]
1127    fn a_failed_install_of_the_new_pin_does_not_fall_back_to_the_previous_one() {
1128        // DOCUMENTS CURRENT BEHAVIOUR: the pin is exact. Offline (or no npm)
1129        // right after an upgrade, opening an agent fails with the install error
1130        // rather than silently running the previous CLI. The previous install is
1131        // still on disk and valid; nothing selects it.
1132        let (_tmp, paths, previous, old_bin) = with_previous_pin_installed();
1133        let out = install_pinned_cli_with(
1134            &paths,
1135            &live_claude_req(false),
1136            &|_, _| {},
1137            fake_npm(false, false),
1138        );
1139        assert!(
1140            matches!(out, Err(InstallError::NpmFailed { .. })),
1141            "{out:?}"
1142        );
1143        assert_eq!(find_installed_for_provider(&paths, "claude"), None);
1144        assert!(old_bin.is_file());
1145        assert!(is_valid_install(
1146            &shared_cli_dir(&paths, "claude", &previous).unwrap(),
1147            "claude"
1148        ));
1149        // The failed attempt left nothing behind that a later attempt would trip on.
1150        let live = crate::backend::providers::get_provider("claude").unwrap();
1151        assert!(!shared_cli_dir(&paths, "claude", live.pinned_version)
1152            .unwrap()
1153            .exists());
1154    }
1155
1156    // ── installing on demand (agent.open) ──────────────────────────────────
1157
1158    fn live_req() -> NpmInstallRequest {
1159        live_claude_req(false)
1160    }
1161
1162    #[tokio::test]
1163    async fn ensure_installed_installs_the_missing_pin_and_returns_the_shim() {
1164        let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1165        let live = crate::backend::providers::get_provider("claude").unwrap();
1166        let want = npm_bin(
1167            &shared_cli_dir(&paths, "claude", live.pinned_version).unwrap(),
1168            "claude",
1169        );
1170        let bin = ensure_installed_with(
1171            paths.clone(),
1172            live_req(),
1173            std::time::Duration::from_secs(30),
1174            |p, r| install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(true, true)),
1175        )
1176        .await
1177        .expect("installs");
1178        assert_eq!(bin, want);
1179        assert!(bin.is_file());
1180        assert_eq!(find_installed_for_provider(&paths, "claude"), Some(bin));
1181    }
1182
1183    #[tokio::test]
1184    async fn ensure_installed_reuses_an_install_that_finished_meanwhile() {
1185        let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1186        install_pinned_cli_with(
1187            &paths,
1188            &live_claude_req(true),
1189            &|_, _| {},
1190            fake_npm(true, true),
1191        )
1192        .unwrap();
1193        let bin = ensure_installed_with(
1194            paths,
1195            live_req(),
1196            std::time::Duration::from_secs(30),
1197            |p, r| {
1198                install_pinned_cli_with(
1199                    p,
1200                    r,
1201                    &|_, _| {},
1202                    |_: &Path, _: &str, _: bool| -> std::io::Result<NpmRun> {
1203                        panic!("npm must not run again")
1204                    },
1205                )
1206            },
1207        )
1208        .await
1209        .expect("reuses");
1210        assert!(bin.is_file());
1211    }
1212
1213    #[tokio::test]
1214    async fn ensure_installed_reports_a_failed_install_with_its_reason() {
1215        let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1216        let err = ensure_installed_with(
1217            paths.clone(),
1218            live_req(),
1219            std::time::Duration::from_secs(30),
1220            |p, r| install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(false, false)),
1221        )
1222        .await
1223        .unwrap_err();
1224        assert!(
1225            matches!(&err, EnsureError::Failed(m) if m.contains("npm install exited 1")),
1226            "{err:?}"
1227        );
1228        assert_eq!(find_installed_for_provider(&paths, "claude"), None);
1229    }
1230
1231    #[tokio::test]
1232    async fn ensure_installed_stops_waiting_but_does_not_cancel_a_slow_install() {
1233        let (_tmp, paths, _prev, _old) = with_previous_pin_installed();
1234        let finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1235        let flag = finished.clone();
1236        let err = ensure_installed_with(
1237            paths.clone(),
1238            live_req(),
1239            std::time::Duration::from_millis(50),
1240            move |p, r| {
1241                std::thread::sleep(std::time::Duration::from_millis(400));
1242                let out = install_pinned_cli_with(p, r, &|_, _| {}, fake_npm(true, true));
1243                flag.store(true, std::sync::atomic::Ordering::SeqCst);
1244                out
1245            },
1246        )
1247        .await
1248        .unwrap_err();
1249        assert!(matches!(err, EnsureError::StillInstalling), "{err:?}");
1250        // The caller got its answer quickly; the install carries on and lands.
1251        for _ in 0..100 {
1252            if finished.load(std::sync::atomic::Ordering::SeqCst) {
1253                break;
1254            }
1255            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1256        }
1257        assert!(
1258            finished.load(std::sync::atomic::Ordering::SeqCst),
1259            "the install must keep running"
1260        );
1261        assert!(
1262            find_installed_for_provider(&paths, "claude").is_some(),
1263            "and a later open finds it"
1264        );
1265    }
1266}