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    let first = command.split_whitespace().next().unwrap_or(command);
137    let stem = Path::new(first)
138        .file_stem() // also strips .exe / .cmd on Windows
139        .and_then(|s| s.to_str())
140        .unwrap_or(first);
141    INTERPRETERS.contains(&stem.to_ascii_lowercase().as_str())
142}
143
144/// Resolve `command` to an absolute path on disk.
145///
146/// - If `command` is already absolute or contains a path separator, canonicalize
147///   it (resolves symlinks).
148/// - Otherwise consult [`augmented_path_var`] (and try a `.exe` suffix on
149///   Windows). Returns the first match found, canonicalized.
150///
151/// Resolving against the AUGMENTED PATH — not the raw ambient one — is what
152/// keeps install-time and runtime agreeing. `mur agent mcp add` / addon import
153/// run under whatever PATH their parent had: a terminal has `~/.local/bin`, a
154/// Hub-spawned sidecar does not. The runtime always spawns against
155/// `augmented_path_var`, so a raw-PATH resolve here made `command: uvx`
156/// installable from a shell and "could not find `uvx` on PATH" from the Hub —
157/// the same entry, two answers. Ambient entries still keep priority, so this
158/// only ever finds MORE binaries, never a different one.
159///
160/// Returns an error if the binary can't be located.
161pub fn resolve_command(command: &str) -> Result<PathBuf> {
162    resolve_command_in(&augmented_path_var(), command)
163}
164
165/// [`resolve_command`] against an explicit PATH value instead of the ambient
166/// env. The runtime resolves MCP commands against [`augmented_path_var`] for
167/// BOTH the B0 admission checks and the actual spawn, so the file that gets
168/// hashed is provably the file that gets exec'd.
169pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
170    let p = Path::new(command);
171    if p.is_absolute() || command.contains('/') || command.contains('\\') {
172        return p
173            .canonicalize()
174            .with_context(|| format!("canonicalize {command}"));
175    }
176    for dir in std::env::split_paths(path_var) {
177        let candidate = dir.join(command);
178        if candidate.is_file() {
179            return candidate
180                .canonicalize()
181                .with_context(|| format!("canonicalize {}", candidate.display()));
182        }
183        #[cfg(target_os = "windows")]
184        {
185            let with_exe = dir.join(format!("{command}.exe"));
186            if with_exe.is_file() {
187                return with_exe
188                    .canonicalize()
189                    .with_context(|| format!("canonicalize {}", with_exe.display()));
190            }
191        }
192    }
193    bail!(
194        "could not find `{command}` on PATH (searched: {})",
195        std::env::split_paths(path_var)
196            .map(|d| d.display().to_string())
197            .collect::<Vec<_>>()
198            .join(", ")
199    );
200}
201
202/// The ambient PATH plus the well-known install dirs that GUI/launchd parents
203/// omit (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`).
204///
205/// MCP entries store the command as the user typed it (`node`, `uvx`,
206/// `python3`); which binary that names must not depend on WHO spawned the
207/// runtime. A terminal hands it the user's full PATH and
208/// `mur agent install-service` derives a rich one into the unit file — but a
209/// Hub-spawned sidecar inherits the GUI's minimal PATH, which is how
210/// `command: node` works in a shell and dies under the Hub with nothing
211/// pointing here. Ambient entries keep priority; only missing standard dirs
212/// are appended, so an explicit PATH override still wins.
213pub fn augmented_path_var() -> std::ffi::OsString {
214    let current = std::env::var_os("PATH").unwrap_or_default();
215    let mut dirs_list: Vec<PathBuf> = std::env::split_paths(&current).collect();
216    let mut extras: Vec<PathBuf> = vec![
217        PathBuf::from("/opt/homebrew/bin"),
218        PathBuf::from("/usr/local/bin"),
219    ];
220    if let Some(home) = dirs::home_dir() {
221        extras.push(home.join(".local/bin"));
222    }
223    for e in extras {
224        if !dirs_list.contains(&e) {
225            dirs_list.push(e);
226        }
227    }
228    std::env::join_paths(dirs_list).unwrap_or(current)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn interpreter_commands_are_recognised_including_paths_and_args() {
237        for c in [
238            "npx",
239            "node",
240            "python3",
241            "uvx",
242            "bunx",
243            "deno",
244            "sh",
245            "/opt/homebrew/bin/npx",
246            "npx @yawlabs/fetch-mcp",
247            "NPX",
248            "npx.cmd",
249        ] {
250            assert!(
251                is_interpreter_command(c),
252                "`{c}` should count as an interpreter"
253            );
254        }
255    }
256
257    #[test]
258    fn real_server_binaries_are_not_interpreters() {
259        for c in [
260            "mur-mcp-server",
261            "/Users/x/.mur/mcp-servers/mur-mcp-server",
262            "agent-browser",
263            "mur-research-gateway",
264            "nodemon-ish",
265        ] {
266            assert!(!is_interpreter_command(c), "`{c}` is the server itself");
267        }
268    }
269
270    #[test]
271    fn errors_on_missing_binary() {
272        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
273    }
274
275    /// Regression: `mur agent mcp add` / addon import resolved against the RAW
276    /// ambient PATH while the runtime spawned against the augmented one, so
277    /// `command: uvx` installed fine from a terminal (whose PATH lists
278    /// `~/.local/bin`) and failed with "could not find `uvx` on PATH" under the
279    /// Hub, whose sidecar inherits the GUI's minimal PATH. Same entry, two
280    /// answers.
281    ///
282    /// The test plants a binary in a standard dir that the ambient PATH does
283    /// NOT list, then resolves under that minimal PATH — the Hub's situation
284    /// exactly. Only an augmented-PATH resolve finds it.
285    #[cfg(unix)]
286    #[test]
287    fn install_time_resolve_finds_binaries_the_ambient_path_omits() {
288        use std::os::unix::fs::PermissionsExt;
289
290        let home = tempfile::tempdir().unwrap();
291        let local_bin = home.path().join(".local/bin");
292        std::fs::create_dir_all(&local_bin).unwrap();
293        let tool = local_bin.join("uvx-fixture");
294        std::fs::write(&tool, "#!/bin/sh\n").unwrap();
295        std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
296
297        // The Hub's PATH: /usr/bin and /bin, no ~/.local/bin. HOME is what
298        // `augmented_path_var` appends `.local/bin` to, so point it at the
299        // fixture.
300        let mut envg = crate::test_env::EnvGuard::hold();
301        envg.set_var("PATH", "/usr/bin:/bin");
302        envg.set_var("HOME", home.path());
303
304        assert!(
305            resolve_command_in(
306                &std::env::var_os("PATH").unwrap(),
307                tool.file_name().unwrap().to_str().unwrap()
308            )
309            .is_err(),
310            "fixture must be off the raw ambient PATH, or this proves nothing"
311        );
312
313        let resolved = resolve_command(tool.file_name().unwrap().to_str().unwrap())
314            .expect("install-time resolve must search ~/.local/bin like the runtime does");
315        assert_eq!(resolved, tool.canonicalize().unwrap());
316    }
317
318    #[cfg(unix)]
319    #[test]
320    fn resolves_bare_program_on_path_to_absolute() {
321        // The whole point: a bare program name resolves to an absolute path.
322        // (The runtime pin check used to open it relative to CWD and soft-fail.)
323        let resolved = resolve_command("sh").expect("sh is on PATH");
324        assert!(
325            resolved.is_absolute(),
326            "expected absolute, got {resolved:?}"
327        );
328        assert!(resolved.exists());
329    }
330
331    #[test]
332    fn absolute_path_is_canonicalized() {
333        let tmp = tempfile::NamedTempFile::new().unwrap();
334        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
335        assert!(resolved.is_absolute());
336    }
337
338    #[test]
339    fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
340        // Read-only against the ambient env (other tests resolve on PATH in
341        // parallel, so no set_var here).
342        let ambient: Vec<PathBuf> =
343            std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
344        let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
345        assert!(
346            aug.starts_with(&ambient),
347            "ambient PATH must keep priority: {aug:?}"
348        );
349        for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
350            let d = PathBuf::from(d);
351            let in_ambient = ambient.iter().filter(|x| **x == d).count();
352            let in_aug = aug.iter().filter(|x| **x == d).count();
353            // Appended when absent; an ambient PATH that already lists it
354            // (even more than once) is passed through untouched.
355            assert_eq!(
356                in_aug,
357                in_ambient.max(1),
358                "{d:?}: expected append-only-when-absent"
359            );
360        }
361    }
362
363    #[test]
364    fn resolve_command_in_uses_the_given_path_not_the_env() {
365        let dir = tempfile::tempdir().unwrap();
366        let exe = dir.path().join("fake-mcp");
367        std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
368        let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
369        let found = resolve_command_in(&var, "fake-mcp").unwrap();
370        assert_eq!(found, exe.canonicalize().unwrap());
371        assert!(
372            resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
373            "an empty path var must not fall back to the ambient PATH"
374        );
375    }
376
377    #[test]
378    fn install_if_stale_copies_then_is_idempotent_and_updates() {
379        let dir = tempfile::tempdir().unwrap();
380        let src = dir.path().join("src-bin");
381        let target = dir.path().join("mcp-servers/mur-mcp-server"); // parent must be created
382        std::fs::write(&src, b"v1").unwrap();
383
384        // Missing target -> copied.
385        install_if_stale(&src, &target).unwrap();
386        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
387        #[cfg(unix)]
388        {
389            use std::os::unix::fs::PermissionsExt;
390            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
391            assert_eq!(mode & 0o111, 0o111, "target must be executable");
392        }
393
394        // Unchanged source -> no-op, still v1.
395        install_if_stale(&src, &target).unwrap();
396        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
397
398        // Updated source -> refreshed.
399        std::fs::write(&src, b"v2-newer").unwrap();
400        install_if_stale(&src, &target).unwrap();
401        assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
402    }
403}