aprender_mcp/apr_bin.rs
1//! Resolution of the `apr` binary this server delegates to.
2//!
3//! Every subprocess-backed MCP tool shells out to `apr <subcommand> --json`.
4//! Until this module existed, that spawn was a literal `Command::new("apr")`,
5//! which asks the operating system to search `$PATH`. That is the exact
6//! anti-pattern `CLAUDE.md` opens with ("NEVER hardcode or PATH-resolve an
7//! `apr` binary"), and it produced a wrong-answer channel in the field:
8//!
9//! * `apr mcp` launched from the freshly installed 0.63.0 artifact executed
10//! `/home/noah/.local/bin/apr`, which is 0.60.0, for all eight subprocess
11//! tools — while `apr.version` kept answering `0.63.0` from in-process
12//! state. The one tool a client uses to establish provenance reported a
13//! version that none of the other tools actually ran.
14//! * A user who runs the binary by path without putting its install directory
15//! on `$PATH` gets `Failed to spawn ...: No such file or directory` from
16//! eight of nine tools.
17//!
18//! [`apr_binary`] fixes both: when the running executable *is* `apr`, the
19//! server delegates to **itself**, so `apr mcp` from 0.63.0 runs 0.63.0.
20//!
21//! # Resolution order
22//!
23//! 1. `$APR_BIN`, if set and non-empty. Escape hatch for embedders and for
24//! tests that need to point the server at a mock.
25//! 2. [`std::env::current_exe`], **if its file stem is exactly `apr`**. The
26//! stem check is what keeps the library usable outside the `apr` binary:
27//! under `cargo test` the current executable is
28//! `target/debug/deps/aprender_mcp-<hash>`, which must not be spawned with
29//! `validate model.gguf --json`.
30//! 3. The bare name `apr`, resolved by the OS through `$PATH`. Reached only
31//! when the host process is not `apr` itself.
32
33use std::ffi::OsString;
34use std::path::{Path, PathBuf};
35
36/// Environment variable that overrides binary resolution entirely.
37pub const APR_BIN_ENV: &str = "APR_BIN";
38
39/// The program the subprocess-backed tools should execute.
40///
41/// See the [module docs](self) for the resolution order.
42#[must_use]
43pub fn apr_binary() -> PathBuf {
44 resolve(std::env::var_os(APR_BIN_ENV), std::env::current_exe().ok())
45}
46
47/// Pure core of [`apr_binary`], parameterised over the two pieces of process
48/// state it reads so the resolution policy is testable without mutating the
49/// environment of the running test process.
50#[must_use]
51pub fn resolve(override_var: Option<OsString>, current_exe: Option<PathBuf>) -> PathBuf {
52 if let Some(explicit) = override_var {
53 if !explicit.is_empty() {
54 return PathBuf::from(explicit);
55 }
56 }
57 if let Some(exe) = current_exe {
58 if is_apr_binary(&exe) {
59 return exe;
60 }
61 }
62 PathBuf::from("apr")
63}
64
65/// True when `path` names the `apr` CLI itself (`apr`, or `apr.exe` on
66/// Windows). Deliberately an exact stem match: `aprender_mcp-1a2b3c` and
67/// `apr-cli` are *not* `apr`, and spawning them with `apr` subcommands would
68/// be worse than falling back to `$PATH`.
69fn is_apr_binary(path: &Path) -> bool {
70 path.file_stem().is_some_and(|stem| stem == "apr")
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use std::io::Write;
77
78 /// Write an executable shell script at `path` that prints `marker`.
79 fn write_marker_bin(path: &Path, marker: &str) {
80 let mut f = std::fs::File::create(path).expect("create marker bin");
81 writeln!(f, "#!/bin/sh").expect("shebang");
82 writeln!(f, "echo {marker}").expect("body");
83 f.sync_all().expect("sync");
84 drop(f);
85 #[cfg(unix)]
86 {
87 use std::os::unix::fs::PermissionsExt;
88 let mut perms = std::fs::metadata(path).expect("stat").permissions();
89 perms.set_mode(0o755);
90 std::fs::set_permissions(path, perms).expect("chmod");
91 }
92 }
93
94 /// Execute a just-written shim, retrying only on ETXTBSY.
95 ///
96 /// `write_marker_bin` already syncs and drops its own handle, so the fd
97 /// that makes the file "busy" is not ours. Under `cargo test --workspace
98 /// --lib` this module's neighbours in `tools::subprocess::tests` spawn
99 /// subprocesses concurrently; if one of them forks in the window where our
100 /// write fd to the shim is still open, the forked child inherits that fd
101 /// and holds it until its own exec. Our exec of the shim then fails with
102 /// ETXTBSY. `O_CLOEXEC` closes the fd at the child's exec but not before
103 /// it, so the window is real — which is why this test passed standalone
104 /// (`-p aprender-mcp --lib`) and failed under `--workspace --lib` with
105 /// `spawn resolved program .../apr: Text file busy (os error 26)`.
106 ///
107 /// Retrying cannot mask the defect under test: only ETXTBSY is retried,
108 /// the bound is one second, and a shim that is wrong or never becomes
109 /// executable still fails. `exec_marker_bin_survives_a_transient_etxtbsy`
110 /// holds a write fd open on purpose to prove both halves.
111 #[cfg(unix)]
112 fn exec_marker_bin(path: &Path) -> std::process::Output {
113 const ETXTBSY: i32 = 26;
114 let mut last = String::new();
115 for _ in 0..100 {
116 match std::process::Command::new(path).output() {
117 Ok(out) => return out,
118 Err(e) if e.raw_os_error() == Some(ETXTBSY) => {
119 last = e.to_string();
120 std::thread::sleep(std::time::Duration::from_millis(10));
121 }
122 Err(e) => panic!("spawn {}: {e}", path.display()),
123 }
124 }
125 panic!(
126 "spawn {} still busy after 100 attempts: {last}",
127 path.display()
128 );
129 }
130
131 /// FALSIFIER: the ETXTBSY window is real, and `exec_marker_bin` rides it out.
132 ///
133 /// Holding a write handle open reproduces exactly the state a forked
134 /// sibling leaves the shim in. A direct spawn must fail with ETXTBSY (if it
135 /// does not, the premise of the retry is wrong and this test says so);
136 /// the retrying helper must then succeed once the handle drops. Deleting
137 /// the retry loop turns this RED deterministically.
138 #[test]
139 #[cfg(unix)]
140 fn exec_marker_bin_survives_a_transient_etxtbsy() {
141 let dir = scratch_dir("etxtbsy");
142 let shim = dir.join("apr");
143 write_marker_bin(&shim, "RETRY-MARKER");
144
145 let held = std::fs::OpenOptions::new()
146 .write(true)
147 .open(&shim)
148 .expect("hold a write fd open");
149 let direct = std::process::Command::new(&shim).output();
150 assert_eq!(
151 direct.err().and_then(|e| e.raw_os_error()),
152 Some(26),
153 "an open write fd must make a direct spawn fail with ETXTBSY; without that \
154 the retry loop is guarding nothing"
155 );
156
157 std::thread::spawn(move || {
158 std::thread::sleep(std::time::Duration::from_millis(50));
159 drop(held);
160 });
161
162 let out = exec_marker_bin(&shim);
163 assert_eq!(
164 String::from_utf8_lossy(&out.stdout).trim(),
165 "RETRY-MARKER",
166 "the helper must retry through ETXTBSY and then run the shim"
167 );
168 }
169
170 /// Per-process, per-call scratch dir. A fixed path would let two
171 /// concurrent runs of this test binary delete each other's shim.
172 fn scratch_dir(name: &str) -> PathBuf {
173 let nonce = std::time::SystemTime::now()
174 .duration_since(std::time::UNIX_EPOCH)
175 .map(|d| d.as_nanos())
176 .unwrap_or(0);
177 let dir = std::env::temp_dir().join(format!(
178 "aprender-mcp-apr-bin-{name}-{}-{nonce}",
179 std::process::id()
180 ));
181 std::fs::create_dir_all(&dir).expect("mkdir scratch");
182 dir
183 }
184
185 /// FALSIFIER (#2384): when the running executable *is* `apr`, resolution
186 /// must yield that exact executable — not the bare name `apr`, which the
187 /// OS would resolve through `$PATH` to whatever stale `apr` happens to be
188 /// installed first.
189 ///
190 /// Behavioural, not shape-based: we execute the resolved program and
191 /// assert it is the one we designated as "self". Before the fix, resolve
192 /// returned `PathBuf::from("apr")`, which is not executable as written
193 /// (no such relative file) and is not the self binary.
194 #[test]
195 #[cfg(unix)]
196 fn resolution_executes_the_current_executable_not_a_path_lookup() {
197 let dir = scratch_dir("self");
198 let self_apr = dir.join("apr");
199 write_marker_bin(&self_apr, "SELF-BINARY-UNDER-TEST");
200
201 let resolved = resolve(None, Some(self_apr.clone()));
202 assert_eq!(
203 resolved,
204 self_apr,
205 "resolution must return the running executable, got {}",
206 resolved.display()
207 );
208
209 let out = exec_marker_bin(&resolved);
210 assert_eq!(
211 String::from_utf8_lossy(&out.stdout).trim(),
212 "SELF-BINARY-UNDER-TEST",
213 "the resolved program must be the current executable"
214 );
215 }
216
217 /// The two `apr` binaries in the field bug differ only by directory, so a
218 /// basename comparison would have passed while the defect was live. Assert
219 /// on the full path: resolving from `/a/apr` must never yield `/b/apr`.
220 #[test]
221 fn resolution_keeps_the_directory_of_the_current_executable() {
222 let a = PathBuf::from("/opt/release-0.63.0/bin/apr");
223 let b = PathBuf::from("/home/user/.local/bin/apr");
224 assert_eq!(resolve(None, Some(a.clone())), a);
225 assert_eq!(resolve(None, Some(b.clone())), b);
226 assert_ne!(resolve(None, Some(a)), b);
227 }
228
229 /// Library-embedded use (and every `cargo test` run) must still fall back
230 /// to `$PATH`: the current executable is a test harness, and spawning it
231 /// with `validate model.gguf --json` would be nonsense.
232 #[test]
233 fn non_apr_host_process_falls_back_to_the_path_name() {
234 let harness = PathBuf::from("/w/target/debug/deps/aprender_mcp-1a2b3c4d");
235 assert_eq!(resolve(None, Some(harness)), PathBuf::from("apr"));
236 assert_eq!(resolve(None, None), PathBuf::from("apr"));
237 }
238
239 /// `apr-cli`, `aprender`, `apr_serve` are not `apr`. Exact stem only.
240 #[test]
241 fn similar_names_are_not_treated_as_apr() {
242 for name in ["apr-cli", "aprender", "apr_serve", "aprx"] {
243 let exe = PathBuf::from("/usr/bin").join(name);
244 assert_eq!(
245 resolve(None, Some(exe)),
246 PathBuf::from("apr"),
247 "{name} must not be mistaken for the apr binary"
248 );
249 }
250 }
251
252 /// An `.exe` suffix is stripped by `file_stem`, so `apr.exe` is `apr`.
253 /// (Written with `/` separators so the assertion means the same thing on
254 /// every host — `\` is not a separator on Unix.)
255 #[test]
256 fn exe_suffix_is_recognised() {
257 let exe = PathBuf::from("/Program Files/apr/apr.exe");
258 assert_eq!(resolve(None, Some(exe.clone())), exe);
259 }
260
261 /// `$APR_BIN` wins over self-resolution, and an empty value is ignored
262 /// (an exported-but-empty variable must not spawn `""`).
263 #[test]
264 fn explicit_override_wins_and_empty_is_ignored() {
265 let exe = PathBuf::from("/opt/bin/apr");
266 assert_eq!(
267 resolve(Some(OsString::from("/mock/apr")), Some(exe.clone())),
268 PathBuf::from("/mock/apr")
269 );
270 assert_eq!(resolve(Some(OsString::new()), Some(exe.clone())), exe);
271 }
272}