1use 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
15const ANTIGRAVITY_MINIMUM_VERSION: Version = Version::new(1, 1, 7);
18const AGENT_CLI_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
20const AGENT_CLI_UPDATE_TIMEOUT: Duration = Duration::from_mins(5);
22const AGENT_CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
24const GEMINI_NPM_PACKAGE_PATH: &str = "/lib/node_modules/@google/gemini-cli/";
26const GEMINI_NPM_PACKAGE_SPEC: &str = "@google/gemini-cli@latest";
28
29#[derive(Clone)]
31struct AntigravityCompatibilitySnapshot {
32 fingerprint: Option<AntigravityExecutableFingerprint>,
33 result: Result<(), String>,
34}
35
36#[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
48static ANTIGRAVITY_COMPATIBILITY: OnceLock<Mutex<Option<AntigravityCompatibilitySnapshot>>> =
51 OnceLock::new();
52
53struct AgentCliUpdateCommand {
55 args: &'static [&'static str],
56 executable_path: PathBuf,
57}
58
59impl AgentCliUpdateCommand {
60 fn new(executable_path: PathBuf, args: &'static [&'static str]) -> Self {
62 Self {
63 args,
64 executable_path,
65 }
66 }
67}
68
69#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
71pub trait AgentAvailabilityProbe: Send + Sync {
72 fn available_agent_kinds(&self) -> Vec<AgentKind>;
74
75 fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
77 AgentCliInfo::from_kinds(&self.available_agent_kinds())
78 }
79}
80
81pub 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
94pub struct StaticAgentAvailabilityProbe {
96 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#[must_use]
108pub fn executable_name(agent_kind: AgentKind) -> &'static str {
109 agent_kind.executable_name()
110}
111
112fn 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
129fn 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
144fn 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
166pub(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
184fn 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
207fn 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
221fn 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
238fn 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
266fn 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
276fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
279 path_entry.join(executable_name)
280}
281
282fn 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
296fn 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
314fn 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
341fn 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
351fn 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
366fn 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
382fn 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
405fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
408 detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
409}
410
411fn 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
427fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
430 command_output_with_timeout(executable_path, &["--version"], timeout)
431}
432
433fn 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
452fn 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
470fn 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
492fn 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 static ANTIGRAVITY_CACHE_TEST_LOCK: Mutex<()> = Mutex::new(());
533
534 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 fn test_executable_name_matches_agent_cli_names() {
545 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 fn test_real_agent_availability_probe_filters_missing_executables() {
556 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
575
576 assert_eq!(
578 available_agent_kinds,
579 vec![AgentKind::Antigravity, AgentKind::Codex]
580 );
581 }
582
583 #[test]
584 fn test_available_agent_kinds_from_path_filters_old_antigravity() {
587 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
606
607 assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
609 }
610
611 #[test]
612 fn test_cached_antigravity_support_tracks_refreshed_executable() {
615 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 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_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 fn test_validate_antigravity_cli_version_accepts_supported_versions() {
658 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 fn test_validate_antigravity_cli_version_rejects_old_version() {
666 let error = validate_antigravity_cli_version(Some("1.1.6"))
668 .expect_err("old Antigravity should be rejected");
669
670 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 fn test_validate_antigravity_cli_version_rejects_unknown_versions() {
682 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!(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 fn test_validate_cached_antigravity_cli_support_requires_matching_fingerprint() {
699 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 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_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 fn test_ensure_antigravity_cli_supported_on_path_rejects_missing_executable() {
753 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 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!(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 fn test_available_agent_clis_from_path_includes_versions() {
774 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 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
785
786 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 fn test_available_agent_clis_from_path_updates_before_version_probe() {
800 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 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
820
821 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 fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
836 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 let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
873
874 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 fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
888 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 let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
902
903 assert_eq!(detected_version, Some("1.2.3".to_string()));
905 }
906
907 #[test]
908 fn test_available_agent_clis_from_path_updates_npm_global_gemini() {
911 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 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
954
955 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 fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
973 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 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!(!did_update);
998 assert!(!update_marker_path.exists());
999 }
1000
1001 #[test]
1002 fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
1004 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 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!(did_finish);
1030 }
1031
1032 #[test]
1033 fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
1036 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 let detected_version =
1046 detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
1047
1048 assert_eq!(detected_version, None);
1050 }
1051
1052 #[test]
1053 fn test_parse_agent_cli_version_output_falls_back_to_line() {
1055 let output = "Claude Code development build\n";
1057
1058 let parsed_version = parse_agent_cli_version_output(output);
1060
1061 assert_eq!(
1063 parsed_version,
1064 Some("Claude Code development build".to_string())
1065 );
1066 }
1067
1068 #[test]
1069 fn test_real_agent_availability_probe_ignores_non_executable_files() {
1072 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
1082
1083 assert!(available_agent_kinds.is_empty());
1085 }
1086}