diff --git a/CHANGELOG.md b/CHANGELOG.md index 5175213..68ea464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **The panel no longer claims installed CLIs are "not available on PATH".** + macOS launches GUI apps — and the MCP servers GUI clients spawn — with the + bare launchd `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), so the panel could + not see a gcloud living in `~/google-cloud-sdk/bin` or a Homebrew-installed + gh, and every tier-2 button answered "install it" for a tool that works + fine in the terminal. The panel and `patchbay-mcp` now detect that bare + inheritance at startup and adopt the login shell's `PATH` (asked of the + user's own shell, with a hard timeout), so they resolve exactly the + binaries a terminal would. Terminal launches are untouched: a `PATH` with + any user entry on it is left alone. + ## [0.4.0] - 2026-08-17 ### Added diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9175030..ec585b1 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -369,6 +369,10 @@ async fn mcp_copy( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Launched from Finder or the Dock, this process gets launchd's bare + // PATH and every `which gcloud` inside the probes fails. Adopt the login + // shell's PATH before the first command can run a probe. + patchbay_core::adopt_login_shell_path(); tauri::Builder::default() // Self-update. The updater reads the signed feed named in // tauri.conf.json; `process` supplies the relaunch that takes the user diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index d2a1fdc..11ad9ad 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -46,6 +46,7 @@ pub mod paths; pub mod probe; pub mod probes; pub mod registry; +pub mod shell_path; pub mod types; pub mod util; pub mod versions; @@ -69,6 +70,7 @@ pub use migrate::{ pub use paths::Paths; pub use probe::Probe; pub use registry::Registry; +pub use shell_path::adopt_login_shell_path; pub use types::{ ActiveConcept, ConnectionState, Expiry, KeyRef, Note, NoteKind, PermissionScope, PermissionsReport, Profile, SwitchOutcome, ToolCategory, ToolStatus, VerifyOutcome, diff --git a/crates/patchbay-core/src/shell_path.rs b/crates/patchbay-core/src/shell_path.rs new file mode 100644 index 0000000..2e97214 --- /dev/null +++ b/crates/patchbay-core/src/shell_path.rs @@ -0,0 +1,197 @@ +//! `PATH` repair for processes launched outside a login shell. +//! +//! macOS hands GUI apps and launchd children a bare +//! `/usr/bin:/bin:/usr/sbin:/sbin` — nothing the user's shell startup files +//! add (Homebrew, `~/google-cloud-sdk/bin`, npm prefixes, cargo) is on it. +//! Inside such a process every `which` lookup fails, so the panel and an MCP +//! server spawned by a GUI client both report installed CLIs as "not available +//! on PATH" while the same probe in a terminal finds them instantly. +//! +//! [`adopt_login_shell_path`] closes the gap: when — and only when — the +//! inherited `PATH` looks like the launchd default, it asks the user's own +//! shell (as a login + interactive shell, so `~/.zprofile` *and* `~/.zshrc` +//! both get their say) what `PATH` it would give a terminal, and adopts that +//! for the rest of the process. Terminal launches pay nothing: their `PATH` +//! already carries user entries, which fails the launchd-default test. +//! +//! The shell runs with stdin and stderr on `/dev/null` and a hard timeout, so +//! a prompt-happy rc file can delay startup by at most [`SHELL_TIMEOUT`], never +//! hang it. Its stdout is parsed only between two markers printed by our own +//! command; rc-file chatter outside them is discarded unread. + +use std::time::Duration; + +/// Two of these bracket the `PATH` value in the shell's stdout, so rc-file +/// output cannot masquerade as it. +const MARKER: &str = "<>"; + +/// How long the login shell gets before it is killed and the repair skipped. +/// Generous: nvm-laden zshrcs take around a second, not five. +const SHELL_TIMEOUT: Duration = Duration::from_secs(5); + +/// Replace this process's `PATH` with the user's login-shell `PATH` when the +/// inherited one is the bare launchd default. Call once, at the top of `main`, +/// before anything resolves or spawns a CLI. A no-op in a terminal, on +/// non-unix platforms, and whenever the shell cannot answer. +pub fn adopt_login_shell_path() { + #[cfg(unix)] + { + let current = std::env::var("PATH").unwrap_or_default(); + if !is_launchd_default(¤t) { + return; + } + let Some(shell_path) = login_shell_path() else { + return; + }; + let merged = merge_paths(&shell_path, ¤t); + if !merged.is_empty() && merged != current { + std::env::set_var("PATH", merged); + } + } +} + +/// `true` when every entry is one launchd (or an installer's postflight) puts +/// there on its own — i.e. no evidence a user-configured environment reached +/// this process. One entry outside the set means someone set a real `PATH`, +/// and second-guessing it would be wrong more often than right. +fn is_launchd_default(path: &str) -> bool { + const SYSTEM_DIRS: &[&str] = &[ + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + "/usr/local/bin", + "/Library/Apple/usr/bin", + "/System/Cryptexes/App/usr/bin", + ]; + let mut entries = path.split(':').filter(|e| !e.is_empty()).peekable(); + entries.peek().is_some() && entries.all(|e| SYSTEM_DIRS.contains(&e)) +} + +/// `preferred` first, then whatever `current` adds, first occurrence wins. +/// The login shell's ordering is what the user's terminal resolves with, so +/// it must also be what decides between two installs of the same tool here. +fn merge_paths(preferred: &str, current: &str) -> String { + let mut seen: Vec<&str> = Vec::new(); + for entry in preferred.split(':').chain(current.split(':')) { + if !entry.is_empty() && !seen.contains(&entry) { + seen.push(entry); + } + } + seen.join(":") +} + +/// The command the login shell runs: print `PATH` between two markers. +/// fish spells "the colon-joined PATH" differently, every POSIX-ish shell +/// (zsh, bash, sh, dash, ksh) accepts the `"$PATH"` form. +fn print_path_command(shell: &str) -> String { + if shell.rsplit('/').next() == Some("fish") { + format!("printf '{MARKER}%s{MARKER}' (string join : $PATH)") + } else { + format!("printf '{MARKER}%s{MARKER}' \"$PATH\"") + } +} + +/// The text our own printf produced, or `None` when the markers never showed +/// up (a shell that refused the command, an rc file that exec'd away). +fn between_markers(output: &str) -> Option<&str> { + let start = output.find(MARKER)? + MARKER.len(); + let len = output[start..].find(MARKER)?; + Some(&output[start..start + len]) +} + +/// Ask `$SHELL` (login + interactive) for its `PATH`. `None` on any failure — +/// the caller keeps the `PATH` it has, which is the worst case today, not a +/// new one. +#[cfg(unix)] +fn login_shell_path() -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::Instant; + + let shell = std::env::var("SHELL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/bin/sh".to_string()); + + let mut child = Command::new(&shell) + .args(["-l", "-i", "-c", &print_path_command(&shell)]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + // Poll rather than block: an rc file waiting on a prompt must cost a + // bounded delay, not a hung process. The single printf cannot fill the + // pipe, so the child never blocks on us either. + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if start.elapsed() < SHELL_TIMEOUT => { + std::thread::sleep(Duration::from_millis(25)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + + let mut output = String::new(); + child.stdout.take()?.read_to_string(&mut output).ok()?; + between_markers(&output) + .map(str::to_string) + .filter(|p| !p.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launchd_default_is_recognised() { + assert!(is_launchd_default("/usr/bin:/bin:/usr/sbin:/sbin")); + assert!(is_launchd_default( + "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + )); + } + + /// One user entry — a Homebrew prefix, a home-dir bin — means a real + /// environment reached us and the repair must stand down. + #[test] + fn a_user_entry_defeats_the_launchd_test() { + assert!(!is_launchd_default( + "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + )); + assert!(!is_launchd_default( + "/Users/dev/google-cloud-sdk/bin:/usr/bin:/bin" + )); + assert!(!is_launchd_default("")); + } + + #[test] + fn merge_prefers_the_shell_order_and_deduplicates() { + assert_eq!( + merge_paths("/opt/homebrew/bin:/usr/bin:/bin", "/usr/bin:/bin:/sbin"), + "/opt/homebrew/bin:/usr/bin:/bin:/sbin" + ); + assert_eq!(merge_paths("", "/usr/bin::/bin"), "/usr/bin:/bin"); + } + + #[test] + fn markers_isolate_the_path_from_rc_chatter() { + let noisy = format!("welcome banner\n{MARKER}/a:/b{MARKER}\ntrailing"); + assert_eq!(between_markers(&noisy), Some("/a:/b")); + assert_eq!(between_markers("no markers here"), None); + assert_eq!(between_markers(MARKER), None); + } + + #[test] + fn fish_gets_its_own_spelling() { + assert!(print_path_command("/opt/homebrew/bin/fish").contains("string join")); + assert!(print_path_command("/bin/zsh").contains("\"$PATH\"")); + } +} diff --git a/crates/patchbay-mcp/src/main.rs b/crates/patchbay-mcp/src/main.rs index 53e623e..eec1cb6 100644 --- a/crates/patchbay-mcp/src/main.rs +++ b/crates/patchbay-mcp/src/main.rs @@ -23,6 +23,10 @@ use server::PatchbayServer; #[tokio::main] async fn main() -> anyhow::Result<()> { + // A GUI MCP client (Claude Desktop, an IDE) spawns this server with + // launchd's bare PATH, under which every `which gcloud` fails. Adopt the + // login shell's PATH before the first probe can resolve a binary. + patchbay_core::adopt_login_shell_path(); // Detect once: the probe set is bound to this machine's config paths, and // each probe re-reads its files on every call, so nothing goes stale. let registry = patchbay_core::Registry::detect()?;