Skip to content

Commit 98c013d

Browse files
its-mashclaude
andauthored
fix: resolve npx/node PATH on macOS GUI apps (#113)
Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 337c159 commit 98c013d

4 files changed

Lines changed: 734 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: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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+
// ── merge_paths tests ──────────────────────────────────────────
157+
158+
#[cfg(unix)]
159+
#[test]
160+
fn test_merge_paths_deduplicates() {
161+
let result = merge_paths("/usr/bin:/usr/local/bin", "/usr/bin:/opt/homebrew/bin");
162+
assert_eq!(result, "/usr/bin:/usr/local/bin:/opt/homebrew/bin");
163+
}
164+
165+
#[cfg(unix)]
166+
#[test]
167+
fn test_merge_paths_primary_order_preserved() {
168+
let result = merge_paths("/a:/b:/c", "/d:/b:/e");
169+
assert_eq!(result, "/a:/b:/c:/d:/e");
170+
}
171+
172+
#[cfg(unix)]
173+
#[test]
174+
fn test_merge_paths_empty_entries_skipped() {
175+
let result = merge_paths("/a::/b", ":/c:");
176+
assert_eq!(result, "/a:/b:/c");
177+
}
178+
179+
#[cfg(unix)]
180+
#[test]
181+
fn test_merge_paths_empty_secondary() {
182+
let result = merge_paths("/a:/b", "");
183+
assert_eq!(result, "/a:/b");
184+
}
185+
186+
#[cfg(unix)]
187+
#[test]
188+
fn test_merge_paths_empty_primary() {
189+
let result = merge_paths("", "/a:/b");
190+
assert_eq!(result, "/a:/b");
191+
}
192+
193+
#[cfg(unix)]
194+
#[test]
195+
fn test_merge_paths_both_empty() {
196+
let result = merge_paths("", "");
197+
assert_eq!(result, "");
198+
}
199+
200+
#[cfg(unix)]
201+
#[test]
202+
fn test_merge_paths_identical() {
203+
let result = merge_paths("/a:/b:/c", "/a:/b:/c");
204+
assert_eq!(result, "/a:/b:/c");
205+
}
206+
207+
#[cfg(unix)]
208+
#[test]
209+
fn test_merge_paths_many_duplicates() {
210+
let result = merge_paths("/a:/b:/c:/d", "/d:/c:/b:/a:/e");
211+
assert_eq!(result, "/a:/b:/c:/d:/e");
212+
}
213+
214+
// ── get_shell_path tests ───────────────────────────────────────
215+
216+
#[cfg(unix)]
217+
#[test]
218+
fn test_get_shell_path_returns_something() {
219+
// On any Unix system with a shell, this should succeed
220+
let path = get_shell_path();
221+
assert!(path.is_some(), "Should resolve shell PATH on Unix");
222+
let path_str = path.unwrap().to_string_lossy();
223+
assert!(
224+
path_str.contains("/usr/bin") || path_str.contains("/bin"),
225+
"PATH should contain standard directories: {}",
226+
path_str
227+
);
228+
}
229+
230+
#[cfg(unix)]
231+
#[test]
232+
fn test_get_shell_path_is_cached() {
233+
// Calling twice should return the exact same reference (OnceLock)
234+
let first = get_shell_path();
235+
let second = get_shell_path();
236+
assert!(first.is_some());
237+
assert!(second.is_some());
238+
// Same pointer — verifies caching via OnceLock
239+
assert!(std::ptr::eq(first.unwrap(), second.unwrap()));
240+
}
241+
242+
#[cfg(unix)]
243+
#[test]
244+
fn test_get_shell_path_has_no_trailing_newline() {
245+
let path = get_shell_path();
246+
if let Some(p) = path {
247+
let s = p.to_string_lossy();
248+
assert!(
249+
!s.ends_with('\n') && !s.ends_with('\r'),
250+
"PATH should not have trailing newlines: {:?}",
251+
s
252+
);
253+
}
254+
}
255+
256+
#[cfg(unix)]
257+
#[test]
258+
fn test_get_shell_path_entries_are_not_empty() {
259+
let path = get_shell_path();
260+
if let Some(p) = path {
261+
let s = p.to_string_lossy();
262+
for entry in s.split(':') {
263+
assert!(
264+
!entry.is_empty(),
265+
"PATH should not contain empty entries: {:?}",
266+
s
267+
);
268+
}
269+
}
270+
}
271+
272+
#[cfg(unix)]
273+
#[test]
274+
fn test_get_shell_path_is_valid_utf8() {
275+
let path = get_shell_path();
276+
if let Some(p) = path {
277+
assert!(p.to_str().is_some(), "PATH should be valid UTF-8: {:?}", p);
278+
}
279+
}
280+
281+
// ── try_resolve_path_from_shell tests ──────────────────────────
282+
283+
#[cfg(unix)]
284+
#[test]
285+
fn test_try_resolve_shell_with_login_flag() {
286+
// /bin/sh should work with -l -c
287+
let result = try_resolve_path_from_shell("/bin/sh", &["-l", "-c"]);
288+
assert!(result.is_some(), "Should resolve PATH from /bin/sh -l -c");
289+
let path = result.unwrap();
290+
assert!(!path.is_empty(), "PATH should not be empty");
291+
assert!(
292+
path.contains("/bin") || path.contains("/usr"),
293+
"PATH should contain standard dirs: {}",
294+
path
295+
);
296+
}
297+
298+
#[cfg(unix)]
299+
#[test]
300+
fn test_try_resolve_shell_nonexistent_shell() {
301+
let result = try_resolve_path_from_shell("/nonexistent/shell_binary_xyz", &["-l", "-c"]);
302+
assert!(result.is_none(), "Should fail for nonexistent shell");
303+
}
304+
305+
#[cfg(unix)]
306+
#[test]
307+
fn test_try_resolve_shell_invalid_flags() {
308+
// --bogus-flag should cause the shell to error
309+
let result = try_resolve_path_from_shell("/bin/sh", &["--bogus-flag-xyz", "-c"]);
310+
assert!(result.is_none(), "Should fail with invalid shell flags");
311+
}
312+
}

0 commit comments

Comments
 (0)