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