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