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