Skip to main content

agentmux_srv/backend/
tool_store.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tool store: ensures CLI tools (jq, rg, etc.) are available to agent
5//! subprocesses. Reads a bundled catalog JSON, checks system/bundled/managed
6//! install paths, and can download + verify tools on demand.
7
8#[cfg(windows)]
9use agentmux_common::win32::NoWindow;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16// Embedded catalog — path is relative to THIS FILE's location.
17// File: crates/srv/src/backend/tool_store.rs
18// Catalog: crates/srv/src/config/tool-catalog.json
19const CATALOG_JSON: &str = include_str!("../config/tool-catalog.json");
20
21// ---- Catalog structs ----
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct ToolCatalog {
25    pub tools: Vec<ToolSpec>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ToolSpec {
30    pub id: String,
31    pub display: String,
32    pub description: String,
33    pub tier: u8,
34    pub version: String,
35    pub check_cmd: String,
36    /// Flag (e.g. `"--version"`) to pass to the binary to get its version string.
37    /// If absent, version probing is skipped and the catalog version is reported.
38    #[serde(default)]
39    pub version_arg: Option<String>,
40    pub platforms: HashMap<String, PlatformSpec>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct PlatformSpec {
45    pub url: String,
46    pub sha256: String,
47    /// "none" | "zip" | "tar_gz"
48    pub extract: String,
49    /// Final filename placed in the bin/ directory.
50    pub bin: String,
51    /// Path of the target file inside the archive (zip/tar_gz only).
52    pub bin_in_archive: Option<String>,
53}
54
55// ---- Status types ----
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
58#[ts(export, export_to = "../../../frontend/types/rpc/")]
59#[serde(rename_all = "snake_case")]
60pub enum ToolStatus {
61    InstalledSystem,
62    InstalledBundled,
63    InstalledManaged,
64    Missing,
65    Unavailable,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
69#[ts(export, export_to = "../../../frontend/types/rpc/")]
70pub struct ToolStatusEntry {
71    pub id: String,
72    pub display: String,
73    pub description: String,
74    pub tier: u8,
75    pub status: ToolStatus,
76    pub version: Option<String>,
77    pub path: Option<String>,
78}
79
80// ---- Directory helpers ----
81
82/// Returns the bundled tools bin dir: `<exe_dir>/tools/bin/`.
83///
84/// In development (binary lives under `target/debug/` or `target/release/`)
85/// we return `None` so dev builds don't accidentally depend on a non-existent
86/// bundled dir.
87pub fn bundled_tools_dir() -> Option<PathBuf> {
88    let exe = std::env::current_exe().ok()?;
89    let exe_dir = exe.parent()?;
90
91    // Skip if we're running from a Cargo target directory.
92    let exe_str = exe_dir.to_string_lossy();
93    if exe_str.contains("target/debug")
94        || exe_str.contains("target/release")
95        || exe_str.contains("target\\debug")
96        || exe_str.contains("target\\release")
97    {
98        return None;
99    }
100
101    let bundled = exe_dir.join("tools").join("bin");
102    Some(bundled)
103}
104
105/// Returns `~/.agentmux/tools/bin/`.
106pub fn user_tools_dir() -> Option<PathBuf> {
107    let home = dirs::home_dir()?;
108    Some(home.join(".agentmux").join("tools").join("bin"))
109}
110
111/// Where the user tools dir goes relative to the existing PATH. The bundled
112/// dir always goes first: it is app-owned and version-locked, and a stale
113/// system copy shadowing it is the exit-130 trap
114/// (RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md).
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum UserToolsPrecedence {
117    /// Before the existing PATH. Agent CLIs: in `task dev` there is no
118    /// bundled dir and the wrapper lives in the user dir, so a system copy
119    /// must not win.
120    BeforeExisting,
121    /// After the existing PATH. Interactive shells: the user's own system
122    /// PATH wins for tools they installed via /tools.
123    AfterExisting,
124}
125
126/// `existing` with the given tool dirs added (bundled first, user per
127/// `user_precedence`). `None` when neither dir is given, so the caller can
128/// leave PATH untouched.
129pub fn compose_tools_path(
130    existing: &str,
131    bundled: Option<&std::path::Path>,
132    user: Option<&std::path::Path>,
133    user_precedence: UserToolsPrecedence,
134) -> Option<String> {
135    if bundled.is_none() && user.is_none() {
136        return None;
137    }
138    let mut parts: Vec<String> = Vec::new();
139    if let Some(d) = bundled {
140        parts.push(d.to_string_lossy().into_owned());
141    }
142    if let (Some(d), UserToolsPrecedence::BeforeExisting) = (user, user_precedence) {
143        parts.push(d.to_string_lossy().into_owned());
144    }
145    if !existing.is_empty() {
146        parts.push(existing.to_string());
147    }
148    if let (Some(d), UserToolsPrecedence::AfterExisting) = (user, user_precedence) {
149        parts.push(d.to_string_lossy().into_owned());
150    }
151    let sep = if cfg!(windows) { ";" } else { ":" };
152    Some(parts.join(sep))
153}
154
155/// [`compose_tools_path`] over the tool dirs that exist on this machine.
156pub fn tools_path(existing: &str, user_precedence: UserToolsPrecedence) -> Option<String> {
157    let bundled = bundled_tools_dir().filter(|d| d.exists());
158    let user = user_tools_dir().filter(|d| d.exists());
159    compose_tools_path(existing, bundled.as_deref(), user.as_deref(), user_precedence)
160}
161
162/// The `agentmux-bashwrap` agents' Bash commands run through: the bundled
163/// copy, else the user tools dir's (where `task dev` puts it), in the order
164/// [`tools_path`] puts them on PATH. An absolute path, so a stale copy
165/// elsewhere on PATH can never be the one that runs
166/// (RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md).
167pub fn bashwrap_path() -> Option<PathBuf> {
168    let name = if cfg!(windows) { "agentmux-bashwrap.exe" } else { "agentmux-bashwrap" };
169    [bundled_tools_dir(), user_tools_dir()]
170        .into_iter()
171        .flatten()
172        .map(|dir| dir.join(name))
173        .find(|p| p.is_file())
174}
175
176/// Returns `~/.agentmux/tools/downloads/`.
177fn downloads_dir() -> Option<PathBuf> {
178    let home = dirs::home_dir()?;
179    Some(home.join(".agentmux").join("tools").join("downloads"))
180}
181
182// ---- Catalog ----
183
184/// Load and parse the embedded catalog.
185pub fn load_catalog() -> Result<ToolCatalog, String> {
186    serde_json::from_str(CATALOG_JSON).map_err(|e| format!("parse tool-catalog.json: {e}"))
187}
188
189// ---- Platform detection ----
190
191/// Returns the platform key for the current compile target.
192pub fn current_platform() -> Result<&'static str, String> {
193    // Use std::env::consts at runtime so this compiles on every platform
194    // without unreachable-expression warnings.
195    match (std::env::consts::OS, std::env::consts::ARCH) {
196        ("windows", "x86_64") => Ok("windows-x64"),
197        ("macos", "aarch64") => Ok("macos-arm64"),
198        ("macos", "x86_64") => Ok("macos-x64"),
199        ("linux", "x86_64") => Ok("linux-x64"),
200        ("linux", "aarch64") => Ok("linux-arm64"),
201        (os, arch) => Err(format!("unsupported platform: os={os} arch={arch}")),
202    }
203}
204
205// ---- System PATH probe ----
206
207/// Returns true if `name` can be found on the system PATH.
208fn probe_system_path(name: &str) -> bool {
209    #[cfg(windows)]
210    {
211        std::process::Command::new("where")
212            .arg(name)
213            .no_window()
214            .output()
215            .map(|o| o.status.success())
216            .unwrap_or(false)
217    }
218    #[cfg(not(windows))]
219    {
220        std::process::Command::new("which")
221            .arg(name)
222            .output()
223            .map(|o| o.status.success())
224            .unwrap_or(false)
225    }
226}
227
228/// Returns the system PATH location of `name`, or None.
229fn system_path_of(name: &str) -> Option<String> {
230    #[cfg(windows)]
231    let output = {
232        std::process::Command::new("where")
233            .arg(name)
234            .no_window()
235            .output()
236            .ok()?
237    };
238    #[cfg(not(windows))]
239    let output = std::process::Command::new("which").arg(name).output().ok()?;
240
241    if output.status.success() {
242        let path = String::from_utf8_lossy(&output.stdout)
243            .lines()
244            .next()
245            .unwrap_or("")
246            .trim()
247            .to_string();
248        if !path.is_empty() {
249            return Some(path);
250        }
251    }
252    None
253}
254
255/// Run `cmd --version-arg` and extract the first version-looking token.
256/// Returns `Some(version_string)` on success, `None` if the command fails or
257/// the output contains no recognisable version token.
258pub(crate) fn probe_version(cmd: &str, version_arg: &Option<String>) -> Option<String> {
259    let arg = version_arg.as_deref()?;
260    let mut command = std::process::Command::new(cmd);
261    // `cmd` is a third-party binary, so it gets the strict policy for the same
262    // reason `open_browser` does (invariant I7) — short-lived and output-parsed
263    // is a weaker argument than "does not need our identity at all".
264    crate::backend::pane_env::sanitize_external_std_command(&mut command);
265    command.arg(arg);
266    #[cfg(windows)]
267    {
268        command.no_window();
269    }
270    let output = command.output().ok()?;
271    // Some tools write version to stderr (e.g. older jq), try both.
272    let text = if !output.stdout.is_empty() {
273        String::from_utf8_lossy(&output.stdout).into_owned()
274    } else {
275        String::from_utf8_lossy(&output.stderr).into_owned()
276    };
277    // Extract first token that looks like a semver number.
278    for word in text.split_whitespace() {
279        let candidate = word.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
280        if candidate.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
281            && candidate.contains('.')
282        {
283            return Some(candidate.to_string());
284        }
285    }
286    None
287}
288
289// ---- Status query ----
290
291/// Get the install status of every tool in the catalog.
292///
293/// Priority: system PATH > bundled dir > user-managed dir.
294pub fn get_tool_statuses() -> Vec<ToolStatusEntry> {
295    let catalog = match load_catalog() {
296        Ok(c) => c,
297        Err(e) => {
298            tracing::error!(error = %e, "failed to load tool catalog");
299            return Vec::new();
300        }
301    };
302
303    let platform = current_platform().ok();
304
305    catalog
306        .tools
307        .iter()
308        .map(|spec| {
309            // Check platform availability first so we can return Unavailable early.
310            let platform_key = match &platform {
311                Some(k) => *k,
312                None => {
313                    return ToolStatusEntry {
314                        id: spec.id.clone(),
315                        display: spec.display.clone(),
316                        description: spec.description.clone(),
317                        tier: spec.tier,
318                        status: ToolStatus::Unavailable,
319                        version: None,
320                        path: None,
321                    };
322                }
323            };
324
325            if !spec.platforms.contains_key(platform_key) {
326                return ToolStatusEntry {
327                    id: spec.id.clone(),
328                    display: spec.display.clone(),
329                    description: spec.description.clone(),
330                    tier: spec.tier,
331                    status: ToolStatus::Unavailable,
332                    version: None,
333                    path: None,
334                };
335            }
336
337            let bin_name = &spec.platforms[platform_key].bin;
338
339            // 1. System PATH wins.
340            if probe_system_path(&spec.check_cmd) {
341                let path = system_path_of(&spec.check_cmd);
342                // Probe the actual installed version rather than assuming the catalog version.
343                let version = probe_version(&spec.check_cmd, &spec.version_arg);
344                return ToolStatusEntry {
345                    id: spec.id.clone(),
346                    display: spec.display.clone(),
347                    description: spec.description.clone(),
348                    tier: spec.tier,
349                    status: ToolStatus::InstalledSystem,
350                    version,
351                    path,
352                };
353            }
354
355            // 2. Bundled dir.
356            if let Some(bundled) = bundled_tools_dir() {
357                let p = bundled.join(bin_name);
358                if p.exists() {
359                    return ToolStatusEntry {
360                        id: spec.id.clone(),
361                        display: spec.display.clone(),
362                        description: spec.description.clone(),
363                        tier: spec.tier,
364                        status: ToolStatus::InstalledBundled,
365                        version: Some(spec.version.clone()),
366                        path: Some(p.to_string_lossy().into_owned()),
367                    };
368                }
369            }
370
371            // 3. User-managed store.
372            if let Some(user_bin) = user_tools_dir() {
373                let p = user_bin.join(bin_name);
374                if p.exists() {
375                    return ToolStatusEntry {
376                        id: spec.id.clone(),
377                        display: spec.display.clone(),
378                        description: spec.description.clone(),
379                        tier: spec.tier,
380                        status: ToolStatus::InstalledManaged,
381                        version: Some(spec.version.clone()),
382                        path: Some(p.to_string_lossy().into_owned()),
383                    };
384                }
385            }
386
387            // 4. Not found.
388            ToolStatusEntry {
389                id: spec.id.clone(),
390                display: spec.display.clone(),
391                description: spec.description.clone(),
392                tier: spec.tier,
393                status: ToolStatus::Missing,
394                version: None,
395                path: None,
396            }
397        })
398        .collect()
399}
400
401// ---- Installation ----
402
403/// Download, verify, and install a single tool by ID into the user-managed
404/// store (`~/.agentmux/tools/bin/`).
405///
406/// Returns the installed binary path on success.
407pub async fn install_tool(id: &str, http_client: &reqwest::Client) -> Result<String, String> {
408    let catalog = load_catalog()?;
409    let spec = catalog
410        .tools
411        .iter()
412        .find(|t| t.id == id)
413        .ok_or_else(|| format!("tool '{id}' not found in catalog"))?;
414
415    let platform_key = current_platform()?;
416    let platform_spec = spec
417        .platforms
418        .get(platform_key)
419        .ok_or_else(|| format!("tool '{id}' has no entry for platform '{platform_key}'"))?;
420
421    // Ensure directories exist.
422    let dl_dir = downloads_dir().ok_or("cannot determine downloads dir")?;
423    let bin_dir = user_tools_dir().ok_or("cannot determine user tools dir")?;
424    std::fs::create_dir_all(&dl_dir)
425        .map_err(|e| format!("create downloads dir: {e}"))?;
426    std::fs::create_dir_all(&bin_dir)
427        .map_err(|e| format!("create bin dir: {e}"))?;
428
429    // Download to a temp file.
430    let dl_path = dl_dir.join(format!("{id}-download"));
431    tracing::info!(tool = %id, url = %platform_spec.url, dest = %dl_path.display(), "downloading tool");
432
433    {
434        let bytes = http_client
435            .get(&platform_spec.url)
436            .send()
437            .await
438            .map_err(|e| format!("download '{id}': {e}"))?
439            .error_for_status()
440            .map_err(|e| format!("download '{id}' HTTP error: {e}"))?
441            .bytes()
442            .await
443            .map_err(|e| format!("read download body '{id}': {e}"))?;
444
445        std::fs::write(&dl_path, &bytes)
446            .map_err(|e| format!("write download file: {e}"))?;
447    }
448
449    // Verify SHA-256.
450    {
451        let data = std::fs::read(&dl_path)
452            .map_err(|e| format!("read downloaded file for hash: {e}"))?;
453        let mut hasher = Sha256::new();
454        hasher.update(&data);
455        let digest = hex::encode(hasher.finalize());
456        if digest != platform_spec.sha256 {
457            let _ = std::fs::remove_file(&dl_path);
458            return Err(format!(
459                "SHA-256 mismatch for '{id}': expected={} got={digest}",
460                platform_spec.sha256,
461            ));
462        }
463        tracing::debug!(tool = %id, "SHA-256 verified");
464    }
465
466    // Extract / copy into bin dir.
467    let dest_path = bin_dir.join(&platform_spec.bin);
468
469    match platform_spec.extract.as_str() {
470        "none" => {
471            std::fs::copy(&dl_path, &dest_path)
472                .map_err(|e| format!("copy '{id}' to bin: {e}"))?;
473        }
474        "zip" => {
475            let archive_in = platform_spec
476                .bin_in_archive
477                .as_deref()
478                .ok_or_else(|| format!("tool '{id}': extract=zip but bin_in_archive is missing"))?;
479
480            let zip_data = std::fs::read(&dl_path)
481                .map_err(|e| format!("read zip file: {e}"))?;
482            let cursor = std::io::Cursor::new(zip_data);
483            let mut archive = zip::ZipArchive::new(cursor)
484                .map_err(|e| format!("open zip archive: {e}"))?;
485
486            let mut entry = archive
487                .by_name(archive_in)
488                .map_err(|e| format!("zip entry '{archive_in}': {e}"))?;
489
490            let mut out = std::fs::File::create(&dest_path)
491                .map_err(|e| format!("create dest file: {e}"))?;
492            std::io::copy(&mut entry, &mut out)
493                .map_err(|e| format!("extract zip entry: {e}"))?;
494        }
495        "tar_gz" => {
496            let archive_in = platform_spec
497                .bin_in_archive
498                .as_deref()
499                .ok_or_else(|| format!("tool '{id}': extract=tar_gz but bin_in_archive is missing"))?;
500
501            let gz_file = std::fs::File::open(&dl_path)
502                .map_err(|e| format!("open tar.gz: {e}"))?;
503            let gz_decoder = flate2::read::GzDecoder::new(gz_file);
504            let mut archive = tar::Archive::new(gz_decoder);
505
506            let mut found = false;
507            for entry in archive.entries().map_err(|e| format!("tar entries: {e}"))? {
508                let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
509                let path = entry.path().map_err(|e| format!("tar entry path: {e}"))?;
510                if path.to_string_lossy() == archive_in {
511                    entry
512                        .unpack(&dest_path)
513                        .map_err(|e| format!("unpack tar entry: {e}"))?;
514                    found = true;
515                    break;
516                }
517            }
518            if !found {
519                return Err(format!(
520                    "tar archive does not contain expected entry '{archive_in}'"
521                ));
522            }
523        }
524        other => {
525            return Err(format!("unknown extract mode '{other}' for tool '{id}'"));
526        }
527    }
528
529    // Set executable bit on Unix.
530    #[cfg(unix)]
531    {
532        use std::os::unix::fs::PermissionsExt as _;
533        let mut perms = std::fs::metadata(&dest_path)
534            .map_err(|e| format!("stat installed binary: {e}"))?
535            .permissions();
536        let mode = perms.mode();
537        perms.set_mode(mode | 0o111);
538        std::fs::set_permissions(&dest_path, perms)
539            .map_err(|e| format!("set executable bit: {e}"))?;
540    }
541
542    // Clean up download.
543    let _ = std::fs::remove_file(&dl_path);
544
545    let dest_str = dest_path.to_string_lossy().into_owned();
546    tracing::info!(tool = %id, path = %dest_str, "tool installed successfully");
547    Ok(dest_str)
548}
549
550#[cfg(test)]
551mod tool_status_wire_tests {
552    use super::*;
553    use serde_json::json;
554
555    // `version` and `path` are plain Options with NO skip_serializing_if, so
556    // the keys are ALWAYS present and carry null when unknown. The
557    // hand-written declaration marked both optional (`version?`), i.e. "the
558    // key may be absent" -- which it never is. The generated binding says
559    // `string | null`, and this pins which of the two is true.
560    #[test]
561    fn a_tool_entry_always_carries_version_and_path() {
562        let v = serde_json::to_value(ToolStatusEntry {
563            id: "rg".to_string(),
564            display: "ripgrep".to_string(),
565            description: "search".to_string(),
566            tier: 1,
567            status: ToolStatus::Missing,
568            version: None,
569            path: None,
570        })
571        .expect("serializable");
572        let obj = v.as_object().expect("object");
573        assert!(obj.contains_key("version") && obj.contains_key("path"));
574        assert_eq!(v["version"], json!(null));
575        assert_eq!(v["path"], json!(null));
576    }
577
578    // ToolStatus is a closed set the frontend branches on; it is
579    // `rename_all = "snake_case"` and generates as a string union. Pin the
580    // wire spellings so the union cannot drift from them.
581    #[test]
582    fn tool_status_serializes_to_the_expected_snake_case_tags() {
583        for (variant, tag) in [
584            (ToolStatus::InstalledSystem, "installed_system"),
585            (ToolStatus::InstalledBundled, "installed_bundled"),
586            (ToolStatus::InstalledManaged, "installed_managed"),
587            (ToolStatus::Missing, "missing"),
588            (ToolStatus::Unavailable, "unavailable"),
589        ] {
590            assert_eq!(serde_json::to_value(variant).unwrap(), json!(tag));
591        }
592    }
593}
594
595#[cfg(test)]
596mod tools_path_tests {
597    use super::{compose_tools_path, UserToolsPrecedence};
598    use std::path::Path;
599
600    const SEP: &str = if cfg!(windows) { ";" } else { ":" };
601
602    fn join(parts: &[&str]) -> String {
603        parts.join(SEP)
604    }
605
606    #[test]
607    fn bundled_always_leads_and_user_follows_the_requested_precedence() {
608        let (b, u) = (Path::new("B"), Path::new("U"));
609        assert_eq!(
610            compose_tools_path("SYS", Some(b), Some(u), UserToolsPrecedence::BeforeExisting),
611            Some(join(&["B", "U", "SYS"]))
612        );
613        assert_eq!(
614            compose_tools_path("SYS", Some(b), Some(u), UserToolsPrecedence::AfterExisting),
615            Some(join(&["B", "SYS", "U"]))
616        );
617    }
618
619    #[test]
620    fn no_tool_dirs_leaves_path_untouched() {
621        assert_eq!(compose_tools_path("SYS", None, None, UserToolsPrecedence::BeforeExisting), None);
622    }
623
624    #[test]
625    fn an_empty_inherited_path_adds_no_empty_entry() {
626        // An empty PATH entry means "the current directory" on Unix.
627        assert_eq!(
628            compose_tools_path("", None, Some(Path::new("U")), UserToolsPrecedence::BeforeExisting),
629            Some("U".to_string())
630        );
631        assert_eq!(
632            compose_tools_path("", Some(Path::new("B")), None, UserToolsPrecedence::AfterExisting),
633            Some("B".to_string())
634        );
635    }
636}