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 temp_directory = tempdir().expect("failed to create temp dir");
558        let codex_path = temp_directory.path().join("codex");
559        fs::write(&codex_path, "").expect("failed to create codex executable");
560        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
561            .expect("failed to mark codex executable");
562        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
563
564        // Act
565        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
566
567        // Assert
568        assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
569    }
570
571    #[test]
572    /// Ensures unsupported Antigravity installations are not selectable even
573    /// when the executable is present.
574    fn test_available_agent_kinds_from_path_filters_old_antigravity() {
575        // Arrange
576        let _cache_guard = antigravity_cache_test_guard();
577        let temp_directory = tempdir().expect("failed to create temp dir");
578        let antigravity_path = temp_directory.path().join("agy");
579        let codex_path = temp_directory.path().join("codex");
580        fs::write(
581            &antigravity_path,
582            "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'agy 1.1.6\\n'; fi\n",
583        )
584        .expect("failed to create agy executable");
585        fs::write(&codex_path, "").expect("failed to create codex executable");
586        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
587            .expect("failed to mark agy executable");
588        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
589            .expect("failed to mark codex executable");
590        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
591
592        // Act
593        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
594
595        // Assert
596        assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
597    }
598
599    #[test]
600    /// Ensures refreshed Antigravity compatibility is reused without another
601    /// version process and invalidated when the executable changes.
602    fn test_cached_antigravity_support_tracks_refreshed_executable() {
603        // Arrange
604        let _cache_guard = antigravity_cache_test_guard();
605        let temp_directory = tempdir().expect("failed to create temp dir");
606        let antigravity_path = temp_directory.path().join("agy");
607        fs::write(
608            &antigravity_path,
609            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 0; fi\nif [ \"$1\" = \"--version\" \
610             ]; then printf 'agy 1.2.0\\n'; exit 0; fi\nexit 1\n",
611        )
612        .expect("failed to create agy executable");
613        fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
614            .expect("failed to mark agy executable");
615        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
616
617        // Act
618        let detected_version = refresh_agent_cli_version(
619            AgentKind::Antigravity,
620            &antigravity_path,
621            Some(path_value.as_os_str()),
622        );
623        let cached_result =
624            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()));
625        fs::write(
626            &antigravity_path,
627            "#!/bin/sh\nprintf 'changed Antigravity executable\\n'\n",
628        )
629        .expect("failed to replace agy executable");
630        let changed_result =
631            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()));
632
633        // Assert
634        assert_eq!(detected_version, Some("1.2.0".to_string()));
635        assert_eq!(cached_result, Ok(()));
636        let changed_error =
637            changed_result.expect_err("changed Antigravity executable should fail closed");
638        assert!(changed_error.contains("installation changed"));
639        assert!(changed_error.contains("restart Agentty"));
640    }
641
642    #[test]
643    /// Ensures supported stable and prefixed Antigravity versions pass the
644    /// compatibility check.
645    fn test_validate_antigravity_cli_version_accepts_supported_versions() {
646        // Arrange / Act / Assert
647        assert_eq!(validate_antigravity_cli_version(Some("1.1.7")), Ok(()));
648        assert_eq!(validate_antigravity_cli_version(Some("v1.2.0")), Ok(()));
649    }
650
651    #[test]
652    /// Ensures old Antigravity versions return an actionable upgrade error.
653    fn test_validate_antigravity_cli_version_rejects_old_version() {
654        // Arrange / Act
655        let error = validate_antigravity_cli_version(Some("1.1.6"))
656            .expect_err("old Antigravity should be rejected");
657
658        // Assert
659        assert_eq!(
660            error,
661            "Antigravity CLI 1.1.7 or newer is required, but `1.1.6` is installed. Run `agy \
662             update`, then retry."
663        );
664    }
665
666    #[test]
667    /// Ensures missing and malformed version output both explain how to
668    /// recover.
669    fn test_validate_antigravity_cli_version_rejects_unknown_versions() {
670        // Arrange / Act
671        let missing_error = validate_antigravity_cli_version(None)
672            .expect_err("missing Antigravity version should be rejected");
673        let malformed_error = validate_antigravity_cli_version(Some("development"))
674            .expect_err("malformed Antigravity version should be rejected");
675
676        // Assert
677        assert!(missing_error.contains("did not report a version"));
678        assert!(missing_error.contains("Run `agy update`"));
679        assert!(malformed_error.contains("reported `development`"));
680        assert!(malformed_error.contains("Run `agy update`"));
681    }
682
683    #[test]
684    /// Ensures turn-time validation reuses only a result for the exact
685    /// executable fingerprint that was previously probed.
686    fn test_validate_cached_antigravity_cli_support_requires_matching_fingerprint() {
687        // Arrange
688        let fingerprint = AntigravityExecutableFingerprint {
689            device: 1,
690            inode: 2,
691            length: 3,
692            modified_nanoseconds: 4,
693            modified_seconds: 5,
694            mode: 0o100_755,
695            path: PathBuf::from("/test/agy"),
696        };
697        let changed_fingerprint = AntigravityExecutableFingerprint {
698            length: 30,
699            ..fingerprint.clone()
700        };
701        let supported_snapshot = AntigravityCompatibilitySnapshot {
702            fingerprint: Some(fingerprint.clone()),
703            result: Ok(()),
704        };
705        let unsupported_snapshot = AntigravityCompatibilitySnapshot {
706            fingerprint: Some(fingerprint.clone()),
707            result: Err("Run `agy update`, then retry.".to_string()),
708        };
709
710        // Act
711        let supported_result =
712            validate_cached_antigravity_cli_support(Some(&supported_snapshot), Some(&fingerprint));
713        let unsupported_result = validate_cached_antigravity_cli_support(
714            Some(&unsupported_snapshot),
715            Some(&fingerprint),
716        );
717        let missing_snapshot_error =
718            validate_cached_antigravity_cli_support(None, Some(&fingerprint))
719                .expect_err("a missing snapshot should fail closed");
720        let changed_executable_error = validate_cached_antigravity_cli_support(
721            Some(&supported_snapshot),
722            Some(&changed_fingerprint),
723        )
724        .expect_err("a changed executable should invalidate the snapshot");
725
726        // Assert
727        assert_eq!(supported_result, Ok(()));
728        assert_eq!(
729            unsupported_result,
730            Err("Run `agy update`, then retry.".to_string())
731        );
732        assert!(missing_snapshot_error.contains("has not been validated yet"));
733        assert!(changed_executable_error.contains("installation changed"));
734        assert!(changed_executable_error.contains("restart Agentty"));
735    }
736
737    #[test]
738    /// Ensures a missing Antigravity executable returns an actionable
739    /// installation error.
740    fn test_ensure_antigravity_cli_supported_on_path_rejects_missing_executable() {
741        // Arrange
742        let _cache_guard = antigravity_cache_test_guard();
743        let temp_directory = tempdir().expect("failed to create temp dir");
744        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
745
746        // Act
747        let error = ensure_antigravity_cli_supported_on_path(Some(path_value.as_os_str()))
748            .expect_err("missing Antigravity should be rejected");
749        let cached_error =
750            ensure_cached_antigravity_cli_supported_on_path(Some(path_value.as_os_str()))
751                .expect_err("cached missing Antigravity should remain rejected");
752
753        // Assert
754        assert!(error.contains("`agy` was not found on `PATH`"));
755        assert!(error.contains("Install it or run `agy update`"));
756        assert_eq!(cached_error, error);
757    }
758
759    #[test]
760    /// Ensures available CLI metadata includes parsed command versions.
761    fn test_available_agent_clis_from_path_includes_versions() {
762        // Arrange
763        let temp_directory = tempdir().expect("failed to create temp dir");
764        let codex_path = temp_directory.path().join("codex");
765        fs::write(&codex_path, "#!/bin/sh\nprintf 'codex-cli 1.2.3\\n'\n")
766            .expect("failed to create codex executable");
767        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
768            .expect("failed to mark codex executable");
769        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
770
771        // Act
772        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
773
774        // Assert
775        assert_eq!(
776            available_agent_clis,
777            vec![AgentCliInfo::new(
778                AgentKind::Codex,
779                Some("1.2.3".to_string())
780            )]
781        );
782    }
783
784    #[test]
785    /// Ensures the startup CLI refresh runs `update` before probing the
786    /// visible version.
787    fn test_available_agent_clis_from_path_updates_before_version_probe() {
788        // Arrange
789        let temp_directory = tempdir().expect("failed to create temp dir");
790        let codex_path = temp_directory.path().join("codex");
791        let version_path = temp_directory.path().join("codex-version");
792        let script = format!(
793            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
794             0; fi\nif [ \"$1\" = \"--version\" ]; then if [ -f \"{}\" ]; then read version < \
795             \"{}\"; else version='1.0.0-old'; fi; printf 'codex-cli %s\\n' \"$version\"; exit 0; \
796             fi\nexit 1\n",
797            version_path.display(),
798            version_path.display(),
799            version_path.display(),
800        );
801        fs::write(&codex_path, script).expect("failed to create codex executable");
802        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
803            .expect("failed to mark codex executable");
804        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
805
806        // Act
807        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
808
809        // Assert
810        assert_eq!(
811            available_agent_clis,
812            vec![AgentCliInfo::new(
813                AgentKind::Codex,
814                Some("9.9.9-updated".to_string())
815            )]
816        );
817        assert!(version_path.exists());
818    }
819
820    #[test]
821    /// Ensures CLI refreshes start independently so one slow provider does
822    /// not delay every following provider.
823    fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
824        // Arrange
825        let codex_started = Arc::new(AtomicBool::new(false));
826        let refresh_cli_version = {
827            let codex_started = Arc::clone(&codex_started);
828
829            move |_agent_kind: AgentKind, executable_path: &Path| {
830                if executable_path.file_name() == Some(OsStr::new("agy")) {
831                    let started_at = Instant::now();
832                    while !codex_started.load(Ordering::SeqCst)
833                        && started_at.elapsed() < Duration::from_millis(200)
834                    {
835                        std::thread::sleep(Duration::from_millis(1));
836                    }
837
838                    return if codex_started.load(Ordering::SeqCst) {
839                        Some("agy-concurrent".to_string())
840                    } else {
841                        Some("agy-sequential".to_string())
842                    };
843                }
844
845                if executable_path.file_name() == Some(OsStr::new("codex")) {
846                    codex_started.store(true, Ordering::SeqCst);
847
848                    return Some("codex-current".to_string());
849                }
850
851                None
852            }
853        };
854        let executable_agent_clis = vec![
855            (AgentKind::Antigravity, PathBuf::from("agy")),
856            (AgentKind::Codex, PathBuf::from("codex")),
857        ];
858
859        // Act
860        let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
861
862        // Assert
863        assert_eq!(
864            agent_clis,
865            vec![
866                AgentCliInfo::new(AgentKind::Antigravity, Some("agy-concurrent".to_string())),
867                AgentCliInfo::new(AgentKind::Codex, Some("codex-current".to_string())),
868            ]
869        );
870    }
871
872    #[test]
873    /// Ensures failed CLI updates do not prevent the post-update version
874    /// probe from refreshing the row.
875    fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
876        // Arrange
877        let temp_directory = tempdir().expect("failed to create temp dir");
878        let codex_path = temp_directory.path().join("codex");
879        fs::write(
880            &codex_path,
881            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 1; fi\nif [ \"$1\" = \"--version\" \
882             ]; then printf 'codex-cli 1.2.3\\n'; exit 0; fi\nexit 1\n",
883        )
884        .expect("failed to create codex executable");
885        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
886            .expect("failed to mark codex executable");
887
888        // Act
889        let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
890
891        // Assert
892        assert_eq!(detected_version, Some("1.2.3".to_string()));
893    }
894
895    #[test]
896    /// Ensures npm-global Gemini installations update through npm and expose
897    /// the refreshed version.
898    fn test_npm_global_gemini_update_refreshes_version() {
899        // Arrange
900        let temp_directory = tempdir().expect("failed to create temp dir");
901        let bin_directory = temp_directory.path().join("bin");
902        let gemini_package_directory = temp_directory
903            .path()
904            .join("lib/node_modules/@google/gemini-cli/bundle");
905        let gemini_package_path = gemini_package_directory.join("gemini.js");
906        let gemini_path = bin_directory.join("gemini");
907        let npm_path = bin_directory.join("npm");
908        let version_path = temp_directory.path().join("gemini-version");
909        fs::create_dir_all(&bin_directory).expect("failed to create bin directory");
910        fs::create_dir_all(&gemini_package_directory)
911            .expect("failed to create Gemini package directory");
912        fs::write(
913            &gemini_package_path,
914            format!(
915                "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 91; fi\nif [ \"$1\" = \
916                 \"--version\" ]; then if [ -f \"{}\" ]; then read version < \"{}\"; else \
917                 version='1.0.0-old'; fi; printf 'gemini %s\\n' \"$version\"; exit 0; fi\nexit 1\n",
918                version_path.display(),
919                version_path.display(),
920            ),
921        )
922        .expect("failed to create Gemini executable");
923        fs::write(
924            &npm_path,
925            format!(
926                "#!/bin/sh\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ] && [ \"$3\" = \
927                 \"@google/gemini-cli@latest\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
928                 0; fi\nexit 1\n",
929                version_path.display(),
930            ),
931        )
932        .expect("failed to create npm executable");
933        fs::set_permissions(&gemini_package_path, fs::Permissions::from_mode(0o755))
934            .expect("failed to mark Gemini executable");
935        fs::set_permissions(&npm_path, fs::Permissions::from_mode(0o755))
936            .expect("failed to mark npm executable");
937        symlink(&gemini_package_path, &gemini_path).expect("failed to link Gemini executable");
938        let path_value = env::join_paths([&bin_directory]).expect("valid path");
939
940        // Act
941        let did_update = run_agent_cli_update_with_timeout(
942            AgentKind::Gemini,
943            &gemini_path,
944            Some(path_value.as_os_str()),
945            Duration::from_secs(10),
946        );
947        let detected_version =
948            detect_agent_cli_version_with_timeout(&gemini_path, Duration::from_secs(10));
949
950        // Assert
951        assert!(did_update);
952        assert_eq!(detected_version, Some("9.9.9-updated".to_string()));
953        assert_eq!(
954            fs::read_to_string(version_path).expect("updated Gemini version"),
955            "9.9.9-updated\n"
956        );
957    }
958
959    #[test]
960    /// Ensures Gemini installations with an unknown owner do not launch the
961    /// removed native update command.
962    fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
963        // Arrange
964        let temp_directory = tempdir().expect("failed to create temp dir");
965        let gemini_path = temp_directory.path().join("gemini");
966        let update_marker_path = temp_directory.path().join("gemini-update");
967        fs::write(
968            &gemini_path,
969            format!(
970                "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then touch \"{}\"; exit 0; fi\nexit 1\n",
971                update_marker_path.display(),
972            ),
973        )
974        .expect("failed to create Gemini executable");
975        fs::set_permissions(&gemini_path, fs::Permissions::from_mode(0o755))
976            .expect("failed to mark Gemini executable");
977
978        // Act
979        let did_update = run_agent_cli_update_with_timeout(
980            AgentKind::Gemini,
981            &gemini_path,
982            None,
983            Duration::from_millis(100),
984        );
985
986        // Assert
987        assert!(!did_update);
988        assert!(!update_marker_path.exists());
989    }
990
991    #[test]
992    /// Ensures noisy CLI update commands cannot block on unread pipe buffers.
993    fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
994        // Arrange
995        let temp_directory = tempdir().expect("failed to create temp dir");
996        let codex_path = temp_directory.path().join("codex");
997        fs::write(
998            &codex_path,
999            "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then i=0; while [ \"$i\" -lt 4096 ]; do \
1000             printf \
1001             '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\n'; \
1002             printf \
1003             'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\\n' \
1004             >&2; i=$((i + 1)); done; exit 0; fi\nexit 1\n",
1005        )
1006        .expect("failed to create noisy codex executable");
1007        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
1008            .expect("failed to mark codex executable");
1009
1010        // Act
1011        let did_finish = run_agent_cli_update_with_timeout(
1012            AgentKind::Codex,
1013            &codex_path,
1014            None,
1015            Duration::from_secs(10),
1016        );
1017
1018        // Assert
1019        assert!(did_finish);
1020    }
1021
1022    #[test]
1023    /// Ensures unresponsive CLI version commands time out without returning a
1024    /// version.
1025    fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
1026        // Arrange
1027        let temp_directory = tempdir().expect("failed to create temp dir");
1028        let codex_path = temp_directory.path().join("codex");
1029        fs::write(&codex_path, "#!/bin/sh\nwhile :; do :; done\n")
1030            .expect("failed to create hanging codex executable");
1031        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
1032            .expect("failed to mark codex executable");
1033
1034        // Act
1035        let detected_version =
1036            detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
1037
1038        // Assert
1039        assert_eq!(detected_version, None);
1040    }
1041
1042    #[test]
1043    /// Ensures non-version text falls back to the first useful output line.
1044    fn test_parse_agent_cli_version_output_falls_back_to_line() {
1045        // Arrange
1046        let output = "Claude Code development build\n";
1047
1048        // Act
1049        let parsed_version = parse_agent_cli_version_output(output);
1050
1051        // Assert
1052        assert_eq!(
1053            parsed_version,
1054            Some("Claude Code development build".to_string())
1055        );
1056    }
1057
1058    #[test]
1059    /// Ensures probe discovery ignores non-executable files even when their
1060    /// names match supported agent CLIs.
1061    fn test_real_agent_availability_probe_ignores_non_executable_files() {
1062        // Arrange
1063        let temp_directory = tempdir().expect("failed to create temp dir");
1064        let codex_path = temp_directory.path().join("codex");
1065        fs::write(&codex_path, "").expect("failed to create codex file");
1066        fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
1067            .expect("failed to mark codex non-executable");
1068        let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
1069
1070        // Act
1071        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
1072
1073        // Assert
1074        assert_eq!(
1075            available_agent_kinds,
1076            [] as [crate::model::agent::AgentKind; 0]
1077        );
1078    }
1079}