Skip to main content

clawft_kernel/node_facts/
host.rs

1//! The seam between probes and the machine: [`ProbeHost`].
2//!
3//! Probes never touch the OS directly; they ask a host to run a tool, read
4//! a file or list a directory. [`SystemHost`] does it for real (with a
5//! per-command timeout and no shell); tests supply a fake host with canned
6//! outputs, so a Linux ARM board or a Coral TPU can be simulated on a Mac.
7
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13/// Largest tool output a probe reads, in bytes.
14pub const MAX_OUTPUT: usize = 1 << 20;
15
16/// What probes may ask of the machine.
17pub trait ProbeHost {
18    /// `std::env::consts::OS` of the probed machine (`macos`, `linux`).
19    fn os(&self) -> String;
20    /// `std::env::consts::ARCH` of the probed machine (`aarch64`, `arm`).
21    fn arch(&self) -> String;
22    /// Resolve a tool name to an executable path, if installed.
23    fn which(&self, tool: &str) -> Option<PathBuf>;
24    /// Run a tool (resolved with [`Self::which`]) and return stdout when it
25    /// exits 0 within the timeout.
26    fn run(&self, tool: &str, args: &[&str]) -> Option<String>;
27    /// Like [`Self::run`] but returns stdout followed by stderr (for tools
28    /// that print their version on stderr, such as `llama-server`).
29    fn run_all(&self, tool: &str, args: &[&str]) -> Option<String>;
30    /// Read a small text file.
31    fn read_file(&self, path: &str) -> Option<String>;
32    /// List directory entry names.
33    fn list_dir(&self, path: &str) -> Option<Vec<String>>;
34    /// True if the path exists.
35    fn exists(&self, path: &str) -> bool;
36}
37
38/// The real machine.
39#[derive(Debug, Clone)]
40pub struct SystemHost {
41    timeout: Duration,
42    extra_dirs: Vec<PathBuf>,
43}
44
45impl Default for SystemHost {
46    fn default() -> Self {
47        Self::new(Duration::from_secs(8))
48    }
49}
50
51type Pipe = Option<Box<dyn Read + Send>>;
52
53fn drain(r: Pipe) -> std::thread::JoinHandle<Vec<u8>> {
54    std::thread::spawn(move || {
55        let mut buf = Vec::new();
56        if let Some(r) = r {
57            let _ = r.take(MAX_OUTPUT as u64).read_to_end(&mut buf);
58        }
59        buf
60    })
61}
62
63impl SystemHost {
64    /// A host whose commands are killed after `timeout`.
65    ///
66    /// Besides `PATH`, tools are looked up in the usual package-manager
67    /// directories, because a daemon often starts with a minimal `PATH`.
68    pub fn new(timeout: Duration) -> Self {
69        let mut extra_dirs: Vec<PathBuf> = [
70            "/opt/homebrew/bin",
71            "/usr/local/bin",
72            "/usr/bin",
73            "/bin",
74            "/usr/sbin",
75            "/sbin",
76        ]
77        .iter()
78        .map(PathBuf::from)
79        .collect();
80        if let Some(home) = std::env::var_os("HOME") {
81            extra_dirs.push(PathBuf::from(home).join(".local/bin"));
82        }
83        Self {
84            timeout,
85            extra_dirs,
86        }
87    }
88
89    fn exec(&self, tool: &str, args: &[&str], merge_stderr: bool) -> Option<String> {
90        let exe = self.which(tool)?;
91        let mut child = Command::new(exe)
92            .args(args)
93            .stdin(Stdio::null())
94            .stdout(Stdio::piped())
95            .stderr(if merge_stderr {
96                Stdio::piped()
97            } else {
98                Stdio::null()
99            })
100            .spawn()
101            .ok()?;
102        // Drain the pipes on threads so a chatty tool cannot fill a pipe and
103        // block before we notice it has exited.
104        let out = drain(
105            child
106                .stdout
107                .take()
108                .map(|r| Box::new(r) as Box<dyn Read + Send>),
109        );
110        let err = drain(
111            child
112                .stderr
113                .take()
114                .map(|r| Box::new(r) as Box<dyn Read + Send>),
115        );
116        let deadline = Instant::now() + self.timeout;
117        let status = loop {
118            match child.try_wait() {
119                Ok(Some(status)) => break Some(status),
120                Ok(None) if Instant::now() < deadline => {
121                    std::thread::sleep(Duration::from_millis(20));
122                }
123                _ => {
124                    let _ = child.kill();
125                    let _ = child.wait();
126                    break None;
127                }
128            }
129        };
130        // On failure or timeout the readers are detached, not joined: a
131        // grandchild may still hold a pipe open.
132        match status {
133            Some(s) if s.success() => {
134                let mut bytes = out.join().ok()?;
135                bytes.extend(err.join().ok()?);
136                Some(String::from_utf8_lossy(&bytes).into_owned())
137            }
138            _ => None,
139        }
140    }
141}
142
143fn is_executable(p: &Path) -> bool {
144    #[cfg(unix)]
145    {
146        use std::os::unix::fs::PermissionsExt;
147        p.metadata()
148            .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
149            .unwrap_or(false)
150    }
151    #[cfg(not(unix))]
152    {
153        p.is_file()
154    }
155}
156
157impl ProbeHost for SystemHost {
158    fn os(&self) -> String {
159        std::env::consts::OS.to_string()
160    }
161
162    fn arch(&self) -> String {
163        std::env::consts::ARCH.to_string()
164    }
165
166    fn which(&self, tool: &str) -> Option<PathBuf> {
167        if tool.is_empty() || tool.contains('/') {
168            return None;
169        }
170        let path_dirs = std::env::var_os("PATH")
171            .map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
172            .unwrap_or_default();
173        path_dirs
174            .into_iter()
175            .chain(self.extra_dirs.iter().cloned())
176            .map(|d| d.join(tool))
177            .find(|p| is_executable(p))
178    }
179
180    fn run(&self, tool: &str, args: &[&str]) -> Option<String> {
181        self.exec(tool, args, false)
182    }
183
184    fn run_all(&self, tool: &str, args: &[&str]) -> Option<String> {
185        self.exec(tool, args, true)
186    }
187
188    fn read_file(&self, path: &str) -> Option<String> {
189        let f = std::fs::File::open(path).ok()?;
190        let mut buf = Vec::new();
191        f.take(MAX_OUTPUT as u64).read_to_end(&mut buf).ok()?;
192        Some(String::from_utf8_lossy(&buf).into_owned())
193    }
194
195    fn list_dir(&self, path: &str) -> Option<Vec<String>> {
196        let mut names: Vec<String> = std::fs::read_dir(path)
197            .ok()?
198            .filter_map(|e| e.ok())
199            .filter_map(|e| e.file_name().into_string().ok())
200            .collect();
201        names.sort();
202        Some(names)
203    }
204
205    fn exists(&self, path: &str) -> bool {
206        Path::new(path).exists()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn which_rejects_paths_and_finds_sh() {
216        let h = SystemHost::default();
217        assert!(h.which("../sh").is_none());
218        assert!(h.which("").is_none());
219        assert!(h.which("sh").is_some());
220    }
221
222    #[test]
223    fn run_returns_stdout_only_on_success() {
224        let h = SystemHost::default();
225        assert_eq!(
226            h.run("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
227            Some("hi\n")
228        );
229        assert_eq!(
230            h.run_all("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
231            Some("hi\nerr\n")
232        );
233        assert!(h.run("sh", &["-c", "exit 3"]).is_none());
234    }
235
236    #[test]
237    fn run_kills_a_tool_that_overruns_the_timeout() {
238        let h = SystemHost::new(Duration::from_millis(200));
239        let t = Instant::now();
240        assert!(h.run("sh", &["-c", "sleep 5"]).is_none());
241        assert!(t.elapsed() < Duration::from_secs(3));
242    }
243}