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 `PATH` (and try a `.exe` suffix on Windows). Returns the
149///   first match found, canonicalized.
150///
151/// Returns an error if the binary can't be located.
152pub fn resolve_command(command: &str) -> Result<PathBuf> {
153    let path_var = std::env::var_os("PATH")
154        .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
155    resolve_command_in(&path_var, command)
156}
157
158/// [`resolve_command`] against an explicit PATH value instead of the ambient
159/// env. The runtime resolves MCP commands against [`augmented_path_var`] for
160/// BOTH the B0 admission checks and the actual spawn, so the file that gets
161/// hashed is provably the file that gets exec'd.
162pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
163    let p = Path::new(command);
164    if p.is_absolute() || command.contains('/') || command.contains('\\') {
165        return p
166            .canonicalize()
167            .with_context(|| format!("canonicalize {command}"));
168    }
169    for dir in std::env::split_paths(path_var) {
170        let candidate = dir.join(command);
171        if candidate.is_file() {
172            return candidate
173                .canonicalize()
174                .with_context(|| format!("canonicalize {}", candidate.display()));
175        }
176        #[cfg(target_os = "windows")]
177        {
178            let with_exe = dir.join(format!("{command}.exe"));
179            if with_exe.is_file() {
180                return with_exe
181                    .canonicalize()
182                    .with_context(|| format!("canonicalize {}", with_exe.display()));
183            }
184        }
185    }
186    bail!("could not find `{command}` on PATH");
187}
188
189/// The ambient PATH plus the well-known install dirs that GUI/launchd parents
190/// omit (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`).
191///
192/// MCP entries store the command as the user typed it (`node`, `uvx`,
193/// `python3`); which binary that names must not depend on WHO spawned the
194/// runtime. A terminal hands it the user's full PATH and
195/// `mur agent install-service` derives a rich one into the unit file — but a
196/// Hub-spawned sidecar inherits the GUI's minimal PATH, which is how
197/// `command: node` works in a shell and dies under the Hub with nothing
198/// pointing here. Ambient entries keep priority; only missing standard dirs
199/// are appended, so an explicit PATH override still wins.
200pub fn augmented_path_var() -> std::ffi::OsString {
201    let current = std::env::var_os("PATH").unwrap_or_default();
202    let mut dirs_list: Vec<PathBuf> = std::env::split_paths(&current).collect();
203    let mut extras: Vec<PathBuf> = vec![
204        PathBuf::from("/opt/homebrew/bin"),
205        PathBuf::from("/usr/local/bin"),
206    ];
207    if let Some(home) = dirs::home_dir() {
208        extras.push(home.join(".local/bin"));
209    }
210    for e in extras {
211        if !dirs_list.contains(&e) {
212            dirs_list.push(e);
213        }
214    }
215    std::env::join_paths(dirs_list).unwrap_or(current)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn interpreter_commands_are_recognised_including_paths_and_args() {
224        for c in [
225            "npx",
226            "node",
227            "python3",
228            "uvx",
229            "bunx",
230            "deno",
231            "sh",
232            "/opt/homebrew/bin/npx",
233            "npx @yawlabs/fetch-mcp",
234            "NPX",
235            "npx.cmd",
236        ] {
237            assert!(
238                is_interpreter_command(c),
239                "`{c}` should count as an interpreter"
240            );
241        }
242    }
243
244    #[test]
245    fn real_server_binaries_are_not_interpreters() {
246        for c in [
247            "mur-mcp-server",
248            "/Users/x/.mur/mcp-servers/mur-mcp-server",
249            "agent-browser",
250            "mur-research-gateway",
251            "nodemon-ish",
252        ] {
253            assert!(!is_interpreter_command(c), "`{c}` is the server itself");
254        }
255    }
256
257    #[test]
258    fn errors_on_missing_binary() {
259        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
260    }
261
262    #[cfg(unix)]
263    #[test]
264    fn resolves_bare_program_on_path_to_absolute() {
265        // The whole point: a bare program name resolves to an absolute path.
266        // (The runtime pin check used to open it relative to CWD and soft-fail.)
267        let resolved = resolve_command("sh").expect("sh is on PATH");
268        assert!(
269            resolved.is_absolute(),
270            "expected absolute, got {resolved:?}"
271        );
272        assert!(resolved.exists());
273    }
274
275    #[test]
276    fn absolute_path_is_canonicalized() {
277        let tmp = tempfile::NamedTempFile::new().unwrap();
278        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
279        assert!(resolved.is_absolute());
280    }
281
282    #[test]
283    fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
284        // Read-only against the ambient env (other tests resolve on PATH in
285        // parallel, so no set_var here).
286        let ambient: Vec<PathBuf> =
287            std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
288        let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
289        assert!(
290            aug.starts_with(&ambient),
291            "ambient PATH must keep priority: {aug:?}"
292        );
293        for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
294            let d = PathBuf::from(d);
295            let in_ambient = ambient.iter().filter(|x| **x == d).count();
296            let in_aug = aug.iter().filter(|x| **x == d).count();
297            // Appended when absent; an ambient PATH that already lists it
298            // (even more than once) is passed through untouched.
299            assert_eq!(
300                in_aug,
301                in_ambient.max(1),
302                "{d:?}: expected append-only-when-absent"
303            );
304        }
305    }
306
307    #[test]
308    fn resolve_command_in_uses_the_given_path_not_the_env() {
309        let dir = tempfile::tempdir().unwrap();
310        let exe = dir.path().join("fake-mcp");
311        std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
312        let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
313        let found = resolve_command_in(&var, "fake-mcp").unwrap();
314        assert_eq!(found, exe.canonicalize().unwrap());
315        assert!(
316            resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
317            "an empty path var must not fall back to the ambient PATH"
318        );
319    }
320
321    #[test]
322    fn install_if_stale_copies_then_is_idempotent_and_updates() {
323        let dir = tempfile::tempdir().unwrap();
324        let src = dir.path().join("src-bin");
325        let target = dir.path().join("mcp-servers/mur-mcp-server"); // parent must be created
326        std::fs::write(&src, b"v1").unwrap();
327
328        // Missing target -> copied.
329        install_if_stale(&src, &target).unwrap();
330        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
331        #[cfg(unix)]
332        {
333            use std::os::unix::fs::PermissionsExt;
334            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
335            assert_eq!(mode & 0o111, 0o111, "target must be executable");
336        }
337
338        // Unchanged source -> no-op, still v1.
339        install_if_stale(&src, &target).unwrap();
340        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
341
342        // Updated source -> refreshed.
343        std::fs::write(&src, b"v2-newer").unwrap();
344        install_if_stale(&src, &target).unwrap();
345        assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
346    }
347}