agentmux_common/
toolchain_path.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! GUI-launch PATH enrichment.
5//!
6//! When AgentMux launches from Finder / Dock / a mounted DMG as a `.app`,
7//! it inherits launchd's stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`).
8//! nvm- and Homebrew-installed `node` / `npm` / `git` live in the user's
9//! *login-shell* PATH, which a GUI launch never sees — so the CLI installer
10//! (`Command::new("npm")`) dies with `npm: command not found`, and spawned
11//! agent CLIs can't find their interpreter either.
12//!
13//! [`resolve_login_path`] reconstructs a usable PATH from the user's login
14//! shell (`$SHELL -lic 'printf … "$PATH"'`, with a hard timeout) unioned with
15//! well-known toolchain directories. It is **purely additive** — inherited
16//! system directories are never dropped — and a no-op on Windows (whose GUI
17//! apps inherit a usable PATH).
18//!
19//! See `docs/specs/SPEC_TOOLCHAIN_MANAGER_2026-06-15.md` §3.
20
21use std::collections::HashSet;
22use std::path::Path;
23
24/// How the effective PATH was produced — surfaced in logs and (later) the
25/// Toolchain modal so PATH problems are diagnosable rather than mysterious.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PathSource {
28    /// The inherited PATH was already sufficient (or we're on Windows); unchanged.
29    Inherited,
30    /// Augmented from the user's login shell (`$SHELL -lic`).
31    LoginShell,
32    /// Login-shell capture failed; augmented from well-known dirs only.
33    FallbackDirs,
34}
35
36impl PathSource {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            PathSource::Inherited => "inherited",
40            PathSource::LoginShell => "login-shell",
41            PathSource::FallbackDirs => "fallback-dirs",
42        }
43    }
44}
45
46/// The enriched PATH plus a record of how it was derived.
47#[derive(Debug, Clone)]
48pub struct EnrichedPath {
49    pub path: String,
50    pub source: PathSource,
51}
52
53/// The launchd-default PATH a Finder/Dock-launched macOS `.app` inherits.
54const LAUNCHD_DEFAULT_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
55
56/// Build a usable PATH for spawning the toolchain (`node`/`npm`/`git`/`docker`
57/// and the provider CLIs). Additive over the inherited PATH; never removes an
58/// inherited entry. On Windows this returns the inherited PATH unchanged.
59pub fn resolve_login_path() -> EnrichedPath {
60    let inherited = std::env::var("PATH").unwrap_or_default();
61
62    #[cfg(not(unix))]
63    {
64        return EnrichedPath {
65            path: inherited,
66            source: PathSource::Inherited,
67        };
68    }
69
70    #[cfg(unix)]
71    {
72        let login_shell = capture_login_shell_path(&resolve_user_shell());
73        build_enriched(&inherited, login_shell.as_deref(), &wellknown_dirs())
74    }
75}
76
77/// True when the current PATH is the stripped launchd default (every entry is
78/// one of `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin`) — i.e. a GUI launch that
79/// never saw the user's shell. Used by the srv to decide whether the cheap
80/// guard should pay for a login-shell spawn on a *direct* launch.
81pub fn looks_like_launchd_default(path: &str) -> bool {
82    let defaults: HashSet<&str> = LAUNCHD_DEFAULT_DIRS.iter().copied().collect();
83    let entries: Vec<&str> = path.split(':').filter(|e| !e.is_empty()).collect();
84    !entries.is_empty() && entries.iter().all(|e| defaults.contains(e))
85}
86
87/// Enrich the *current process'* PATH in place, but only when it looks like the
88/// stripped launchd default. Idempotent and cheap when the PATH is already
89/// healthy (no shell spawn). Returns the source used. Intended for the srv's
90/// direct-launch fallback; the host enriches the srv's PATH unconditionally
91/// when it spawns it (it knows it is the GUI entry point).
92pub fn enrich_current_process_path() -> PathSource {
93    let current = std::env::var("PATH").unwrap_or_default();
94    if !looks_like_launchd_default(&current) {
95        return PathSource::Inherited;
96    }
97    let enriched = resolve_login_path();
98    if enriched.source != PathSource::Inherited {
99        std::env::set_var("PATH", &enriched.path);
100    }
101    enriched.source
102}
103
104/// The user's login shell, falling back to a per-OS default.
105#[cfg(unix)]
106fn resolve_user_shell() -> String {
107    std::env::var("SHELL")
108        .ok()
109        .filter(|s| !s.is_empty())
110        .unwrap_or_else(|| {
111            if cfg!(target_os = "macos") {
112                "/bin/zsh".to_string()
113            } else {
114                "/bin/bash".to_string()
115            }
116        })
117}
118
119/// Run `<shell> -lic 'printf …"$PATH"'` and return the captured PATH, or `None`
120/// on timeout / failure / empty. A sentinel wraps the value so noise printed by
121/// the user's rc files to stdout can't corrupt the parse. Bounded by a 2s hard
122/// timeout so a slow/hung rc file can never block startup.
123#[cfg(unix)]
124fn capture_login_shell_path(shell: &str) -> Option<String> {
125    use std::io::Read;
126    use std::process::{Command, Stdio};
127    use std::sync::mpsc;
128    use std::time::Duration;
129
130    // `printf` without a trailing newline; markers isolate our value from any
131    // banner the rc files emit to stdout.
132    let script = r#"printf '__AMUX_PATH__%s__AMUX_END__' "$PATH""#;
133
134    let mut child = Command::new(shell)
135        .args(["-lic", script])
136        .stdin(Stdio::null())
137        .stdout(Stdio::piped())
138        .stderr(Stdio::null())
139        .spawn()
140        .ok()?;
141
142    let mut stdout = child.stdout.take()?;
143    // Read on a worker thread so a hung child can't block us past the timeout.
144    let (tx, rx) = mpsc::channel();
145    std::thread::spawn(move || {
146        let mut buf = String::new();
147        let _ = stdout.read_to_string(&mut buf);
148        let _ = tx.send(buf);
149    });
150
151    let buf = match rx.recv_timeout(Duration::from_secs(2)) {
152        Ok(b) => {
153            let _ = child.wait();
154            b
155        }
156        Err(_) => {
157            // Timed out — kill the child; the reader thread unblocks when the
158            // pipe closes and exits on its own (its send is discarded).
159            let _ = child.kill();
160            let _ = child.wait();
161            return None;
162        }
163    };
164
165    parse_sentinel(&buf)
166}
167
168/// Extract the PATH wrapped between the sentinels, ignoring surrounding noise.
169fn parse_sentinel(buf: &str) -> Option<String> {
170    const START: &str = "__AMUX_PATH__";
171    const END: &str = "__AMUX_END__";
172    let s = buf.find(START)? + START.len();
173    let e = buf[s..].find(END)? + s;
174    let path = &buf[s..e];
175    if path.is_empty() {
176        None
177    } else {
178        Some(path.to_string())
179    }
180}
181
182/// Well-known toolchain directories, existence-checked and de-duplicated. An
183/// absent directory costs nothing (filtered out), so the list can be generous.
184#[cfg(unix)]
185fn wellknown_dirs() -> Vec<String> {
186    let home = std::env::var("HOME").unwrap_or_default();
187    let mut candidates: Vec<String> = Vec::new();
188
189    if !home.is_empty() {
190        if let Some(bin) = nvm_current_bin(&home) {
191            candidates.push(bin);
192        }
193    }
194    candidates.push("/opt/homebrew/bin".to_string());
195    candidates.push("/opt/homebrew/sbin".to_string());
196    candidates.push("/usr/local/bin".to_string());
197    if !home.is_empty() {
198        candidates.push(format!("{home}/.local/bin"));
199        candidates.push(format!("{home}/.cargo/bin"));
200    }
201    candidates.push("/opt/local/bin".to_string());
202
203    #[cfg(target_os = "linux")]
204    {
205        candidates.push("/snap/bin".to_string());
206        candidates.push("/var/lib/flatpak/exports/bin".to_string());
207        if !home.is_empty() {
208            candidates.push(format!("{home}/.local/share/flatpak/exports/bin"));
209        }
210    }
211
212    candidates
213        .into_iter()
214        .filter(|d| Path::new(d).is_dir())
215        .collect()
216}
217
218/// Resolve the `bin` dir of the nvm "current/default" Node, if nvm is present.
219/// Prefers the `default` alias; falls back to the highest installed version.
220#[cfg(unix)]
221fn nvm_current_bin(home: &str) -> Option<String> {
222    let versions_dir = format!("{home}/.nvm/versions/node");
223    if !Path::new(&versions_dir).is_dir() {
224        return None;
225    }
226
227    // Prefer the `default` alias (e.g. "v20.18.0", "20", "lts/*", "node").
228    if let Ok(alias) = std::fs::read_to_string(format!("{home}/.nvm/alias/default")) {
229        let a = alias.trim();
230        if !a.is_empty() {
231            let v = if a.starts_with('v') {
232                a.to_string()
233            } else {
234                format!("v{a}")
235            };
236            let bin = format!("{versions_dir}/{v}/bin");
237            if Path::new(&bin).is_dir() {
238                return Some(bin);
239            }
240        }
241    }
242
243    // Fall back to the highest installed version (numeric, not lexical, so
244    // v10 > v9).
245    let mut versions: Vec<(u64, u64, u64, String)> = std::fs::read_dir(&versions_dir)
246        .ok()?
247        .filter_map(|e| e.ok())
248        .filter_map(|e| e.file_name().into_string().ok())
249        .filter(|n| n.starts_with('v'))
250        .map(|n| {
251            let (a, b, c) = parse_semverish(&n);
252            (a, b, c, n)
253        })
254        .collect();
255    versions.sort();
256    versions
257        .last()
258        .map(|(_, _, _, n)| format!("{versions_dir}/{n}/bin"))
259}
260
261/// Parse a leading "vMAJOR.MINOR.PATCH" into a comparable tuple. Missing or
262/// non-numeric parts sort as 0.
263fn parse_semverish(v: &str) -> (u64, u64, u64) {
264    let core = v.strip_prefix('v').unwrap_or(v);
265    let mut it = core.split('.');
266    let p = |o: Option<&str>| o.and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
267    (p(it.next()), p(it.next()), p(it.next()))
268}
269
270/// Pure merge: priority order is login-shell entries, then well-known dirs,
271/// then the inherited PATH — de-duplicated, first occurrence wins. Inherited
272/// entries are always preserved (appended), so the result is a superset.
273fn build_enriched(inherited: &str, login_shell: Option<&str>, wellknown: &[String]) -> EnrichedPath {
274    let mut seen: HashSet<&str> = HashSet::new();
275    let mut out: Vec<&str> = Vec::new();
276    let used_login = login_shell.is_some();
277
278    if let Some(ls) = login_shell {
279        for e in ls.split(':') {
280            if !e.is_empty() && seen.insert(e) {
281                out.push(e);
282            }
283        }
284    }
285
286    let before = out.len();
287    for d in wellknown {
288        if !d.is_empty() && seen.insert(d.as_str()) {
289            out.push(d.as_str());
290        }
291    }
292    let wellknown_added = out.len() > before;
293
294    for e in inherited.split(':') {
295        if !e.is_empty() && seen.insert(e) {
296            out.push(e);
297        }
298    }
299
300    let path = out.join(":");
301    // Did we actually change anything vs. just the inherited set?
302    let changed = path != normalize(inherited);
303    let source = if !changed {
304        PathSource::Inherited
305    } else if used_login {
306        PathSource::LoginShell
307    } else if wellknown_added {
308        PathSource::FallbackDirs
309    } else {
310        PathSource::Inherited
311    };
312
313    EnrichedPath { path, source }
314}
315
316/// De-dup + drop empties, preserving order — for comparing "did we change it".
317fn normalize(path: &str) -> String {
318    let mut seen = HashSet::new();
319    path.split(':')
320        .filter(|e| !e.is_empty() && seen.insert(*e))
321        .collect::<Vec<_>>()
322        .join(":")
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn parse_sentinel_ignores_rc_noise() {
331        let buf = "welcome to my shell!\nsome banner\n__AMUX_PATH__/opt/homebrew/bin:/usr/bin__AMUX_END__";
332        assert_eq!(
333            parse_sentinel(buf).as_deref(),
334            Some("/opt/homebrew/bin:/usr/bin")
335        );
336    }
337
338    #[test]
339    fn parse_sentinel_none_when_missing_or_empty() {
340        assert_eq!(parse_sentinel("no markers here"), None);
341        assert_eq!(parse_sentinel("__AMUX_PATH____AMUX_END__"), None);
342    }
343
344    #[test]
345    fn launchd_default_detection() {
346        assert!(looks_like_launchd_default("/usr/bin:/bin:/usr/sbin:/sbin"));
347        assert!(looks_like_launchd_default("/bin:/usr/bin"));
348        assert!(!looks_like_launchd_default(
349            "/opt/homebrew/bin:/usr/bin:/bin"
350        ));
351        assert!(!looks_like_launchd_default(""));
352    }
353
354    #[test]
355    fn semverish_sorts_numerically() {
356        assert!(parse_semverish("v10.0.0") > parse_semverish("v9.99.99"));
357        assert_eq!(parse_semverish("v20.18.1"), (20, 18, 1));
358        assert_eq!(parse_semverish("vbogus"), (0, 0, 0));
359    }
360
361    #[test]
362    fn build_prepends_login_shell_and_preserves_inherited() {
363        let e = build_enriched(
364            "/usr/bin:/bin",
365            Some("/opt/homebrew/bin:/usr/bin:/bin"),
366            &[],
367        );
368        // login-shell entry comes first; inherited /bin still present once.
369        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
370        assert_eq!(e.source, PathSource::LoginShell);
371    }
372
373    #[test]
374    fn build_falls_back_to_wellknown_when_no_login_shell() {
375        let e = build_enriched(
376            "/usr/bin:/bin",
377            None,
378            &["/opt/homebrew/bin".to_string()],
379        );
380        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
381        assert_eq!(e.source, PathSource::FallbackDirs);
382    }
383
384    #[test]
385    fn build_reports_inherited_when_nothing_added() {
386        // login shell returns exactly the inherited set (e.g. launched from a
387        // terminal that already had the system PATH and nothing else).
388        let e = build_enriched("/usr/bin:/bin", Some("/usr/bin:/bin"), &[]);
389        assert_eq!(e.path, "/usr/bin:/bin");
390        assert_eq!(e.source, PathSource::Inherited);
391    }
392
393    #[test]
394    fn build_dedups_across_all_sources() {
395        let e = build_enriched(
396            "/usr/bin:/bin:/usr/bin",
397            Some("/opt/homebrew/bin:/usr/bin"),
398            &["/opt/homebrew/bin".to_string(), "/usr/local/bin".to_string()],
399        );
400        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin");
401    }
402
403    #[cfg(unix)]
404    #[test]
405    fn capture_via_real_sh_roundtrips_path() {
406        // /bin/sh honors -c; -lic is accepted (login+interactive flags are
407        // tolerated). The script echoes $PATH between the sentinels.
408        let got = capture_login_shell_path("/bin/sh");
409        // On any unix dev/CI box /bin/sh exists and $PATH is non-empty.
410        assert!(got.is_some(), "expected a captured PATH from /bin/sh");
411        assert!(!got.unwrap().is_empty());
412    }
413}