Skip to main content

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