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