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 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
566
567 assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
569 }
570
571 #[test]
572 fn test_available_agent_kinds_from_path_filters_old_antigravity() {
575 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
594
595 assert_eq!(available_agent_kinds, vec![AgentKind::Codex]);
597 }
598
599 #[test]
600 fn test_cached_antigravity_support_tracks_refreshed_executable() {
603 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 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_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 fn test_validate_antigravity_cli_version_accepts_supported_versions() {
646 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 fn test_validate_antigravity_cli_version_rejects_old_version() {
654 let error = validate_antigravity_cli_version(Some("1.1.6"))
656 .expect_err("old Antigravity should be rejected");
657
658 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 fn test_validate_antigravity_cli_version_rejects_unknown_versions() {
670 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!(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 fn test_validate_cached_antigravity_cli_support_requires_matching_fingerprint() {
687 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 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_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 fn test_ensure_antigravity_cli_supported_on_path_rejects_missing_executable() {
741 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 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!(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 fn test_available_agent_clis_from_path_includes_versions() {
762 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 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
773
774 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 fn test_available_agent_clis_from_path_updates_before_version_probe() {
788 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 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
808
809 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 fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
824 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 let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
861
862 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 fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
876 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 let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
890
891 assert_eq!(detected_version, Some("1.2.3".to_string()));
893 }
894
895 #[test]
896 fn test_npm_global_gemini_update_refreshes_version() {
899 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 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!(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 fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
963 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 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!(!did_update);
988 assert!(!update_marker_path.exists());
989 }
990
991 #[test]
992 fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
994 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 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!(did_finish);
1020 }
1021
1022 #[test]
1023 fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
1026 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 let detected_version =
1036 detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
1037
1038 assert_eq!(detected_version, None);
1040 }
1041
1042 #[test]
1043 fn test_parse_agent_cli_version_output_falls_back_to_line() {
1045 let output = "Claude Code development build\n";
1047
1048 let parsed_version = parse_agent_cli_version_output(output);
1050
1051 assert_eq!(
1053 parsed_version,
1054 Some("Claude Code development build".to_string())
1055 );
1056 }
1057
1058 #[test]
1059 fn test_real_agent_availability_probe_ignores_non_executable_files() {
1062 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 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
1072
1073 assert_eq!(
1075 available_agent_kinds,
1076 [] as [crate::model::agent::AgentKind; 0]
1077 );
1078 }
1079}