Skip to main content

agentmux_srv/server/
install_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `install.start` / `install.cancel` RPC handlers.
5//!
6//! Phase α of `SPEC_AGENT_INSTALL_STAGE_2026_05_17.md`. Spawns
7//! `npm install <package>` for the requested provider, streams every
8//! line of stdout+stderr to `install_chunk` MPS events scoped to
9//! `install:<sessionId>`, and emits a terminal `{ op: "done", ok,
10//! error? }` event when the child exits (or the user cancels).
11//!
12//! Phase α scope:
13//!  - npm-only install (existing per-version layout at
14//!    `~/.agentmux/<version>/cli/<provider>/`).
15//!  - Plain piped stdio (no PTY) — npm doesn't isatty-gate its output
16//!    line-by-line, so pipes are fine here. PTY would be required for
17//!    interactive post-install steps (Phase δ).
18//!  - Single in-flight install per session id. The frontend modal owns
19//!    the session id and prevents parallel installs at the UI layer.
20//!  - Cancel kills the child via `kill_on_drop` when the abort handle
21//!    fires. The partial `node_modules` dir is rm-rf'd best-effort.
22//!  - No verify / doctor / post-install steps yet — those land in
23//!    Phase β.
24
25#[cfg(windows)]
26use agentmux_common::win32::NoWindow;
27use std::sync::Arc;
28
29use parking_lot::Mutex;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32
33use crate::backend::rpc::engine::WshRpcEngine;
34use crate::backend::mps::Broker;
35use crate::server::AppState;
36
37pub const COMMAND_INSTALL_START: &str = "install.start";
38pub const COMMAND_INSTALL_CANCEL: &str = "install.cancel";
39pub const COMMAND_INSTALL_CHECK: &str = "install.check";
40pub const COMMAND_RESOLVE_PREREQS: &str = "resolve.prereqs";
41
42/// `install_chunk`: one line of an install's output, or its end. Scoped `install:<sessionId>`;
43/// `system.install` sends the same events.
44#[derive(Debug, Clone, Serialize, ts_rs::TS)]
45#[ts(export, export_to = "../../../frontend/types/rpc/")]
46#[serde(untagged)]
47pub enum InstallChunkEvent {
48    Line {
49        #[serde(rename = "sessionId")]
50        session_id: String,
51        line: String,
52        stream: InstallStream,
53    },
54    Done {
55        #[serde(rename = "sessionId")]
56        session_id: String,
57        op: InstallDoneOp,
58        ok: bool,
59        /// Why it failed: a message, or an `AgentMuxError` in its wire form. `null` when it didn't.
60        #[ts(type = "unknown")]
61        error: serde_json::Value,
62    },
63}
64
65impl InstallChunkEvent {
66    pub fn line(session_id: &str, line: String, stream: InstallStream) -> Self {
67        Self::Line { session_id: session_id.to_string(), line, stream }
68    }
69    pub fn done(session_id: &str, ok: bool, error: serde_json::Value) -> Self {
70        Self::Done { session_id: session_id.to_string(), op: InstallDoneOp::Done, ok, error }
71    }
72}
73
74#[derive(Debug, Clone, Copy, Serialize, ts_rs::TS)]
75#[ts(export, export_to = "../../../frontend/types/rpc/")]
76#[serde(rename_all = "lowercase")]
77pub enum InstallStream {
78    Stdout,
79    Stderr,
80}
81
82/// The `op` of the last event of an install.
83#[derive(Debug, Clone, Copy, Serialize, ts_rs::TS)]
84#[ts(export, export_to = "../../../frontend/types/rpc/")]
85#[serde(rename_all = "lowercase")]
86pub enum InstallDoneOp {
87    Done,
88}
89
90#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
91#[ts(export, export_to = "../../../frontend/types/rpc/")]
92#[serde(rename_all = "camelCase")]
93pub struct InstallStartReq {
94    pub provider_id: String,
95    pub cli_command: String,
96    pub npm_package: String,
97    /// `#[serde(default)]`, so it is omittable on the wire — but the
98    /// hand-written stub declared it REQUIRED, and the generated type keeps it
99    /// required (ts-rs cannot express an optional non-`Option` property
100    /// anyway). Callers are stricter than the server here, which is the safe
101    /// direction; noting it so the asymmetry is deliberate rather than lost.
102    #[serde(default)]
103    pub pinned_version: String,
104}
105
106#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
107#[ts(export, export_to = "../../../frontend/types/rpc/")]
108#[serde(rename_all = "camelCase")]
109pub struct InstallCancelReq {
110    pub session_id: String,
111}
112
113#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
114#[ts(export, export_to = "../../../frontend/types/rpc/")]
115#[serde(rename_all = "camelCase")]
116pub struct InstallCheckReq {
117    pub provider_id: String,
118    pub cli_command: String,
119    /// npm package name, so the check can also report which version is
120    /// installed (read from that package's `package.json` in the per-version
121    /// cache). Optional: callers that only care about presence may omit it,
122    /// and every provider whose catalog entry has no `npmPackage` never had a
123    /// managed install to inspect in the first place.
124    #[serde(default)]
125    pub npm_package: Option<String>,
126}
127
128/// Request for `resolve.prereqs`. Was a function-local anonymous struct.
129#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
130#[ts(export, export_to = "../../../frontend/types/rpc/")]
131pub struct ResolvePrereqsReq {
132    pub tools: Vec<String>,
133    /// Tools from `tools` whose version should also be read, for prereqs with
134    /// a minimum version (e.g. OpenClaw needs Node 24.16+). Only these are
135    /// executed (`<resolved path> --version`); every other tool stays a
136    /// path-only lookup. Names not also in `tools` are ignored.
137    #[serde(default)]
138    #[ts(optional)]
139    pub versions: Option<Vec<String>>,
140}
141
142/// Result of `install.start`. Was an inline `json!({ "sessionId": .. })`.
143#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
144#[ts(export, export_to = "../../../frontend/types/rpc/")]
145#[serde(rename_all = "camelCase")]
146pub struct InstallStartResult {
147    pub session_id: String,
148}
149
150/// Result of `install.check`.
151#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
152#[ts(export, export_to = "../../../frontend/types/rpc/")]
153#[serde(rename_all = "camelCase")]
154pub struct InstallCheckResult {
155    pub installed: bool,
156    /// Version of the managed install, when it could be read. `None` means
157    /// "not known" — never "current". The caller compares this against the
158    /// provider's pin (see `frontend/app/view/agent/providers/version-drift.ts`),
159    /// and an absent version must resolve to `unknown` rather than quietly
160    /// looking up to date.
161    ///
162    /// Read from `package.json` rather than by running `<cli> --version`: the
163    /// agent picker calls this once per card, and spawning a process per card
164    /// to answer a question a file read already answers is not a trade worth
165    /// making.
166    pub version: Option<String>,
167}
168
169/// Result of `install.cancel`.
170///
171/// `error` is genuinely `string | null`, NOT an optional property: the handler
172/// writes an explicit `Value::Null` on success, so the key is always present.
173/// The hand-written stub declared it `error?: string`, which said the key could
174/// be absent — it never is. Generating the type corrects that.
175#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
176#[ts(export, export_to = "../../../frontend/types/rpc/")]
177#[serde(rename_all = "camelCase")]
178pub struct InstallCancelResult {
179    pub success: bool,
180    pub error: Option<String>,
181}
182
183/// One tool's resolution in `resolve.prereqs`.
184#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
185#[ts(export, export_to = "../../../frontend/types/rpc/")]
186pub struct PrereqToolResolution {
187    pub tool: String,
188    pub found: bool,
189    /// Always present, null when the tool was not found.
190    pub path: Option<String>,
191    /// The tool's version, when the request asked for it (`versions`) and
192    /// it could be read. Null means "not asked" or "unknown" — never
193    /// "new enough"; the caller decides how to treat an unknown version.
194    pub version: Option<String>,
195}
196
197/// Result of `resolve.prereqs`.
198#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
199#[ts(export, export_to = "../../../frontend/types/rpc/")]
200pub struct ResolvePrereqsResult {
201    pub results: Vec<PrereqToolResolution>,
202}
203
204/// Per-session abort handle so `install.cancel` can kill an in-flight
205/// install. Also tracks `active_providers` so concurrent sessions for
206/// the *same* provider directory are rejected — without that, cancel
207/// of one would `rm_rf` the shared dir mid-install for the other.
208/// `parking_lot::Mutex` since the engine is sync at the handler
209/// boundary.
210#[derive(Default)]
211pub struct InstallSessionRegistry {
212    sessions: Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<()>>>,
213    active_providers: Mutex<std::collections::HashSet<String>>,
214    /// Same idea as `active_providers`, but a separate set keyed by
215    /// system-tool id (`"git"`, `"node"`, …) rather than provider id — a
216    /// distinct namespace so a future provider literally named e.g. "git"
217    /// can never collide with a system-tool claim. See
218    /// `system_install_handlers.rs` / `SPEC_SYSTEM_TOOLCHAIN_INSTALLER_2026_08_24.md`.
219    active_system_tools: Mutex<std::collections::HashSet<String>>,
220}
221
222impl InstallSessionRegistry {
223    pub fn new() -> Arc<Self> {
224        Arc::new(Self::default())
225    }
226
227    pub(crate) fn insert(&self, session_id: String, tx: tokio::sync::oneshot::Sender<()>) {
228        self.sessions.lock().insert(session_id, tx);
229    }
230
231    /// Try to claim a provider; returns false if another session is
232    /// already installing this provider.
233    fn try_claim_provider(&self, provider_id: &str) -> bool {
234        self.active_providers.lock().insert(provider_id.to_string())
235    }
236
237    fn release_provider(&self, provider_id: &str) {
238        self.active_providers.lock().remove(provider_id);
239    }
240
241    /// Try to claim a system tool (git/node/npm/python); returns false if
242    /// another session is already installing this tool.
243    pub(crate) fn try_claim_system_tool(&self, tool_id: &str) -> bool {
244        self.active_system_tools.lock().insert(tool_id.to_string())
245    }
246
247    pub(crate) fn release_system_tool(&self, tool_id: &str) {
248        self.active_system_tools.lock().remove(tool_id);
249    }
250
251    fn cancel(&self, session_id: &str) -> bool {
252        if let Some(tx) = self.sessions.lock().remove(session_id) {
253            let _ = tx.send(());
254            true
255        } else {
256            false
257        }
258    }
259
260    pub(crate) fn drop_session(&self, session_id: &str) {
261        self.sessions.lock().remove(session_id);
262    }
263}
264
265/// Provider ids feed into the install dir path; reject anything that
266/// could escape `~/.agentmux/<version>/cli/<provider>/`.
267fn is_safe_provider_id(s: &str) -> bool {
268    !s.is_empty()
269        && s.len() <= 64
270        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
271}
272
273/// CLI command names are joined into the bin-resolution path; same
274/// allowlist as provider ids, plus `.` (some real CLIs include dots,
275/// e.g. `eslint.cmd`).
276fn is_safe_cli_command(s: &str) -> bool {
277    !s.is_empty()
278        && s.len() <= 64
279        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
280        && !s.contains("..")
281}
282
283/// The pinned CLI version an install of `provider_id` uses: the backend
284/// provider registry's pin (the source of truth), else the caller's.
285fn effective_pin(provider_id: &str, requested: &str) -> String {
286    crate::backend::providers::get_provider(provider_id)
287        .map(|p| p.pinned_version.to_string())
288        .filter(|v| !v.is_empty())
289        .unwrap_or_else(|| requested.to_string())
290}
291
292/// Where a new install of `provider_id` goes: the shared, pinned-version dir
293/// (`backend::cli_install`), which is also what `resolvecli` and the launch
294/// path look in first. Honors portable / installed mode + the
295/// `AGENTMUX_HOME_OVERRIDE` test override via `DataPaths::from_env()`.
296fn provider_install_dir(provider_id: &str, pinned_version: &str) -> Option<std::path::PathBuf> {
297    let paths = agentmux_common::DataPaths::from_env()?;
298    Some(crate::backend::cli_install::install_dir(
299        &paths,
300        provider_id,
301        pinned_version,
302    ))
303}
304
305/// Every dir an existing install of `provider_id` may be in, in lookup order:
306/// the shared pinned-version dir, then the legacy per-AgentMux-version one.
307fn provider_installed_dirs(provider_id: &str) -> Vec<std::path::PathBuf> {
308    let Some(paths) = agentmux_common::DataPaths::from_env() else {
309        return Vec::new();
310    };
311    let pin = effective_pin(provider_id, "");
312    // The shared dir only once its install is marked complete — a shim alone
313    // may be another instance's install in progress (Codex P2 on #3927).
314    crate::backend::cli_install::shared_cli_dir(&paths, provider_id, &pin)
315        .filter(|d| crate::backend::cli_install::is_complete(d))
316        .into_iter()
317        .chain(std::iter::once(
318            crate::backend::cli_install::legacy_cli_dir(&paths, provider_id),
319        ))
320        .collect()
321}
322
323/// Returns the path to the installed CLI binary if present in the
324/// per-version cache, else None. Used by `install.check`.
325/// Locate a system tool (e.g. `git`, `gh`) on this process's PATH and return
326/// its absolute path; None when it isn't there.
327///
328/// Used by `resolve.prereqs` to pre-launch-check whether a provider's
329/// system dependencies are installed. The lookup is path-only — never
330/// executes the tool. (`resolve.prereqs` runs `--version` separately, and
331/// only for tools the caller lists in `versions`.)
332/// See SPEC_PROVIDER_SYSTEM_PREREQS_2026_05_18.md.
333pub(crate) async fn resolve_tool_path(tool: &str) -> Option<String> {
334    // In-process PATH search (the `which` crate: PATHEXT-aware on Windows),
335    // not a spawned `which`/`where`. The spawn made "the lookup program is
336    // missing" indistinguishable from "the tool is missing": on a minimal
337    // Linux without `which`, node/npm read as absent and launches were
338    // blocked (Codex P2 on #3891). No console window to suppress either.
339    let tool = tool.to_string();
340    tokio::task::spawn_blocking(move || find_on_path(&tool, std::env::var_os("PATH").as_deref()))
341        .await
342        .ok()
343        .flatten()
344}
345
346/// Version of the prereq binary at `path` (already resolved on PATH), via
347/// `--version`, bounded so a hung binary can't stall the launch check.
348async fn probe_prereq_version(path: &str) -> Option<String> {
349    let path = path.to_string();
350    let probe = tokio::task::spawn_blocking(move || {
351        crate::backend::tool_store::probe_version(&path, &Some("--version".to_string()))
352    });
353    tokio::time::timeout(std::time::Duration::from_secs(5), probe)
354        .await
355        .ok()?
356        .ok()
357        .flatten()
358}
359
360/// `tool`'s absolute path in the directories of `path` (a PATH-style list).
361fn find_on_path(tool: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
362    let cwd = std::env::current_dir().ok()?;
363    which::which_in(tool, path, cwd).ok().map(|p| p.to_string_lossy().into_owned())
364}
365
366/// Version of a managed provider install, read from the installed package's
367/// own `package.json`. `None` for anything unreadable — a missing directory, a
368/// partial install, unparseable JSON — all of which mean "we do not know",
369/// which is a distinct answer from "up to date" and must stay distinct.
370///
371/// `npm_package` becomes a path segment, so the join goes through
372/// [`crate::backend::base::safe_join_within_base`] rather than an ad-hoc
373/// check. An earlier revision hand-rolled the validation (empty / `..` /
374/// leading `/` / leading `\` / NUL) and missed the Windows **drive-letter**
375/// form: `PathBuf::push("C:\\Users\\…")` REPLACES the whole path rather than
376/// appending, so `npm_package = "C:\\Users\\Victim\\AppData"` escaped the
377/// install dir entirely and read an attacker-chosen `package.json`
378/// (ReAgent P1 on #3350). That helper already rejects drive-letter and
379/// drive-relative prefixes, UNC roots, rooted paths and `..`, and accepts
380/// both separators — which is exactly why it exists and why this must not
381/// grow a second copy of the same reasoning.
382///
383/// Scoped names (`@scope/name`) are legitimate and produce one nested
384/// directory; the helper splits on `/` and `\` and handles them.
385///
386/// `dir` is the install whose shim `install.check` resolved: the version is
387/// read from that same install, never from another dir's leftover manifest —
388/// otherwise a shared dir with a manifest but no shim would lend its version
389/// to the legacy binary actually in use and hide drift (Codex P2 on #3927).
390fn installed_version_in(dir: &std::path::Path, npm_package: &str) -> Option<String> {
391    let base = dir.join("node_modules");
392    let pkg_dir = crate::backend::base::safe_join_within_base(&base, npm_package).ok()?;
393    manifest_version(&pkg_dir.join("package.json"))
394}
395
396fn manifest_version(manifest: &std::path::Path) -> Option<String> {
397    let raw = std::fs::read_to_string(manifest).ok()?;
398    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
399    parsed.get("version")?.as_str().map(|s| s.to_string())
400}
401
402fn installed_bin_in(dir: &std::path::Path, cli_command: &str) -> Option<std::path::PathBuf> {
403    let bin_dir = dir.join("node_modules").join(".bin");
404    let candidates: &[&str] = if cfg!(windows) {
405        &[".cmd", ".exe", ""]
406    } else {
407        &["", ".cmd"]
408    };
409    candidates
410        .iter()
411        .map(|suffix| bin_dir.join(format!("{cli_command}{suffix}")))
412        .find(|p| p.is_file())
413}
414
415/// The first of `dirs` holding `cli_command`'s shim — the install a launch
416/// would use. Pure over `dirs`, for tests.
417fn installed_dir_among(
418    dirs: Vec<std::path::PathBuf>,
419    cli_command: &str,
420) -> Option<std::path::PathBuf> {
421    dirs.into_iter()
422        .find(|dir| installed_bin_in(dir, cli_command).is_some())
423}
424
425fn resolve_installed_dir(provider_id: &str, cli_command: &str) -> Option<std::path::PathBuf> {
426    installed_dir_among(provider_installed_dirs(provider_id), cli_command)
427}
428
429pub fn register_install_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
430    let registry = state.install_sessions.clone();
431    let broker = state.broker.clone();
432    engine.register_typed(
433        COMMAND_INSTALL_START,
434        move |req: InstallStartReq, _ctx| {
435            let registry = registry.clone();
436            let broker = broker.clone();
437            async move {
438                if !is_safe_provider_id(&req.provider_id) {
439                    return Err(format!(
440                        "install.start: invalid provider id {:?} — must match [a-zA-Z0-9_-]+",
441                        req.provider_id
442                    ));
443                }
444                if !is_safe_cli_command(&req.cli_command) {
445                    return Err(format!(
446                        "install.start: invalid cli command {:?}",
447                        req.cli_command
448                    ));
449                }
450                if req.npm_package.is_empty() {
451                    return Err(format!(
452                        "install.start: provider {} has no npm_package — only npm-installable providers are supported in Phase α",
453                        req.provider_id
454                    ));
455                }
456                if !registry.try_claim_provider(&req.provider_id) {
457                    return Err(format!(
458                        "install.start: provider {} is already being installed in another session",
459                        req.provider_id
460                    ));
461                }
462                let session_id = format!("install-{}", uuid::Uuid::new_v4());
463                tracing::info!(
464                    session_id = %session_id,
465                    provider_id = %req.provider_id,
466                    npm_package = %req.npm_package,
467                    pinned_version = %req.pinned_version,
468                    "install.start"
469                );
470
471                let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
472                registry.insert(session_id.clone(), cancel_tx);
473
474                spawn_install_task(
475                    broker,
476                    registry,
477                    session_id.clone(),
478                    req.provider_id,
479                    req.cli_command,
480                    req.npm_package,
481                    req.pinned_version,
482                    cancel_rx,
483                );
484
485                Ok(InstallStartResult { session_id })
486            }
487        },
488    );
489
490    engine.register_typed(
491        COMMAND_INSTALL_CHECK,
492        move |req: InstallCheckReq, _ctx| {
493            async move {
494                if !is_safe_provider_id(&req.provider_id) {
495                    return Err(format!(
496                        "install.check: invalid provider id {:?}",
497                        req.provider_id
498                    ));
499                }
500                if !is_safe_cli_command(&req.cli_command) {
501                    return Err(format!(
502                        "install.check: invalid cli command {:?}",
503                        req.cli_command
504                    ));
505                }
506                let installed_dir = resolve_installed_dir(&req.provider_id, &req.cli_command);
507                // Only meaningful when something is actually installed — a
508                // stale package.json next to a missing binary would otherwise
509                // report a version for a provider that cannot launch — and
510                // read from that same install.
511                let version = installed_dir.as_deref().and_then(|dir| {
512                    req.npm_package
513                        .as_deref()
514                        .and_then(|pkg| installed_version_in(dir, pkg))
515                });
516                Ok(InstallCheckResult {
517                    installed: installed_dir.is_some(),
518                    version,
519                })
520            }
521        },
522    );
523
524    engine.register_typed(
525        COMMAND_RESOLVE_PREREQS,
526        move |req: ResolvePrereqsReq, _ctx| async move {
527                let mut results = Vec::with_capacity(req.tools.len());
528                for tool in &req.tools {
529                    if !is_safe_cli_command(tool) {
530                        return Err(format!(
531                            "resolve.prereqs: invalid tool name {:?}",
532                            tool
533                        ));
534                    }
535                    let path = resolve_tool_path(tool).await;
536                    let wants_version = req
537                        .versions
538                        .as_deref()
539                        .is_some_and(|v| v.iter().any(|t| t == tool));
540                    let version = match (&path, wants_version) {
541                        (Some(p), true) => probe_prereq_version(p).await,
542                        _ => None,
543                    };
544                    results.push(PrereqToolResolution {
545                        tool: tool.clone(),
546                        found: path.is_some(),
547                        path,
548                        version,
549                    });
550                }
551                Ok(ResolvePrereqsResult { results })
552        },
553    );
554
555    let registry = state.install_sessions.clone();
556    engine.register_typed(
557        COMMAND_INSTALL_CANCEL,
558        move |req: InstallCancelReq, _ctx| {
559            let registry = registry.clone();
560            async move {
561                let ok = registry.cancel(&req.session_id);
562                Ok(InstallCancelResult {
563                    success: ok,
564                    error: if ok {
565                        None
566                    } else {
567                        Some(format!("unknown or already-terminal session: {}", req.session_id))
568                    },
569                })
570            }
571        },
572    );
573}
574
575#[allow(clippy::too_many_arguments)]
576fn spawn_install_task(
577    broker: Arc<Broker>,
578    registry: Arc<InstallSessionRegistry>,
579    session_id: String,
580    provider_id: String,
581    cli_command: String,
582    npm_package: String,
583    pinned_version: String,
584    mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
585) {
586    tokio::spawn(async move {
587        use std::process::Stdio;
588        use tokio::io::{AsyncBufReadExt, BufReader};
589        use tokio::process::Command;
590
591        let scope = format!("install:{}", session_id);
592        // The last lines npm printed, for the log when an install fails: the
593        // dialog shows the whole output, but nothing else kept it, so a failed
594        // install left no trace of why (only `install.start` was logged).
595        let tail: Arc<Mutex<std::collections::VecDeque<String>>> = Arc::default();
596        let log_done = |ok: bool, error: Option<&str>| {
597            if ok {
598                tracing::info!(session_id = %session_id, provider_id = %provider_id, "install.done");
599            } else {
600                let tail = tail.lock().iter().cloned().collect::<Vec<_>>().join(" | ");
601                tracing::warn!(
602                    session_id = %session_id,
603                    provider_id = %provider_id,
604                    error = error.unwrap_or(""),
605                    npm_tail = %tail,
606                    "install.failed"
607                );
608            }
609        };
610        let emit_line = |broker: &Broker, line: String, stream: InstallStream| {
611            broker.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_id, line, stream), vec![scope.clone()], 1024);
612        };
613        // Legacy emit path — the `error` field is a free-text string.
614        // New code paths should use `emit_done_typed` below to emit
615        // the wire-format `AgentMuxError` object so the frontend can
616        // render a friendly `<ErrorBanner />`.
617        let emit_done = |broker: &Broker, ok: bool, error: Option<String>| {
618            log_done(ok, error.as_deref());
619            broker.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::done(&session_id, ok, json!(error)), vec![scope.clone()], 1024);
620        };
621        let emit_done_typed = |broker: &Broker, err: agentmux_common::AgentMuxError| {
622            log_done(false, Some(&err.to_string()));
623            broker.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::done(&session_id, false, err.to_wire()), vec![scope.clone()], 1024);
624        };
625
626        // One pin decides both the dir and the package version, so an
627        // install can never put version X in a dir named for version Y.
628        let pinned_version = effective_pin(&provider_id, &pinned_version);
629        let provider_dir = match provider_install_dir(&provider_id, &pinned_version) {
630            Some(p) => p,
631            None => {
632                emit_done(&broker, false, Some("cannot determine home directory".into()));
633                registry.drop_session(&session_id);
634                registry.release_provider(&provider_id);
635                return;
636            }
637        };
638
639        // The shared dir is written by every AgentMux instance on this
640        // machine: serialize on its install lock (held until this task ends).
641        // Waiting for another instance's install must stay cancellable, so
642        // poll the lock and race `install.cancel` instead of blocking in it
643        // (Codex P2 on #3927: a blocking wait ignored cancel, kept the
644        // provider claimed, and could later report success for a cancelled
645        // session).
646        let shared_dir = agentmux_common::DataPaths::from_env().and_then(|p| {
647            crate::backend::cli_install::shared_cli_dir(&p, &provider_id, &pinned_version)
648        });
649        let _install_guard = loop {
650            let lock_provider = provider_id.clone();
651            let lock_pin = pinned_version.clone();
652            let res = tokio::task::spawn_blocking(move || {
653                let paths = agentmux_common::DataPaths::from_env()
654                    .ok_or_else(|| std::io::Error::other("DataPaths::from_env() failed"))?;
655                crate::backend::cli_install::try_lock_install(&paths, &lock_provider, &lock_pin)
656            })
657            .await;
658            match res {
659                Ok(Ok(Some(g))) => break g,
660                Ok(Ok(None)) => {
661                    tokio::select! {
662                        _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => continue,
663                        _ = &mut cancel_rx => {
664                            tracing::info!(session_id = %session_id, "install.cancel: cancelled while waiting for another instance's install");
665                            emit_done(&broker, false, Some("cancelled".into()));
666                            registry.drop_session(&session_id);
667                            registry.release_provider(&provider_id);
668                            return;
669                        }
670                    }
671                }
672                Ok(Err(e)) => {
673                    emit_done(
674                        &broker,
675                        false,
676                        Some(format!("cannot take the CLI install lock: {e}")),
677                    );
678                    registry.drop_session(&session_id);
679                    registry.release_provider(&provider_id);
680                    return;
681                }
682                Err(e) => {
683                    emit_done(
684                        &broker,
685                        false,
686                        Some(format!("install lock task panicked: {e}")),
687                    );
688                    registry.drop_session(&session_id);
689                    registry.release_provider(&provider_id);
690                    return;
691                }
692            }
693        };
694        // Reuse only a FINISHED install (completion marker, not just a shim
695        // npm may have written early); clear a failed or interrupted one.
696        if let Some(dir) = shared_dir.as_deref() {
697            if crate::backend::cli_install::is_complete(dir)
698                && installed_bin_in(dir, &cli_command).is_some()
699            {
700                emit_line(
701                    &broker,
702                    "already installed by another AgentMux instance".to_string(),
703                    InstallStream::Stdout,
704                );
705                emit_done(&broker, true, None);
706                registry.drop_session(&session_id);
707                registry.release_provider(&provider_id);
708                return;
709            }
710            if let Err(e) = crate::backend::cli_install::clear_unless_valid(dir, &cli_command) {
711                emit_done(
712                    &broker,
713                    false,
714                    Some(format!(
715                        "cannot clear an incomplete install at {}: {e}",
716                        dir.display()
717                    )),
718                );
719                registry.drop_session(&session_id);
720                registry.release_provider(&provider_id);
721                return;
722            }
723        }
724        if let Err(e) = std::fs::create_dir_all(&provider_dir) {
725            // The disk-full / permission-denied / path-not-found cases
726            // route to the typed catalog so the frontend renders a
727            // friendly "Device out of space" message instead of the
728            // raw OS error. Other IO kinds fall through to Legacy.
729            let err = agentmux_common::AgentMuxError::from_io_with_path(
730                provider_dir.display().to_string(),
731                e,
732            );
733            emit_done_typed(&broker, err);
734            registry.drop_session(&session_id);
735            registry.release_provider(&provider_id);
736            return;
737        }
738        let provider_dir_str = provider_dir.to_string_lossy().to_string();
739
740        // The pinned package plus any companions (pi-acp's `pi`): one npm run,
741        // one dir, one completion marker.
742        let pkg_args: Vec<String> = match crate::backend::providers::get_provider(&provider_id) {
743            Some(p) if p.npm_package == npm_package => p.npm_install_specs(&pinned_version),
744            _ if pinned_version.is_empty() => vec![npm_package.clone()],
745            _ => vec![format!("{}@{}", npm_package, pinned_version)],
746        };
747
748        // `--progress=false` is unconditional: npm only renders the
749        // progress bar when both stdout and stderr are TTYs, and this
750        // task pipes both, so leaving progress at the default would
751        // produce no visible spinner anyway. `--loglevel=verbose` is
752        // also unconditional: the user's only signal of progress
753        // during long installs is the per-package fetch/extract
754        // chatter, so we always pay for the noise to gain the signal.
755        let mut npm_args: Vec<String> = std::iter::once("install".to_string())
756            .chain(pkg_args)
757            .chain([
758                "--prefix".to_string(),
759                provider_dir_str.clone(),
760                "--no-audit".to_string(),
761                "--no-fund".to_string(),
762                "--progress=false".to_string(),
763                "--loglevel=verbose".to_string(),
764            ])
765            .collect();
766        // AgentMux's own packages come from the public registry whatever the
767        // machine's .npmrc routes their scope to (cli_install.rs).
768        npm_args.extend(crate::backend::cli_install::npm_registry_args(&npm_package));
769
770        emit_line(
771            &broker,
772            format!("$ npm {}", npm_args.join(" ")),
773            InstallStream::Stdout,
774        );
775
776        let mut cmd = Command::new(if cfg!(windows) { "npm.cmd" } else { "npm" });
777        // `npm install` runs arbitrary postinstall scripts — the textbook case for
778        // not handing a process this instance's identity or API endpoint.
779        crate::backend::pane_env::sanitize_external_command(&mut cmd);
780        cmd.args(&npm_args);
781        cmd.stdin(Stdio::null())
782            .stdout(Stdio::piped())
783            .stderr(Stdio::piped())
784            .kill_on_drop(true);
785        #[cfg(windows)]
786        {
787            cmd.no_window(); // CREATE_NO_WINDOW
788        }
789
790        let mut child = match cmd.spawn() {
791            Ok(c) => c,
792            Err(e) => {
793                emit_done(&broker, false, Some(format!("spawn npm: {e}")));
794                registry.drop_session(&session_id);
795                registry.release_provider(&provider_id);
796                return;
797            }
798        };
799
800        let stdout = child.stdout.take().expect("piped");
801        let stderr = child.stderr.take().expect("piped");
802
803        let broker_out = broker.clone();
804        let session_out = session_id.clone();
805        let scope_out = scope.clone();
806        let tail_out = tail.clone();
807        let stdout_task = tokio::spawn(async move {
808            let mut lines = BufReader::new(stdout).lines();
809            while let Ok(Some(line)) = lines.next_line().await {
810                push_tail(&tail_out, &line);
811                broker_out.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_out, line, InstallStream::Stdout), vec![scope_out.clone()], 1024);
812            }
813        });
814
815        let broker_err = broker.clone();
816        let session_err = session_id.clone();
817        let scope_err = scope.clone();
818        let tail_err = tail.clone();
819        let stderr_task = tokio::spawn(async move {
820            let mut lines = BufReader::new(stderr).lines();
821            while let Ok(Some(line)) = lines.next_line().await {
822                push_tail(&tail_err, &line);
823                broker_err.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_err, line, InstallStream::Stderr), vec![scope_err.clone()], 1024);
824            }
825        });
826
827        tokio::select! {
828            wait = child.wait() => {
829                let _ = stdout_task.await;
830                let _ = stderr_task.await;
831                match wait {
832                    Ok(s) if s.success() => {
833                        // npm exit 0 ≠ "binary is on disk". Verify the
834                        // expected bin shim exists so a package/bin
835                        // rename or provider-config mismatch surfaces
836                        // as an install failure rather than a phantom
837                        // launch with a non-existent cmd path.
838                        if installed_bin_in(&provider_dir, &cli_command).is_some() {
839                            // Still under the install lock: publish the shared
840                            // install to every reader only now that it's verified.
841                            let marked = match shared_dir.as_deref() {
842                                Some(dir) => crate::backend::cli_install::mark_complete(dir, &pinned_version),
843                                None => Ok(()),
844                            };
845                            match marked {
846                                Ok(()) => emit_done(&broker, true, None),
847                                Err(e) => emit_done(&broker, false, Some(format!("cannot mark the install complete: {e}"))),
848                            }
849                        } else {
850                            emit_done(
851                                &broker,
852                                false,
853                                Some(format!(
854                                    "npm install reported success but {} not found in {}/node_modules/.bin/",
855                                    cli_command,
856                                    provider_dir.display()
857                                )),
858                            );
859                        }
860                    }
861                    Ok(s) => emit_done(&broker, false, Some(format!("npm exited {:?}", s.code()))),
862                    Err(e) => emit_done(&broker, false, Some(format!("wait: {e}"))),
863                }
864            }
865            _ = &mut cancel_rx => {
866                tracing::info!(session_id = %session_id, "install.cancel: killing child");
867                let _ = child.kill().await;
868                let _ = stdout_task.await;
869                let _ = stderr_task.await;
870                // Wipe the partial install so a retry doesn't inherit
871                // a half-written package-lock.json. Best-effort —
872                // logging only on failure.
873                if let Err(e) = std::fs::remove_dir_all(&provider_dir) {
874                    // Best-effort cleanup; tag the log with the typed
875                    // code so grouped support requests can grep by
876                    // `amx_code` rather than free-text matching.
877                    let mux = agentmux_common::AgentMuxError::from_io_with_path(
878                        provider_dir.display().to_string(),
879                        e,
880                    );
881                    tracing::warn!(
882                        target: "amx::error",
883                        session_id = %session_id,
884                        amx_code = %mux.code(),
885                        provider_dir = %provider_dir.display(),
886                        error = %mux,
887                        "install.cancel: remove partial dir failed"
888                    );
889                }
890                emit_done(&broker, false, Some("cancelled".into()));
891            }
892        }
893
894        registry.drop_session(&session_id);
895        registry.release_provider(&provider_id);
896    });
897}
898
899/// How many lines of npm output a failed install logs.
900const INSTALL_LOG_TAIL_LINES: usize = 20;
901
902fn push_tail(tail: &Mutex<std::collections::VecDeque<String>>, line: &str) {
903    let mut tail = tail.lock();
904    if tail.len() == INSTALL_LOG_TAIL_LINES {
905        tail.pop_front();
906    }
907    tail.push_back(line.to_string());
908}
909
910#[cfg(test)]
911mod tests {
912    use super::{find_on_path, is_safe_cli_command, is_safe_provider_id};
913
914    #[test]
915    fn the_log_keeps_only_the_last_lines_of_npm_output() {
916        let tail = parking_lot::Mutex::new(std::collections::VecDeque::new());
917        for i in 0..(super::INSTALL_LOG_TAIL_LINES + 5) {
918            super::push_tail(&tail, &format!("line {i}"));
919        }
920        let tail = tail.lock();
921        assert_eq!(tail.len(), super::INSTALL_LOG_TAIL_LINES);
922        assert_eq!(tail.front().map(String::as_str), Some("line 5"));
923        assert_eq!(
924            tail.back().cloned(),
925            Some(format!("line {}", super::INSTALL_LOG_TAIL_LINES + 4))
926        );
927    }
928
929    /// The lookup is in-process: a PATH holding only the tool (no `which`
930    /// binary anywhere on it) still finds it. Codex P2 on #3891.
931    #[test]
932    fn find_on_path_needs_no_which_binary() {
933        let dir = tempfile::tempdir().unwrap();
934        let name = if cfg!(windows) { "node.exe" } else { "node" };
935        let exe = dir.path().join(name);
936        std::fs::write(&exe, b"").unwrap();
937        #[cfg(unix)]
938        {
939            use std::os::unix::fs::PermissionsExt;
940            std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
941        }
942        let found = find_on_path("node", Some(dir.path().as_os_str())).expect("node found");
943        assert_eq!(std::path::Path::new(&found), exe.as_path());
944        assert!(find_on_path("npm", Some(dir.path().as_os_str())).is_none());
945    }
946
947    #[test]
948    fn safe_provider_ids_accepted() {
949        for id in ["claude", "claude-code", "open_claw", "Codex42"] {
950            assert!(is_safe_provider_id(id), "{id} should be accepted");
951        }
952    }
953
954    #[test]
955    fn unsafe_provider_ids_rejected() {
956        for id in [
957            "",
958            "../escape",
959            "a/b",
960            "a\\b",
961            "a b",
962            ".",
963            "..",
964            "a..b",
965            "a/../b",
966            "\0null",
967            &"x".repeat(65),
968        ] {
969            assert!(!is_safe_provider_id(id), "{id:?} should be rejected");
970        }
971    }
972
973    #[test]
974    fn safe_cli_commands_accepted() {
975        for cmd in ["claude", "claude-code", "kimi.cmd", "agentmux-srv", "open_claw"] {
976            assert!(is_safe_cli_command(cmd), "{cmd} should be accepted");
977        }
978    }
979
980    #[test]
981    fn unsafe_cli_commands_rejected() {
982        for cmd in [
983            "",
984            "../etc/passwd",
985            "../../etc/passwd",
986            "a/b",
987            "a\\b",
988            "a b",
989            "..",
990            "a..b",
991            "a/../b",
992            "\0null",
993            &"x".repeat(65),
994        ] {
995            assert!(!is_safe_cli_command(cmd), "{cmd:?} should be rejected");
996        }
997    }
998}
999
1000// Request/result shape tests for the four install-related commands.
1001#[cfg(test)]
1002mod req_shape_tests {
1003    use super::*;
1004    use serde_json::json;
1005
1006    #[test]
1007    fn install_chunk_events_serialize_as_before() {
1008        let line = InstallChunkEvent::line("s1", "added 3 packages".into(), InstallStream::Stderr);
1009        assert_eq!(serde_json::to_value(&line).unwrap(), json!({ "sessionId": "s1", "line": "added 3 packages", "stream": "stderr" }));
1010        let done = InstallChunkEvent::done("s1", false, json!(None::<String>));
1011        assert_eq!(serde_json::to_value(&done).unwrap(), json!({ "sessionId": "s1", "op": "done", "ok": false, "error": null }));
1012    }
1013
1014    // These structs rely on rename_all = "camelCase" to match the wire. Drop
1015    // that attribute and everything still compiles and the binding still
1016    // generates -- it just silently stops parsing real payloads.
1017    #[test]
1018    fn the_requests_accept_the_camel_case_payloads_the_stub_sends() {
1019        let start: InstallStartReq = serde_json::from_value(json!({
1020            "providerId": "claude",
1021            "cliCommand": "claude",
1022            "npmPackage": "@anthropic-ai/claude-code",
1023            "pinnedVersion": "1.2.3",
1024        }))
1025        .expect("install.start");
1026        assert_eq!(start.pinned_version, "1.2.3");
1027
1028        // pinnedVersion is #[serde(default)], so the server accepts it missing
1029        // even though the generated binding marks it required -- callers are
1030        // stricter than the wire, which is the safe direction.
1031        let no_version: InstallStartReq = serde_json::from_value(json!({
1032            "providerId": "claude",
1033            "cliCommand": "claude",
1034            "npmPackage": "p",
1035        }))
1036        .expect("pinnedVersion is optional on the wire");
1037        assert_eq!(no_version.pinned_version, "");
1038
1039        serde_json::from_value::<InstallCancelReq>(json!({"sessionId": "s1"}))
1040            .expect("install.cancel");
1041        serde_json::from_value::<InstallCheckReq>(
1042            json!({"providerId": "claude", "cliCommand": "claude"}),
1043        )
1044        .expect("install.check");
1045        serde_json::from_value::<ResolvePrereqsReq>(json!({"tools": ["node", "git"]}))
1046            .expect("resolve.prereqs");
1047        let with_versions = serde_json::from_value::<ResolvePrereqsReq>(
1048            json!({"tools": ["node", "git"], "versions": ["node"]}),
1049        )
1050        .expect("resolve.prereqs with versions");
1051        assert_eq!(with_versions.versions.as_deref(), Some(&["node".to_string()][..]));
1052
1053        assert!(
1054            serde_json::from_value::<InstallCancelReq>(json!({"session_id": "s1"})).is_err(),
1055            "snake_case is not the wire format for these"
1056        );
1057    }
1058
1059    // THE DRIFT THIS SLICE FIXES. The handler writes an explicit null for
1060    // `error` on success, so the key is ALWAYS present. The hand-written stub
1061    // said `error?: string`, i.e. the key may be absent -- it never is.
1062    #[test]
1063    fn cancel_result_always_carries_the_error_key() {
1064        let ok = serde_json::to_value(InstallCancelResult { success: true, error: None })
1065            .expect("serializable");
1066        assert_eq!(ok, json!({"success": true, "error": null}));
1067        assert!(
1068            ok.as_object().unwrap().contains_key("error"),
1069            "error is `string | null`, not an optional property"
1070        );
1071    }
1072
1073    #[test]
1074    fn prereq_results_carry_a_nullable_path() {
1075        let v = serde_json::to_value(ResolvePrereqsResult {
1076            results: vec![PrereqToolResolution {
1077                tool: "node".to_string(),
1078                found: false,
1079                path: None,
1080                version: None,
1081            }],
1082        })
1083        .expect("serializable");
1084        assert_eq!(
1085            v,
1086            json!({"results": [{"tool": "node", "found": false, "path": null, "version": null}]})
1087        );
1088    }
1089}
1090
1091#[cfg(test)]
1092mod installed_version_tests {
1093    use super::installed_version_in;
1094
1095    fn read_installed_version(_provider: &str, npm_package: &str) -> Option<String> {
1096        installed_version_in(
1097            &std::env::temp_dir().join("agentmux-no-such-install"),
1098            npm_package,
1099        )
1100    }
1101
1102    /// The npm package name becomes a path segment, so a hostile or malformed
1103    /// one must be rejected BEFORE any filesystem access. These cases return
1104    /// early on the validation branch, so they hold regardless of whether a
1105    /// data dir exists in the test environment — which is also why they are
1106    /// the part worth pinning: the happy path needs a real per-version cache,
1107    /// but the rejection path is pure and is the one with teeth.
1108    ///
1109    /// The drive-letter cases are the ones an earlier revision of this
1110    /// function got wrong (ReAgent P1 on #3350): `PathBuf::push` REPLACES the
1111    /// path when the pushed component carries a drive prefix, so these escaped
1112    /// the install dir entirely while a `starts_with('/')`/`starts_with('\\')`
1113    /// check waved them through. They are listed explicitly so a future
1114    /// refactor back to a hand-rolled check fails here instead of shipping.
1115    #[test]
1116    fn rejects_path_escapes_and_malformed_package_names() {
1117        for bad in [
1118            "",
1119            "..",
1120            "../../../etc/passwd",
1121            "@scope/../../escape",
1122            "/absolute/path",
1123            "\\windows\\absolute",
1124            "has\0null",
1125            "C:\\Users\\Victim\\AppData",
1126            "C:/some/dir",
1127            "C:payload.txt",
1128            "\\\\server\\share\\evil",
1129        ] {
1130            assert_eq!(
1131                read_installed_version("claude", bad),
1132                None,
1133                "expected {bad:?} to be rejected"
1134            );
1135        }
1136    }
1137
1138    /// A legitimate scoped name (`@anthropic-ai/claude-code`) must NOT be
1139    /// caught by the escape check — it is the common case, and one nested
1140    /// directory is exactly what npm creates for it. Returns None here only
1141    /// because no such install exists in the test environment; the assertion
1142    /// that matters is that it does not panic and is not rejected for the
1143    /// wrong reason.
1144    #[test]
1145    fn scoped_package_names_are_not_treated_as_escapes() {
1146        let _ = read_installed_version("claude", "@anthropic-ai/claude-code");
1147    }
1148
1149    /// Codex P2 on #3927: the version comes from the install whose shim is
1150    /// used. A completed shared dir that lost its shim (manifest still there)
1151    /// must not lend its version to the legacy binary actually launched.
1152    #[test]
1153    fn the_version_is_read_from_the_install_whose_shim_is_used() {
1154        use super::installed_dir_among;
1155        let (pkg, cli) = ("@anthropic-ai/claude-code", "claude");
1156        let manifest = |dir: &std::path::Path| {
1157            dir.join("node_modules")
1158                .join("@anthropic-ai")
1159                .join("claude-code")
1160                .join("package.json")
1161        };
1162        let install = |dir: &std::path::Path, version: &str, shim: bool| {
1163            let m = manifest(dir);
1164            std::fs::create_dir_all(m.parent().unwrap()).unwrap();
1165            std::fs::write(&m, format!(r#"{{"version":"{version}"}}"#)).unwrap();
1166            if shim {
1167                let bin = dir.join("node_modules").join(".bin");
1168                std::fs::create_dir_all(&bin).unwrap();
1169                let name = if cfg!(windows) {
1170                    "claude.cmd"
1171                } else {
1172                    "claude"
1173                };
1174                std::fs::write(bin.join(name), "").unwrap();
1175            }
1176        };
1177        let tmp = tempfile::tempdir().unwrap();
1178        let (shared, legacy) = (tmp.path().join("shared"), tmp.path().join("legacy"));
1179        install(&shared, "2.1.280", false);
1180        install(&legacy, "2.1.100", true);
1181        let dir = installed_dir_among(vec![shared.clone(), legacy.clone()], cli)
1182            .expect("the legacy shim");
1183        assert_eq!(dir, legacy);
1184        assert_eq!(installed_version_in(&dir, pkg).as_deref(), Some("2.1.100"));
1185
1186        // With both shims present the shared install wins, with its own version.
1187        install(&shared, "2.1.280", true);
1188        let dir = installed_dir_among(vec![shared.clone(), legacy], cli).unwrap();
1189        assert_eq!(dir, shared);
1190        assert_eq!(installed_version_in(&dir, pkg).as_deref(), Some("2.1.280"));
1191
1192        // The used install's manifest unreadable: "unknown", not another
1193        // install's version.
1194        std::fs::write(manifest(&shared), "not json").unwrap();
1195        assert_eq!(installed_version_in(&shared, pkg), None);
1196    }
1197}