1#[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#[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 #[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#[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)]
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 #[serde(default)]
125 pub npm_package: Option<String>,
126}
127
128#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
130#[ts(export, export_to = "../../../frontend/types/rpc/")]
131pub struct ResolvePrereqsReq {
132 pub tools: Vec<String>,
133 #[serde(default)]
138 #[ts(optional)]
139 pub versions: Option<Vec<String>>,
140}
141
142#[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#[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 pub version: Option<String>,
167}
168
169#[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#[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 pub path: Option<String>,
191 pub version: Option<String>,
195}
196
197#[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#[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 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 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 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
265fn 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
273fn 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
283fn 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
292fn 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
305fn 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 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
323pub(crate) async fn resolve_tool_path(tool: &str) -> Option<String> {
334 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
346async 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
360fn 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
366fn 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
415fn 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 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 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 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 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 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 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 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 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 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 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 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(); }
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 if installed_bin_in(&provider_dir, &cli_command).is_some() {
839 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 if let Err(e) = std::fs::remove_dir_all(&provider_dir) {
874 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
899const 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 #[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#[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 #[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 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 #[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 #[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 #[test]
1145 fn scoped_package_names_are_not_treated_as_escapes() {
1146 let _ = read_installed_version("claude", "@anthropic-ai/claude-code");
1147 }
1148
1149 #[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 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 std::fs::write(manifest(&shared), "not json").unwrap();
1195 assert_eq!(installed_version_in(&shared, pkg), None);
1196 }
1197}