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