Skip to main content

mars_agents/harness/
host.rs

1use std::collections::{BTreeMap, HashSet};
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4use std::time::Duration;
5
6use wait_timeout::ChildExt;
7
8use crate::harness::registry::{self, HarnessId};
9use crate::models::probes::ProbeRefreshMode;
10use crate::models::probes::cursor_cache::CachedCursorProbeOutcome;
11use crate::models::probes::opencode_cache::CachedProbeOutcome;
12use crate::models::probes::pi_cache::CachedPiProbeOutcome;
13use crate::models::probes::{CursorProbeResult, OpenCodeProbeResult, PiProbeResult};
14
15#[derive(Debug, Clone)]
16pub struct CapabilityCollectionOptions {
17    /// `MARS_OFFLINE` — skip network/catalog assumptions; probes treat env as offline.
18    pub offline: bool,
19    pub probe_refresh: ProbeRefreshMode,
20}
21
22impl Default for CapabilityCollectionOptions {
23    fn default() -> Self {
24        Self {
25            offline: false,
26            probe_refresh: ProbeRefreshMode::Background,
27        }
28    }
29}
30
31#[derive(Debug, Clone)]
32pub struct CapabilitySnapshot {
33    pub executable: BTreeMap<HarnessId, ExecutableState>,
34    pub opencode: CachedProbeOutcome,
35    pub pi: CachedPiProbeOutcome,
36    pub cursor: CachedCursorProbeOutcome,
37    pub offline: bool,
38}
39
40impl CapabilitySnapshot {
41    pub fn installed_harnesses(&self) -> HashSet<String> {
42        self.executable
43            .iter()
44            .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
45            .map(|(id, _)| id)
46            .map(|id| id.as_str().to_string())
47            .collect()
48    }
49}
50
51/// Command-scoped lazy capability session.
52///
53/// Executable checks are collected immediately. Harness probe checks are
54/// loaded lazily on first use per harness and memoized for the command.
55#[derive(Debug, Clone)]
56pub struct CapabilitySession {
57    executable: BTreeMap<HarnessId, ExecutableState>,
58    installed: HashSet<String>,
59    offline: bool,
60    probe_refresh: ProbeRefreshMode,
61    opencode: Option<CachedProbeOutcome>,
62    pi: Option<CachedPiProbeOutcome>,
63    cursor: Option<CachedCursorProbeOutcome>,
64}
65
66impl CapabilitySession {
67    pub fn collect(options: &CapabilityCollectionOptions) -> Self {
68        Self::collect_with_resolver(options, &PathExecutableResolver)
69    }
70
71    pub fn collect_with_resolver(
72        options: &CapabilityCollectionOptions,
73        resolver: &dyn ExecutableResolver,
74    ) -> Self {
75        let mut executable = BTreeMap::new();
76
77        for descriptor in registry::descriptors() {
78            let state = resolver.resolve(descriptor.binary);
79            executable.insert(descriptor.id, state);
80        }
81
82        let installed = executable
83            .iter()
84            .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
85            .map(|(id, _)| id.as_str().to_string())
86            .collect::<HashSet<_>>();
87
88        Self {
89            executable,
90            installed,
91            offline: options.offline,
92            probe_refresh: options.probe_refresh,
93            opencode: None,
94            pi: None,
95            cursor: None,
96        }
97    }
98
99    pub fn installed_harnesses(&self) -> HashSet<String> {
100        self.installed.clone()
101    }
102
103    pub(crate) fn extend_installed_harnesses<I>(&mut self, harnesses: I)
104    where
105        I: IntoIterator<Item = String>,
106    {
107        self.installed.extend(harnesses);
108    }
109
110    pub fn opencode_outcome(&mut self) -> &CachedProbeOutcome {
111        self.opencode.get_or_insert_with(|| {
112            cached_opencode_outcome(&self.installed, self.offline, self.probe_refresh)
113        })
114    }
115
116    pub fn loaded_opencode_outcome(&self) -> Option<&CachedProbeOutcome> {
117        self.opencode.as_ref()
118    }
119
120    pub fn loaded_pi_outcome(&self) -> Option<&CachedPiProbeOutcome> {
121        self.pi.as_ref()
122    }
123
124    pub fn loaded_cursor_outcome(&self) -> Option<&CachedCursorProbeOutcome> {
125        self.cursor.as_ref()
126    }
127
128    pub fn loaded_opencode_probe_result(&self) -> Option<&OpenCodeProbeResult> {
129        self.loaded_opencode_outcome()
130            .and_then(CachedProbeOutcome::result)
131    }
132
133    pub fn loaded_pi_probe_result(&self) -> Option<&PiProbeResult> {
134        self.loaded_pi_outcome()
135            .and_then(CachedPiProbeOutcome::result)
136    }
137
138    pub fn loaded_cursor_probe_result(&self) -> Option<&CursorProbeResult> {
139        self.loaded_cursor_outcome()
140            .and_then(CachedCursorProbeOutcome::result)
141    }
142
143    pub fn pi_outcome(&mut self) -> &CachedPiProbeOutcome {
144        self.pi.get_or_insert_with(|| {
145            cached_pi_outcome(&self.installed, self.offline, self.probe_refresh)
146        })
147    }
148
149    pub fn cursor_outcome(&mut self) -> &CachedCursorProbeOutcome {
150        self.cursor.get_or_insert_with(|| {
151            cached_cursor_outcome(&self.installed, self.offline, self.probe_refresh)
152        })
153    }
154
155    pub fn opencode_probe_result(&mut self) -> Option<OpenCodeProbeResult> {
156        self.opencode_outcome().result().cloned()
157    }
158
159    pub fn pi_probe_result(&mut self) -> Option<PiProbeResult> {
160        self.pi_outcome().result().cloned()
161    }
162
163    pub fn cursor_probe_result(&mut self) -> Option<CursorProbeResult> {
164        self.cursor_outcome().result().cloned()
165    }
166
167    pub fn into_snapshot(mut self) -> CapabilitySnapshot {
168        let opencode = self.opencode.take().unwrap_or_else(|| {
169            cached_opencode_outcome(&self.installed, self.offline, self.probe_refresh)
170        });
171        let pi = self.pi.take().unwrap_or_else(|| {
172            cached_pi_outcome(&self.installed, self.offline, self.probe_refresh)
173        });
174        let cursor = self.cursor.take().unwrap_or_else(|| {
175            cached_cursor_outcome(&self.installed, self.offline, self.probe_refresh)
176        });
177
178        CapabilitySnapshot {
179            executable: self.executable,
180            opencode,
181            pi,
182            cursor,
183            offline: self.offline,
184        }
185    }
186}
187
188fn cached_opencode_outcome(
189    installed: &HashSet<String>,
190    is_offline: bool,
191    probe_refresh: ProbeRefreshMode,
192) -> CachedProbeOutcome {
193    crate::models::probes::opencode_cache::probe_cached(installed, is_offline, probe_refresh)
194}
195
196fn cached_pi_outcome(
197    installed: &HashSet<String>,
198    is_offline: bool,
199    probe_refresh: ProbeRefreshMode,
200) -> CachedPiProbeOutcome {
201    crate::models::probes::pi_cache::probe_cached(installed, is_offline, probe_refresh)
202}
203
204fn cached_cursor_outcome(
205    installed: &HashSet<String>,
206    is_offline: bool,
207    probe_refresh: ProbeRefreshMode,
208) -> CachedCursorProbeOutcome {
209    crate::models::probes::cursor_cache::probe_cached(installed, is_offline, probe_refresh)
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum ExecutableState {
214    Found { path: PathBuf },
215    Missing,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum AuthState {
220    NotApplicable,
221    Authenticated,
222    Unauthenticated,
223    Unknown { reason: String },
224}
225
226pub trait ExecutableResolver {
227    fn resolve(&self, binary: &str) -> ExecutableState;
228}
229
230#[derive(Debug, Default, Clone, Copy)]
231pub struct PathExecutableResolver;
232
233impl ExecutableResolver for PathExecutableResolver {
234    fn resolve(&self, binary: &str) -> ExecutableState {
235        if let Ok(path) = which::which(binary) {
236            return ExecutableState::Found { path };
237        }
238
239        #[cfg(windows)]
240        {
241            for ext in ["exe", "cmd", "bat"] {
242                if let Ok(path) = which::which(format!("{binary}.{ext}")) {
243                    return ExecutableState::Found { path };
244                }
245            }
246        }
247
248        ExecutableState::Missing
249    }
250}
251
252pub fn collect_capability_snapshot(options: &CapabilityCollectionOptions) -> CapabilitySnapshot {
253    collect_capability_snapshot_with_resolver(options, &PathExecutableResolver)
254}
255
256pub fn collect_capability_snapshot_with_resolver(
257    options: &CapabilityCollectionOptions,
258    resolver: &dyn ExecutableResolver,
259) -> CapabilitySnapshot {
260    CapabilitySession::collect_with_resolver(options, resolver).into_snapshot()
261}
262
263pub fn native_harness_authenticated(harness: &str) -> bool {
264    native_auth_state_for_name(harness) == AuthState::Authenticated
265}
266
267pub fn native_auth_state_for_name(harness: &str) -> AuthState {
268    let Some(id) = registry::parse(harness) else {
269        return AuthState::Unknown {
270            reason: "unknown harness".to_string(),
271        };
272    };
273
274    let resolver = PathExecutableResolver;
275    let state = resolver.resolve(registry::descriptor(id).binary);
276    native_auth_state(id, &state, &resolver, auth_probe_timeout())
277}
278
279fn native_auth_state(
280    id: HarnessId,
281    executable: &ExecutableState,
282    resolver: &dyn ExecutableResolver,
283    timeout: Duration,
284) -> AuthState {
285    let (binary, args) = match id {
286        HarnessId::Codex => ("codex", &["login", "status"][..]),
287        HarnessId::Claude => ("claude", &["auth", "status"][..]),
288        _ => return AuthState::NotApplicable,
289    };
290
291    if !matches!(executable, ExecutableState::Found { .. }) {
292        return AuthState::Unauthenticated;
293    }
294
295    run_status_command(binary, args, timeout, resolver)
296}
297
298pub fn auth_probe_timeout() -> Duration {
299    std::env::var("MARS_NATIVE_HARNESS_AUTH_TIMEOUT_SECS")
300        .ok()
301        .and_then(|value| value.parse::<u64>().ok())
302        .map(Duration::from_secs)
303        .unwrap_or(Duration::from_secs(2))
304}
305
306fn run_status_command(
307    command: &str,
308    args: &[&str],
309    timeout: Duration,
310    resolver: &dyn ExecutableResolver,
311) -> AuthState {
312    let program = resolve_binary_path(command, resolver).unwrap_or_else(|| PathBuf::from(command));
313
314    let mut child = match Command::new(program)
315        .args(args)
316        .stdin(Stdio::null())
317        .stdout(Stdio::null())
318        .stderr(Stdio::null())
319        .spawn()
320    {
321        Ok(child) => child,
322        Err(error) => {
323            return AuthState::Unknown {
324                reason: format!("spawn failed: {error}"),
325            };
326        }
327    };
328
329    match child.wait_timeout(timeout) {
330        Ok(Some(status)) if status.success() => AuthState::Authenticated,
331        Ok(Some(_)) => AuthState::Unauthenticated,
332        Ok(None) => {
333            let _ = child.kill();
334            let _ = child.wait();
335            AuthState::Unknown {
336                reason: "auth probe timeout".to_string(),
337            }
338        }
339        Err(error) => AuthState::Unknown {
340            reason: format!("auth probe wait failed: {error}"),
341        },
342    }
343}
344
345pub fn resolve_binary_path(binary: &str, resolver: &dyn ExecutableResolver) -> Option<PathBuf> {
346    match resolver.resolve(binary) {
347        ExecutableState::Found { path } => Some(path),
348        ExecutableState::Missing => None,
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use std::collections::HashMap;
356
357    #[derive(Default)]
358    struct FakeResolver {
359        map: HashMap<String, ExecutableState>,
360    }
361
362    impl ExecutableResolver for FakeResolver {
363        fn resolve(&self, binary: &str) -> ExecutableState {
364            self.map
365                .get(binary)
366                .cloned()
367                .unwrap_or(ExecutableState::Missing)
368        }
369    }
370
371    #[test]
372    fn snapshot_marks_installed_harnesses_from_resolver() {
373        let mut resolver = FakeResolver::default();
374        resolver.map.insert(
375            "pi".to_string(),
376            ExecutableState::Found {
377                path: PathBuf::from("/tmp/pi"),
378            },
379        );
380
381        let options = CapabilityCollectionOptions {
382            offline: true,
383            probe_refresh: ProbeRefreshMode::Skip,
384        };
385        let snapshot = collect_capability_snapshot_with_resolver(&options, &resolver);
386
387        let installed = snapshot.installed_harnesses();
388        assert!(installed.contains("pi"));
389        assert!(!installed.contains("codex"));
390    }
391
392    #[test]
393    fn native_auth_for_non_native_harness_is_not_applicable() {
394        let resolver = FakeResolver::default();
395        let state = native_auth_state(
396            HarnessId::Pi,
397            &ExecutableState::Found {
398                path: PathBuf::from("/tmp/pi"),
399            },
400            &resolver,
401            Duration::from_secs(1),
402        );
403
404        assert_eq!(state, AuthState::NotApplicable);
405    }
406
407    #[test]
408    fn resolve_binary_path_returns_none_when_missing() {
409        let resolver = FakeResolver::default();
410        assert_eq!(resolve_binary_path("codex", &resolver), None);
411    }
412}