Skip to main content

harness/
program_path.rs

1//! Finding an agent's CLI, and the PATH it needs to run.
2//!
3//! None of this is about spawning or streaming — [`cli_stream`] does that, and
4//! takes the environment it is given. This is the part that matters because
5//! everything we run is a program *the user* installed, wherever their tooling
6//! put it: an agent CLI, or the `npx` / `uvx` / container command behind an MCP
7//! server. Node is where it bites hardest, not the limit of what it serves —
8//! this module was called `node_cli` for that reason and the name kept
9//! suggesting MCP had a node dependency it does not have.
10//!
11//! A desktop app launched from Finder inherits the minimal launchd PATH
12//! (`/usr/bin:/bin:/usr/sbin:/sbin`), so an nvm-installed `node` is invisible
13//! and the CLI exits 127. Worse, a CLI installed under one node version and run
14//! against whichever node leads the inherited PATH fails in subtler ways. So a
15//! bare name is resolved to its absolute path first, and that program's own
16//! directory — where its sibling `node` lives in an nvm install — goes to the
17//! front of the child's PATH.
18//!
19//! Verifying any of this needs a real double-click: `open <app>` leaks the
20//! launching shell's PATH and passes when the packaged app would fail.
21//!
22//! # Platforms
23//!
24//! Resolution is portable — `PATH` is split with [`std::env::split_paths`] and
25//! Windows names are tried with each `PATHEXT` suffix. The **fallback list is
26//! not**: [`hardcoded_node_dirs`] names Homebrew, `~/.local/bin` and
27//! `~/.nvm/versions/node/*/bin`, which are macOS and Linux locations, and
28//! [`login_shell_path`] asks a POSIX login shell. On Windows both come back
29//! empty or unhelpful, so a CLI outside the inherited `PATH` will not be found
30//! — nvm-windows keeps its versions under `%APPDATA%\nvm`, which nothing here
31//! looks for yet.
32
33use std::path::{Path, PathBuf};
34use std::sync::OnceLock;
35
36use cli_stream::hidden_command;
37
38/// Ask a CLI for its version, on the augmented PATH. `None` when it cannot be
39/// run, exits non-zero, or says nothing — each of which means the same thing to
40/// a caller: this is not an installed, working CLI.
41///
42/// Every adapter wrapping a CLI needs exactly this, and it is the probe that
43/// decides whether a harness reads as installed at all — so it lives once,
44/// beside [`hidden_command`] and [`augmented_path`], rather than being
45/// copied per adapter and drifting.
46pub fn probe_version(program: &str) -> Option<String> {
47    let output = hidden_command(program).arg("--version").env("PATH", augmented_path()).output().ok()?;
48    if !output.status.success() {
49        return None;
50    }
51    let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
52    (!text.is_empty()).then_some(text)
53}
54
55fn augment_path_for_program(program: &Path) -> String {
56    prepend_program_dir(program, &augmented_path())
57}
58
59/// Resolve a bare program name (`bob`, `claude`) to its absolute path on the
60/// augmented PATH, so the spawn and the node pairing agree on *one* location.
61///
62/// Without this, a bare name splits the brain: the OS resolves the *program*
63/// against the parent process's PATH, while the child's `#!/usr/bin/env node`
64/// shebang resolves *node* against the PATH we set — and
65/// `prepend_program_dir` can't pair the program with its sibling node
66/// because a bare name has no parent dir. Concretely: an nvm-installed `bob`
67/// found under `v24/bin` could re-exec on a `v20` node that happened to lead
68/// the inherited PATH, and die on a v24-only flag ("exited with code 9").
69/// Resolving to the absolute path first means the program's own directory —
70/// holding the exact `node` it was installed with — is prepended and wins.
71///
72/// A program given with an explicit path is returned untouched; a bare name
73/// that can't be found is also returned untouched, so the spawn still fails
74/// with the clear "No such file" error rather than a synthetic one here.
75pub fn resolve_program(program: PathBuf) -> PathBuf {
76    if program.parent().is_some_and(|p| !p.as_os_str().is_empty()) {
77        return program; // explicit path — caller's choice wins
78    }
79    resolve_on_path(&program, &augmented_path()).unwrap_or(program)
80}
81
82/// The first runnable file called `name` on `path_env`. Pure with respect to
83/// env and spawn (filesystem only), so it is unit-testable.
84///
85/// Entries are split with [`std::env::split_paths`] rather than on `:`, because
86/// Windows separates with `;` — and on Windows a bare name is not the file
87/// name: `claude` is `claude.exe` or `claude.cmd`, so each `PATHEXT` suffix is
88/// tried in turn.
89fn resolve_on_path(name: &Path, path_env: &str) -> Option<PathBuf> {
90    // Once, not once per directory: on Windows this reads an environment
91    // variable, and a PATH routinely has dozens of entries.
92    let extensions = split_extensions(&pathext());
93    std::env::split_paths(path_env)
94        .filter(|dir| !dir.as_os_str().is_empty())
95        .flat_map(|dir| {
96            let base = dir.join(name);
97            let mut candidates = vec![base.clone()];
98            for extension in &extensions {
99                let mut with_extension = base.clone().into_os_string();
100                with_extension.push(extension);
101                candidates.push(PathBuf::from(with_extension));
102            }
103            candidates
104        })
105        .find(|candidate| is_executable_file(candidate))
106}
107
108/// Unix has no extension convention for programs — a program's name is its
109/// file name.
110#[cfg(unix)]
111fn pathext() -> String {
112    String::new()
113}
114
115/// Windows names its programs `claude.exe` / `claude.cmd`, and `PATHEXT` lists
116/// the suffixes to try; the literal is what the OS falls back to when it is
117/// unset.
118#[cfg(not(unix))]
119fn pathext() -> String {
120    std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_owned())
121}
122
123/// Split a `PATHEXT` value into suffixes.
124///
125/// Separated from [`pathext`] so the parsing compiles and is tested on every
126/// platform, not only the one it ships on: behind a `cfg` it was unreachable
127/// from any test here, which reads as untested rather than as passing.
128fn split_extensions(pathext: &str) -> Vec<String> {
129    pathext
130        .split(';')
131        .filter(|extension| !extension.is_empty())
132        .map(str::to_owned)
133        .collect()
134}
135
136#[cfg(unix)]
137fn is_executable_file(path: &Path) -> bool {
138    use std::os::unix::fs::PermissionsExt;
139    std::fs::metadata(path)
140        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
141        .unwrap_or(false)
142}
143
144#[cfg(not(unix))]
145fn is_executable_file(path: &Path) -> bool {
146    path.is_file()
147}
148
149/// `base_path` with the program's own directory in front, so the `node` it was
150/// installed beside — the sibling in an nvm install — is the one its shebang
151/// finds. Pure (no env, no spawn), so it is unit-tested directly.
152///
153/// Only an **absolute** directory is prepended. This runs after
154/// [`keep_absolute_entries`] and lands at the front, so a relative one would
155/// outrank every filtered entry and reopen exactly the hole that filter
156/// closes: we spawn with `current_dir` set to the user's workspace, which the
157/// agent itself can write to, so `node_modules/.bin/claude` would put a
158/// workspace-relative directory first on PATH.
159///
160/// Joined with [`std::env::join_paths`] rather than `:` — Windows separates
161/// with `;`, where a hardcoded colon builds a PATH the OS reads as one
162/// nonexistent directory. A directory that cannot be expressed in a PATH at
163/// all (it contains the separator) yields `base_path` unchanged: without the
164/// prepend we lose the node pairing, but a corrupt PATH loses everything.
165fn prepend_program_dir(program: &Path, base_path: &str) -> String {
166    let Some(dir) = program.parent().filter(|dir| dir.is_absolute()) else {
167        return base_path.to_owned();
168    };
169    let entries = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(base_path));
170    std::env::join_paths(entries)
171        .map_or_else(|_| base_path.to_owned(), |joined| joined.to_string_lossy().into_owned())
172}
173
174/// A PATH that resolves Node-based CLIs (bob, claude, codex) even from a
175/// process launched by Finder/Launchpad, which inherits only the minimal
176/// launchd PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) rather than the user's
177/// shell PATH.
178///
179/// Strategy: keep the process's own PATH first (an explicit PATH still wins),
180/// then append the user's **real** PATH as resolved by their login shell —
181/// which sources their rc, so it knows where nvm / pnpm / volta / asdf / fnm /
182/// Homebrew put `node`, with no guessing. If the shell query is unavailable
183/// (no `$SHELL`, a timeout, a sandboxed app that can't spawn, …) we fall back
184/// to a hardcoded best-effort list, so we're never worse than before.
185///
186/// Used by the run path (which prepends the resolved binary's own dir on top
187/// of this) and by readiness probes that locate `claude`/`codex` via a bare
188/// `Command::new(name)`. Computed once and cached for the process — the
189/// (bounded) shell spawn happens at most once per launch, lazily on the first
190/// readiness/run/login, never at construction.
191pub fn augmented_path() -> String {
192    static CACHED: OnceLock<String> = OnceLock::new();
193    CACHED.get_or_init(compute_augmented_path).clone()
194}
195
196fn compute_augmented_path() -> String {
197    // The user's real PATH (nvm/pnpm/volta/asdf/Homebrew) via their login
198    // shell; a hardcoded best-effort list if that's unavailable.
199    let discovered = login_shell_path().unwrap_or_else(hardcoded_node_dirs);
200    compose_augmented_path(std::env::var("PATH").ok(), discovered)
201}
202
203/// The process's own PATH first — anything explicitly set still wins — then
204/// whatever discovery turned up.
205///
206/// Takes both as arguments rather than reading the environment, so the
207/// "already set" case can be tested with a PATH this process does not have.
208/// Reading it directly, the only assertion available was that some entry of
209/// the real PATH survived — and the discovered PATH contains those same
210/// entries, so the test passed whether the guard worked or not.
211fn compose_augmented_path(process_path: Option<String>, discovered: String) -> String {
212    let mut entries: Vec<PathBuf> = Vec::new();
213    if let Some(existing) = process_path.filter(|path| !path.is_empty()) {
214        entries.extend(std::env::split_paths(&existing));
215    }
216    entries.extend(std::env::split_paths(&discovered));
217    let joined = std::env::join_paths(entries)
218        .map(|value| value.to_string_lossy().into_owned())
219        .unwrap_or_default();
220    keep_absolute_entries(&joined)
221}
222
223/// Keep only **absolute** PATH entries, dropping relative or empty ones (`.`,
224/// `""`, a direnv-style `node_modules/.bin`). Security: we spawn with
225/// `current_dir` set to the user's workspace — where the agent itself writes
226/// files and synced/downloaded content lands — so a relative/empty PATH entry
227/// (which resolves against that cwd) could run a planted `node`/`claude`. An
228/// empty entry is the classic implicit-cwd vector. Absolute dirs only.
229fn keep_absolute_entries(path: &str) -> String {
230    // `split_paths` / `join_paths` and `Path::is_absolute`, not `:` and
231    // `starts_with('/')`: Windows separates with `;` and its entries begin
232    // `C:\`, so the unix forms shredded a real PATH into fragments, dropped
233    // every one as "not absolute", and left only the fallback — an augmented
234    // PATH made entirely of unix directories that machine does not have.
235    let absolute: Vec<PathBuf> =
236        std::env::split_paths(path).filter(|entry| entry.is_absolute()).collect();
237    std::env::join_paths(absolute)
238        .map(|joined| joined.to_string_lossy().into_owned())
239        .unwrap_or_default()
240}
241
242/// Resolve PATH by asking the user's login + interactive shell — it sources
243/// their rc, so it knows wherever any node manager (nvm / pnpm / volta / asdf /
244/// fnm / Homebrew) put `node`, without us guessing. Bounded by a timeout so a
245/// slow or interactive rc can't hang us; returns `None` (→ hardcoded fallback)
246/// on any failure: no `$SHELL`, spawn refused (e.g. a sandboxed app), timeout,
247/// or no PATH in the output. Reads PATH from `env` (OS colon format,
248/// shell-agnostic — works for fish too) rather than expanding `$PATH`.
249///
250/// This *executes the user's shell rc*, exactly as opening a terminal does —
251/// their own shell, on their own machine. It is not a privilege/auth step: no
252/// "login session" is created; `-l`/`-i` only select which startup files are
253/// sourced (login profiles + the interactive rc where nvm usually lives).
254/// Printed on its own line right before `env`, so the parser can skip any
255/// shell-init chatter / terminal escape sequences (e.g. iTerm2 shell
256/// integration's `]1337;…` OSC codes) the interactive shell emits before our
257/// command runs — which would otherwise prepend to the `PATH=` line.
258/// Unix only, and a module rather than scattered `#[cfg]` attributes: the
259/// imports this needs are unused on Windows, and gating them one by one is how
260/// `Arc`/`Mutex` ended up gated in `cli-stream` while the code using them was
261/// not — a break nobody sees without cross-compiling. Kept together, a mismatch
262/// cannot compile on either platform.
263#[cfg(unix)]
264mod login_shell {
265    use std::io::Read;
266    use std::process::{Command, Stdio};
267    use std::sync::mpsc;
268    use std::thread;
269    use std::time::Duration;
270
271    const PATH_SENTINEL: &str = "__CLI_STREAM_PATH__";
272
273    pub(super) fn query() -> Option<String> {
274        let shell = std::env::var("SHELL").ok().filter(|s| !s.is_empty())?;
275        // Print a sentinel line, then dump the environment. Reading PATH from `env`
276        // (not by expanding `$PATH`) keeps it OS colon format and shell-agnostic
277        // (fish stores PATH as a list); the sentinel lets the parser ignore
278        // anything the interactive shell prints at startup before `env` runs.
279        let script = format!("printf '\\n{PATH_SENTINEL}\\n'; env");
280        let mut child = Command::new(&shell)
281            .arg("-lic") // -l: login profiles, -i: interactive rc (nvm), -c: command
282            .arg(&script)
283            .stdin(Stdio::null())
284            .stdout(Stdio::piped())
285            .stderr(Stdio::null())
286            .spawn()
287            .ok()?;
288        // Read on a worker thread so the whole query can be bounded by a timeout —
289        // a misbehaving rc must not hang the app. Read bytes + lossy-decode (rather
290        // than `read_to_string`) so non-UTF-8 in the env dump degrades to
291        // replacement chars instead of discarding the whole output.
292        let mut stdout = child.stdout.take()?;
293        let (tx, rx) = mpsc::channel();
294        thread::spawn(move || {
295            let mut buf = Vec::new();
296            let _ = stdout.read_to_end(&mut buf);
297            let _ = tx.send(String::from_utf8_lossy(&buf).into_owned());
298        });
299        // 4s: generous enough for a heavy rc (oh-my-zsh + plugins + nvm lazy-load)
300        // to finish, since this is paid at most once (cached); on timeout we kill
301        // the shell and fall back to the hardcoded list.
302        let output = match rx.recv_timeout(Duration::from_secs(4)) {
303            Ok(buf) => buf,
304            Err(_) => {
305                let _ = child.kill();
306                let _ = child.wait();
307                return None;
308            }
309        };
310        let _ = child.wait();
311        parse_path_from_shell_output(&output)
312    }
313
314    /// Extract the `PATH=…` value from the shell's `printf <sentinel>; env` output.
315    /// Everything up to (and including) the last sentinel is discarded — that's
316    /// where shell-init chatter and terminal escape sequences live — then the
317    /// `PATH=` line is read from the clean `env` dump that follows. `None` if the
318    /// sentinel is missing (query misbehaved) or PATH is absent/empty.
319    pub(super) fn parse_path_from_shell_output(output: &str) -> Option<String> {
320        output
321            .rsplit_once(PATH_SENTINEL)?
322            .1
323            .lines()
324            .find_map(|line| line.strip_prefix("PATH="))
325            .map(str::trim)
326            .filter(|p| !p.is_empty())
327            .map(str::to_owned)
328    }
329
330} // mod login_shell
331
332/// The user's real PATH, or `None` where we cannot ask: the query is a POSIX
333/// shell invocation, so Windows falls straight through to the hardcoded list.
334fn login_shell_path() -> Option<String> {
335    #[cfg(unix)]
336    {
337        login_shell::query()
338    }
339    #[cfg(not(unix))]
340    {
341        None
342    }
343}
344
345/// Hardcoded best-effort node locations — the fallback when the login-shell
346/// query is unavailable. Leans on the *universal* dirs every distro + macOS
347/// share: `/usr/bin` + `/usr/local/bin` are where apt/dnf/yum/pacman and the
348/// official Node tarball install, so the common Linux container case is covered
349/// without distro-specific guessing. Plus macOS Homebrew, the official-installer
350/// dir, and any nvm-managed node. Anything manager-specific (pnpm/volta/asdf,
351/// Linuxbrew, snap, …) is what the login-shell query is for — and a missing
352/// dir is just skipped, so this is never worse than the bare launchd PATH.
353fn hardcoded_node_dirs() -> String {
354    // Every directory below is a unix convention, and the login-shell query
355    // this backs up is unix-only too. On Windows the process PATH is already
356    // the whole answer — there is no rc file a spawn misses — so guessing adds
357    // nothing, and once cost the real PATH entirely.
358    if cfg!(windows) {
359        return String::new();
360    }
361    let mut parts: Vec<String> =
362        vec!["/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_owned()];
363    if let Ok(home) = std::env::var("HOME") {
364        if !home.is_empty() {
365            let home_path = Path::new(&home);
366            // Official-installer location for several agent CLIs.
367            parts.push(home_path.join(".local/bin").display().to_string());
368            // nvm: ~/.nvm/versions/node/<version>/bin — where npm-global
369            // CLIs (bob, claude, codex) live under an nvm-managed node.
370            if let Ok(entries) = std::fs::read_dir(home_path.join(".nvm/versions/node")) {
371                for entry in entries.flatten() {
372                    let bin = entry.path().join("bin");
373                    if bin.is_dir() {
374                        parts.push(bin.display().to_string());
375                    }
376                }
377            }
378        }
379    }
380    parts.join(":")
381}
382
383
384/// Resolving an agent's CLI before running it.
385///
386/// An extension on [`cli_stream::Command`], so resolution reads as a step in
387/// the same builder rather than a function wrapping it:
388///
389/// ```no_run
390/// use cli_stream::Command;
391/// use harness::ResolveCli;
392///
393/// # fn main() -> Result<(), cli_stream::StreamError> {
394/// let handle = Command::new("claude").args(["-p", "hi"]).resolve_cli().stream(|_| {})?;
395/// # let _ = handle;
396/// # Ok(())
397/// # }
398/// ```
399pub trait ResolveCli {
400    /// Resolve a bare program name to its absolute path, and put that program's
401    /// own directory at the front of `PATH`.
402    ///
403    /// Every adapter driving a CLI goes through here. The engine takes the
404    /// environment it is given — spawning is its job, knowing where a user's
405    /// nvm lives is not — so this is the one place that knowledge is applied,
406    /// and the first place to look when a packaged app cannot find a CLI that a
407    /// terminal finds fine.
408    ///
409    /// A `PATH` the caller set still wins: it is applied after this one.
410    #[must_use]
411    fn resolve_cli(self) -> Self;
412}
413
414impl ResolveCli for cli_stream::Command {
415    fn resolve_cli(self) -> Self {
416        let program = resolve_program(self.program);
417        let mut env = vec![("PATH".to_owned(), augment_path_for_program(&program))];
418        env.extend(self.env);
419        cli_stream::Command { program, env, ..self }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use proptest::prelude::*;
427
428    /// PATH-ish entries: absolute dirs, plus every shape the filter exists to
429    /// reject — relative, bare, dot, and empty.
430    fn path_entry() -> impl Strategy<Value = String> {
431        prop_oneof![
432            4 => "/(usr|opt|home)(/[a-z]{1,6}){0,3}",
433            1 => "[a-z]{1,6}(/[a-z]{1,6}){0,2}",
434            1 => Just(".".to_owned()),
435            1 => Just(String::new()),
436        ]
437    }
438
439    fn path_string() -> impl Strategy<Value = String> {
440        prop::collection::vec(path_entry(), 0..8).prop_map(|entries| entries.join(":"))
441    }
442
443    /// Split the way the platform joins — a literal `:` cuts a Windows
444    /// `C:\...` entry in half and asserts on the fragment.
445    fn entries(path: &str) -> Vec<String> {
446        std::env::split_paths(path).map(|e| e.to_string_lossy().into_owned()).collect()
447    }
448
449    /// The bug CI found, pinned on whichever platform runs it: a PATH is
450    /// composed with the platform's own separator, so a real one survives
451    /// instead of being shredded into fragments that all look relative.
452    #[test]
453    fn a_real_path_survives_composition() {
454        let (process_dir, discovered_dir) = if cfg!(windows) {
455            (r"C:\Windows\System32", r"C:\tools\bin")
456        } else {
457            ("/usr/bin", "/opt/tools/bin")
458        };
459        let join = |dir: &str| {
460            std::env::join_paths([dir]).expect("joinable").into_string().expect("utf-8")
461        };
462
463        let composed = compose_augmented_path(Some(join(process_dir)), join(discovered_dir));
464        let kept: Vec<PathBuf> = std::env::split_paths(&composed).collect();
465
466        assert_eq!(
467            kept.first(),
468            Some(&PathBuf::from(process_dir)),
469            "the process PATH must lead: {composed}"
470        );
471        assert!(
472            kept.contains(&PathBuf::from(discovered_dir)),
473            "the discovered PATH must survive: {composed}"
474        );
475    }
476
477    proptest! {
478        /// The security invariant, stated once for every input rather than for
479        /// three examples: nothing that could resolve against the spawn cwd —
480        /// the user's workspace, where the agent itself writes files — survives.
481        #[test]
482        fn no_entry_that_resolves_against_the_cwd_survives(path in path_string()) {
483            let kept = keep_absolute_entries(&path);
484            if kept.is_empty() {
485                return Ok(());
486            }
487            for entry in entries(&kept) {
488                prop_assert!(Path::new(&entry).is_absolute(), "{entry:?} is not absolute");
489            }
490        }
491
492        /// The other half, and the one a safety property cannot state: every
493        /// real directory survives. "Drop everything" satisfies "nothing
494        /// relative survives" perfectly, and would present every installed CLI
495        /// as missing — so the filter has to be pinned from both sides.
496        #[test]
497        fn every_absolute_directory_survives(path in path_string()) {
498            let kept = keep_absolute_entries(&path);
499            let survivors = entries(&kept);
500            for entry in entries(&path).into_iter().filter(|e| Path::new(e).is_absolute()) {
501                prop_assert!(survivors.contains(&entry), "dropped {entry:?}");
502            }
503        }
504
505        /// And it only ever removes: no entry is invented or rewritten.
506        #[test]
507        fn filtering_never_invents_an_entry(path in path_string()) {
508            let kept = keep_absolute_entries(&path);
509            if kept.is_empty() {
510                return Ok(());
511            }
512            let original = entries(&path);
513            for entry in entries(&kept) {
514                prop_assert!(original.contains(&entry), "{entry:?} was not in the input");
515            }
516        }
517
518        /// Prepending the program's own directory must not undo the filter.
519        /// It runs *after* `keep_absolute_entries` and lands at the front, so a
520        /// relative directory here outranks every real one.
521        #[test]
522        fn prepending_cannot_reintroduce_a_cwd_relative_entry(
523            program in "([a-z]{1,6}/){0,3}[a-z]{1,6}",
524            base in path_string(),
525        ) {
526            let base = keep_absolute_entries(&base);
527            let combined = prepend_program_dir(Path::new(&program), &base);
528            if combined.is_empty() {
529                return Ok(());
530            }
531            for entry in entries(&combined) {
532                prop_assert!(Path::new(&entry).is_absolute(), "{entry:?} is not absolute");
533            }
534        }
535
536        /// Prepending is additive: every directory already on the path is still
537        /// on it. Losing one silently makes a CLI "not installed".
538        #[test]
539        fn prepending_keeps_every_directory_it_was_given(base in path_string()) {
540            let base = keep_absolute_entries(&base);
541            let combined = prepend_program_dir(Path::new("/opt/tool/bin/claude"), &base);
542            for entry in entries(&base).into_iter().filter(|entry| !entry.is_empty()) {
543                prop_assert!(entries(&combined).contains(&entry), "lost {entry:?}");
544            }
545        }
546    }
547
548    /// A throwaway CLI that answers however the test needs.
549    #[cfg(unix)]
550    fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
551        use std::os::unix::fs::PermissionsExt;
552        let dir = std::env::temp_dir().join(format!("cs-probe-{tag}-{}", std::process::id()));
553        std::fs::create_dir_all(&dir).unwrap();
554        let path = dir.join("cli");
555        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
556        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
557        path
558    }
559
560    #[cfg(unix)]
561    #[test]
562    fn a_version_is_only_reported_when_the_cli_actually_gave_one() {
563        // This is what "installed" means to every adapter that wraps a CLI, so
564        // an empty or failed `--version` must not read as a successful probe.
565        let ok = fake_cli("version", "echo '1.2.3 (Some CLI)'");
566        assert_eq!(probe_version(ok.to_str().unwrap()).as_deref(), Some("1.2.3 (Some CLI)"));
567
568        let blank = fake_cli("blank", "exit 0");
569        assert_eq!(probe_version(blank.to_str().unwrap()), None, "no version is not a version");
570
571        let broken = fake_cli("broken", "echo 9.9.9; exit 3");
572        assert_eq!(probe_version(broken.to_str().unwrap()), None, "a failed probe is not installed");
573
574        assert_eq!(probe_version("definitely-not-a-real-binary-xyz"), None, "and neither is an absent one");
575    }
576
577    // The fallback's contents are unix conventions — Homebrew, `~/.local/bin`,
578    // nvm's layout — and it is empty on Windows by design, so these assert
579    // about a platform rather than about behaviour.
580    #[cfg(unix)]
581    #[test]
582    fn hardcoded_fallback_includes_macos_defaults() {
583        // The fallback (used when the login-shell query is unavailable) must
584        // still carry Homebrew + the system bins, so a launchd-spawned `.app`
585        // resolves CLIs even without a usable shell — the original
586        // "not installed" fix.
587        let path = hardcoded_node_dirs();
588        assert!(
589            path.contains("/opt/homebrew/bin"),
590            "missing Apple-Silicon Homebrew bin"
591        );
592        assert!(
593            path.contains("/usr/local/bin"),
594            "missing Intel Homebrew / system bin"
595        );
596        assert!(path.contains("/usr/bin"), "missing system bin");
597    }
598
599    #[cfg(unix)]
600    #[test]
601    fn parse_path_from_shell_output_skips_chatter_before_the_sentinel() {
602        use super::login_shell::parse_path_from_shell_output;
603
604        // Real-world shape: iTerm2 OSC escapes + a banner emitted at shell
605        // startup, BEFORE our sentinel + `env` dump. Only the post-sentinel
606        // PATH= line counts — note the pre-sentinel "PATH=/decoy" is ignored.
607        let output = "\u{1b}]1337;RemoteHost=x\u{7}welcome banner\nPATH=/decoy\n__CLI_STREAM_PATH__\nHOME=/Users/x\nPATH=/opt/homebrew/bin:/usr/bin\nLANG=en_US";
608        assert_eq!(
609            parse_path_from_shell_output(output).as_deref(),
610            Some("/opt/homebrew/bin:/usr/bin")
611        );
612        // No sentinel (query misbehaved) → None, so the caller falls back —
613        // even if a bare PATH= is present.
614        assert_eq!(parse_path_from_shell_output("PATH=/usr/bin"), None);
615        // Sentinel present but PATH absent/empty → None.
616        assert_eq!(
617            parse_path_from_shell_output("__CLI_STREAM_PATH__\nFOO=bar"),
618            None
619        );
620        assert_eq!(
621            parse_path_from_shell_output("__CLI_STREAM_PATH__\nPATH=\nFOO=bar"),
622            None
623        );
624    }
625
626    #[test]
627    fn keep_absolute_entries_drops_relative_and_empty() {
628        // Relative (`node_modules/.bin`, `.`) and empty entries — which resolve
629        // against the spawn cwd (the user's workspace) — are dropped; absolute
630        // dirs survive in order.
631        // Built with the platform's own separator: the filter is the security
632        // boundary, so it has to be asserted on Windows too, and a literal `:`
633        // there splits `C:\...` into a fragment that fails for the wrong
634        // reason.
635        let (a, b, c) = if cfg!(windows) {
636            (r"C:\tools\bin", r"C:\Windows\System32", r"C:\Windows")
637        } else {
638            ("/opt/homebrew/bin", "/usr/bin", "/bin")
639        };
640        let mixed = [a, "node_modules/.bin", b, ".", "", c].join(&sep().to_string());
641        let expected = [a, b, c].join(&sep().to_string());
642        assert_eq!(keep_absolute_entries(&mixed), expected);
643        assert_eq!(keep_absolute_entries(b), b);
644        // All-relative → empty (caller still has the process PATH ahead of it).
645        let relative_only = [".", "rel", ""].join(&sep().to_string());
646        assert_eq!(keep_absolute_entries(&relative_only), "");
647    }
648
649    #[test]
650    fn pathext_becomes_suffixes_with_the_empty_ones_dropped() {
651        // A trailing or doubled `;` is ordinary in a real PATHEXT, and an empty
652        // suffix would probe the bare name a second time rather than a variant.
653        assert_eq!(
654            split_extensions(".EXE;.CMD;;.BAT;"),
655            [".EXE", ".CMD", ".BAT"],
656        );
657        // What unix supplies: no suffixes, so only the bare name is tried.
658        assert!(split_extensions("").is_empty());
659    }
660
661    #[test]
662    fn prepend_program_dir_puts_the_binary_dir_first() {
663        // Absolute paths and the PATH separator are both platform-shaped, so
664        // the expectation is built with the platform's own joiner rather than
665        // a literal `:` that only holds on unix.
666        let bin = if cfg!(windows) { r"C:\tools\bin" } else { "/opt/tools/bin" };
667        let other = if cfg!(windows) { r"C:\Windows\System32" } else { "/usr/bin" };
668        let base = std::env::join_paths([other]).expect("joinable").into_string().expect("utf-8");
669
670        let combined = prepend_program_dir(&Path::new(bin).join("bob"), &base);
671        let entries: Vec<PathBuf> = std::env::split_paths(&combined).collect();
672        assert_eq!(entries.first(), Some(&PathBuf::from(bin)), "the binary's dir leads: {combined}");
673        assert!(entries.contains(&PathBuf::from(other)), "and the base survives: {combined}");
674
675        // A bare program name has no parent dir → base path unchanged.
676        assert_eq!(prepend_program_dir(Path::new("bob"), &base), base);
677    }
678
679    #[cfg(unix)]
680    #[test]
681    fn only_a_runnable_file_counts_as_the_program() {
682        // This is the filter that decides whether a name found on PATH is a CLI
683        // we can run. Saying yes to a directory or an unexecutable file picks it
684        // over the real binary further down PATH.
685        use std::os::unix::fs::PermissionsExt;
686        let dir = std::env::temp_dir().join(format!("hl-exec-{}", std::process::id()));
687        std::fs::create_dir_all(&dir).unwrap();
688
689        let runnable = dir.join("runnable");
690        std::fs::write(&runnable, "#!/bin/sh\n").unwrap();
691        std::fs::set_permissions(&runnable, std::fs::Permissions::from_mode(0o755)).unwrap();
692        assert!(is_executable_file(&runnable));
693
694        let plain = dir.join("plain.txt");
695        std::fs::write(&plain, "not a program").unwrap();
696        assert!(!is_executable_file(&plain), "a readable file is not a runnable one");
697        assert!(!is_executable_file(&dir), "a directory is not a program");
698        assert!(!is_executable_file(&dir.join("absent")), "and neither is nothing");
699
700        let _ = std::fs::remove_dir_all(&dir);
701    }
702
703    #[test]
704    fn a_bare_name_is_resolved_to_the_binary_it_will_actually_run() {
705        // The point of resolving before spawning: the absolute path is what
706        // pairs a CLI with the `node` beside it. Left as a bare name, the child
707        // resolves it against whatever PATH it ends up with instead.
708        // A program every platform has. On Windows this also exercises the
709        // PATHEXT search, which is the half `sh` alone would never reach.
710        let name = if cfg!(windows) { "cmd" } else { "sh" };
711        let resolved = resolve_program(PathBuf::from(name));
712        assert!(resolved.is_absolute(), "a name on PATH resolves to its real location: {resolved:?}");
713        assert!(
714            resolved.file_stem().is_some_and(|stem| stem.eq_ignore_ascii_case(name)),
715            "and to the right binary: {resolved:?}"
716        );
717
718        let unknown = PathBuf::from("definitely-not-a-real-binary-xyz");
719        assert_eq!(
720            resolve_program(unknown.clone()),
721            unknown,
722            "an unresolvable name is left alone so the spawn reports the real error"
723        );
724    }
725
726    #[test]
727    fn the_augmented_path_extends_the_one_we_already_have() {
728        // Augmenting must add, never replace: a PATH the host deliberately set
729        // has to keep working, or a run that was fine becomes "not installed".
730        let existing = std::env::var("PATH").expect("a test process has a PATH");
731        let augmented = compute_augmented_path();
732        // `split_paths` knows the platform's separator; `:` would split a
733        // Windows `C:\...` entry in half and assert on the fragment.
734        let first = std::env::split_paths(&existing)
735            .find(|entry| entry.is_absolute())
736            .expect("an absolute entry");
737        let kept = std::env::split_paths(&augmented).any(|entry| entry == first);
738        assert!(kept, "{first:?} must survive into {augmented}");
739    }
740
741    #[test]
742    fn the_process_path_leads_and_an_absent_one_contributes_nothing() {
743        // Order is the whole point: a PATH the host deliberately set has to be
744        // searched before anything we discovered, or we override a deliberate
745        // choice. Asserted against directories this process does not have, so
746        // it cannot pass because the real PATH happened to contain them.
747        let (host, found) = if cfg!(windows) {
748            (r"C:\host\bin", r"C:\found\bin")
749        } else {
750            ("/host/bin", "/found/bin")
751        };
752        assert_eq!(
753            compose_augmented_path(Some(host.to_owned()), found.to_owned()),
754            [host, found].join(&sep().to_string()),
755        );
756        // Unset and empty both mean "nothing to keep" — and must not leave an
757        // empty entry behind, which is the implicit-cwd vector.
758        assert_eq!(compose_augmented_path(None, found.to_owned()), found);
759        assert_eq!(compose_augmented_path(Some(String::new()), found.to_owned()), found);
760    }
761
762    /// The PATH *list* separator, which `std::path::MAIN_SEPARATOR` is not —
763    /// that one separates components within a single path.
764    fn sep() -> char {
765        if cfg!(windows) {
766            ';'
767        } else {
768            ':'
769        }
770    }
771
772    #[cfg(unix)]
773    #[test]
774    fn the_fallback_looks_where_agent_clis_are_actually_installed() {
775        // Used when the login shell cannot be asked. Missing the home-relative
776        // directories is what leaves an nvm-installed CLI invisible.
777        let dirs = hardcoded_node_dirs();
778        assert!(dirs.contains("/usr/local/bin") && dirs.contains("/opt/homebrew/bin"));
779        if let Ok(home) = std::env::var("HOME") {
780            if !home.is_empty() {
781                assert!(
782                    dirs.contains(&format!("{home}/.local/bin")),
783                    "the official-installer location is where several agent CLIs land: {dirs}"
784                );
785            }
786        }
787    }
788
789    #[test]
790    fn augmented_path_is_nonempty_and_usable() {
791        // Exercises the cached public path once. `/usr/bin` is present whether
792        // the shell query succeeds (real PATH) or falls back (hardcoded), and
793        // is on the bare launchd PATH too — so this holds in any environment.
794        let path = augmented_path();
795        assert!(!path.is_empty(), "an empty PATH finds nothing at all");
796        // "A real directory to search", not "/usr/bin" — the claim is that the
797        // augmented PATH is usable, and naming a unix directory asserted the
798        // platform instead. On Windows the process PATH is the whole answer,
799        // and it must survive composition rather than being shredded.
800        let usable = std::env::split_paths(&path).any(|entry| entry.is_dir());
801        assert!(usable, "no entry of the augmented PATH is a real directory: {path}");
802    }
803
804    #[test]
805    fn resolve_program_returns_explicit_paths_untouched() {
806        // A caller-supplied path is the caller's choice — no PATH lookup.
807        let explicit = PathBuf::from("/opt/somewhere/bob");
808        assert_eq!(resolve_program(explicit.clone()), explicit);
809        let relative = PathBuf::from("./bin/bob");
810        assert_eq!(resolve_program(relative.clone()), relative);
811    }
812
813    #[cfg(unix)]
814    #[test]
815    fn resolve_on_path_finds_the_first_executable_match() {
816        use std::os::unix::fs::PermissionsExt;
817        let root = tempfile::tempdir().expect("tempdir");
818        // dir_a holds a NON-executable `bob` (must be skipped); dir_b an
819        // executable one (must win even though dir_a comes first on PATH).
820        let dir_a = root.path().join("a");
821        let dir_b = root.path().join("b");
822        std::fs::create_dir_all(&dir_a).unwrap();
823        std::fs::create_dir_all(&dir_b).unwrap();
824        std::fs::write(dir_a.join("bob"), "#!/bin/sh\n").unwrap();
825        let exec = dir_b.join("bob");
826        std::fs::write(&exec, "#!/bin/sh\n").unwrap();
827        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
828
829        let path_env = format!("{}:{}", dir_a.display(), dir_b.display());
830        assert_eq!(resolve_on_path(Path::new("bob"), &path_env), Some(exec));
831        // An unknown name resolves to nothing.
832        assert_eq!(
833            resolve_on_path(Path::new("definitely-missing"), &path_env),
834            None
835        );
836    }
837
838    /// Write a file the resolver should accept as runnable, named the way the
839    /// platform names programs, and answer with the bare name to look it up by.
840    /// On Windows those differ — that gap *is* what `PATHEXT` probing closes.
841    fn install_runnable(dir: &Path, stem: &str) -> PathBuf {
842        #[cfg(unix)]
843        {
844            use std::os::unix::fs::PermissionsExt;
845            let path = dir.join(stem);
846            std::fs::write(&path, "#!/bin/sh\n").unwrap();
847            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
848            path
849        }
850        #[cfg(not(unix))]
851        {
852            let path = dir.join(format!("{stem}.EXE"));
853            std::fs::write(&path, "").unwrap();
854            path
855        }
856    }
857
858    #[cfg(unix)]
859    #[test]
860    fn unix_does_not_invent_a_suffix_the_os_would_not_run() {
861        // The other half of the test below, and the same lesson as
862        // `keep_absolute_entries`: asserting that Windows *does* probe suffixes
863        // says nothing about unix not doing it. Here a program's name is its
864        // file name, so `tool` must not be answered by `tool.EXE` — the OS
865        // would never run it for that name.
866        use std::os::unix::fs::PermissionsExt;
867        let root = tempfile::tempdir().expect("tempdir");
868        let decoy = root.path().join("tool.EXE");
869        std::fs::write(&decoy, "#!/bin/sh\n").unwrap();
870        std::fs::set_permissions(&decoy, std::fs::Permissions::from_mode(0o755)).unwrap();
871
872        let path_env = root.path().display().to_string();
873        assert_eq!(resolve_on_path(Path::new("tool"), &path_env), None);
874    }
875
876    #[test]
877    fn a_bare_name_resolves_however_the_platform_spells_the_file() {
878        // The unix cases above are gated, which left everything about
879        // resolution untested on Windows — including the suffix probing that
880        // exists only for Windows, where `claude` is `claude.exe`. This is the
881        // same assertion with the platform's own naming factored out, so CI
882        // exercises the probe on the platform it was written for.
883        let root = tempfile::tempdir().expect("tempdir");
884        let installed = install_runnable(root.path(), "tool");
885        let path_env = root.path().display().to_string();
886
887        assert_eq!(resolve_on_path(Path::new("tool"), &path_env), Some(installed));
888        assert_eq!(resolve_on_path(Path::new("tool-missing"), &path_env), None);
889    }
890}
891
892/// Spawning a real CLI and looking at the environment it actually got. Unix
893/// only: the fixture is a shell script.
894#[cfg(all(test, unix))]
895mod spawned {
896    use super::*;
897    use std::sync::{Arc, Mutex};
898
899    /// A CLI that prints the PATH it was handed, so a test can see what the
900    /// child really received rather than what we meant to send.
901    fn path_echoing_cli(tag: &str) -> PathBuf {
902        use std::os::unix::fs::PermissionsExt;
903        let dir = std::env::temp_dir().join(format!("hl-spawn-{tag}-{}", std::process::id()));
904        std::fs::create_dir_all(&dir).unwrap();
905        let cli = dir.join("fake-agent");
906        std::fs::write(&cli, "#!/bin/sh\nprintf '%s\\n' \"$PATH\"\n").unwrap();
907        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
908        cli
909    }
910
911    /// A stand-in for the user's login shell. It ignores its `-lic` arguments
912    /// and answers the way a real one does: startup chatter first, then the
913    /// sentinel, then an `env` dump — the shape the parser has to survive.
914    fn fake_login_shell(tag: &str, path_line: &str) -> PathBuf {
915        use std::os::unix::fs::PermissionsExt;
916        let dir = std::env::temp_dir().join(format!("hl-shell-{tag}-{}", std::process::id()));
917        std::fs::create_dir_all(&dir).unwrap();
918        let shell = dir.join("fake-shell");
919        std::fs::write(
920            &shell,
921            format!(
922                "#!/bin/sh\nprintf 'rc chatter\\n'\nprintf '\\n__CLI_STREAM_PATH__\\n'\n\
923                 printf 'HOME=/x\\n{path_line}\\nTERM=xterm\\n'\n"
924            ),
925        )
926        .unwrap();
927        std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
928        shell
929    }
930
931    /// `SHELL` is process-global, so the two cases below cannot run alongside
932    /// each other.
933    static SHELL_ENV: Mutex<()> = Mutex::new(());
934
935    #[test]
936    fn the_path_comes_from_the_shell_we_asked() {
937        // This is the mechanism behind a CLI reading as installed at all in a
938        // Finder-launched app, and it was previously covered only down to its
939        // parser — the spawn, the sentinel handshake and the `$SHELL` guard had
940        // nothing exercising them. A fake shell reaches all three without
941        // depending on how this machine's rc happens to be set up.
942        let _guard = SHELL_ENV.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
943        let restore = std::env::var("SHELL").ok();
944
945        let shell = fake_login_shell("ok", "PATH=/fake/node/bin:/usr/bin");
946        std::env::set_var("SHELL", &shell);
947        assert_eq!(
948            login_shell_path().as_deref(),
949            Some("/fake/node/bin:/usr/bin"),
950            "the answer must come from the shell, past its startup chatter",
951        );
952
953        // No shell to ask is not an empty PATH — the caller must fall back to
954        // the hardcoded list rather than treat "" as the user's real PATH.
955        std::env::set_var("SHELL", "");
956        assert_eq!(login_shell_path(), None);
957
958        match restore {
959            Some(value) => std::env::set_var("SHELL", value),
960            None => std::env::remove_var("SHELL"),
961        }
962    }
963
964    fn run(program: PathBuf, env: Vec<(String, String)>) -> String {
965        let lines: Arc<Mutex<Vec<String>>> = Arc::default();
966        let sink = Arc::clone(&lines);
967        let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
968        let flag = Arc::clone(&done);
969        let spawn = cli_stream::Command::new(program).cwd(std::env::temp_dir()).run_id("t").env(env);
970        let _handle = spawn.resolve_cli().stream(move |event| {
971            match event {
972                cli_stream::Event::Stdout { line, .. } => sink.lock().unwrap().push(line),
973                cli_stream::Event::Exited { .. } => flag.store(true, std::sync::atomic::Ordering::SeqCst),
974                _ => {}
975            }
976        })
977        .expect("the fixture should spawn");
978        let mut finished = false;
979        for _ in 0..200 {
980            if done.load(std::sync::atomic::Ordering::SeqCst) {
981                finished = true;
982                break;
983            }
984            std::thread::sleep(std::time::Duration::from_millis(25));
985        }
986        assert!(finished, "the fixture never exited; its output would be whatever arrived in time");
987        let out = lines.lock().unwrap().join("\n");
988        out
989    }
990
991    #[test]
992    fn a_spawned_cli_gets_its_own_directory_at_the_front_of_path() {
993        // The whole reason this module exists. A Finder-launched .app inherits
994        // `/usr/bin:/bin:/usr/sbin:/sbin`, so a CLI installed under nvm cannot
995        // see the `node` it was installed beside and exits 127.
996        //
997        // Nothing in a terminal reproduces that — `open <app>` leaks the
998        // launching shell's PATH and passes either way — so this assertion is
999        // the only thing standing between the fix and its silent removal.
1000        let cli = path_echoing_cli("front");
1001        let parent = cli.parent().unwrap().display().to_string();
1002        let seen = run(cli.clone(), Vec::new());
1003
1004        assert!(
1005            seen.starts_with(&parent),
1006            "the program's own directory must lead PATH.\n  wanted first: {parent}\n  child saw:    {seen}"
1007        );
1008        let _ = std::fs::remove_dir_all(cli.parent().unwrap());
1009    }
1010
1011    #[test]
1012    fn a_path_the_caller_supplies_still_wins() {
1013        // Documented behaviour: the augmentation is a floor, not a cage. A host
1014        // that knows exactly which environment it wants gets it.
1015        let cli = path_echoing_cli("override");
1016        let seen = run(cli.clone(), vec![("PATH".to_owned(), "/only/this".to_owned())]);
1017        assert_eq!(seen.trim(), "/only/this", "the caller's PATH is applied last");
1018        let _ = std::fs::remove_dir_all(cli.parent().unwrap());
1019    }
1020}
1021