Skip to main content

lean_ctx/core/
portable_binary.rs

1/// Verbatim hook-binary override (#708), or `None` when unset/blank.
2///
3/// Users who sync agent settings (`~/.claude/settings.json`, …) across
4/// machines with different usernames set `LEAN_CTX_HOOK_BINARY` (env, wins)
5/// or `hook_binary` (config.toml) to an env-based form like
6/// `$HOME/.local/bin/lean-ctx`. Hook hosts execute commands through a shell,
7/// which expands the variable at run time. The value is emitted verbatim by
8/// every hook writer and accepted verbatim by `doctor`'s staleness check —
9/// scoped to hook/agent artifacts only, never to launchd/systemd units
10/// (which do not expand shell variables).
11pub fn hook_binary_override() -> Option<String> {
12    std::env::var("LEAN_CTX_HOOK_BINARY")
13        .ok()
14        .or_else(|| crate::core::config::Config::load().hook_binary.clone())
15        .map(|s| s.trim().to_string())
16        .filter(|s| !s.is_empty())
17}
18
19/// Identify our hook executable without treating arbitrary user wrappers as owned.
20pub fn is_lean_ctx_hook_binary(binary: &str) -> bool {
21    let binary = binary.trim().trim_matches(['\'', '"']);
22    let name = binary.rsplit(['/', '\\']).next().unwrap_or(binary);
23    matches!(
24        name,
25        "lean-ctx" | "lean-ctx.exe" | "lean-ctx.cmd" | "lean-ctx.bat"
26    ) || hook_binary_override()
27        .is_some_and(|configured| configured.trim_matches(['\'', '"']) == binary)
28}
29
30/// True when `command` is `<our hook binary> hook <operation>`: the binary is
31/// ours ([`is_lean_ctx_hook_binary`]) and carries no shell metacharacters, so
32/// a user wrapper is never mistaken for a LeanCTX hook.
33pub fn is_lean_ctx_hook_command(command: &str, operation: &str) -> bool {
34    let Some((binary, action)) = command.trim().rsplit_once(" hook ") else {
35        return false;
36    };
37    let binary = binary.trim();
38    let quoted = binary.len() >= 2
39        && ((binary.starts_with('"') && binary.ends_with('"'))
40            || (binary.starts_with('\'') && binary.ends_with('\'')));
41    let binary_body = if quoted {
42        &binary[1..binary.len() - 1]
43    } else {
44        binary
45    };
46    action == operation
47        && !binary_body.contains(['\'', '"', '`'])
48        && (quoted || !binary_body.chars().any(char::is_whitespace))
49        && is_lean_ctx_hook_binary(binary)
50}
51
52pub fn resolve_portable_binary() -> String {
53    let current = std::env::current_exe()
54        .ok()
55        .map(|p| p.to_string_lossy().into_owned());
56
57    // The first `lean-ctx` on PATH that is not a test/example binary in a
58    // cargo output directory (#2232).
59    let which_raw = crate::core::pathutil::find_all_on_path("lean-ctx")
60        .into_iter()
61        .map(|p| p.to_string_lossy().into_owned())
62        .find(|s| !s.is_empty() && !is_transient_build_binary(s));
63
64    choose_binary_path(current.as_deref(), which_raw.as_deref())
65}
66
67/// Directories of a cargo profile that hold only intermediate artifacts:
68/// test and example binaries, build scripts, incremental state.
69const TRANSIENT_CARGO_DIRS: &[&str] = &["deps", "build", "examples", "incremental"];
70
71/// Whether `path` is an intermediate cargo artifact (#2232): anything under
72/// `target/{debug,release}/{deps,build,examples,incremental}/` or the same
73/// below a `target/<triple>/` directory, or below any other cargo profile
74/// directory recognised by what cargo wrote into it (a `CARGO_TARGET_DIR` of
75/// another name, a named profile).
76///
77/// Such a binary — a `cargo test` harness like `lean_ctx_server-1a2b3c4d` —
78/// disappears with the next build or `cargo clean`, so it must never be baked
79/// into a hook or agent config. A developer's own `target/release/lean-ctx`
80/// sits directly in the profile directory, is not transient, and stays a
81/// valid dev install.
82pub fn is_transient_build_binary(path: &str) -> bool {
83    let comps: Vec<&str> = path.split(['/', '\\']).collect();
84    (1..comps.len().saturating_sub(1)).any(|i| {
85        if !TRANSIENT_CARGO_DIRS.contains(&comps[i]) {
86            return false;
87        }
88        let profile = comps[i - 1];
89        let named_profile = matches!(profile, "debug" | "release")
90            && ((i >= 2 && comps[i - 2].ends_with("target"))
91                || (i >= 3 && comps[i - 3].ends_with("target")));
92        named_profile || is_cargo_profile_dir(std::path::Path::new(&comps[..i].join("/")))
93    })
94}
95
96/// True when writing `binary` into the agent or shell configuration of
97/// `home` would bake a transient cargo artifact into the real account's
98/// configuration (#2232). A test that runs a server or a hook refresh with
99/// the real `HOME` would otherwise point the user's live hooks at a test
100/// binary that the next build deletes.
101///
102/// The real home comes from the account database, not `$HOME`, so tests with
103/// a temporary `HOME` keep exercising the writers. Logs why when it refuses.
104pub fn refuses_transient_binary_for_home(binary: &str, home: &std::path::Path) -> bool {
105    let real_home = crate::core::config::shell_policy::real_user_home();
106    let refused = transient_binary_for_real_home(binary, home, real_home.as_deref());
107    if refused {
108        tracing::warn!(
109            "not writing transient build binary {binary} into the hook configuration of {}; \
110             install lean-ctx (or put it on PATH) and run `lean-ctx setup`",
111            home.display()
112        );
113    }
114    refused
115}
116
117fn transient_binary_for_real_home(
118    binary: &str,
119    home: &std::path::Path,
120    real_home: Option<&std::path::Path>,
121) -> bool {
122    if !is_transient_build_binary(binary.trim().trim_matches(['\'', '"'])) {
123        return false;
124    }
125    // Without an account-database entry there is no real home to protect.
126    let Some(real_home) = real_home else {
127        return false;
128    };
129    let canonical = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
130    canonical(home) == canonical(real_home)
131}
132
133/// File names a `lean-ctx` launcher can have inside a `PATH` directory.
134const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
135    &["lean-ctx.exe", "lean-ctx.cmd"]
136} else {
137    &["lean-ctx"]
138};
139
140/// The directories on `PATH`, in lookup order.
141pub fn path_dirs() -> Vec<std::path::PathBuf> {
142    std::env::var_os("PATH")
143        .map(|p| {
144            std::env::split_paths(&p)
145                .filter(|d| !d.as_os_str().is_empty())
146                .collect()
147        })
148        .unwrap_or_default()
149}
150
151/// True when `dir` is one of `path_dirs`, directly or through a symlink.
152pub fn dir_on_path(dir: &std::path::Path, path_dirs: &[std::path::PathBuf]) -> bool {
153    let canonical = std::fs::canonicalize(dir).ok();
154    path_dirs
155        .iter()
156        .any(|d| d == dir || (canonical.is_some() && std::fs::canonicalize(d).ok() == canonical))
157}
158
159/// Every `lean-ctx` launcher on `PATH`, in lookup order: the entry a shell
160/// runs for `lean-ctx`, followed by the ones it shadows.
161pub fn launchers_on_path(path_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
162    path_dirs
163        .iter()
164        .filter_map(|d| {
165            LAUNCHER_NAMES
166                .iter()
167                .map(|name| d.join(name))
168                .find(|p| p.is_file())
169        })
170        .collect()
171}
172
173/// The binary path to embed in machine-local shell artifacts — the shell
174/// hooks, `env.sh` and the `_lc` shims (#1851).
175///
176/// Package managers expose `lean-ctx` on `PATH` through a stable entry (a
177/// Homebrew symlink, a scoop shim, an npm or mise launcher) while the binary
178/// itself lives in a versioned directory off `PATH`. The first launcher on
179/// `PATH` that is verified to run this installation — a symlink to it, a Scoop
180/// shim naming it, a mise/asdf shim owning its install tree — is embedded
181/// instead, so artifacts follow updates. An unrelated build on `PATH` must
182/// never replace an explicitly selected Pro/custom installation.
183pub fn stable_shell_binary(binary: &str) -> String {
184    stable_shell_binary_in(binary, &path_dirs())
185}
186
187/// The binary path to write into agent configurations — MCP server `command`
188/// fields and agent hooks (#1873).
189///
190/// These outlive the running build just like the shell artifacts of #1851: a
191/// Scoop or Homebrew install runs from a versioned directory the next update
192/// removes, which left every configured MCP server pointing at a deleted
193/// binary. A stable launcher is used only when its target identity is verified
194/// (see [`stable_shell_binary`]); otherwise the exact binary is kept.
195pub fn resolve_agent_binary() -> String {
196    stable_shell_binary(&resolve_portable_binary())
197}
198
199fn stable_shell_binary_in(binary: &str, path_dirs: &[std::path::PathBuf]) -> String {
200    let path = std::path::Path::new(binary);
201    if !path.is_absolute() || path.parent().is_some_and(|d| dir_on_path(d, path_dirs)) {
202        return binary.to_string();
203    }
204    let Ok(canonical) = std::fs::canonicalize(path) else {
205        return binary.to_string();
206    };
207    launchers_on_path(path_dirs)
208        .iter()
209        .find(|launcher| launcher_runs(launcher, &canonical))
210        .map_or_else(
211            || binary.to_string(),
212            |p| sanitize_exe_path(&p.to_string_lossy()),
213        )
214}
215
216/// True when running `launcher` is verified to execute the installation of
217/// `target` (canonical) — never merely because it is named `lean-ctx`.
218fn launcher_runs(launcher: &std::path::Path, target: &std::path::Path) -> bool {
219    std::fs::canonicalize(launcher).is_ok_and(|l| l == target)
220        || scoop_shim_target(launcher)
221            .is_some_and(|t| std::fs::canonicalize(t).is_ok_and(|t| t == target))
222        || version_manager_shim_owns(launcher, target)
223}
224
225/// Upper bound for a Scoop `.shim` file; real ones are one or two lines.
226const MAX_SHIM_BYTES: u64 = 4096;
227
228/// The executable a Scoop shim runs (#1873). Scoop's `shims\lean-ctx.exe` is a
229/// generic launcher that reads `path = "…\apps\lean-ctx\current\lean-ctx.exe"`
230/// from the sibling `lean-ctx.shim`; `current` is a junction to the versioned
231/// directory, so the canonical target is the running build.
232fn scoop_shim_target(launcher: &std::path::Path) -> Option<std::path::PathBuf> {
233    use std::io::Read;
234    let mut text = String::new();
235    std::fs::File::open(launcher.with_extension("shim"))
236        .ok()?
237        .take(MAX_SHIM_BYTES + 1)
238        .read_to_string(&mut text)
239        .ok()?;
240    if text.len() as u64 > MAX_SHIM_BYTES {
241        return None;
242    }
243    text.lines()
244        .find_map(|line| {
245            let (key, value) = line.split_once('=')?;
246            (key.trim() == "path").then(|| std::path::PathBuf::from(value.trim().trim_matches('"')))
247        })
248        .filter(|p| p.is_absolute())
249}
250
251/// mise and asdf keep a `shims` directory next to an `installs` tree
252/// (`<data>/shims/lean-ctx`, `<data>/installs/<tool>/<version>/…`). Their shim
253/// dispatches only into that tree, so it owns every binary inside it and keeps
254/// working after the versioned directory is upgraded away.
255fn version_manager_shim_owns(launcher: &std::path::Path, target: &std::path::Path) -> bool {
256    let Some(shims) = launcher.parent() else {
257        return false;
258    };
259    if shims.file_name() != Some(std::ffi::OsStr::new("shims")) {
260        return false;
261    }
262    shims.parent().is_some_and(|root| {
263        std::fs::canonicalize(root.join("installs"))
264            .is_ok_and(|installs| target.starts_with(installs))
265    })
266}
267
268/// Whether `dir` is a directory cargo builds a profile into. Cargo names it
269/// after the profile and puts it wherever `CARGO_TARGET_DIR`, `--target-dir`
270/// or `--target` say, so it is recognised by what cargo writes into it: its
271/// build lock, or the fingerprint and dependency directories.
272fn is_cargo_profile_dir(dir: &std::path::Path) -> bool {
273    dir.join(".cargo-lock").is_file()
274        || (dir.join(".fingerprint").is_dir() && dir.join("deps").is_dir())
275}
276
277/// Whether `path` is an artifact in a cargo output directory on this disk:
278/// directly in a profile directory (`<profile>/lean-ctx`) or in its
279/// `deps`/`examples` (test and example binaries).
280///
281/// The one structural answer for every caller that must tell a developer's
282/// build from an installation (uninstall, dev-install, the path baked into
283/// generated artifacts). It reads the disk, so it says "no" for a path that
284/// does not exist; callers keep their name-based check next to it. A wrong
285/// "yes" needs cargo's own bookkeeping files next to an installed binary.
286pub fn in_cargo_output_dir(path: &std::path::Path) -> bool {
287    let Some(dir) = path.parent() else {
288        return false;
289    };
290    if is_cargo_profile_dir(dir) {
291        return true;
292    }
293    matches!(
294        dir.file_name().and_then(|name| name.to_str()),
295        Some("deps" | "examples")
296    ) && dir.parent().is_some_and(is_cargo_profile_dir)
297}
298
299/// Decide which `lean-ctx` path to bake into generated artifacts (autostart
300/// plists, daemon spawn, MCP server command, agent/shell hooks, update
301/// scheduler). The chosen path must be the *exact build the user is running*, so
302/// every artifact agrees and a single `setup`/`dev-install` can never leave the
303/// daemon on a different build than the proxy/MCP config.
304///
305/// Preference order:
306/// 1. `current_exe` when absolute and not inside a transient Cargo build dir —
307///    by construction the build currently in use.
308/// 2. `which lean-ctx` — the installed copy on PATH; used when the running binary
309///    lives in `target/{debug,release}` (`cargo run -- setup`), where the
310///    installed copy is the intended target.
311/// 3. an absolute `current_exe` even from a build dir — still better than a bare
312///    name (keeps generated hooks absolute, see #367).
313/// 4. bare `lean-ctx`.
314///
315/// Prior to #2444 this preferred `which` first, making the baked path depend on
316/// ambient PATH ordering at generation time. That was non-deterministic: the
317/// daemon autostart could capture a stale Homebrew copy shadowing `~/.local/bin`
318/// while the proxy/MCP config captured the fresh build — silently running two
319/// different builds at once.
320fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
321    // `target/{debug,release}` plus a relocated `[build] target-dir` whose name
322    // ends in `target` (e.g. a machine-wide `~/.cargo/shared-target`), by
323    // name; any other cargo output directory (`--target <triple>`, a named
324    // profile, a target dir of another name) by what cargo wrote into it.
325    let is_build_artifact = |p: &str| {
326        p.split(['/', '\\'])
327            .collect::<Vec<_>>()
328            .windows(2)
329            .any(|w| w[0].ends_with("target") && matches!(w[1], "debug" | "release"))
330            || in_cargo_output_dir(std::path::Path::new(p))
331    };
332
333    // 1. Prefer the running binary when it lives in a stable install location.
334    if let Some(exe) = current_exe
335        && std::path::Path::new(exe).is_absolute()
336        && !is_build_artifact(exe)
337    {
338        return sanitize_exe_path(exe);
339    }
340
341    // 2. Otherwise fall back to the installed copy on PATH — never a test or
342    //    example binary that only happens to be on PATH (#2232).
343    if let Some(raw) = which_raw {
344        let path = pick_best_binary_line(raw);
345        if std::path::Path::new(&path).is_absolute() && !is_transient_build_binary(&path) {
346            return sanitize_exe_path(&path);
347        }
348    }
349
350    // 3. An absolute build-artifact path still beats a bare name.
351    if let Some(exe) = current_exe
352        && std::path::Path::new(exe).is_absolute()
353    {
354        return sanitize_exe_path(exe);
355    }
356
357    // 4. Last resort.
358    "lean-ctx".to_string()
359}
360
361/// On Windows, `where lean-ctx` returns multiple lines (e.g. `lean-ctx` and
362/// `lean-ctx.cmd`). Pick the `.cmd`/`.exe` variant if available, otherwise
363/// the first line.
364fn pick_best_binary_line(raw: &str) -> String {
365    let lines: Vec<&str> = raw
366        .lines()
367        .map(str::trim)
368        .filter(|l| !l.is_empty())
369        .collect();
370    if lines.len() <= 1 {
371        return lines.first().unwrap_or(&"lean-ctx").to_string();
372    }
373    if cfg!(windows)
374        && let Some(cmd) = lines.iter().find(|l| {
375            std::path::Path::new(*l).extension().is_some_and(|ext| {
376                ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
377            })
378        })
379    {
380        return cmd.to_string();
381    }
382    lines[0].to_string()
383}
384
385fn sanitize_exe_path(path: &str) -> String {
386    let cleaned = path.trim_end_matches(" (deleted)");
387    if cfg!(windows) {
388        super::pathutil::normalize_tool_path(cleaned)
389    } else {
390        cleaned.to_string()
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn single_line_returns_as_is() {
400        assert_eq!(
401            pick_best_binary_line("/usr/bin/lean-ctx"),
402            "/usr/bin/lean-ctx"
403        );
404    }
405
406    #[test]
407    fn multiline_returns_first_line() {
408        let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
409        let result = pick_best_binary_line(raw);
410        assert_eq!(result, "/usr/bin/lean-ctx");
411    }
412
413    #[test]
414    fn empty_returns_fallback() {
415        assert_eq!(pick_best_binary_line(""), "lean-ctx");
416    }
417
418    #[test]
419    fn sanitize_removes_deleted_suffix() {
420        assert_eq!(
421            sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
422            "/usr/bin/lean-ctx"
423        );
424    }
425
426    #[test]
427    fn whitespace_lines_are_filtered() {
428        let raw = "  /usr/bin/lean-ctx  \n  \n  /usr/local/bin/lean-ctx  ";
429        assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
430    }
431
432    #[cfg(windows)]
433    #[test]
434    fn sanitize_normalizes_msys_path_on_windows() {
435        assert_eq!(
436            sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
437            "C:/Users/ABC/.local/bin/lean-ctx"
438        );
439    }
440
441    #[cfg(windows)]
442    #[test]
443    fn sanitize_keeps_native_windows_path() {
444        assert_eq!(
445            sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
446            "C:/Users/ABC/lean-ctx.exe"
447        );
448    }
449
450    #[cfg(not(windows))]
451    #[test]
452    fn sanitize_unix_path_unchanged() {
453        assert_eq!(
454            sanitize_exe_path("/usr/local/bin/lean-ctx"),
455            "/usr/local/bin/lean-ctx"
456        );
457    }
458
459    /// #708: the env override is emitted verbatim — no absolutization, no
460    /// rewriting — so synced settings keep `$HOME/...` forms; blank means
461    /// unset so an empty export cannot wedge hook generation.
462    #[test]
463    fn hook_binary_override_env_is_verbatim_and_blank_is_none() {
464        let _lock = crate::core::data_dir::test_env_lock();
465        crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", "$HOME/.local/bin/lean-ctx");
466        assert_eq!(
467            hook_binary_override().as_deref(),
468            Some("$HOME/.local/bin/lean-ctx")
469        );
470        crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", "   ");
471        assert_eq!(hook_binary_override(), None);
472        crate::test_env::remove_var("LEAN_CTX_HOOK_BINARY");
473    }
474
475    #[test]
476    fn resolve_portable_binary_is_absolute() {
477        // #367: generated hook commands must use an absolute binary path, never
478        // a bare `lean-ctx`, because agents run hooks under non-login shells
479        // without the install dir on PATH. `which`/`current_exe()` both yield
480        // an absolute path in any normal environment (incl. the test harness).
481        let resolved = resolve_portable_binary();
482        assert!(
483            std::path::Path::new(&resolved).is_absolute(),
484            "resolve_portable_binary must return an absolute path, got: {resolved}"
485        );
486    }
487
488    #[test]
489    fn nothing_resolvable_returns_bare_name() {
490        // #2444: neither a usable running binary nor a PATH hit -> bare name.
491        assert_eq!(choose_binary_path(None, None), "lean-ctx");
492        // A relative current_exe is not a usable absolute path.
493        assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
494    }
495
496    // Unix absolute paths (the `/...` form is not absolute on Windows).
497    #[cfg(not(windows))]
498    mod unix_paths {
499        use super::*;
500
501        #[test]
502        fn current_exe_beats_path_lookup() {
503            // The core of #2444: the *running* build wins over a divergent PATH
504            // entry (e.g. a stale Homebrew copy shadowing ~/.local/bin).
505            let chosen = choose_binary_path(
506                Some("/Users/dev/.local/bin/lean-ctx"),
507                Some("/opt/homebrew/bin/lean-ctx"),
508            );
509            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
510        }
511
512        #[test]
513        fn release_build_artifact_falls_back_to_path() {
514            // `cargo run --release -- setup`: bake the installed copy, not the
515            // transient build output.
516            let chosen = choose_binary_path(
517                Some("/work/lean-ctx/rust/target/release/lean-ctx"),
518                Some("/Users/dev/.local/bin/lean-ctx"),
519            );
520            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
521        }
522
523        #[test]
524        fn debug_build_artifact_falls_back_to_path() {
525            let chosen = choose_binary_path(
526                Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
527                Some("/usr/local/bin/lean-ctx"),
528            );
529            assert_eq!(chosen, "/usr/local/bin/lean-ctx");
530        }
531
532        #[test]
533        fn relocated_target_dir_falls_back_to_path() {
534            // A machine-wide `[build] target-dir` must not be baked into the
535            // autostart plists: every other worktree's build overwrites it.
536            let chosen = choose_binary_path(
537                Some("/Users/dev/.cargo/shared-target/release/lean-ctx"),
538                Some("/Users/dev/.local/bin/lean-ctx"),
539            );
540            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
541            // A bare `release` directory outside any target dir is an install.
542            let chosen = choose_binary_path(
543                Some("/opt/lean-ctx/release/lean-ctx"),
544                Some("/Users/dev/.local/bin/lean-ctx"),
545            );
546            assert_eq!(chosen, "/opt/lean-ctx/release/lean-ctx");
547        }
548
549        #[test]
550        fn build_artifact_without_path_keeps_absolute_current_exe() {
551            // No installed copy on PATH -> an absolute build path still beats the
552            // bare name, so generated hooks stay absolute (#367).
553            let chosen =
554                choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
555            assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
556        }
557
558        #[test]
559        fn relative_current_exe_falls_back_to_path() {
560            let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
561            assert_eq!(chosen, "/usr/bin/lean-ctx");
562        }
563
564        /// #2232: test, example and build-script binaries are transient; a
565        /// developer's own build in the profile directory is not.
566        #[test]
567        fn transient_build_binaries_are_recognised() {
568            for transient in [
569                "/work/lean-ctx/rust/target/debug/deps/lean_ctx_server-1a2b3c4d",
570                "/work/lean-ctx/rust/target/release/deps/lean_ctx-abc123",
571                "/work/rust/target/debug/examples/demo",
572                "/work/rust/target/debug/build/lean-ctx-0f/build-script-build",
573                "/work/rust/target/debug/incremental/x/lean-ctx",
574                "/work/rust/target/x86_64-unknown-linux-gnu/debug/deps/lean_ctx-1",
575                "/Users/dev/.cargo/shared-target/debug/deps/lean_ctx-1",
576                r"C:\work\rust\target\debug\deps\lean_ctx-1.exe",
577            ] {
578                assert!(is_transient_build_binary(transient), "{transient}");
579            }
580            for stable in [
581                "/work/lean-ctx/rust/target/release/lean-ctx",
582                "/work/lean-ctx/rust/target/debug/lean-ctx",
583                "/Users/dev/.local/bin/lean-ctx",
584                "/opt/build/deps/lean-ctx",
585                "/usr/local/bin/lean-ctx",
586                "lean-ctx",
587            ] {
588                assert!(!is_transient_build_binary(stable), "{stable}");
589            }
590        }
591
592        /// #2232: a cargo output directory of any name, by what cargo wrote.
593        #[test]
594        fn transient_binary_in_unnamed_target_dir_is_recognised_on_disk() {
595            let tmp = tempfile::tempdir().unwrap();
596            let profile = tmp.path().join("target-agent-shared/debug");
597            std::fs::create_dir_all(profile.join("deps")).unwrap();
598            std::fs::write(profile.join(".cargo-lock"), "").unwrap();
599            let test_bin = profile.join("deps/lean_ctx_server-1a2b");
600            assert!(is_transient_build_binary(&test_bin.to_string_lossy()));
601            assert!(!is_transient_build_binary(
602                &profile.join("lean-ctx").to_string_lossy()
603            ));
604        }
605
606        /// #2232: a test binary that is on PATH is never chosen over the
607        /// running build.
608        #[test]
609        fn transient_path_entry_is_not_chosen() {
610            let chosen = choose_binary_path(
611                Some("/work/rust/target/debug/deps/lean_ctx_server-1a2b"),
612                Some("/work/rust/target/debug/deps/lean-ctx"),
613            );
614            assert_eq!(chosen, "/work/rust/target/debug/deps/lean_ctx_server-1a2b");
615        }
616
617        /// #2232: a transient binary is refused for the real account home
618        /// only; temp homes (tests) and stable binaries pass.
619        #[test]
620        fn transient_binary_refused_only_for_real_home() {
621            let tmp = tempfile::tempdir().unwrap();
622            let real = tmp.path().join("real");
623            let other = tmp.path().join("other");
624            std::fs::create_dir_all(&real).unwrap();
625            std::fs::create_dir_all(&other).unwrap();
626            let test_bin = "/work/rust/target/debug/deps/lean_ctx_server-1a2b3c4d";
627            assert!(transient_binary_for_real_home(test_bin, &real, Some(&real)));
628            assert!(transient_binary_for_real_home(
629                &format!("\"{test_bin}\""),
630                &real,
631                Some(&real)
632            ));
633            assert!(!transient_binary_for_real_home(
634                test_bin,
635                &other,
636                Some(&real)
637            ));
638            assert!(!transient_binary_for_real_home(test_bin, &real, None));
639            assert!(!transient_binary_for_real_home(
640                "/work/rust/target/release/lean-ctx",
641                &real,
642                Some(&real)
643            ));
644            assert!(!transient_binary_for_real_home(
645                "/Users/dev/.local/bin/lean-ctx",
646                &real,
647                Some(&real)
648            ));
649        }
650
651        #[test]
652        fn path_lookup_multiline_picks_first() {
653            let chosen = choose_binary_path(
654                None,
655                Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
656            );
657            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
658        }
659    }
660
661    /// #1851: package-manager layouts, a versioned install dir off PATH and a
662    /// stable launcher dir on it.
663    #[cfg(unix)]
664    mod stable_shell_binary {
665        use super::super::*;
666        use std::path::{Path, PathBuf};
667
668        fn launcher(dir: &Path) -> PathBuf {
669            std::fs::create_dir_all(dir).unwrap();
670            let path = dir.join("lean-ctx");
671            std::fs::write(&path, "#!/bin/sh\n").unwrap();
672            path
673        }
674
675        #[test]
676        fn binary_in_a_path_dir_is_kept() {
677            let tmp = tempfile::tempdir().unwrap();
678            let bin = launcher(&tmp.path().join("bin"));
679            let other = launcher(&tmp.path().join("other"));
680            let dirs: Vec<PathBuf> =
681                vec![other.parent().unwrap().into(), bin.parent().unwrap().into()];
682            let bin = bin.to_string_lossy();
683            assert_eq!(stable_shell_binary_in(&bin, &dirs), bin);
684        }
685
686        #[test]
687        fn versioned_install_dir_resolves_to_the_path_launcher() {
688            let tmp = tempfile::tempdir().unwrap();
689            let cellar = launcher(&tmp.path().join("Cellar/lean-ctx/3.10.2/bin"));
690            let unrelated = launcher(&tmp.path().join("other"));
691            let shims = tmp.path().join("shims/lean-ctx");
692            std::fs::create_dir_all(shims.parent().unwrap()).unwrap();
693            std::os::unix::fs::symlink(&cellar, &shims).unwrap();
694            let dirs = vec![
695                unrelated.parent().unwrap().into(),
696                shims.parent().unwrap().into(),
697            ];
698            assert_eq!(
699                stable_shell_binary_in(&cellar.to_string_lossy(), &dirs),
700                shims.to_string_lossy()
701            );
702        }
703
704        #[test]
705        fn symlinked_path_dir_counts_as_on_path() {
706            let tmp = tempfile::tempdir().unwrap();
707            let bin = launcher(&tmp.path().join("real"));
708            let link = tmp.path().join("link");
709            std::os::unix::fs::symlink(bin.parent().unwrap(), &link).unwrap();
710            let bin = bin.to_string_lossy();
711            assert_eq!(stable_shell_binary_in(&bin, &[link]), bin);
712        }
713
714        #[test]
715        fn no_launcher_on_path_keeps_the_binary() {
716            let tmp = tempfile::tempdir().unwrap();
717            let bin = launcher(&tmp.path().join("apps/3.10.2"));
718            let bin = bin.to_string_lossy();
719            assert_eq!(stable_shell_binary_in(&bin, &[tmp.path().join("x")]), bin);
720            assert_eq!(stable_shell_binary_in("lean-ctx", &[]), "lean-ctx");
721        }
722
723        #[test]
724        fn unrelated_public_launcher_keeps_the_selected_binary() {
725            let tmp = tempfile::tempdir().unwrap();
726            let selected = launcher(&tmp.path().join("private-install"));
727            let public = launcher(&tmp.path().join("public-bin"));
728            let selected = selected.to_string_lossy();
729            assert_eq!(
730                stable_shell_binary_in(&selected, &[public.parent().unwrap().into()]),
731                selected
732            );
733        }
734
735        fn shim_file(launcher: &Path, target: &Path) {
736            let body = format!("path = \"{}\"\nargs = \n", target.display());
737            std::fs::write(launcher.with_extension("shim"), body).unwrap();
738        }
739
740        #[test]
741        fn shim_file_naming_the_binary_verifies_the_launcher() {
742            let tmp = tempfile::tempdir().unwrap();
743            let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
744            let shim = launcher(&tmp.path().join("shims"));
745            shim_file(&shim, &exe);
746            assert_eq!(
747                stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
748                shim.to_string_lossy()
749            );
750        }
751
752        #[test]
753        fn shim_file_naming_another_binary_is_ignored() {
754            let tmp = tempfile::tempdir().unwrap();
755            let exe = launcher(&tmp.path().join("private-install"));
756            let public = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
757            let shim = launcher(&tmp.path().join("shims"));
758            shim_file(&shim, &public);
759            let exe = exe.to_string_lossy();
760            assert_eq!(
761                stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
762                exe
763            );
764        }
765
766        #[test]
767        fn oversized_shim_file_is_ignored() {
768            let tmp = tempfile::tempdir().unwrap();
769            let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
770            let shim = launcher(&tmp.path().join("shims"));
771            let padding = " ".repeat(MAX_SHIM_BYTES as usize);
772            let body = format!("{padding}\npath = \"{}\"\n", exe.display());
773            std::fs::write(shim.with_extension("shim"), body).unwrap();
774            let exe = exe.to_string_lossy();
775            assert_eq!(
776                stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
777                exe
778            );
779        }
780
781        #[test]
782        fn version_manager_shim_owning_the_install_tree_is_used() {
783            let tmp = tempfile::tempdir().unwrap();
784            let data = tmp.path().join("mise");
785            let exe = launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
786            let shim = launcher(&data.join("shims"));
787            assert_eq!(
788                stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
789                shim.to_string_lossy()
790            );
791        }
792
793        #[test]
794        fn version_manager_shim_of_another_tree_is_ignored() {
795            let tmp = tempfile::tempdir().unwrap();
796            let exe = launcher(&tmp.path().join("private/installs/lean-ctx/3.10.4/bin"));
797            let data = tmp.path().join("mise");
798            launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
799            let shim = launcher(&data.join("shims"));
800            let exe = exe.to_string_lossy();
801            assert_eq!(
802                stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
803                exe
804            );
805        }
806    }
807
808    /// #1873: a Scoop install runs from `apps\lean-ctx\<version>`, which the
809    /// next update removes; MCP configs must get the shim on PATH instead.
810    #[cfg(windows)]
811    #[test]
812    fn scoop_versioned_exe_resolves_to_the_shim() {
813        let tmp = tempfile::tempdir().unwrap();
814        let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
815        let shims = tmp.path().join("shims");
816        for dir in [&apps, &shims] {
817            std::fs::create_dir_all(dir).unwrap();
818            std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
819        }
820        let exe = apps.join("lean-ctx.exe");
821        std::fs::write(
822            shims.join("lean-ctx.shim"),
823            format!("path = \"{}\"\r\n", exe.display()),
824        )
825        .unwrap();
826        assert_eq!(
827            stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
828            sanitize_exe_path(&shims.join("lean-ctx.exe").to_string_lossy())
829        );
830    }
831
832    /// An opaque Windows launcher without a `.shim` naming the running build
833    /// does not establish executable identity.
834    #[cfg(windows)]
835    #[test]
836    fn unverified_scoop_shim_keeps_the_selected_executable() {
837        let tmp = tempfile::tempdir().unwrap();
838        let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
839        let shims = tmp.path().join("shims");
840        for dir in [&apps, &shims] {
841            std::fs::create_dir_all(dir).unwrap();
842            std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
843        }
844        let exe = apps.join("lean-ctx.exe");
845        assert_eq!(
846            stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
847            exe.to_string_lossy()
848        );
849    }
850
851    #[test]
852    fn agent_binary_is_absolute() {
853        // #1873: MCP hosts spawn `command` directly, so the stable-launcher
854        // mapping must never degrade the path to a bare name.
855        let resolved = resolve_agent_binary();
856        assert!(
857            std::path::Path::new(&resolved).is_absolute(),
858            "resolve_agent_binary must return an absolute path, got: {resolved}"
859        );
860    }
861}