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::{MetadataExt, PermissionsExt};
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Output, Stdio};
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10
11use semver::Version;
12
13use crate::model::agent::{AgentCliInfo, AgentKind};
14
15/// Oldest Antigravity CLI release supported by Agentty's native stream
16/// protocol.
17const ANTIGRAVITY_MINIMUM_VERSION: Version = Version::new(1, 1, 7);
18/// Maximum time spent waiting for one provider CLI `--version` command.
19const AGENT_CLI_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
20/// Maximum time spent waiting for one provider CLI `update` command.
21const AGENT_CLI_UPDATE_TIMEOUT: Duration = Duration::from_mins(5);
22/// Poll interval used while waiting for one bounded provider CLI subprocess.
23const AGENT_CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
24/// Canonical npm-global path segment for the Gemini CLI package.
25const GEMINI_NPM_PACKAGE_PATH: &str = "/lib/node_modules/@google/gemini-cli/";
26/// npm package spec used to refresh a globally installed Gemini CLI.
27const GEMINI_NPM_PACKAGE_SPEC: &str = "@google/gemini-cli@latest";
28
29/// Cached result of validating one exact Antigravity executable.
30#[derive(Clone)]
31struct AntigravityCompatibilitySnapshot {
32    fingerprint: Option<AntigravityExecutableFingerprint>,
33    result: Result<(), String>,
34}
35
36/// Metadata used to invalidate compatibility after `agy` changes on disk.
37#[derive(Clone, Debug, PartialEq, Eq)]
38struct AntigravityExecutableFingerprint {
39    device: u64,
40    inode: u64,
41    length: u64,
42    modified_nanoseconds: i64,
43    modified_seconds: i64,
44    mode: u32,
45    path: PathBuf,
46}
47
48/// Process-wide Antigravity compatibility snapshot populated by startup
49/// discovery and CLI refresh.
50static ANTIGRAVITY_COMPATIBILITY: OnceLock<Mutex<Option<AntigravityCompatibilitySnapshot>>> =
51    OnceLock::new();
52
53/// Executable plus arguments for one provider CLI startup update.
54struct AgentCliUpdateCommand {
55    args: &'static [&'static str],
56    executable_path: PathBuf,
57}
58
59impl AgentCliUpdateCommand {
60    /// Creates one provider update command.
61    fn new(executable_path: PathBuf, args: &'static [&'static str]) -> Self {
62        Self {
63            args,
64            executable_path,
65        }
66    }
67}
68
69/// Detects which provider CLIs are locally runnable on the current machine.
70#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
71pub trait AgentAvailabilityProbe: Send + Sync {
72    /// Returns the agent kinds whose backing CLI executable is available.
73    fn available_agent_kinds(&self) -> Vec<AgentKind>;
74
75    /// Returns available agent CLI executables and their refreshed versions.
76    fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
77        AgentCliInfo::from_kinds(&self.available_agent_kinds())
78    }
79}
80
81/// Production availability probe backed by `PATH` executable discovery.
82pub struct RealAgentAvailabilityProbe;
83
84impl AgentAvailabilityProbe for RealAgentAvailabilityProbe {
85    fn available_agent_kinds(&self) -> Vec<AgentKind> {
86        available_agent_kinds_from_path(env::var_os("PATH").as_deref())
87    }
88
89    fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
90        available_agent_clis_from_path(env::var_os("PATH").as_deref())
91    }
92}
93
94/// Availability probe that returns one caller-provided snapshot.
95pub struct StaticAgentAvailabilityProbe {
96    /// Agent kinds reported as available by the static probe.
97    pub available_agent_kinds: Vec<AgentKind>,
98}
99
100impl AgentAvailabilityProbe for StaticAgentAvailabilityProbe {
101    fn available_agent_kinds(&self) -> Vec<AgentKind> {
102        self.available_agent_kinds.clone()
103    }
104}
105
106/// Returns the CLI executable name used by the provided agent kind.
107#[must_use]
108pub fn executable_name(agent_kind: AgentKind) -> &'static str {
109    agent_kind.executable_name()
110}
111
112/// Returns available agent CLI metadata from one `PATH` value.
113fn available_agent_clis_from_path(path_value: Option<&OsStr>) -> Vec<AgentCliInfo> {
114    let executable_agent_clis = AgentKind::ALL
115        .iter()
116        .copied()
117        .filter_map(|agent_kind| {
118            let executable_path = executable_path_on_path(path_value, executable_name(agent_kind))?;
119
120            Some((agent_kind, executable_path))
121        })
122        .collect();
123
124    refresh_agent_cli_versions(executable_agent_clis, |agent_kind, executable_path| {
125        refresh_agent_cli_version(agent_kind, executable_path, path_value)
126    })
127}
128
129/// Returns agent kinds whose executables are present on one `PATH` value.
130fn available_agent_kinds_from_path(path_value: Option<&OsStr>) -> Vec<AgentKind> {
131    AgentKind::ALL
132        .iter()
133        .copied()
134        .filter(|agent_kind| {
135            if *agent_kind == AgentKind::Antigravity {
136                return ensure_antigravity_cli_supported_on_path(path_value).is_ok();
137            }
138
139            executable_path_on_path(path_value, executable_name(*agent_kind)).is_some()
140        })
141        .collect()
142}
143
144/// Validates one Antigravity executable resolved from the provided `PATH`.
145fn ensure_antigravity_cli_supported_on_path(path_value: Option<&OsStr>) -> Result<(), String> {
146    let Some(executable_path) =
147        executable_path_on_path(path_value, executable_name(AgentKind::Antigravity))
148    else {
149        let result = Err(format!(
150            "Antigravity CLI {ANTIGRAVITY_MINIMUM_VERSION} or newer is required, but `agy` was \
151             not found on `PATH`. Install it or run `agy update`, then restart Agentty."
152        ));
153
154        cache_antigravity_cli_support(None, result.clone());
155
156        return result;
157    };
158    let detected_version = detect_agent_cli_version(&executable_path);
159    let result = validate_antigravity_cli_version(detected_version.as_deref());
160
161    cache_antigravity_cli_support(Some(&executable_path), result.clone());
162
163    result
164}
165
166/// Checks one `PATH` against the cached Antigravity compatibility snapshot.
167pub(super) fn ensure_cached_antigravity_cli_supported_on_path(
168    path_value: Option<&OsStr>,
169) -> Result<(), String> {
170    let executable_path =
171        executable_path_on_path(path_value, executable_name(AgentKind::Antigravity));
172    let current_fingerprint = executable_path
173        .as_deref()
174        .and_then(antigravity_executable_fingerprint);
175    let snapshot = ANTIGRAVITY_COMPATIBILITY
176        .get_or_init(|| Mutex::new(None))
177        .lock()
178        .ok()
179        .and_then(|snapshot| snapshot.clone());
180
181    validate_cached_antigravity_cli_support(snapshot.as_ref(), current_fingerprint.as_ref())
182}
183
184/// Returns a cached result only when it describes the current executable.
185fn validate_cached_antigravity_cli_support(
186    snapshot: Option<&AntigravityCompatibilitySnapshot>,
187    current_fingerprint: Option<&AntigravityExecutableFingerprint>,
188) -> Result<(), String> {
189    let Some(snapshot) = snapshot else {
190        return Err(
191            "Antigravity CLI has not been validated yet. Wait for CLI discovery to finish or \
192             restart Agentty, then retry."
193                .to_string(),
194        );
195    };
196    if snapshot.fingerprint.as_ref() != current_fingerprint {
197        return Err(
198            "Antigravity CLI installation changed after Agentty validated it. Wait for CLI \
199             discovery to finish or restart Agentty, then retry."
200                .to_string(),
201        );
202    }
203
204    snapshot.result.clone()
205}
206
207/// Stores one compatibility result alongside the exact executable it covers.
208fn cache_antigravity_cli_support(executable_path: Option<&Path>, result: Result<(), String>) {
209    let snapshot = AntigravityCompatibilitySnapshot {
210        fingerprint: executable_path.and_then(antigravity_executable_fingerprint),
211        result,
212    };
213    if let Ok(mut cached_snapshot) = ANTIGRAVITY_COMPATIBILITY
214        .get_or_init(|| Mutex::new(None))
215        .lock()
216    {
217        *cached_snapshot = Some(snapshot);
218    }
219}
220
221/// Captures stable metadata for one resolved Antigravity executable.
222fn antigravity_executable_fingerprint(
223    executable_path: &Path,
224) -> Option<AntigravityExecutableFingerprint> {
225    let metadata = executable_path.metadata().ok()?;
226
227    Some(AntigravityExecutableFingerprint {
228        device: metadata.dev(),
229        inode: metadata.ino(),
230        length: metadata.len(),
231        modified_nanoseconds: metadata.mtime_nsec(),
232        modified_seconds: metadata.mtime(),
233        mode: metadata.mode(),
234        path: executable_path.to_path_buf(),
235    })
236}
237
238/// Validates one parsed Antigravity version string against the supported
239/// minimum.
240fn validate_antigravity_cli_version(detected_version: Option<&str>) -> Result<(), String> {
241    let Some(detected_version) = detected_version else {
242        return Err(format!(
243            "Antigravity CLI {ANTIGRAVITY_MINIMUM_VERSION} or newer is required, but `agy \
244             --version` did not report a version. Run `agy update`, then retry."
245        ));
246    };
247    let normalized_version = detected_version
248        .strip_prefix('v')
249        .unwrap_or(detected_version);
250    let parsed_version = Version::parse(normalized_version).map_err(|_| {
251        format!(
252            "Antigravity CLI {ANTIGRAVITY_MINIMUM_VERSION} or newer is required, but `agy \
253             --version` reported `{detected_version}`. Run `agy update`, then retry."
254        )
255    })?;
256    if parsed_version < ANTIGRAVITY_MINIMUM_VERSION {
257        return Err(format!(
258            "Antigravity CLI {ANTIGRAVITY_MINIMUM_VERSION} or newer is required, but \
259             `{detected_version}` is installed. Run `agy update`, then retry."
260        ));
261    }
262
263    Ok(())
264}
265
266/// Returns the first executable path matching one command name on `PATH`.
267fn executable_path_on_path(path_value: Option<&OsStr>, executable_name: &str) -> Option<PathBuf> {
268    path_value
269        .map(env::split_paths)
270        .into_iter()
271        .flatten()
272        .map(|path_entry| candidate_path_for_executable_name(&path_entry, executable_name))
273        .find(|candidate_path| is_executable_file(candidate_path))
274}
275
276/// Returns the candidate filesystem path for one executable name within a
277/// single `PATH` entry.
278fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
279    path_entry.join(executable_name)
280}
281
282/// Returns whether the candidate path is a regular file with at least one
283/// execute bit set.
284fn is_executable_file(candidate_path: &Path) -> bool {
285    let Ok(metadata) = candidate_path.metadata() else {
286        return false;
287    };
288
289    if !metadata.is_file() {
290        return false;
291    }
292
293    metadata.permissions().mode() & 0o111 != 0
294}
295
296/// Runs one available CLI's update command, then extracts the installed
297/// version token from a fresh version probe.
298fn refresh_agent_cli_version(
299    agent_kind: AgentKind,
300    executable_path: &Path,
301    path_value: Option<&OsStr>,
302) -> Option<String> {
303    run_agent_cli_update(agent_kind, executable_path, path_value);
304
305    let detected_version = detect_agent_cli_version(executable_path);
306    if agent_kind == AgentKind::Antigravity {
307        let result = validate_antigravity_cli_version(detected_version.as_deref());
308        cache_antigravity_cli_support(Some(executable_path), result);
309    }
310
311    detected_version
312}
313
314/// Refreshes all available CLI versions concurrently while preserving
315/// provider display order.
316fn refresh_agent_cli_versions(
317    executable_agent_clis: Vec<(AgentKind, PathBuf)>,
318    refresh_cli_version: impl Fn(AgentKind, &Path) -> Option<String> + Sync,
319) -> Vec<AgentCliInfo> {
320    std::thread::scope(|scope| {
321        let refresh_cli_version = &refresh_cli_version;
322        let refresh_handles = executable_agent_clis
323            .into_iter()
324            .map(|(agent_kind, executable_path)| {
325                (
326                    agent_kind,
327                    scope.spawn(move || refresh_cli_version(agent_kind, &executable_path)),
328                )
329            })
330            .collect::<Vec<_>>();
331
332        refresh_handles
333            .into_iter()
334            .map(|(agent_kind, refresh_handle)| {
335                AgentCliInfo::new(agent_kind, refresh_handle.join().unwrap_or(None))
336            })
337            .collect()
338    })
339}
340
341/// Runs one available CLI's best-effort provider or package-manager update.
342fn run_agent_cli_update(agent_kind: AgentKind, executable_path: &Path, path_value: Option<&OsStr>) {
343    let _ = run_agent_cli_update_with_timeout(
344        agent_kind,
345        executable_path,
346        path_value,
347        AGENT_CLI_UPDATE_TIMEOUT,
348    );
349}
350
351/// Runs one available CLI's best-effort update with a caller-provided timeout.
352fn run_agent_cli_update_with_timeout(
353    agent_kind: AgentKind,
354    executable_path: &Path,
355    path_value: Option<&OsStr>,
356    timeout: Duration,
357) -> bool {
358    let Some(update_command) = agent_cli_update_command(agent_kind, executable_path, path_value)
359    else {
360        return false;
361    };
362
363    command_status_with_timeout(&update_command, timeout).is_some()
364}
365
366/// Builds the supported startup update command for one provider CLI.
367fn agent_cli_update_command(
368    agent_kind: AgentKind,
369    executable_path: &Path,
370    path_value: Option<&OsStr>,
371) -> Option<AgentCliUpdateCommand> {
372    if agent_kind == AgentKind::Gemini {
373        return gemini_npm_update_command(executable_path, path_value);
374    }
375
376    Some(AgentCliUpdateCommand::new(
377        executable_path.to_path_buf(),
378        &["update"],
379    ))
380}
381
382/// Builds Gemini's supported npm-global update command when the discovered
383/// executable resolves into the global Gemini CLI package.
384///
385/// Canonicalization failure is treated as an unknown installation because
386/// Agentty cannot safely prove that npm owns the executable.
387fn gemini_npm_update_command(
388    executable_path: &Path,
389    path_value: Option<&OsStr>,
390) -> Option<AgentCliUpdateCommand> {
391    let canonical_executable_path = executable_path.canonicalize().ok()?;
392    let normalized_executable_path = canonical_executable_path.to_string_lossy();
393    if !normalized_executable_path.contains(GEMINI_NPM_PACKAGE_PATH) {
394        return None;
395    }
396
397    let npm_executable_path = executable_path_on_path(path_value, "npm")?;
398
399    Some(AgentCliUpdateCommand::new(
400        npm_executable_path,
401        &["install", "-g", GEMINI_NPM_PACKAGE_SPEC],
402    ))
403}
404
405/// Runs one available CLI's version command and extracts the installed
406/// version token from its output.
407fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
408    detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
409}
410
411/// Runs one available CLI's version command with a caller-provided timeout.
412fn detect_agent_cli_version_with_timeout(
413    executable_path: &Path,
414    timeout: Duration,
415) -> Option<String> {
416    let output = version_command_output(executable_path, timeout)?;
417    if !output.status.success() {
418        return None;
419    }
420
421    let stdout_text = String::from_utf8_lossy(&output.stdout);
422    let stderr_text = String::from_utf8_lossy(&output.stderr);
423    parse_agent_cli_version_output(&stdout_text)
424        .or_else(|| parse_agent_cli_version_output(&stderr_text))
425}
426
427/// Runs one provider CLI `--version` command and stops waiting once the
428/// timeout expires.
429fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
430    command_output_with_timeout(executable_path, &["--version"], timeout)
431}
432
433/// Runs one provider CLI command with output discarded and stops waiting once
434/// the timeout expires.
435fn command_status_with_timeout(
436    update_command: &AgentCliUpdateCommand,
437    timeout: Duration,
438) -> Option<()> {
439    let mut child = Command::new(&update_command.executable_path)
440        .args(update_command.args)
441        .stdin(Stdio::null())
442        .stdout(Stdio::null())
443        .stderr(Stdio::null())
444        .spawn()
445        .ok()?;
446    wait_for_child_exit(&mut child, timeout)?;
447    let _ = child.wait().ok()?;
448
449    Some(())
450}
451
452/// Runs one provider CLI command and stops waiting once the timeout expires.
453fn command_output_with_timeout(
454    executable_path: &Path,
455    args: &[&str],
456    timeout: Duration,
457) -> Option<Output> {
458    let mut child = Command::new(executable_path)
459        .args(args)
460        .stdin(Stdio::null())
461        .stdout(Stdio::piped())
462        .stderr(Stdio::piped())
463        .spawn()
464        .ok()?;
465    wait_for_child_exit(&mut child, timeout)?;
466
467    child.wait_with_output().ok()
468}
469
470/// Waits for one child process to exit, killing it when the timeout expires.
471fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Option<()> {
472    let started_at = Instant::now();
473
474    loop {
475        if child.try_wait().ok()?.is_some() {
476            return Some(());
477        }
478
479        if started_at.elapsed() >= timeout {
480            let _ = child.kill();
481            let _ = child.wait();
482
483            return None;
484        }
485
486        std::thread::sleep(
487            AGENT_CLI_COMMAND_POLL_INTERVAL.min(timeout.saturating_sub(started_at.elapsed())),
488        );
489    }
490}
491
492/// Parses a provider CLI version from the first useful `--version` output
493/// line.
494fn parse_agent_cli_version_output(output: &str) -> Option<String> {
495    let line = output
496        .lines()
497        .map(str::trim)
498        .find(|line| !line.is_empty())?;
499    let version_token = line
500        .split_whitespace()
501        .map(|token| {
502            token.trim_matches(|character: char| {
503                matches!(character, ',' | ';' | ':' | '(' | ')' | '[' | ']')
504            })
505        })
506        .find(|token| {
507            let normalized = token.strip_prefix('v').unwrap_or(token);
508
509            normalized
510                .chars()
511                .next()
512                .is_some_and(|character| character.is_ascii_digit())
513                && normalized.contains('.')
514        });
515
516    Some(version_token.unwrap_or(line).to_string())
517}
518
519#[cfg(test)]
520mod tests {
521    use std::fs;
522    use std::os::unix::fs::{PermissionsExt, symlink};
523    use std::sync::atomic::{AtomicBool, Ordering};
524    use std::sync::{Arc, MutexGuard};
525
526    use tempfile::tempdir;
527
528    use super::*;
529
530    /// Serializes tests that update the process-wide Antigravity compatibility
531    /// snapshot.
532    static ANTIGRAVITY_CACHE_TEST_LOCK: Mutex<()> = Mutex::new(());
533
534    /// Acquires the test-only Antigravity cache guard, recovering after a
535    /// failed assertion poisoned an earlier guard.
536    fn antigravity_cache_test_guard() -> MutexGuard<'static, ()> {
537        ANTIGRAVITY_CACHE_TEST_LOCK
538            .lock()
539            .unwrap_or_else(std::sync::PoisonError::into_inner)
540    }
541
542    #[test]
543    /// Ensures executable names stay aligned with provider command names.
544    fn test_executable_name_matches_agent_cli_names() {
545        // Arrange / Act / Assert
546        assert_eq!(executable_name(AgentKind::Antigravity), "agy");
547        assert_eq!(executable_name(AgentKind::Claude), "claude");
548        assert_eq!(executable_name(AgentKind::Codex), "codex");
549        assert_eq!(executable_name(AgentKind::Gemini), "gemini");
550    }
551
552    #[test]
553    /// Ensures the production probe reports only agent kinds whose
554    /// executables are present on the current `PATH`.
555    fn test_real_agent_availability_probe_filters_missing_executables() {
556        // Arrange
557        let _cache_guard = antigravity_cache_test_guard();
558        let temp_directory = tempdir().expect("failed to create temp dir");
559        let antigravity_path = temp_directory.path().join("agy");
560        let codex_path = temp_directory.path().join("codex");
561        fs::write(
562            &antigravity_path,
563            "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'agy 1.2.0\\n'; fi\n",
564        )
565        .expect("failed to create agy executable");
566        fs::write(&codex_path, "").expect("failed to create codex executable");
567        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
568            .expect("failed to mark agy executable");
569        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
570            .expect("failed to mark codex executable");
571        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
572
573        // Act
574        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
575
576        // Assert
577        assert_eq!(
578            available_agent_kinds,
579            vec![AgentKind::Antigravity, AgentKind::Codex]
580        );
581    }
582
583    #[test]
584    /// Ensures unsupported Antigravity installations are not selectable even
585    /// when the executable is present.
586    fn test_available_agent_kinds_from_path_filters_old_antigravity() {
587        // Arrange
588        let _cache_guard = antigravity_cache_test_guard();
589        let temp_directory = tempdir().expect("failed to create temp dir");
590        let antigravity_path = temp_directory.path().join("agy");
591        let codex_path = temp_directory.path().join("codex");
592        fs::write(
593            &antigravity_path,
594            "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'agy 1.1.6\\n'; fi\n",
595        )
596        .expect("failed to create agy executable");
597        fs::write(&codex_path, "").expect("failed to create codex executable");
598        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
599            .expect("failed to mark agy executable");
600        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
601            .expect("failed to mark codex executable");
602        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
603
604        // Act
605        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
606
607        // Assert
608        assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
609    }
610
611    #[test]
612    /// Ensures refreshed Antigravity compatibility is reused without another
613    /// version process and invalidated when the executable changes.
614    fn test_cached_antigravity_support_tracks_refreshed_executable() {
615        // Arrange
616        let _cache_guard = antigravity_cache_test_guard();
617        let temp_directory = tempdir().expect("failed to create temp dir");
618        let antigravity_path = temp_directory.path().join("agy");
619        fs::write(
620            &antigravity_path,
621            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 0; fi\nif [ \"$1\" = \"--version\" \
622             ]; then printf 'agy 1.2.0\\n'; exit 0; fi\nexit 1\n",
623        )
624        .expect("failed to create agy executable");
625        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
626            .expect("failed to mark agy executable");
627        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
628
629        // Act
630        let detected_version = refresh_agent_cli_version(
631            AgentKind::Antigravity,
632            &antigravity_path,
633            Some(path_value.as_os_str()),
634        );
635        let cached_result =
636            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()));
637        fs::write(
638            &antigravity_path,
639            "#!/bin/sh\nprintf 'changed Antigravity executable\\n'\n",
640        )
641        .expect("failed to replace agy executable");
642        let changed_result =
643            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()));
644
645        // Assert
646        assert_eq!(detected_version, Some("1.2.0".to_string()));
647        assert_eq!(cached_result, Ok(()));
648        let changed_error =
649            changed_result.expect_err("changed Antigravity executable should fail closed");
650        assert!(changed_error.contains("installation changed"));
651        assert!(changed_error.contains("restart Agentty"));
652    }
653
654    #[test]
655    /// Ensures supported stable and prefixed Antigravity versions pass the
656    /// compatibility check.
657    fn test_validate_antigravity_cli_version_accepts_supported_versions() {
658        // Arrange / Act / Assert
659        assert_eq!(validate_antigravity_cli_version(Some("1.1.7")), Ok(()));
660        assert_eq!(validate_antigravity_cli_version(Some("v1.2.0")), Ok(()));
661    }
662
663    #[test]
664    /// Ensures old Antigravity versions return an actionable upgrade error.
665    fn test_validate_antigravity_cli_version_rejects_old_version() {
666        // Arrange / Act
667        let error = validate_antigravity_cli_version(Some("1.1.6"))
668            .expect_err("old Antigravity should be rejected");
669
670        // Assert
671        assert_eq!(
672            error,
673            "Antigravity CLI 1.1.7 or newer is required, but `1.1.6` is installed. Run `agy \
674             update`, then retry."
675        );
676    }
677
678    #[test]
679    /// Ensures missing and malformed version output both explain how to
680    /// recover.
681    fn test_validate_antigravity_cli_version_rejects_unknown_versions() {
682        // Arrange / Act
683        let missing_error = validate_antigravity_cli_version(None)
684            .expect_err("missing Antigravity version should be rejected");
685        let malformed_error = validate_antigravity_cli_version(Some("development"))
686            .expect_err("malformed Antigravity version should be rejected");
687
688        // Assert
689        assert!(missing_error.contains("did not report a version"));
690        assert!(missing_error.contains("Run `agy update`"));
691        assert!(malformed_error.contains("reported `development`"));
692        assert!(malformed_error.contains("Run `agy update`"));
693    }
694
695    #[test]
696    /// Ensures turn-time validation reuses only a result for the exact
697    /// executable fingerprint that was previously probed.
698    fn test_validate_cached_antigravity_cli_support_requires_matching_fingerprint() {
699        // Arrange
700        let fingerprint = AntigravityExecutableFingerprint {
701            device: 1,
702            inode: 2,
703            length: 3,
704            modified_nanoseconds: 4,
705            modified_seconds: 5,
706            mode: 0o100_755,
707            path: PathBuf::from("/test/agy"),
708        };
709        let changed_fingerprint = AntigravityExecutableFingerprint {
710            length: 30,
711            ..fingerprint.clone()
712        };
713        let supported_snapshot = AntigravityCompatibilitySnapshot {
714            fingerprint: Some(fingerprint.clone()),
715            result: Ok(()),
716        };
717        let unsupported_snapshot = AntigravityCompatibilitySnapshot {
718            fingerprint: Some(fingerprint.clone()),
719            result: Err("Run `agy update`, then retry.".to_string()),
720        };
721
722        // Act
723        let supported_result =
724            validate_cached_antigravity_cli_support(Some(&supported_snapshot), Some(&fingerprint));
725        let unsupported_result = validate_cached_antigravity_cli_support(
726            Some(&unsupported_snapshot),
727            Some(&fingerprint),
728        );
729        let missing_snapshot_error =
730            validate_cached_antigravity_cli_support(None, Some(&fingerprint))
731                .expect_err("a missing snapshot should fail closed");
732        let changed_executable_error = validate_cached_antigravity_cli_support(
733            Some(&supported_snapshot),
734            Some(&changed_fingerprint),
735        )
736        .expect_err("a changed executable should invalidate the snapshot");
737
738        // Assert
739        assert_eq!(supported_result, Ok(()));
740        assert_eq!(
741            unsupported_result,
742            Err("Run `agy update`, then retry.".to_string())
743        );
744        assert!(missing_snapshot_error.contains("has not been validated yet"));
745        assert!(changed_executable_error.contains("installation changed"));
746        assert!(changed_executable_error.contains("restart Agentty"));
747    }
748
749    #[test]
750    /// Ensures a missing Antigravity executable returns an actionable
751    /// installation error.
752    fn test_ensure_antigravity_cli_supported_on_path_rejects_missing_executable() {
753        // Arrange
754        let _cache_guard = antigravity_cache_test_guard();
755        let temp_directory = tempdir().expect("failed to create temp dir");
756        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
757
758        // Act
759        let error = ensure_antigravity_cli_supported_on_path(Some(path_value.as_os_str()))
760            .expect_err("missing Antigravity should be rejected");
761        let cached_error =
762            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()))
763                .expect_err("cached missing Antigravity should remain rejected");
764
765        // Assert
766        assert!(error.contains("`agy` was not found on `PATH`"));
767        assert!(error.contains("Install it or run `agy update`"));
768        assert_eq!(cached_error, error);
769    }
770
771    #[test]
772    /// Ensures available CLI metadata includes parsed command versions.
773    fn test_available_agent_clis_from_path_includes_versions() {
774        // Arrange
775        let temp_directory = tempdir().expect("failed to create temp dir");
776        let codex_path = temp_directory.path().join("codex");
777        fs::write(&codex_path, "#!/bin/sh\nprintf 'codex-cli 1.2.3\\n'\n")
778            .expect("failed to create codex executable");
779        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
780            .expect("failed to mark codex executable");
781        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
782
783        // Act
784        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
785
786        // Assert
787        assert_eq!(
788            available_agent_clis,
789            vec![AgentCliInfo::new(
790                AgentKind::Codex,
791                Some("1.2.3".to_string())
792            )]
793        );
794    }
795
796    #[test]
797    /// Ensures the startup CLI refresh runs `update` before probing the
798    /// visible version.
799    fn test_available_agent_clis_from_path_updates_before_version_probe() {
800        // Arrange
801        let temp_directory = tempdir().expect("failed to create temp dir");
802        let codex_path = temp_directory.path().join("codex");
803        let version_path = temp_directory.path().join("codex-version");
804        let script = format!(
805            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
806             0; fi\nif [ \"$1\" = \"--version\" ]; then if [ -f \"{}\" ]; then read version < \
807             \"{}\"; else version='1.0.0-old'; fi; printf 'codex-cli %s\\n' \"$version\"; exit 0; \
808             fi\nexit 1\n",
809            version_path.display(),
810            version_path.display(),
811            version_path.display(),
812        );
813        fs::write(&codex_path, script).expect("failed to create codex executable");
814        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
815            .expect("failed to mark codex executable");
816        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
817
818        // Act
819        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
820
821        // Assert
822        assert_eq!(
823            available_agent_clis,
824            vec![AgentCliInfo::new(
825                AgentKind::Codex,
826                Some("9.9.9-updated".to_string())
827            )]
828        );
829        assert!(version_path.exists());
830    }
831
832    #[test]
833    /// Ensures CLI refreshes start independently so one slow provider does
834    /// not delay every following provider.
835    fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
836        // Arrange
837        let codex_started = Arc::new(AtomicBool::new(false));
838        let refresh_cli_version = {
839            let codex_started = Arc::clone(&codex_started);
840
841            move |_agent_kind: AgentKind, executable_path: &Path| {
842                if executable_path.file_name() == Some(OsStr::new("agy")) {
843                    let started_at = Instant::now();
844                    while !codex_started.load(Ordering::SeqCst)
845                        && started_at.elapsed() < Duration::from_millis(200)
846                    {
847                        std::thread::sleep(Duration::from_millis(1));
848                    }
849
850                    return if codex_started.load(Ordering::SeqCst) {
851                        Some("agy-concurrent".to_string())
852                    } else {
853                        Some("agy-sequential".to_string())
854                    };
855                }
856
857                if executable_path.file_name() == Some(OsStr::new("codex")) {
858                    codex_started.store(true, Ordering::SeqCst);
859
860                    return Some("codex-current".to_string());
861                }
862
863                None
864            }
865        };
866        let executable_agent_clis = vec![
867            (AgentKind::Antigravity, PathBuf::from("agy")),
868            (AgentKind::Codex, PathBuf::from("codex")),
869        ];
870
871        // Act
872        let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
873
874        // Assert
875        assert_eq!(
876            agent_clis,
877            vec![
878                AgentCliInfo::new(AgentKind::Antigravity, Some("agy-concurrent".to_string())),
879                AgentCliInfo::new(AgentKind::Codex, Some("codex-current".to_string())),
880            ]
881        );
882    }
883
884    #[test]
885    /// Ensures failed CLI updates do not prevent the post-update version
886    /// probe from refreshing the row.
887    fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
888        // Arrange
889        let temp_directory = tempdir().expect("failed to create temp dir");
890        let codex_path = temp_directory.path().join("codex");
891        fs::write(
892            &codex_path,
893            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 1; fi\nif [ \"$1\" = \"--version\" \
894             ]; then printf 'codex-cli 1.2.3\\n'; exit 0; fi\nexit 1\n",
895        )
896        .expect("failed to create codex executable");
897        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
898            .expect("failed to mark codex executable");
899
900        // Act
901        let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
902
903        // Assert
904        assert_eq!(detected_version, Some("1.2.3".to_string()));
905    }
906
907    #[test]
908    /// Ensures npm-global Gemini installations update through npm instead of
909    /// treating `update` as an interactive Gemini query.
910    fn test_available_agent_clis_from_path_updates_npm_global_gemini() {
911        // Arrange
912        let temp_directory = tempdir().expect("failed to create temp dir");
913        let bin_directory = temp_directory.path().join("bin");
914        let gemini_package_directory = temp_directory
915            .path()
916            .join("lib/node_modules/@google/gemini-cli/bundle");
917        let gemini_package_path = gemini_package_directory.join("gemini.js");
918        let gemini_path = bin_directory.join("gemini");
919        let npm_path = bin_directory.join("npm");
920        let version_path = temp_directory.path().join("gemini-version");
921        fs::create_dir_all(&bin_directory).expect("failed to create bin directory");
922        fs::create_dir_all(&gemini_package_directory)
923            .expect("failed to create Gemini package directory");
924        fs::write(
925            &gemini_package_path,
926            format!(
927                "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 91; fi\nif [ \"$1\" = \
928                 \"--version\" ]; then if [ -f \"{}\" ]; then read version < \"{}\"; else \
929                 version='1.0.0-old'; fi; printf 'gemini %s\\n' \"$version\"; exit 0; fi\nexit 1\n",
930                version_path.display(),
931                version_path.display(),
932            ),
933        )
934        .expect("failed to create Gemini executable");
935        fs::write(
936            &npm_path,
937            format!(
938                "#!/bin/sh\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ] && [ \"$3\" = \
939                 \"@google/gemini-cli@latest\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
940                 0; fi\nexit 1\n",
941                version_path.display(),
942            ),
943        )
944        .expect("failed to create npm executable");
945        fs::set_permissions(&gemini_package_path, fs::Permissions::from_mode(0o755))
946            .expect("failed to mark Gemini executable");
947        fs::set_permissions(&npm_path, fs::Permissions::from_mode(0o755))
948            .expect("failed to mark npm executable");
949        symlink(&gemini_package_path, &gemini_path).expect("failed to link Gemini executable");
950        let path_value = env::join_paths([&bin_directory]).expect("valid path");
951
952        // Act
953        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
954
955        // Assert
956        assert_eq!(
957            available_agent_clis,
958            vec![AgentCliInfo::new(
959                AgentKind::Gemini,
960                Some("9.9.9-updated".to_string())
961            )]
962        );
963        assert_eq!(
964            fs::read_to_string(version_path).expect("updated Gemini version"),
965            "9.9.9-updated\n"
966        );
967    }
968
969    #[test]
970    /// Ensures Gemini installations with an unknown owner do not launch the
971    /// removed native update command.
972    fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
973        // Arrange
974        let temp_directory = tempdir().expect("failed to create temp dir");
975        let gemini_path = temp_directory.path().join("gemini");
976        let update_marker_path = temp_directory.path().join("gemini-update");
977        fs::write(
978            &gemini_path,
979            format!(
980                "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then touch \"{}\"; exit 0; fi\nexit 1\n",
981                update_marker_path.display(),
982            ),
983        )
984        .expect("failed to create Gemini executable");
985        fs::set_permissions(&gemini_path, fs::Permissions::from_mode(0o755))
986            .expect("failed to mark Gemini executable");
987
988        // Act
989        let did_update = run_agent_cli_update_with_timeout(
990            AgentKind::Gemini,
991            &gemini_path,
992            None,
993            Duration::from_millis(100),
994        );
995
996        // Assert
997        assert!(!did_update);
998        assert!(!update_marker_path.exists());
999    }
1000
1001    #[test]
1002    /// Ensures noisy CLI update commands cannot block on unread pipe buffers.
1003    fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
1004        // Arrange
1005        let temp_directory = tempdir().expect("failed to create temp dir");
1006        let codex_path = temp_directory.path().join("codex");
1007        fs::write(
1008            &codex_path,
1009            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then i=0; while [ \"$i\" -lt 4096 ]; do \
1010             printf \
1011             '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\n'; \
1012             printf \
1013             'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\\n' \
1014             >&2; i=$((i + 1)); done; exit 0; fi\nexit 1\n",
1015        )
1016        .expect("failed to create noisy codex executable");
1017        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
1018            .expect("failed to mark codex executable");
1019
1020        // Act
1021        let did_finish = run_agent_cli_update_with_timeout(
1022            AgentKind::Codex,
1023            &codex_path,
1024            None,
1025            Duration::from_secs(10),
1026        );
1027
1028        // Assert
1029        assert!(did_finish);
1030    }
1031
1032    #[test]
1033    /// Ensures unresponsive CLI version commands time out without returning a
1034    /// version.
1035    fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
1036        // Arrange
1037        let temp_directory = tempdir().expect("failed to create temp dir");
1038        let codex_path = temp_directory.path().join("codex");
1039        fs::write(&codex_path, "#!/bin/sh\nwhile :; do :; done\n")
1040            .expect("failed to create hanging codex executable");
1041        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
1042            .expect("failed to mark codex executable");
1043
1044        // Act
1045        let detected_version =
1046            detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
1047
1048        // Assert
1049        assert_eq!(detected_version, None);
1050    }
1051
1052    #[test]
1053    /// Ensures non-version text falls back to the first useful output line.
1054    fn test_parse_agent_cli_version_output_falls_back_to_line() {
1055        // Arrange
1056        let output = "Claude Code development build\n";
1057
1058        // Act
1059        let parsed_version = parse_agent_cli_version_output(output);
1060
1061        // Assert
1062        assert_eq!(
1063            parsed_version,
1064            Some("Claude Code development build".to_string())
1065        );
1066    }
1067
1068    #[test]
1069    /// Ensures probe discovery ignores non-executable files even when their
1070    /// names match supported agent CLIs.
1071    fn test_real_agent_availability_probe_ignores_non_executable_files() {
1072        // Arrange
1073        let temp_directory = tempdir().expect("failed to create temp dir");
1074        let codex_path = temp_directory.path().join("codex");
1075        fs::write(&codex_path, "").expect("failed to create codex file");
1076        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
1077            .expect("failed to mark codex non-executable");
1078        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
1079
1080        // Act
1081        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
1082
1083        // Assert
1084        assert!(available_agent_kinds.is_empty());
1085    }
1086}