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::cursor_cache::{self, CachedCursorProbeOutcome};
10use crate::models::probes::opencode_cache::{self, CachedProbeOutcome};
11use crate::models::probes::pi_cache::{self, CachedPiProbeOutcome};
12
13#[derive(Debug, Clone)]
14pub struct CapabilityCollectionOptions {
15    pub offline: bool,
16    pub allow_probe_refresh: bool,
17}
18
19impl Default for CapabilityCollectionOptions {
20    fn default() -> Self {
21        Self {
22            offline: false,
23            allow_probe_refresh: true,
24        }
25    }
26}
27
28#[derive(Debug, Clone)]
29pub struct CapabilitySnapshot {
30    pub executable: BTreeMap<HarnessId, ExecutableState>,
31    pub auth: BTreeMap<HarnessId, AuthState>,
32    pub opencode: CachedProbeOutcome,
33    pub pi: CachedPiProbeOutcome,
34    pub cursor: CachedCursorProbeOutcome,
35    pub offline: bool,
36}
37
38impl CapabilitySnapshot {
39    pub fn installed_harnesses(&self) -> HashSet<String> {
40        self.executable
41            .iter()
42            .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
43            .map(|(id, _)| id)
44            .map(|id| id.as_str().to_string())
45            .collect()
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ExecutableState {
51    Found { path: PathBuf },
52    Missing,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AuthState {
57    NotApplicable,
58    Authenticated,
59    Unauthenticated,
60    Unknown { reason: String },
61}
62
63pub trait ExecutableResolver {
64    fn resolve(&self, binary: &str) -> ExecutableState;
65}
66
67#[derive(Debug, Default, Clone, Copy)]
68pub struct PathExecutableResolver;
69
70impl ExecutableResolver for PathExecutableResolver {
71    fn resolve(&self, binary: &str) -> ExecutableState {
72        if let Ok(path) = which::which(binary) {
73            return ExecutableState::Found { path };
74        }
75
76        #[cfg(windows)]
77        {
78            for ext in ["exe", "cmd", "bat"] {
79                if let Ok(path) = which::which(format!("{binary}.{ext}")) {
80                    return ExecutableState::Found { path };
81                }
82            }
83        }
84
85        ExecutableState::Missing
86    }
87}
88
89pub fn collect_capability_snapshot(options: &CapabilityCollectionOptions) -> CapabilitySnapshot {
90    collect_capability_snapshot_with_resolver(options, &PathExecutableResolver)
91}
92
93pub fn collect_capability_snapshot_with_resolver(
94    options: &CapabilityCollectionOptions,
95    resolver: &dyn ExecutableResolver,
96) -> CapabilitySnapshot {
97    let mut executable = BTreeMap::new();
98    let mut auth = BTreeMap::new();
99
100    for descriptor in registry::descriptors() {
101        let state = resolver.resolve(descriptor.binary);
102        executable.insert(descriptor.id, state.clone());
103        auth.insert(
104            descriptor.id,
105            native_auth_state(descriptor.id, &state, resolver, auth_probe_timeout()),
106        );
107    }
108
109    let installed = executable
110        .iter()
111        .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
112        .map(|(id, _)| id)
113        .map(|id| id.as_str().to_string())
114        .collect::<HashSet<_>>();
115
116    let opencode_offline = options.offline || !options.allow_probe_refresh;
117    let pi_offline = options.offline || !options.allow_probe_refresh;
118    let cursor_offline = options.offline || !options.allow_probe_refresh;
119
120    CapabilitySnapshot {
121        executable,
122        auth,
123        opencode: opencode_cache::probe_cached(&installed, opencode_offline),
124        pi: pi_cache::probe_cached(&installed, pi_offline),
125        cursor: cursor_cache::probe_cached(&installed, cursor_offline),
126        offline: options.offline,
127    }
128}
129
130pub fn native_harness_authenticated(harness: &str) -> bool {
131    native_auth_state_for_name(harness) == AuthState::Authenticated
132}
133
134pub fn native_auth_state_for_name(harness: &str) -> AuthState {
135    let Some(id) = registry::parse(harness) else {
136        return AuthState::Unknown {
137            reason: "unknown harness".to_string(),
138        };
139    };
140
141    let resolver = PathExecutableResolver;
142    let state = resolver.resolve(registry::descriptor(id).binary);
143    native_auth_state(id, &state, &resolver, auth_probe_timeout())
144}
145
146fn native_auth_state(
147    id: HarnessId,
148    executable: &ExecutableState,
149    resolver: &dyn ExecutableResolver,
150    timeout: Duration,
151) -> AuthState {
152    let (binary, args) = match id {
153        HarnessId::Codex => ("codex", &["login", "status"][..]),
154        HarnessId::Claude => ("claude", &["auth", "status"][..]),
155        _ => return AuthState::NotApplicable,
156    };
157
158    if !matches!(executable, ExecutableState::Found { .. }) {
159        return AuthState::Unauthenticated;
160    }
161
162    run_status_command(binary, args, timeout, resolver)
163}
164
165pub fn auth_probe_timeout() -> Duration {
166    std::env::var("MARS_NATIVE_HARNESS_AUTH_TIMEOUT_SECS")
167        .ok()
168        .and_then(|value| value.parse::<u64>().ok())
169        .map(Duration::from_secs)
170        .unwrap_or(Duration::from_secs(2))
171}
172
173fn run_status_command(
174    command: &str,
175    args: &[&str],
176    timeout: Duration,
177    resolver: &dyn ExecutableResolver,
178) -> AuthState {
179    let program = resolve_binary_path(command, resolver).unwrap_or_else(|| PathBuf::from(command));
180
181    let mut child = match Command::new(program)
182        .args(args)
183        .stdin(Stdio::null())
184        .stdout(Stdio::null())
185        .stderr(Stdio::null())
186        .spawn()
187    {
188        Ok(child) => child,
189        Err(error) => {
190            return AuthState::Unknown {
191                reason: format!("spawn failed: {error}"),
192            };
193        }
194    };
195
196    match child.wait_timeout(timeout) {
197        Ok(Some(status)) if status.success() => AuthState::Authenticated,
198        Ok(Some(_)) => AuthState::Unauthenticated,
199        Ok(None) => {
200            let _ = child.kill();
201            let _ = child.wait();
202            AuthState::Unknown {
203                reason: "auth probe timeout".to_string(),
204            }
205        }
206        Err(error) => AuthState::Unknown {
207            reason: format!("auth probe wait failed: {error}"),
208        },
209    }
210}
211
212pub fn resolve_binary_path(binary: &str, resolver: &dyn ExecutableResolver) -> Option<PathBuf> {
213    match resolver.resolve(binary) {
214        ExecutableState::Found { path } => Some(path),
215        ExecutableState::Missing => None,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::collections::HashMap;
223
224    #[derive(Default)]
225    struct FakeResolver {
226        map: HashMap<String, ExecutableState>,
227    }
228
229    impl ExecutableResolver for FakeResolver {
230        fn resolve(&self, binary: &str) -> ExecutableState {
231            self.map
232                .get(binary)
233                .cloned()
234                .unwrap_or(ExecutableState::Missing)
235        }
236    }
237
238    #[test]
239    fn snapshot_marks_installed_harnesses_from_resolver() {
240        let mut resolver = FakeResolver::default();
241        resolver.map.insert(
242            "pi".to_string(),
243            ExecutableState::Found {
244                path: PathBuf::from("/tmp/pi"),
245            },
246        );
247
248        let options = CapabilityCollectionOptions {
249            offline: true,
250            allow_probe_refresh: false,
251        };
252        let snapshot = collect_capability_snapshot_with_resolver(&options, &resolver);
253
254        let installed = snapshot.installed_harnesses();
255        assert!(installed.contains("pi"));
256        assert!(!installed.contains("codex"));
257    }
258
259    #[test]
260    fn native_auth_for_non_native_harness_is_not_applicable() {
261        let resolver = FakeResolver::default();
262        let state = native_auth_state(
263            HarnessId::Pi,
264            &ExecutableState::Found {
265                path: PathBuf::from("/tmp/pi"),
266            },
267            &resolver,
268            Duration::from_secs(1),
269        );
270
271        assert_eq!(state, AuthState::NotApplicable);
272    }
273
274    #[test]
275    fn resolve_binary_path_returns_none_when_missing() {
276        let resolver = FakeResolver::default();
277        assert_eq!(resolve_binary_path("codex", &resolver), None);
278    }
279}