Skip to main content

ag_agent/agent/
availability.rs

1//! Machine-scoped agent executable discovery.
2
3use std::env;
4use std::ffi::OsStr;
5use std::os::unix::fs::PermissionsExt;
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Output, Stdio};
8use std::time::{Duration, Instant};
9
10use crate::model::agent::{AgentCliInfo, AgentKind};
11
12/// Maximum time spent waiting for one provider CLI `--version` command.
13const AGENT_CLI_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
14/// Maximum time spent waiting for one provider CLI `update` command.
15const AGENT_CLI_UPDATE_TIMEOUT: Duration = Duration::from_mins(5);
16/// Poll interval used while waiting for one bounded provider CLI subprocess.
17const AGENT_CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
18
19/// Detects which provider CLIs are locally runnable on the current machine.
20#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
21pub trait AgentAvailabilityProbe: Send + Sync {
22    /// Returns the agent kinds whose backing CLI executable is available.
23    fn available_agent_kinds(&self) -> Vec<AgentKind>;
24
25    /// Returns available agent CLI executables and their refreshed versions.
26    fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
27        AgentCliInfo::from_kinds(&self.available_agent_kinds())
28    }
29}
30
31/// Production availability probe backed by `PATH` executable discovery.
32pub struct RealAgentAvailabilityProbe;
33
34impl AgentAvailabilityProbe for RealAgentAvailabilityProbe {
35    fn available_agent_kinds(&self) -> Vec<AgentKind> {
36        available_agent_kinds_from_path(env::var_os("PATH").as_deref())
37    }
38
39    fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
40        available_agent_clis_from_path(env::var_os("PATH").as_deref())
41    }
42}
43
44/// Availability probe that returns one caller-provided snapshot.
45pub struct StaticAgentAvailabilityProbe {
46    /// Agent kinds reported as available by the static probe.
47    pub available_agent_kinds: Vec<AgentKind>,
48}
49
50impl AgentAvailabilityProbe for StaticAgentAvailabilityProbe {
51    fn available_agent_kinds(&self) -> Vec<AgentKind> {
52        self.available_agent_kinds.clone()
53    }
54}
55
56/// Returns the CLI executable name used by the provided agent kind.
57#[must_use]
58pub fn executable_name(agent_kind: AgentKind) -> &'static str {
59    agent_kind.executable_name()
60}
61
62/// Returns available agent CLI metadata from one `PATH` value.
63fn available_agent_clis_from_path(path_value: Option<&OsStr>) -> Vec<AgentCliInfo> {
64    let executable_agent_clis = AgentKind::ALL
65        .iter()
66        .copied()
67        .filter_map(|agent_kind| {
68            let executable_path = executable_path_on_path(path_value, executable_name(agent_kind))?;
69
70            Some((agent_kind, executable_path))
71        })
72        .collect();
73
74    refresh_agent_cli_versions(executable_agent_clis, refresh_agent_cli_version)
75}
76
77/// Returns agent kinds whose executables are present on one `PATH` value.
78fn available_agent_kinds_from_path(path_value: Option<&OsStr>) -> Vec<AgentKind> {
79    AgentKind::ALL
80        .iter()
81        .copied()
82        .filter(|agent_kind| {
83            executable_path_on_path(path_value, executable_name(*agent_kind)).is_some()
84        })
85        .collect()
86}
87
88/// Returns the first executable path matching one command name on `PATH`.
89fn executable_path_on_path(path_value: Option<&OsStr>, executable_name: &str) -> Option<PathBuf> {
90    path_value
91        .map(env::split_paths)
92        .into_iter()
93        .flatten()
94        .map(|path_entry| candidate_path_for_executable_name(&path_entry, executable_name))
95        .find(|candidate_path| is_executable_file(candidate_path))
96}
97
98/// Returns the candidate filesystem path for one executable name within a
99/// single `PATH` entry.
100fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
101    path_entry.join(executable_name)
102}
103
104/// Returns whether the candidate path is a regular file with at least one
105/// execute bit set.
106fn is_executable_file(candidate_path: &Path) -> bool {
107    let Ok(metadata) = candidate_path.metadata() else {
108        return false;
109    };
110
111    if !metadata.is_file() {
112        return false;
113    }
114
115    metadata.permissions().mode() & 0o111 != 0
116}
117
118/// Runs one available CLI's update command, then extracts the installed
119/// version token from a fresh version probe.
120fn refresh_agent_cli_version(executable_path: &Path) -> Option<String> {
121    run_agent_cli_update(executable_path);
122
123    detect_agent_cli_version(executable_path)
124}
125
126/// Refreshes all available CLI versions concurrently while preserving
127/// provider display order.
128fn refresh_agent_cli_versions(
129    executable_agent_clis: Vec<(AgentKind, PathBuf)>,
130    refresh_cli_version: impl Fn(&Path) -> Option<String> + Sync,
131) -> Vec<AgentCliInfo> {
132    std::thread::scope(|scope| {
133        let refresh_cli_version = &refresh_cli_version;
134        let refresh_handles = executable_agent_clis
135            .into_iter()
136            .map(|(agent_kind, executable_path)| {
137                (
138                    agent_kind,
139                    scope.spawn(move || refresh_cli_version(&executable_path)),
140                )
141            })
142            .collect::<Vec<_>>();
143
144        refresh_handles
145            .into_iter()
146            .map(|(agent_kind, refresh_handle)| {
147                AgentCliInfo::new(agent_kind, refresh_handle.join().unwrap_or(None))
148            })
149            .collect()
150    })
151}
152
153/// Runs one available CLI's best-effort self-update command.
154fn run_agent_cli_update(executable_path: &Path) {
155    let _ = run_agent_cli_update_with_timeout(executable_path, AGENT_CLI_UPDATE_TIMEOUT);
156}
157
158/// Runs one available CLI's best-effort self-update command with a
159/// caller-provided timeout.
160fn run_agent_cli_update_with_timeout(executable_path: &Path, timeout: Duration) -> bool {
161    command_status_with_timeout(executable_path, &["update"], timeout).is_some()
162}
163
164/// Runs one available CLI's version command and extracts the installed
165/// version token from its output.
166fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
167    detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
168}
169
170/// Runs one available CLI's version command with a caller-provided timeout.
171fn detect_agent_cli_version_with_timeout(
172    executable_path: &Path,
173    timeout: Duration,
174) -> Option<String> {
175    let output = version_command_output(executable_path, timeout)?;
176    if !output.status.success() {
177        return None;
178    }
179
180    let stdout_text = String::from_utf8_lossy(&output.stdout);
181    let stderr_text = String::from_utf8_lossy(&output.stderr);
182    parse_agent_cli_version_output(&stdout_text)
183        .or_else(|| parse_agent_cli_version_output(&stderr_text))
184}
185
186/// Runs one provider CLI `--version` command and stops waiting once the
187/// timeout expires.
188fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
189    command_output_with_timeout(executable_path, &["--version"], timeout)
190}
191
192/// Runs one provider CLI command with output discarded and stops waiting once
193/// the timeout expires.
194fn command_status_with_timeout(
195    executable_path: &Path,
196    args: &[&str],
197    timeout: Duration,
198) -> Option<()> {
199    let mut child = Command::new(executable_path)
200        .args(args)
201        .stdin(Stdio::null())
202        .stdout(Stdio::null())
203        .stderr(Stdio::null())
204        .spawn()
205        .ok()?;
206    wait_for_child_exit(&mut child, timeout)?;
207    let _ = child.wait().ok()?;
208
209    Some(())
210}
211
212/// Runs one provider CLI command and stops waiting once the timeout expires.
213fn command_output_with_timeout(
214    executable_path: &Path,
215    args: &[&str],
216    timeout: Duration,
217) -> Option<Output> {
218    let mut child = Command::new(executable_path)
219        .args(args)
220        .stdin(Stdio::null())
221        .stdout(Stdio::piped())
222        .stderr(Stdio::piped())
223        .spawn()
224        .ok()?;
225    wait_for_child_exit(&mut child, timeout)?;
226
227    child.wait_with_output().ok()
228}
229
230/// Waits for one child process to exit, killing it when the timeout expires.
231fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Option<()> {
232    let started_at = Instant::now();
233
234    loop {
235        if child.try_wait().ok()?.is_some() {
236            return Some(());
237        }
238
239        if started_at.elapsed() >= timeout {
240            let _ = child.kill();
241            let _ = child.wait();
242
243            return None;
244        }
245
246        std::thread::sleep(
247            AGENT_CLI_COMMAND_POLL_INTERVAL.min(timeout.saturating_sub(started_at.elapsed())),
248        );
249    }
250}
251
252/// Parses a provider CLI version from the first useful `--version` output
253/// line.
254fn parse_agent_cli_version_output(output: &str) -> Option<String> {
255    let line = output
256        .lines()
257        .map(str::trim)
258        .find(|line| !line.is_empty())?;
259    let version_token = line
260        .split_whitespace()
261        .map(|token| {
262            token.trim_matches(|character: char| {
263                matches!(character, ',' | ';' | ':' | '(' | ')' | '[' | ']')
264            })
265        })
266        .find(|token| {
267            let normalized = token.strip_prefix('v').unwrap_or(token);
268
269            normalized
270                .chars()
271                .next()
272                .is_some_and(|character| character.is_ascii_digit())
273                && normalized.contains('.')
274        });
275
276    Some(version_token.unwrap_or(line).to_string())
277}
278
279#[cfg(test)]
280mod tests {
281    use std::fs;
282    use std::os::unix::fs::PermissionsExt;
283    use std::sync::Arc;
284    use std::sync::atomic::{AtomicBool, Ordering};
285
286    use tempfile::tempdir;
287
288    use super::*;
289
290    #[test]
291    /// Ensures executable names stay aligned with provider command names.
292    fn test_executable_name_matches_agent_cli_names() {
293        // Arrange / Act / Assert
294        assert_eq!(executable_name(AgentKind::Antigravity), "agy");
295        assert_eq!(executable_name(AgentKind::Claude), "claude");
296        assert_eq!(executable_name(AgentKind::Codex), "codex");
297    }
298
299    #[test]
300    /// Ensures the production probe reports only agent kinds whose
301    /// executables are present on the current `PATH`.
302    fn test_real_agent_availability_probe_filters_missing_executables() {
303        // Arrange
304        let temp_directory = tempdir().expect("failed to create temp dir");
305        let antigravity_path = temp_directory.path().join("agy");
306        let codex_path = temp_directory.path().join("codex");
307        fs::write(&antigravity_path, "").expect("failed to create agy executable");
308        fs::write(&codex_path, "").expect("failed to create codex executable");
309        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
310            .expect("failed to mark agy executable");
311        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
312            .expect("failed to mark codex executable");
313        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
314
315        // Act
316        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
317
318        // Assert
319        assert_eq!(
320            available_agent_kinds,
321            vec![AgentKind::Antigravity, AgentKind::Codex]
322        );
323    }
324
325    #[test]
326    /// Ensures available CLI metadata includes parsed command versions.
327    fn test_available_agent_clis_from_path_includes_versions() {
328        // Arrange
329        let temp_directory = tempdir().expect("failed to create temp dir");
330        let codex_path = temp_directory.path().join("codex");
331        fs::write(&codex_path, "#!/bin/sh\nprintf 'codex-cli 1.2.3\\n'\n")
332            .expect("failed to create codex executable");
333        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
334            .expect("failed to mark codex executable");
335        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
336
337        // Act
338        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
339
340        // Assert
341        assert_eq!(
342            available_agent_clis,
343            vec![AgentCliInfo::new(
344                AgentKind::Codex,
345                Some("1.2.3".to_string())
346            )]
347        );
348    }
349
350    #[test]
351    /// Ensures the startup CLI refresh runs `update` before probing the
352    /// visible version.
353    fn test_available_agent_clis_from_path_updates_before_version_probe() {
354        // Arrange
355        let temp_directory = tempdir().expect("failed to create temp dir");
356        let codex_path = temp_directory.path().join("codex");
357        let version_path = temp_directory.path().join("codex-version");
358        let script = format!(
359            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
360             0; fi\nif [ \"$1\" = \"--version\" ]; then if [ -f \"{}\" ]; then read version < \
361             \"{}\"; else version='1.0.0-old'; fi; printf 'codex-cli %s\\n' \"$version\"; exit 0; \
362             fi\nexit 1\n",
363            version_path.display(),
364            version_path.display(),
365            version_path.display(),
366        );
367        fs::write(&codex_path, script).expect("failed to create codex executable");
368        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
369            .expect("failed to mark codex executable");
370        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
371
372        // Act
373        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
374
375        // Assert
376        assert_eq!(
377            available_agent_clis,
378            vec![AgentCliInfo::new(
379                AgentKind::Codex,
380                Some("9.9.9-updated".to_string())
381            )]
382        );
383        assert!(version_path.exists());
384    }
385
386    #[test]
387    /// Ensures CLI refreshes start independently so one slow provider does
388    /// not delay every following provider.
389    fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
390        // Arrange
391        let codex_started = Arc::new(AtomicBool::new(false));
392        let refresh_cli_version = {
393            let codex_started = Arc::clone(&codex_started);
394
395            move |executable_path: &Path| {
396                if executable_path.file_name() == Some(OsStr::new("agy")) {
397                    let started_at = Instant::now();
398                    while !codex_started.load(Ordering::SeqCst)
399                        && started_at.elapsed() < Duration::from_millis(200)
400                    {
401                        std::thread::sleep(Duration::from_millis(1));
402                    }
403
404                    return if codex_started.load(Ordering::SeqCst) {
405                        Some("agy-concurrent".to_string())
406                    } else {
407                        Some("agy-sequential".to_string())
408                    };
409                }
410
411                if executable_path.file_name() == Some(OsStr::new("codex")) {
412                    codex_started.store(true, Ordering::SeqCst);
413
414                    return Some("codex-current".to_string());
415                }
416
417                None
418            }
419        };
420        let executable_agent_clis = vec![
421            (AgentKind::Antigravity, PathBuf::from("agy")),
422            (AgentKind::Codex, PathBuf::from("codex")),
423        ];
424
425        // Act
426        let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
427
428        // Assert
429        assert_eq!(
430            agent_clis,
431            vec![
432                AgentCliInfo::new(AgentKind::Antigravity, Some("agy-concurrent".to_string())),
433                AgentCliInfo::new(AgentKind::Codex, Some("codex-current".to_string())),
434            ]
435        );
436    }
437
438    #[test]
439    /// Ensures failed CLI updates do not prevent the post-update version
440    /// probe from refreshing the row.
441    fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
442        // Arrange
443        let temp_directory = tempdir().expect("failed to create temp dir");
444        let codex_path = temp_directory.path().join("codex");
445        fs::write(
446            &codex_path,
447            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 1; fi\nif [ \"$1\" = \"--version\" \
448             ]; then printf 'codex-cli 1.2.3\\n'; exit 0; fi\nexit 1\n",
449        )
450        .expect("failed to create codex executable");
451        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
452            .expect("failed to mark codex executable");
453
454        // Act
455        let detected_version = refresh_agent_cli_version(&codex_path);
456
457        // Assert
458        assert_eq!(detected_version, Some("1.2.3".to_string()));
459    }
460
461    #[test]
462    /// Ensures noisy CLI update commands cannot block on unread pipe buffers.
463    fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
464        // Arrange
465        let temp_directory = tempdir().expect("failed to create temp dir");
466        let codex_path = temp_directory.path().join("codex");
467        fs::write(
468            &codex_path,
469            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then i=0; while [ \"$i\" -lt 4096 ]; do \
470             printf \
471             '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\n'; \
472             printf \
473             'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\\n' \
474             >&2; i=$((i + 1)); done; exit 0; fi\nexit 1\n",
475        )
476        .expect("failed to create noisy codex executable");
477        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
478            .expect("failed to mark codex executable");
479
480        // Act
481        let did_finish = run_agent_cli_update_with_timeout(&codex_path, Duration::from_secs(2));
482
483        // Assert
484        assert!(did_finish);
485    }
486
487    #[test]
488    /// Ensures unresponsive CLI version commands time out without returning a
489    /// version.
490    fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
491        // Arrange
492        let temp_directory = tempdir().expect("failed to create temp dir");
493        let codex_path = temp_directory.path().join("codex");
494        fs::write(&codex_path, "#!/bin/sh\nwhile :; do :; done\n")
495            .expect("failed to create hanging codex executable");
496        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
497            .expect("failed to mark codex executable");
498
499        // Act
500        let detected_version =
501            detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
502
503        // Assert
504        assert_eq!(detected_version, None);
505    }
506
507    #[test]
508    /// Ensures non-version text falls back to the first useful output line.
509    fn test_parse_agent_cli_version_output_falls_back_to_line() {
510        // Arrange
511        let output = "Claude Code development build\n";
512
513        // Act
514        let parsed_version = parse_agent_cli_version_output(output);
515
516        // Assert
517        assert_eq!(
518            parsed_version,
519            Some("Claude Code development build".to_string())
520        );
521    }
522
523    #[test]
524    /// Ensures probe discovery ignores non-executable files even when their
525    /// names match supported agent CLIs.
526    fn test_real_agent_availability_probe_ignores_non_executable_files() {
527        // Arrange
528        let temp_directory = tempdir().expect("failed to create temp dir");
529        let codex_path = temp_directory.path().join("codex");
530        fs::write(&codex_path, "").expect("failed to create codex file");
531        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
532            .expect("failed to mark codex non-executable");
533        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
534
535        // Act
536        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
537
538        // Assert
539        assert!(available_agent_kinds.is_empty());
540    }
541}