Skip to main content

mur_common/
exec.rs

1//! Shared executable-path resolution.
2//!
3//! A single source of truth for turning a command (bare program name or path)
4//! into the absolute, symlink-resolved binary that will actually be executed.
5//! Used by both install-time MCP pinning (`mur agent mcp pin`) and the runtime
6//! startup verification (B0 rules 6 & 11) so a bare `command` like `node`
7//! resolves identically across the two passes — otherwise the runtime hashes a
8//! CWD-relative path that doesn't exist and silently skips the pin/signature
9//! check while `Command::new` runs the PATH-resolved binary.
10
11use anyhow::{Context, Result, bail};
12use sha2::{Digest, Sha256};
13use std::path::{Path, PathBuf};
14
15/// File name of the MUR MCP server binary.
16#[cfg(windows)]
17const MCP_SERVER_BIN: &str = "mur-mcp-server.exe";
18#[cfg(not(windows))]
19const MCP_SERVER_BIN: &str = "mur-mcp-server";
20
21/// Canonical location MUR keeps its own copy of the MCP server binary:
22/// `~/.mur/mcp-servers/mur-mcp-server` (honors `$MUR_HOME`). Stable across how
23/// `mur` itself was installed (brew / cargo / source) and across upgrades, so
24/// agent profiles can pin this path once and never go stale.
25pub fn bundled_mcp_server_path() -> PathBuf {
26    crate::trust::mur_home()
27        .join("mcp-servers")
28        .join(MCP_SERVER_BIN)
29}
30
31/// Ensure [`bundled_mcp_server_path`] exists and matches the `mur-mcp-server`
32/// shipped alongside the running `mur` binary, copying it into place when
33/// missing or out of date. Returns the canonical target path.
34///
35/// Source resolution: the sibling of the current executable first (brew, cargo
36/// and source builds all colocate the two binaries), then `mur-mcp-server` on
37/// `PATH`. If no source is found but a copy already exists, that copy is
38/// returned (usable, just can't self-update). Errors only when there is neither
39/// a source nor an existing copy.
40///
41/// Call this BEFORE the kernel sandbox seals — the copy needs write access to
42/// `~/.mur`.
43pub fn ensure_bundled_mcp_server() -> Result<PathBuf> {
44    let target = bundled_mcp_server_path();
45    match locate_mcp_server_source() {
46        Some(src) => {
47            install_if_stale(&src, &target)?;
48            Ok(target)
49        }
50        None if target.is_file() => Ok(target),
51        None => bail!(
52            "mur-mcp-server not found next to `mur` or on PATH, and no copy at {}",
53            target.display()
54        ),
55    }
56}
57
58/// The `mur-mcp-server` to copy from: sibling of `mur` first, then PATH.
59fn locate_mcp_server_source() -> Option<PathBuf> {
60    if let Ok(exe) = std::env::current_exe()
61        && let Some(dir) = exe.parent()
62    {
63        let sibling = dir.join(MCP_SERVER_BIN);
64        if sibling.is_file() {
65            return sibling.canonicalize().ok();
66        }
67    }
68    resolve_command(MCP_SERVER_BIN).ok()
69}
70
71/// Copy `src` to `target` unless `target` already byte-matches it. Idempotent;
72/// writes via a uniquely-named temp file + rename in the target dir so the swap
73/// is atomic and never leaves a half-written binary an agent might try to spawn;
74/// sets mode 0755 on unix.
75fn install_if_stale(src: &Path, target: &Path) -> Result<()> {
76    if target.is_file() && sha256_file(src)? == sha256_file(target)? {
77        return Ok(());
78    }
79    let dir = target
80        .parent()
81        .ok_or_else(|| anyhow::anyhow!("target {} has no parent", target.display()))?;
82    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
83    // Unique temp name so two agents starting at once don't clobber each other.
84    let tmp = dir.join(format!(".{MCP_SERVER_BIN}.{}.tmp", std::process::id()));
85    std::fs::copy(src, &tmp)
86        .with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
87    #[cfg(unix)]
88    {
89        use std::os::unix::fs::PermissionsExt;
90        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
91            .with_context(|| format!("chmod {}", tmp.display()))?;
92    }
93    std::fs::rename(&tmp, target)
94        .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))?;
95    Ok(())
96}
97
98/// Stream-hash `path` SHA-256 (64 KiB chunks; lowercase hex).
99fn sha256_file(path: &Path) -> Result<String> {
100    use std::io::Read;
101    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
102    let mut hasher = Sha256::new();
103    let mut buf = [0u8; 65536];
104    loop {
105        let n = f
106            .read(&mut buf)
107            .with_context(|| format!("read {}", path.display()))?;
108        if n == 0 {
109            break;
110        }
111        hasher.update(&buf[..n]);
112    }
113    Ok(hex::encode(hasher.finalize()))
114}
115
116/// Launchers that run *other* code: hashing one of these tells you nothing
117/// about the MCP server it starts.
118const INTERPRETERS: &[&str] = &[
119    "npx", "node", "bunx", "bun", "deno", "python", "python3", "uv", "uvx", "pipx", "ruby", "perl",
120    "sh", "bash", "zsh",
121];
122
123/// Whether `command` launches an MCP server through an interpreter or package
124/// runner rather than being the server binary itself.
125///
126/// This decides whether a `binary_sha256` pin means anything. For
127/// `command: npx, args: @yawlabs/fetch-mcp` the pin hashes **npx** — so it
128/// breaks on every unrelated Node upgrade while saying nothing at all about
129/// `@yawlabs/fetch-mcp`, which npx resolves and may fetch fresh at run time.
130/// Enforcing such a pin is both fragile and hollow; the honest report is that
131/// the server code is unprotected.
132///
133/// Real coverage for these needs a package-level pin (version + integrity),
134/// which is a different mechanism than hashing a file on disk.
135pub fn is_interpreter_command(command: &str) -> bool {
136    // The spawn treats `command` as one path, so when that whole path is a
137    // real file (`…/Application Support/…/node`) judge it whole. Otherwise
138    // fall back to the first word, which keeps legacy inline-args entries
139    // (`npx @scope/pkg`) classified as before.
140    let first = if Path::new(command).is_file() {
141        command
142    } else {
143        command.split_whitespace().next().unwrap_or(command)
144    };
145    let stem = Path::new(first)
146        .file_stem() // also strips .exe / .cmd on Windows
147        .and_then(|s| s.to_str())
148        .unwrap_or(first);
149    INTERPRETERS.contains(&stem.to_ascii_lowercase().as_str())
150}
151
152/// Resolve `command` to an absolute path on disk.
153///
154/// - If `command` is already absolute or contains a path separator, canonicalize
155///   it (resolves symlinks).
156/// - Otherwise consult [`augmented_path_var`] (and try a `.exe` suffix on
157///   Windows). Returns the first match found, canonicalized.
158///
159/// Resolving against the AUGMENTED PATH — not the raw ambient one — is what
160/// keeps install-time and runtime agreeing. `mur agent mcp add` / addon import
161/// run under whatever PATH their parent had: a terminal has `~/.local/bin`, a
162/// Hub-spawned sidecar does not. The runtime always spawns against
163/// `augmented_path_var`, so a raw-PATH resolve here made `command: uvx`
164/// installable from a shell and "could not find `uvx` on PATH" from the Hub —
165/// the same entry, two answers. Ambient entries still keep priority, so this
166/// only ever finds MORE binaries, never a different one.
167///
168/// Returns an error if the binary can't be located.
169pub fn resolve_command(command: &str) -> Result<PathBuf> {
170    resolve_command_in(&augmented_path_var(), command)
171}
172
173/// The file an MCP server launch will exec for `command`, given the child
174/// `path_var`. One resolver for the spawn, the B0 pin check, and `mur agent
175/// doctor`, so all three judge the same file: a command naming the MUR MCP
176/// server is redirected to [`bundled_mcp_server_path`] when that copy exists
177/// (the seal grants it even where a PATH copy is refused); anything else
178/// resolves whole against `path_var`.
179pub fn resolve_mcp_launch_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
180    resolve_mcp_launch_with(&bundled_mcp_server_path(), path_var, command)
181}
182
183/// [`resolve_mcp_launch_in`] with an explicit bundled-server location.
184pub fn resolve_mcp_launch_with(
185    bundled: &Path,
186    path_var: &std::ffi::OsStr,
187    command: &str,
188) -> Result<PathBuf> {
189    if Path::new(command).file_name() == bundled.file_name() && bundled.is_file() {
190        return Ok(bundled.to_path_buf());
191    }
192    resolve_command_in(path_var, command)
193}
194
195/// [`resolve_command`] against an explicit PATH value instead of the ambient
196/// env. The runtime resolves MCP commands against [`augmented_path_var`] for
197/// BOTH the B0 admission checks and the actual spawn, so the file that gets
198/// hashed is provably the file that gets exec'd.
199pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
200    let p = Path::new(command);
201    if p.is_absolute() || command.contains('/') || command.contains('\\') {
202        return p
203            .canonicalize()
204            .with_context(|| format!("canonicalize {command}"));
205    }
206    for dir in std::env::split_paths(path_var) {
207        let candidate = dir.join(command);
208        if candidate.is_file() {
209            return candidate
210                .canonicalize()
211                .with_context(|| format!("canonicalize {}", candidate.display()));
212        }
213        #[cfg(target_os = "windows")]
214        {
215            let with_exe = dir.join(format!("{command}.exe"));
216            if with_exe.is_file() {
217                return with_exe
218                    .canonicalize()
219                    .with_context(|| format!("canonicalize {}", with_exe.display()));
220            }
221        }
222    }
223    bail!(
224        "could not find `{command}` on PATH (searched: {})",
225        std::env::split_paths(path_var)
226            .map(|d| d.display().to_string())
227            .collect::<Vec<_>>()
228            .join(", ")
229    );
230}
231
232/// The ambient PATH plus the well-known install dirs that GUI/launchd parents
233/// omit (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`).
234///
235/// MCP entries store the command as the user typed it (`node`, `uvx`,
236/// `python3`); which binary that names must not depend on WHO spawned the
237/// runtime. A terminal hands it the user's full PATH and
238/// `mur agent install-service` derives a rich one into the unit file — but a
239/// Hub-spawned sidecar inherits the GUI's minimal PATH, which is how
240/// `command: node` works in a shell and dies under the Hub with nothing
241/// pointing here. Ambient entries keep priority; only missing standard dirs
242/// are appended, so an explicit PATH override still wins.
243pub fn augmented_path_var() -> std::ffi::OsString {
244    let current = std::env::var_os("PATH").unwrap_or_default();
245    let mut dirs_list: Vec<PathBuf> = std::env::split_paths(&current).collect();
246    let mut extras: Vec<PathBuf> = vec![
247        PathBuf::from("/opt/homebrew/bin"),
248        PathBuf::from("/usr/local/bin"),
249    ];
250    if let Some(home) = dirs::home_dir() {
251        extras.push(home.join(".local/bin"));
252    }
253    for e in extras {
254        if !dirs_list.contains(&e) {
255            dirs_list.push(e);
256        }
257    }
258    std::env::join_paths(dirs_list).unwrap_or(current)
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn interpreter_commands_are_recognised_including_paths_and_args() {
267        for c in [
268            "npx",
269            "node",
270            "python3",
271            "uvx",
272            "bunx",
273            "deno",
274            "sh",
275            "/opt/homebrew/bin/npx",
276            "npx @yawlabs/fetch-mcp",
277            "NPX",
278            "npx.cmd",
279        ] {
280            assert!(
281                is_interpreter_command(c),
282                "`{c}` should count as an interpreter"
283            );
284        }
285    }
286
287    #[test]
288    fn interpreter_path_with_spaces_is_judged_whole() {
289        let dir = tempfile::tempdir().unwrap();
290        let sub = dir.path().join("Application Support").join("bin");
291        std::fs::create_dir_all(&sub).unwrap();
292        let node = sub.join("node");
293        std::fs::write(&node, b"").unwrap();
294        let server = sub.join("my-server");
295        std::fs::write(&server, b"").unwrap();
296        assert!(is_interpreter_command(node.to_str().unwrap()));
297        assert!(!is_interpreter_command(server.to_str().unwrap()));
298    }
299
300    #[test]
301    fn real_server_binaries_are_not_interpreters() {
302        for c in [
303            "mur-mcp-server",
304            "/Users/x/.mur/mcp-servers/mur-mcp-server",
305            "agent-browser",
306            "mur-research-gateway",
307            "nodemon-ish",
308        ] {
309            assert!(!is_interpreter_command(c), "`{c}` is the server itself");
310        }
311    }
312
313    #[test]
314    fn errors_on_missing_binary() {
315        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
316    }
317
318    /// Regression: `mur agent mcp add` / addon import resolved against the RAW
319    /// ambient PATH while the runtime spawned against the augmented one, so
320    /// `command: uvx` installed fine from a terminal (whose PATH lists
321    /// `~/.local/bin`) and failed with "could not find `uvx` on PATH" under the
322    /// Hub, whose sidecar inherits the GUI's minimal PATH. Same entry, two
323    /// answers.
324    ///
325    /// The test plants a binary in a standard dir that the ambient PATH does
326    /// NOT list, then resolves under that minimal PATH — the Hub's situation
327    /// exactly. Only an augmented-PATH resolve finds it.
328    #[cfg(unix)]
329    #[test]
330    fn install_time_resolve_finds_binaries_the_ambient_path_omits() {
331        use std::os::unix::fs::PermissionsExt;
332
333        let home = tempfile::tempdir().unwrap();
334        let local_bin = home.path().join(".local/bin");
335        std::fs::create_dir_all(&local_bin).unwrap();
336        let tool = local_bin.join("uvx-fixture");
337        std::fs::write(&tool, "#!/bin/sh\n").unwrap();
338        std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
339
340        // The Hub's PATH: /usr/bin and /bin, no ~/.local/bin. HOME is what
341        // `augmented_path_var` appends `.local/bin` to, so point it at the
342        // fixture.
343        let mut envg = crate::test_env::EnvGuard::hold();
344        envg.set_var("PATH", "/usr/bin:/bin");
345        envg.set_var("HOME", home.path());
346
347        assert!(
348            resolve_command_in(
349                &std::env::var_os("PATH").unwrap(),
350                tool.file_name().unwrap().to_str().unwrap()
351            )
352            .is_err(),
353            "fixture must be off the raw ambient PATH, or this proves nothing"
354        );
355
356        let resolved = resolve_command(tool.file_name().unwrap().to_str().unwrap())
357            .expect("install-time resolve must search ~/.local/bin like the runtime does");
358        assert_eq!(resolved, tool.canonicalize().unwrap());
359    }
360
361    #[cfg(unix)]
362    #[test]
363    fn resolves_bare_program_on_path_to_absolute() {
364        // The whole point: a bare program name resolves to an absolute path.
365        // (The runtime pin check used to open it relative to CWD and soft-fail.)
366        let resolved = resolve_command("sh").expect("sh is on PATH");
367        assert!(
368            resolved.is_absolute(),
369            "expected absolute, got {resolved:?}"
370        );
371        assert!(resolved.exists());
372    }
373
374    #[test]
375    fn absolute_path_is_canonicalized() {
376        let tmp = tempfile::NamedTempFile::new().unwrap();
377        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
378        assert!(resolved.is_absolute());
379    }
380
381    #[test]
382    fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
383        // Read-only against the ambient env (other tests resolve on PATH in
384        // parallel, so no set_var here).
385        let ambient: Vec<PathBuf> =
386            std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
387        let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
388        assert!(
389            aug.starts_with(&ambient),
390            "ambient PATH must keep priority: {aug:?}"
391        );
392        for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
393            let d = PathBuf::from(d);
394            let in_ambient = ambient.iter().filter(|x| **x == d).count();
395            let in_aug = aug.iter().filter(|x| **x == d).count();
396            // Appended when absent; an ambient PATH that already lists it
397            // (even more than once) is passed through untouched.
398            assert_eq!(
399                in_aug,
400                in_ambient.max(1),
401                "{d:?}: expected append-only-when-absent"
402            );
403        }
404    }
405
406    #[test]
407    fn mcp_launch_prefers_the_bundled_server_over_a_path_copy() {
408        let bundled_dir = tempfile::tempdir().unwrap();
409        let path_dir = tempfile::tempdir().unwrap();
410        let bundled = bundled_dir.path().join(MCP_SERVER_BIN);
411        let var = std::env::join_paths([path_dir.path()]).unwrap();
412        std::fs::write(path_dir.path().join(MCP_SERVER_BIN), b"x").unwrap();
413        // No bundled copy yet: falls through to PATH, like the spawn.
414        let got = resolve_mcp_launch_with(&bundled, &var, MCP_SERVER_BIN).unwrap();
415        assert_eq!(
416            got,
417            path_dir.path().join(MCP_SERVER_BIN).canonicalize().unwrap()
418        );
419        std::fs::write(&bundled, b"x").unwrap();
420        assert_eq!(
421            resolve_mcp_launch_with(&bundled, &var, MCP_SERVER_BIN).unwrap(),
422            bundled
423        );
424        // Any other command is untouched by the override.
425        std::fs::write(path_dir.path().join("other"), b"x").unwrap();
426        assert_ne!(
427            resolve_mcp_launch_with(&bundled, &var, "other").unwrap(),
428            bundled
429        );
430    }
431
432    #[test]
433    fn resolve_command_in_uses_the_given_path_not_the_env() {
434        let dir = tempfile::tempdir().unwrap();
435        let exe = dir.path().join("fake-mcp");
436        std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
437        let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
438        let found = resolve_command_in(&var, "fake-mcp").unwrap();
439        assert_eq!(found, exe.canonicalize().unwrap());
440        assert!(
441            resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
442            "an empty path var must not fall back to the ambient PATH"
443        );
444    }
445
446    #[test]
447    fn install_if_stale_copies_then_is_idempotent_and_updates() {
448        let dir = tempfile::tempdir().unwrap();
449        let src = dir.path().join("src-bin");
450        let target = dir.path().join("mcp-servers/mur-mcp-server"); // parent must be created
451        std::fs::write(&src, b"v1").unwrap();
452
453        // Missing target -> copied.
454        install_if_stale(&src, &target).unwrap();
455        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
456        #[cfg(unix)]
457        {
458            use std::os::unix::fs::PermissionsExt;
459            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
460            assert_eq!(mode & 0o111, 0o111, "target must be executable");
461        }
462
463        // Unchanged source -> no-op, still v1.
464        install_if_stale(&src, &target).unwrap();
465        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
466
467        // Updated source -> refreshed.
468        std::fs::write(&src, b"v2-newer").unwrap();
469        install_if_stale(&src, &target).unwrap();
470        assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
471    }
472}