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