Skip to main content

harn_vm/
harness_system.rs

1//! Host introspection for the `harness.system.*` capability surface.
2//!
3//! The methods here back the read-only `cpu()`, `memory()`, `gpus()`,
4//! `temperature()`, `platform()`, and `processes()` accessors on the
5//! `HarnessSystem` sub-handle (issue #1912 / epic #1765). All values are
6//! returned as `serde_json::Value` shapes that `crate::stdlib::json_to_vm_value`
7//! lifts into dicts/lists for the VM.
8//!
9//! Privacy + cross-platform notes:
10//!
11//! * `processes()` includes the current Harn process unconditionally; its
12//!   direct children are tagged with `is_harn_owned: true` when they appear
13//!   in the system snapshot. We deliberately do **not** leak
14//!   `command_line` / `environ` / `cwd` for arbitrary host processes — only
15//!   pid, name, cpu%, memory bytes, and the harn-ownership flag are
16//!   returned. Hosts that need richer per-process introspection should
17//!   reach for their own privileged surface.
18//! * `temperature()` and `gpus()` may return empty / partial data on
19//!   platforms whose `sysinfo` backend doesn't expose those sensors
20//!   (notably Apple Silicon and most containers). Callers must treat the
21//!   fields as best-effort — missing data is conveyed via empty lists or
22//!   `null` field values rather than errors so scripts can degrade
23//!   gracefully (`"if a local GPU is available, prefer local model"`).
24//! * Tagging spawned subprocesses with the active pipeline / session id
25//!   is descoped to a follow-up: it requires plumbing through the
26//!   sandbox spawn path. The current implementation tags only direct
27//!   children of the harn process (parent pid match), which is enough to
28//!   power the emergency-signaling use case in the issue body.
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::sync::Mutex;
32
33use serde_json::{json, Value};
34use sysinfo::{
35    Components, MemoryRefreshKind, Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System,
36};
37
38/// Registry of harn-owned child pids. Subprocess spawners (e.g. the
39/// `command_output` path in `stdlib::sandbox`) may register their
40/// children here so `processes()` can tag them with
41/// `is_harn_owned: true` even after the parent->child link is broken
42/// (e.g. detached agents).
43static HARN_OWNED_PIDS: Mutex<BTreeMap<u32, usize>> = Mutex::new(BTreeMap::new());
44
45/// Owns one claim that a child pid belongs to Harn.
46///
47/// Claims are reference-counted because independent runtime components may
48/// observe the same child. The pid stays tagged until the final guard drops.
49#[must_use = "dropping the registration stops claiming the pid as Harn-owned"]
50pub struct HarnOwnedPidRegistration {
51    pid: u32,
52}
53
54impl Drop for HarnOwnedPidRegistration {
55    fn drop(&mut self) {
56        unregister_harn_owned_pid(self.pid);
57    }
58}
59
60/// Register a pid as Harn-owned for the lifetime of the returned guard.
61pub fn register_harn_owned_pid(pid: u32) -> HarnOwnedPidRegistration {
62    let mut set = HARN_OWNED_PIDS
63        .lock()
64        .unwrap_or_else(std::sync::PoisonError::into_inner);
65    *set.entry(pid).or_default() += 1;
66    HarnOwnedPidRegistration { pid }
67}
68
69fn unregister_harn_owned_pid(pid: u32) {
70    let mut set = HARN_OWNED_PIDS
71        .lock()
72        .unwrap_or_else(std::sync::PoisonError::into_inner);
73    match set.get_mut(&pid) {
74        Some(claims) if *claims > 1 => *claims -= 1,
75        Some(_) => {
76            set.remove(&pid);
77        }
78        None => {}
79    }
80}
81
82fn harn_owned_pids_snapshot() -> BTreeSet<u32> {
83    HARN_OWNED_PIDS
84        .lock()
85        .unwrap_or_else(std::sync::PoisonError::into_inner)
86        .keys()
87        .copied()
88        .collect()
89}
90
91/// Snapshot of CPU topology. `count` reflects logical cores; `frequency_mhz`
92/// is the first-core frequency reported by the OS (typically the current
93/// frequency; nominal on many platforms).
94pub fn cpu_snapshot() -> Value {
95    let mut sys = System::new_with_specifics(
96        RefreshKind::nothing().with_cpu(
97            sysinfo::CpuRefreshKind::nothing()
98                .with_cpu_usage()
99                .with_frequency(),
100        ),
101    );
102    sys.refresh_cpu_all();
103    let cpus = sys.cpus();
104    let count = cpus.len();
105    let physical_count = System::physical_core_count();
106    let (model, frequency_mhz) = match cpus.first() {
107        Some(cpu) => {
108            let brand = cpu.brand().trim().to_string();
109            (
110                if brand.is_empty() { None } else { Some(brand) },
111                Some(cpu.frequency()),
112            )
113        }
114        None => (None, None),
115    };
116    let cpu_usage = if cpus.is_empty() {
117        None
118    } else {
119        let total: f32 = cpus.iter().map(|c| c.cpu_usage()).sum();
120        Some(total as f64 / cpus.len() as f64)
121    };
122    json!({
123        "count": count,
124        "physical_count": physical_count,
125        "model": model,
126        "frequency_mhz": frequency_mhz,
127        "usage_pct": cpu_usage,
128    })
129}
130
131/// Snapshot of host memory. All sizes are bytes; cross-platform with
132/// graceful zeroes on hosts where a metric is unavailable.
133pub fn memory_snapshot() -> Value {
134    let mut sys = System::new_with_specifics(
135        RefreshKind::nothing().with_memory(MemoryRefreshKind::everything()),
136    );
137    sys.refresh_memory();
138    let total = sys.total_memory();
139    let used = sys.used_memory();
140    let available = sys.available_memory();
141    let total_gb = bytes_to_gb(total);
142    let used_gb = bytes_to_gb(used);
143    let available_gb = bytes_to_gb(available);
144    let pressure = if total == 0 {
145        "unknown"
146    } else {
147        let ratio = used as f64 / total as f64;
148        if ratio >= 0.85 {
149            "high"
150        } else if ratio >= 0.6 {
151            "medium"
152        } else {
153            "low"
154        }
155    };
156    json!({
157        "total_bytes": total,
158        "used_bytes": used,
159        "available_bytes": available,
160        "total_gb": total_gb,
161        "used_gb": used_gb,
162        "available_gb": available_gb,
163        "pressure": pressure,
164    })
165}
166
167/// Resident bytes for the current Harn process.
168///
169/// Linux sandboxes may deliberately hide `/proc`, which prevents `sysinfo`
170/// from reporting current RSS. In that case this returns the kernel's peak
171/// resident-set counter so memory telemetry remains available without adding
172/// filesystem authority.
173pub fn current_process_memory_bytes() -> Option<u64> {
174    let pid = Pid::from_u32(std::process::id());
175    let mut sys = System::new();
176    sys.refresh_processes_specifics(
177        ProcessesToUpdate::Some(&[pid]),
178        false,
179        ProcessRefreshKind::nothing().with_memory(),
180    );
181    let reported = sys.process(pid).map(|process| process.memory());
182    reported.filter(|bytes| *bytes > 0).or_else(linux_peak_rss)
183}
184
185#[cfg(target_os = "linux")]
186fn linux_peak_rss() -> Option<u64> {
187    use std::mem::MaybeUninit;
188
189    // SAFETY: getrusage initializes the pointed-to rusage on success. Linux
190    // reports ru_maxrss in KiB.
191    unsafe {
192        let mut usage = MaybeUninit::<libc::rusage>::zeroed();
193        if libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) != 0 {
194            return None;
195        }
196        u64::try_from(usage.assume_init().ru_maxrss)
197            .ok()?
198            .checked_mul(1024)
199    }
200}
201
202#[cfg(not(target_os = "linux"))]
203fn linux_peak_rss() -> Option<u64> {
204    None
205}
206
207/// Snapshot of attached GPUs. `sysinfo` does not expose GPU details
208/// directly across all platforms; we surface a non-fatal empty list so
209/// scripts can write `if !gpus.is_empty()` portably. Richer detection
210/// (NVML, Metal, OpenCL) is a follow-up tracked in the issue body.
211pub fn gpus_snapshot() -> Value {
212    Value::Array(Vec::new())
213}
214
215/// Snapshot of per-component temperatures (celsius). Returns `null`
216/// fields when the host does not expose a sensor, and an empty list
217/// when no thermal sensors are visible at all — common in containers,
218/// VMs, and on macOS where `sysinfo`'s thermal API has long-standing
219/// gaps.
220pub fn temperature_snapshot() -> Value {
221    let components = Components::new_with_refreshed_list();
222    let mut entries = Vec::new();
223    for component in &components {
224        entries.push(json!({
225            "label": component.label(),
226            "celsius": component.temperature(),
227            "max_celsius": component.max(),
228            "critical_celsius": component.critical(),
229        }));
230    }
231    json!({
232        "components": entries,
233    })
234}
235
236/// Snapshot of the host platform: os, arch, version, kernel.
237pub fn platform_snapshot() -> Value {
238    json!({
239        // Stable API identifier rather than sysinfo's display name (`Darwin`,
240        // `Linux`, ...), whose casing and vocabulary vary by host.
241        "os": canonical_os(),
242        "arch": std::env::consts::ARCH,
243        "version": System::os_version(),
244        "kernel": System::kernel_version(),
245        "long_os_version": System::long_os_version(),
246        "hostname": System::host_name(),
247    })
248}
249
250fn canonical_os() -> &'static str {
251    if cfg!(target_os = "macos") {
252        "darwin"
253    } else {
254        std::env::consts::OS
255    }
256}
257
258/// Minimal identity exposed to explicitly authorized scripts.
259pub fn identity_snapshot() -> Value {
260    json!({
261        "username": std::env::var("USER")
262            .or_else(|_| std::env::var("USERNAME"))
263            .unwrap_or_default(),
264        "hostname": System::host_name(),
265        "pid": std::process::id(),
266    })
267}
268
269/// Snapshot of currently visible processes. The current Harn process is
270/// always included. Other processes are listed but with limited
271/// metadata — name, pid, cpu%, memory bytes, and an `is_harn_owned`
272/// flag derived from the parent pid match or the explicit
273/// [`register_harn_owned_pid`] registry. We do not return command line
274/// arguments, environment, or working directory: those can leak
275/// credentials and prompts from peer agents.
276pub fn processes_snapshot() -> Value {
277    let mut sys = System::new();
278    sys.refresh_processes_specifics(
279        ProcessesToUpdate::All,
280        false,
281        ProcessRefreshKind::nothing()
282            .with_cpu()
283            .with_memory()
284            .with_exe(sysinfo::UpdateKind::OnlyIfNotSet),
285    );
286    let our_pid = std::process::id();
287    let our_pid_sys = Pid::from_u32(our_pid);
288    let registry = harn_owned_pids_snapshot();
289
290    let mut entries = Vec::new();
291    for (pid, process) in sys.processes() {
292        let pid_u32 = pid.as_u32();
293        let parent_u32 = process.parent().map(|p| p.as_u32());
294        let is_harn_owned =
295            pid_u32 == our_pid || registry.contains(&pid_u32) || parent_u32 == Some(our_pid);
296        if !is_harn_owned {
297            // Limit per-process detail leakage: peer processes appear
298            // in the list as bare {pid, name} entries. Scripts that
299            // need the broader topology can opt into it via a future
300            // capability extension.
301            entries.push(json!({
302                "pid": pid_u32,
303                "name": process.name().to_string_lossy(),
304                "is_harn_owned": false,
305            }));
306            continue;
307        }
308        entries.push(json!({
309            "pid": pid_u32,
310            "parent_pid": parent_u32,
311            "name": process.name().to_string_lossy(),
312            "cpu_pct": process.cpu_usage(),
313            "mem_bytes": process.memory(),
314            "is_harn_owned": true,
315            "is_self": pid_u32 == our_pid,
316        }));
317    }
318
319    // Stable ordering: harn-owned first, then by pid ascending.
320    entries.sort_by(|a, b| {
321        let a_owned = a
322            .get("is_harn_owned")
323            .and_then(Value::as_bool)
324            .unwrap_or(false);
325        let b_owned = b
326            .get("is_harn_owned")
327            .and_then(Value::as_bool)
328            .unwrap_or(false);
329        b_owned.cmp(&a_owned).then_with(|| {
330            a.get("pid")
331                .and_then(Value::as_u64)
332                .cmp(&b.get("pid").and_then(Value::as_u64))
333        })
334    });
335
336    // sysinfo doesn't always include our own pid before the first
337    // refresh on some platforms (Windows); synthesize an entry so the
338    // contract "processes() always contains the running harn process"
339    // holds even on cold snapshots.
340    if !entries
341        .iter()
342        .any(|entry| entry.get("pid").and_then(Value::as_u64).map(|p| p as u32) == Some(our_pid))
343    {
344        entries.insert(
345            0,
346            json!({
347                "pid": our_pid,
348                "parent_pid": Value::Null,
349                "name": current_process_name(&sys, our_pid_sys),
350                "cpu_pct": 0.0,
351                "mem_bytes": 0,
352                "is_harn_owned": true,
353                "is_self": true,
354            }),
355        );
356    }
357
358    Value::Array(entries)
359}
360
361fn current_process_name(sys: &System, pid: Pid) -> String {
362    sys.process(pid)
363        .map(|process| process.name().to_string_lossy().into_owned())
364        .unwrap_or_else(|| "harn".to_string())
365}
366
367fn bytes_to_gb(bytes: u64) -> f64 {
368    bytes as f64 / 1_073_741_824.0
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn cpu_snapshot_reports_nonzero_count() {
377        let snapshot = cpu_snapshot();
378        let count = snapshot
379            .get("count")
380            .and_then(Value::as_u64)
381            .expect("count present");
382        assert!(count >= 1, "expected at least one logical cpu, got {count}");
383    }
384
385    #[test]
386    fn memory_snapshot_has_nonzero_total() {
387        let snapshot = memory_snapshot();
388        let total = snapshot
389            .get("total_bytes")
390            .and_then(Value::as_u64)
391            .expect("total_bytes present");
392        assert!(total > 0, "total memory should be non-zero, got {total}");
393        let pressure = snapshot
394            .get("pressure")
395            .and_then(Value::as_str)
396            .expect("pressure present");
397        assert!(
398            matches!(pressure, "low" | "medium" | "high" | "unknown"),
399            "pressure should be a known bucket, got {pressure:?}"
400        );
401    }
402
403    #[test]
404    fn gpus_snapshot_returns_list() {
405        let snapshot = gpus_snapshot();
406        assert!(snapshot.is_array(), "gpus snapshot is a list");
407    }
408
409    #[test]
410    fn temperature_snapshot_returns_components_field() {
411        let snapshot = temperature_snapshot();
412        assert!(
413            snapshot.get("components").is_some(),
414            "components field present"
415        );
416        assert!(
417            snapshot.get("components").unwrap().is_array(),
418            "components is array"
419        );
420    }
421
422    #[test]
423    fn platform_snapshot_includes_arch() {
424        let snapshot = platform_snapshot();
425        assert_eq!(
426            snapshot.get("arch").and_then(Value::as_str),
427            Some(std::env::consts::ARCH)
428        );
429    }
430
431    #[test]
432    fn processes_snapshot_includes_self() {
433        let snapshot = processes_snapshot();
434        let entries = snapshot.as_array().expect("array");
435        let our_pid = std::process::id() as u64;
436        let self_entry = entries
437            .iter()
438            .find(|entry| entry.get("pid").and_then(Value::as_u64) == Some(our_pid))
439            .expect("self entry present");
440        assert_eq!(
441            self_entry.get("is_harn_owned").and_then(Value::as_bool),
442            Some(true),
443            "self entry must be harn-owned"
444        );
445    }
446
447    #[test]
448    fn current_process_memory_bytes_reports_self_when_available() {
449        if let Some(bytes) = current_process_memory_bytes() {
450            assert!(bytes > 0, "current process memory should be non-zero");
451        }
452    }
453
454    #[cfg(target_os = "linux")]
455    #[test]
456    fn linux_peak_rss_reports_self() {
457        assert!(linux_peak_rss().is_some_and(|bytes| bytes > 0));
458    }
459
460    #[test]
461    fn harn_owned_pid_process_global_lifetime_waits_for_every_owner() {
462        // pick a pid that's vanishingly unlikely to collide with self
463        let fake = u32::MAX - 1;
464        let first = register_harn_owned_pid(fake);
465        let second = register_harn_owned_pid(fake);
466        assert!(harn_owned_pids_snapshot().contains(&fake));
467        drop(first);
468        assert!(
469            harn_owned_pids_snapshot().contains(&fake),
470            "one owner dropping must not erase another owner's live claim"
471        );
472        drop(second);
473        assert!(!harn_owned_pids_snapshot().contains(&fake));
474    }
475}