Skip to content

Commit 27ed63a

Browse files
committed
fix: resolve npx/node PATH on macOS GUI apps
macOS GUI apps launched from Finder/Dock/Spotlight inherit a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) that excludes tools installed via Homebrew (/opt/homebrew/bin), nvm, Volta, fnm, or /usr/local/bin. This caused "Command not found: npx" errors even when npx was properly installed and worked in terminal. Add shell_env module that spawns the user's login shell ($SHELL -l -i -c) to resolve their fully-initialized PATH including entries from .zshrc, .bashrc, nvm init, Volta setup, etc. The result is cached via OnceLock. Changes: - New shell_env module resolves user's full PATH on Unix (no-op on Windows) - StdioTransport uses which::which_in() with shell PATH for command lookup - Shell PATH is injected into child process env so spawned processes (e.g., npx finding node) also get the full PATH - Falls back gracefully if shell resolution fails - Respects user-set PATH in env overrides Signed-off-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_01EYZVAqzgNBAAtJptw9KRwB Signed-off-by: Claude <noreply@anthropic.com>
1 parent 8120d55 commit 27ed63a

3 files changed

Lines changed: 247 additions & 10 deletions

File tree

crates/mcpmux-gateway/src/pool/transport/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
77
mod http;
88
pub mod resolution;
9-
mod stdio; // Expose resolution module
9+
pub mod shell_env;
10+
mod stdio;
1011

1112
use std::collections::HashMap;
1213
use std::sync::Arc;
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//! Shell environment resolution for GUI applications
2+
//!
3+
//! On macOS (and Linux), GUI applications launched from Finder/Dock/Spotlight
4+
//! inherit a minimal PATH that typically only includes `/usr/bin:/bin:/usr/sbin:/sbin`.
5+
//! This means tools installed via Homebrew (`/opt/homebrew/bin`), nvm, Volta, fnm,
6+
//! or standard `/usr/local/bin` are invisible to the app.
7+
//!
8+
//! This module resolves the user's full login shell PATH by spawning their default
9+
//! shell with login flags and reading back `$PATH`. The result is cached for the
10+
//! lifetime of the process.
11+
12+
use std::ffi::OsString;
13+
use std::sync::OnceLock;
14+
use tracing::{debug, info, warn};
15+
16+
/// Cached shell PATH, resolved once on first access.
17+
static SHELL_PATH: OnceLock<Option<OsString>> = OnceLock::new();
18+
19+
/// Get the user's full shell PATH.
20+
///
21+
/// On Unix (macOS / Linux), this spawns the user's login shell to read the
22+
/// fully-initialized `$PATH`, including entries added by `.zshrc`, `.bashrc`,
23+
/// `.profile`, nvm, Volta, Homebrew, etc.
24+
///
25+
/// On Windows, this returns `None` because Windows GUI apps inherit the full
26+
/// system + user PATH from the registry (no shell sourcing needed).
27+
///
28+
/// The result is cached after the first call.
29+
pub fn get_shell_path() -> Option<&'static OsString> {
30+
SHELL_PATH
31+
.get_or_init(|| {
32+
#[cfg(unix)]
33+
{
34+
resolve_unix_shell_path()
35+
}
36+
#[cfg(not(unix))]
37+
{
38+
None
39+
}
40+
})
41+
.as_ref()
42+
}
43+
44+
/// Resolve the full PATH from the user's login shell on Unix.
45+
///
46+
/// Strategy:
47+
/// 1. Read `$SHELL` to find the user's default shell (falls back to `/bin/sh`)
48+
/// 2. Spawn `$SHELL -l -i -c 'printf "%s" "$PATH"'` to get the fully-initialized PATH
49+
/// - `-l` (login): sources `/etc/profile`, `~/.zprofile` / `~/.bash_profile`
50+
/// - `-i` (interactive): sources `~/.zshrc` / `~/.bashrc` (where nvm/Volta/fnm init lives)
51+
/// - `printf` avoids trailing newlines that `echo` might add
52+
/// 3. If `-i` fails (some shells reject it in non-terminal contexts), retry with just `-l`
53+
/// 4. Merge the resolved PATH with the current process PATH to avoid losing any entries
54+
#[cfg(unix)]
55+
fn resolve_unix_shell_path() -> Option<OsString> {
56+
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
57+
info!("[ShellEnv] Resolving PATH from login shell: {}", shell);
58+
59+
// Try interactive login shell first (gets nvm/Volta/fnm paths from .zshrc/.bashrc)
60+
let shell_path = try_resolve_path_from_shell(&shell, &["-l", "-i", "-c"]).or_else(|| {
61+
debug!("[ShellEnv] Interactive shell failed, trying login-only");
62+
try_resolve_path_from_shell(&shell, &["-l", "-c"])
63+
});
64+
65+
let shell_path = match shell_path {
66+
Some(p) if !p.is_empty() => p,
67+
_ => {
68+
warn!("[ShellEnv] Could not resolve PATH from shell, using process PATH");
69+
return None;
70+
}
71+
};
72+
73+
// Merge: shell PATH + current process PATH (to keep any paths the app already has)
74+
let current_path = std::env::var("PATH").unwrap_or_default();
75+
let merged = merge_paths(&shell_path, &current_path);
76+
77+
info!(
78+
"[ShellEnv] Resolved PATH ({} entries, shell had {} entries)",
79+
merged.split(':').count(),
80+
shell_path.split(':').count()
81+
);
82+
debug!("[ShellEnv] PATH = {}", merged);
83+
84+
Some(OsString::from(merged))
85+
}
86+
87+
/// Try to resolve PATH by running the user's shell with the given flags.
88+
///
89+
/// Uses `printf "%s" "$PATH"` instead of `echo $PATH` to avoid:
90+
/// - Trailing newlines from echo
91+
/// - Shell-specific echo behavior differences
92+
#[cfg(unix)]
93+
fn try_resolve_path_from_shell(shell: &str, flags: &[&str]) -> Option<String> {
94+
use std::process::{Command, Stdio};
95+
96+
// Build command: $SHELL <flags> 'printf "%s" "$PATH"'
97+
let mut cmd = Command::new(shell);
98+
for flag in flags {
99+
cmd.arg(flag);
100+
}
101+
cmd.arg(r#"printf "%s" "$PATH""#);
102+
103+
// Prevent the child from inheriting stdin (avoids tty issues)
104+
cmd.stdin(Stdio::null());
105+
cmd.stdout(Stdio::piped());
106+
cmd.stderr(Stdio::null()); // Suppress shell startup warnings
107+
108+
match cmd.output() {
109+
Ok(output) if output.status.success() => {
110+
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
111+
if path.is_empty() {
112+
debug!("[ShellEnv] Shell returned empty PATH");
113+
None
114+
} else {
115+
Some(path)
116+
}
117+
}
118+
Ok(output) => {
119+
debug!(
120+
"[ShellEnv] Shell exited with status {} (flags: {:?})",
121+
output.status, flags
122+
);
123+
None
124+
}
125+
Err(e) => {
126+
debug!("[ShellEnv] Failed to spawn shell '{}': {}", shell, e);
127+
None
128+
}
129+
}
130+
}
131+
132+
/// Merge two PATH strings, preserving order and deduplicating.
133+
///
134+
/// The `primary` PATH takes precedence (its entries appear first).
135+
/// Entries from `secondary` are appended only if not already present.
136+
#[cfg(unix)]
137+
fn merge_paths(primary: &str, secondary: &str) -> String {
138+
use std::collections::HashSet;
139+
140+
let mut seen = HashSet::new();
141+
let mut merged = Vec::new();
142+
143+
for entry in primary.split(':').chain(secondary.split(':')) {
144+
if !entry.is_empty() && seen.insert(entry.to_string()) {
145+
merged.push(entry.to_string());
146+
}
147+
}
148+
149+
merged.join(":")
150+
}
151+
152+
#[cfg(test)]
153+
mod tests {
154+
use super::*;
155+
156+
#[cfg(unix)]
157+
#[test]
158+
fn test_merge_paths_deduplicates() {
159+
let result = merge_paths("/usr/bin:/usr/local/bin", "/usr/bin:/opt/homebrew/bin");
160+
assert_eq!(result, "/usr/bin:/usr/local/bin:/opt/homebrew/bin");
161+
}
162+
163+
#[cfg(unix)]
164+
#[test]
165+
fn test_merge_paths_primary_order_preserved() {
166+
let result = merge_paths("/a:/b:/c", "/d:/b:/e");
167+
assert_eq!(result, "/a:/b:/c:/d:/e");
168+
}
169+
170+
#[cfg(unix)]
171+
#[test]
172+
fn test_merge_paths_empty_entries_skipped() {
173+
let result = merge_paths("/a::/b", ":/c:");
174+
assert_eq!(result, "/a:/b:/c");
175+
}
176+
177+
#[cfg(unix)]
178+
#[test]
179+
fn test_merge_paths_empty_secondary() {
180+
let result = merge_paths("/a:/b", "");
181+
assert_eq!(result, "/a:/b");
182+
}
183+
184+
#[cfg(unix)]
185+
#[test]
186+
fn test_get_shell_path_returns_something() {
187+
// On any Unix system with a shell, this should succeed
188+
let path = get_shell_path();
189+
assert!(path.is_some(), "Should resolve shell PATH on Unix");
190+
let path_str = path.unwrap().to_string_lossy();
191+
assert!(
192+
path_str.contains("/usr/bin") || path_str.contains("/bin"),
193+
"PATH should contain standard directories: {}",
194+
path_str
195+
);
196+
}
197+
}

crates/mcpmux-gateway/src/pool/transport/stdio.rs

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use tokio::process::{ChildStderr, Command};
2323
use tracing::{debug, error, info, warn};
2424
use uuid::Uuid;
2525

26+
use super::shell_env;
2627
use super::TransportType;
2728
use super::{create_client_handler, Transport, TransportConnectResult};
2829

@@ -186,10 +187,13 @@ impl Transport for StdioTransport {
186187
)
187188
.await;
188189

189-
// Validate command exists
190-
let command_path = match which::which(&self.command)
191-
.or_else(|_| which::which(format!("{}.exe", &self.command)))
192-
{
190+
// Resolve the user's full shell PATH (cached after first call).
191+
// On macOS/Linux, GUI apps have a minimal PATH that doesn't include
192+
// Homebrew, nvm, Volta, fnm, or /usr/local/bin — this fixes that.
193+
let shell_path = shell_env::get_shell_path();
194+
195+
// Validate command exists, using the shell-resolved PATH when available
196+
let command_path = match resolve_command(&self.command, shell_path) {
193197
Ok(path) => path,
194198
Err(_) => {
195199
let hint = command_hint(&self.command);
@@ -210,12 +214,13 @@ impl Transport for StdioTransport {
210214
"Found command"
211215
);
212216

213-
// Spawn the child process using the builder pattern so that stderr
214-
// is configured through the builder (not overridden).
215-
// TokioChildProcess::new() would override our stderr setting with
216-
// Stdio::inherit(), so we must use builder().stderr().spawn().
217+
// Build the child process environment:
218+
// - Start with user-configured env vars (from resolution.rs)
219+
// - Inject the shell-resolved PATH so child processes can find
220+
// their own dependencies (e.g., npx needs to find node)
217221
let args = self.args.clone();
218-
let env = self.env.clone();
222+
let mut env = self.env.clone();
223+
inject_shell_path(&mut env, shell_path);
219224

220225
let (transport, child_stderr) =
221226
match TokioChildProcess::builder(Command::new(&command_path).configure(move |cmd| {
@@ -304,3 +309,37 @@ impl Transport for StdioTransport {
304309
format!("stdio:{}", self.command)
305310
}
306311
}
312+
313+
/// Resolve a command binary using the shell-resolved PATH when available.
314+
///
315+
/// Falls back to the standard `which::which()` (which uses the process PATH)
316+
/// if no shell PATH was resolved.
317+
fn resolve_command(
318+
command: &str,
319+
shell_path: Option<&std::ffi::OsString>,
320+
) -> Result<std::path::PathBuf, which::Error> {
321+
if let Some(path) = shell_path {
322+
which::which_in(command, Some(path), ".")
323+
.or_else(|_| which::which_in(format!("{}.exe", command), Some(path), "."))
324+
} else {
325+
which::which(command).or_else(|_| which::which(format!("{}.exe", command)))
326+
}
327+
}
328+
329+
/// Inject the shell-resolved PATH into the child process environment.
330+
///
331+
/// This ensures child processes (e.g., npx spawning node) can find their
332+
/// own dependencies even when the parent GUI app has a minimal PATH.
333+
///
334+
/// Only injects if the user hasn't explicitly set PATH in their env overrides.
335+
fn inject_shell_path(env: &mut HashMap<String, String>, shell_path: Option<&std::ffi::OsString>) {
336+
if env.contains_key("PATH") {
337+
return; // User explicitly set PATH — respect it
338+
}
339+
340+
if let Some(path) = shell_path {
341+
if let Some(path_str) = path.to_str() {
342+
env.insert("PATH".to_string(), path_str.to_string());
343+
}
344+
}
345+
}

0 commit comments

Comments
 (0)