Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/patchbay-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
197 changes: 197 additions & 0 deletions crates/patchbay-core/src/shell_path.rs
Original file line number Diff line number Diff line change
@@ -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 = "<<patchbay:path>>";

/// 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(&current) {
return;
}
let Some(shell_path) = login_shell_path() else {
return;
};
let merged = merge_paths(&shell_path, &current);
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<String> {
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\""));
}
}
4 changes: 4 additions & 0 deletions crates/patchbay-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
Loading