1use std::env;
4use std::ffi::OsStr;
5use std::os::unix::fs::PermissionsExt;
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Output, Stdio};
8use std::time::{Duration, Instant};
9
10use crate::model::agent::{AgentCliInfo, AgentKind};
11
12const AGENT_CLI_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
14const AGENT_CLI_UPDATE_TIMEOUT: Duration = Duration::from_mins(5);
16const AGENT_CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
18const GEMINI_NPM_PACKAGE_PATH: &str = "/lib/node_modules/@google/gemini-cli/";
20const GEMINI_NPM_PACKAGE_SPEC: &str = "@google/gemini-cli@latest";
22
23struct AgentCliUpdateCommand {
25 args: &'static [&'static str],
26 executable_path: PathBuf,
27}
28
29impl AgentCliUpdateCommand {
30 fn new(executable_path: PathBuf, args: &'static [&'static str]) -> Self {
32 Self {
33 args,
34 executable_path,
35 }
36 }
37}
38
39#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
41pub trait AgentAvailabilityProbe: Send + Sync {
42 fn available_agent_kinds(&self) -> Vec<AgentKind>;
44
45 fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
47 AgentCliInfo::from_kinds(&self.available_agent_kinds())
48 }
49}
50
51pub struct RealAgentAvailabilityProbe;
53
54impl AgentAvailabilityProbe for RealAgentAvailabilityProbe {
55 fn available_agent_kinds(&self) -> Vec<AgentKind> {
56 available_agent_kinds_from_path(env::var_os("PATH").as_deref())
57 }
58
59 fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
60 available_agent_clis_from_path(env::var_os("PATH").as_deref())
61 }
62}
63
64pub struct StaticAgentAvailabilityProbe {
66 pub available_agent_kinds: Vec<AgentKind>,
68}
69
70impl AgentAvailabilityProbe for StaticAgentAvailabilityProbe {
71 fn available_agent_kinds(&self) -> Vec<AgentKind> {
72 self.available_agent_kinds.clone()
73 }
74}
75
76#[must_use]
78pub fn executable_name(agent_kind: AgentKind) -> &'static str {
79 agent_kind.executable_name()
80}
81
82fn available_agent_clis_from_path(path_value: Option<&OsStr>) -> Vec<AgentCliInfo> {
84 let executable_agent_clis = AgentKind::ALL
85 .iter()
86 .copied()
87 .filter_map(|agent_kind| {
88 let executable_path = executable_path_on_path(path_value, executable_name(agent_kind))?;
89
90 Some((agent_kind, executable_path))
91 })
92 .collect();
93
94 refresh_agent_cli_versions(executable_agent_clis, |agent_kind, executable_path| {
95 refresh_agent_cli_version(agent_kind, executable_path, path_value)
96 })
97}
98
99fn available_agent_kinds_from_path(path_value: Option<&OsStr>) -> Vec<AgentKind> {
101 AgentKind::ALL
102 .iter()
103 .copied()
104 .filter(|agent_kind| {
105 executable_path_on_path(path_value, executable_name(*agent_kind)).is_some()
106 })
107 .collect()
108}
109
110fn executable_path_on_path(path_value: Option<&OsStr>, executable_name: &str) -> Option<PathBuf> {
112 path_value
113 .map(env::split_paths)
114 .into_iter()
115 .flatten()
116 .map(|path_entry| candidate_path_for_executable_name(&path_entry, executable_name))
117 .find(|candidate_path| is_executable_file(candidate_path))
118}
119
120fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
123 path_entry.join(executable_name)
124}
125
126fn is_executable_file(candidate_path: &Path) -> bool {
129 let Ok(metadata) = candidate_path.metadata() else {
130 return false;
131 };
132
133 if !metadata.is_file() {
134 return false;
135 }
136
137 metadata.permissions().mode() & 0o111 != 0
138}
139
140fn refresh_agent_cli_version(
143 agent_kind: AgentKind,
144 executable_path: &Path,
145 path_value: Option<&OsStr>,
146) -> Option<String> {
147 run_agent_cli_update(agent_kind, executable_path, path_value);
148
149 detect_agent_cli_version(executable_path)
150}
151
152fn refresh_agent_cli_versions(
155 executable_agent_clis: Vec<(AgentKind, PathBuf)>,
156 refresh_cli_version: impl Fn(AgentKind, &Path) -> Option<String> + Sync,
157) -> Vec<AgentCliInfo> {
158 std::thread::scope(|scope| {
159 let refresh_cli_version = &refresh_cli_version;
160 let refresh_handles = executable_agent_clis
161 .into_iter()
162 .map(|(agent_kind, executable_path)| {
163 (
164 agent_kind,
165 scope.spawn(move || refresh_cli_version(agent_kind, &executable_path)),
166 )
167 })
168 .collect::<Vec<_>>();
169
170 refresh_handles
171 .into_iter()
172 .map(|(agent_kind, refresh_handle)| {
173 AgentCliInfo::new(agent_kind, refresh_handle.join().unwrap_or(None))
174 })
175 .collect()
176 })
177}
178
179fn run_agent_cli_update(agent_kind: AgentKind, executable_path: &Path, path_value: Option<&OsStr>) {
181 let _ = run_agent_cli_update_with_timeout(
182 agent_kind,
183 executable_path,
184 path_value,
185 AGENT_CLI_UPDATE_TIMEOUT,
186 );
187}
188
189fn run_agent_cli_update_with_timeout(
191 agent_kind: AgentKind,
192 executable_path: &Path,
193 path_value: Option<&OsStr>,
194 timeout: Duration,
195) -> bool {
196 let Some(update_command) = agent_cli_update_command(agent_kind, executable_path, path_value)
197 else {
198 return false;
199 };
200
201 command_status_with_timeout(&update_command, timeout).is_some()
202}
203
204fn agent_cli_update_command(
206 agent_kind: AgentKind,
207 executable_path: &Path,
208 path_value: Option<&OsStr>,
209) -> Option<AgentCliUpdateCommand> {
210 if agent_kind == AgentKind::Gemini {
211 return gemini_npm_update_command(executable_path, path_value);
212 }
213
214 Some(AgentCliUpdateCommand::new(
215 executable_path.to_path_buf(),
216 &["update"],
217 ))
218}
219
220fn gemini_npm_update_command(
226 executable_path: &Path,
227 path_value: Option<&OsStr>,
228) -> Option<AgentCliUpdateCommand> {
229 let canonical_executable_path = executable_path.canonicalize().ok()?;
230 let normalized_executable_path = canonical_executable_path.to_string_lossy();
231 if !normalized_executable_path.contains(GEMINI_NPM_PACKAGE_PATH) {
232 return None;
233 }
234
235 let npm_executable_path = executable_path_on_path(path_value, "npm")?;
236
237 Some(AgentCliUpdateCommand::new(
238 npm_executable_path,
239 &["install", "-g", GEMINI_NPM_PACKAGE_SPEC],
240 ))
241}
242
243fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
246 detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
247}
248
249fn detect_agent_cli_version_with_timeout(
251 executable_path: &Path,
252 timeout: Duration,
253) -> Option<String> {
254 let output = version_command_output(executable_path, timeout)?;
255 if !output.status.success() {
256 return None;
257 }
258
259 let stdout_text = String::from_utf8_lossy(&output.stdout);
260 let stderr_text = String::from_utf8_lossy(&output.stderr);
261 parse_agent_cli_version_output(&stdout_text)
262 .or_else(|| parse_agent_cli_version_output(&stderr_text))
263}
264
265fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
268 command_output_with_timeout(executable_path, &["--version"], timeout)
269}
270
271fn command_status_with_timeout(
274 update_command: &AgentCliUpdateCommand,
275 timeout: Duration,
276) -> Option<()> {
277 let mut child = Command::new(&update_command.executable_path)
278 .args(update_command.args)
279 .stdin(Stdio::null())
280 .stdout(Stdio::null())
281 .stderr(Stdio::null())
282 .spawn()
283 .ok()?;
284 wait_for_child_exit(&mut child, timeout)?;
285 let _ = child.wait().ok()?;
286
287 Some(())
288}
289
290fn command_output_with_timeout(
292 executable_path: &Path,
293 args: &[&str],
294 timeout: Duration,
295) -> Option<Output> {
296 let mut child = Command::new(executable_path)
297 .args(args)
298 .stdin(Stdio::null())
299 .stdout(Stdio::piped())
300 .stderr(Stdio::piped())
301 .spawn()
302 .ok()?;
303 wait_for_child_exit(&mut child, timeout)?;
304
305 child.wait_with_output().ok()
306}
307
308fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Option<()> {
310 let started_at = Instant::now();
311
312 loop {
313 if child.try_wait().ok()?.is_some() {
314 return Some(());
315 }
316
317 if started_at.elapsed() >= timeout {
318 let _ = child.kill();
319 let _ = child.wait();
320
321 return None;
322 }
323
324 std::thread::sleep(
325 AGENT_CLI_COMMAND_POLL_INTERVAL.min(timeout.saturating_sub(started_at.elapsed())),
326 );
327 }
328}
329
330fn parse_agent_cli_version_output(output: &str) -> Option<String> {
333 let line = output
334 .lines()
335 .map(str::trim)
336 .find(|line| !line.is_empty())?;
337 let version_token = line
338 .split_whitespace()
339 .map(|token| {
340 token.trim_matches(|character: char| {
341 matches!(character, ',' | ';' | ':' | '(' | ')' | '[' | ']')
342 })
343 })
344 .find(|token| {
345 let normalized = token.strip_prefix('v').unwrap_or(token);
346
347 normalized
348 .chars()
349 .next()
350 .is_some_and(|character| character.is_ascii_digit())
351 && normalized.contains('.')
352 });
353
354 Some(version_token.unwrap_or(line).to_string())
355}
356
357#[cfg(test)]
358mod tests {
359 use std::fs;
360 use std::os::unix::fs::{PermissionsExt, symlink};
361 use std::sync::Arc;
362 use std::sync::atomic::{AtomicBool, Ordering};
363
364 use tempfile::tempdir;
365
366 use super::*;
367
368 #[test]
369 fn test_executable_name_matches_agent_cli_names() {
371 assert_eq!(executable_name(AgentKind::Antigravity), "agy");
373 assert_eq!(executable_name(AgentKind::Claude), "claude");
374 assert_eq!(executable_name(AgentKind::Codex), "codex");
375 assert_eq!(executable_name(AgentKind::Gemini), "gemini");
376 }
377
378 #[test]
379 fn test_real_agent_availability_probe_filters_missing_executables() {
382 let temp_directory = tempdir().expect("failed to create temp dir");
384 let antigravity_path = temp_directory.path().join("agy");
385 let codex_path = temp_directory.path().join("codex");
386 fs::write(&antigravity_path, "").expect("failed to create agy executable");
387 fs::write(&codex_path, "").expect("failed to create codex executable");
388 fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
389 .expect("failed to mark agy executable");
390 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
391 .expect("failed to mark codex executable");
392 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
393
394 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
396
397 assert_eq!(
399 available_agent_kinds,
400 vec![AgentKind::Antigravity, AgentKind::Codex]
401 );
402 }
403
404 #[test]
405 fn test_available_agent_clis_from_path_includes_versions() {
407 let temp_directory = tempdir().expect("failed to create temp dir");
409 let codex_path = temp_directory.path().join("codex");
410 fs::write(&codex_path, "#!/bin/sh\nprintf 'codex-cli 1.2.3\\n'\n")
411 .expect("failed to create codex executable");
412 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
413 .expect("failed to mark codex executable");
414 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
415
416 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
418
419 assert_eq!(
421 available_agent_clis,
422 vec![AgentCliInfo::new(
423 AgentKind::Codex,
424 Some("1.2.3".to_string())
425 )]
426 );
427 }
428
429 #[test]
430 fn test_available_agent_clis_from_path_updates_before_version_probe() {
433 let temp_directory = tempdir().expect("failed to create temp dir");
435 let codex_path = temp_directory.path().join("codex");
436 let version_path = temp_directory.path().join("codex-version");
437 let script = format!(
438 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
439 0; fi\nif [ \"$1\" = \"--version\" ]; then if [ -f \"{}\" ]; then read version < \
440 \"{}\"; else version='1.0.0-old'; fi; printf 'codex-cli %s\\n' \"$version\"; exit 0; \
441 fi\nexit 1\n",
442 version_path.display(),
443 version_path.display(),
444 version_path.display(),
445 );
446 fs::write(&codex_path, script).expect("failed to create codex executable");
447 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
448 .expect("failed to mark codex executable");
449 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
450
451 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
453
454 assert_eq!(
456 available_agent_clis,
457 vec![AgentCliInfo::new(
458 AgentKind::Codex,
459 Some("9.9.9-updated".to_string())
460 )]
461 );
462 assert!(version_path.exists());
463 }
464
465 #[test]
466 fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
469 let codex_started = Arc::new(AtomicBool::new(false));
471 let refresh_cli_version = {
472 let codex_started = Arc::clone(&codex_started);
473
474 move |_agent_kind: AgentKind, executable_path: &Path| {
475 if executable_path.file_name() == Some(OsStr::new("agy")) {
476 let started_at = Instant::now();
477 while !codex_started.load(Ordering::SeqCst)
478 && started_at.elapsed() < Duration::from_millis(200)
479 {
480 std::thread::sleep(Duration::from_millis(1));
481 }
482
483 return if codex_started.load(Ordering::SeqCst) {
484 Some("agy-concurrent".to_string())
485 } else {
486 Some("agy-sequential".to_string())
487 };
488 }
489
490 if executable_path.file_name() == Some(OsStr::new("codex")) {
491 codex_started.store(true, Ordering::SeqCst);
492
493 return Some("codex-current".to_string());
494 }
495
496 None
497 }
498 };
499 let executable_agent_clis = vec![
500 (AgentKind::Antigravity, PathBuf::from("agy")),
501 (AgentKind::Codex, PathBuf::from("codex")),
502 ];
503
504 let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
506
507 assert_eq!(
509 agent_clis,
510 vec![
511 AgentCliInfo::new(AgentKind::Antigravity, Some("agy-concurrent".to_string())),
512 AgentCliInfo::new(AgentKind::Codex, Some("codex-current".to_string())),
513 ]
514 );
515 }
516
517 #[test]
518 fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
521 let temp_directory = tempdir().expect("failed to create temp dir");
523 let codex_path = temp_directory.path().join("codex");
524 fs::write(
525 &codex_path,
526 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 1; fi\nif [ \"$1\" = \"--version\" \
527 ]; then printf 'codex-cli 1.2.3\\n'; exit 0; fi\nexit 1\n",
528 )
529 .expect("failed to create codex executable");
530 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
531 .expect("failed to mark codex executable");
532
533 let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
535
536 assert_eq!(detected_version, Some("1.2.3".to_string()));
538 }
539
540 #[test]
541 fn test_available_agent_clis_from_path_updates_npm_global_gemini() {
544 let temp_directory = tempdir().expect("failed to create temp dir");
546 let bin_directory = temp_directory.path().join("bin");
547 let gemini_package_directory = temp_directory
548 .path()
549 .join("lib/node_modules/@google/gemini-cli/bundle");
550 let gemini_package_path = gemini_package_directory.join("gemini.js");
551 let gemini_path = bin_directory.join("gemini");
552 let npm_path = bin_directory.join("npm");
553 let version_path = temp_directory.path().join("gemini-version");
554 fs::create_dir_all(&bin_directory).expect("failed to create bin directory");
555 fs::create_dir_all(&gemini_package_directory)
556 .expect("failed to create Gemini package directory");
557 fs::write(
558 &gemini_package_path,
559 format!(
560 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 91; fi\nif [ \"$1\" = \
561 \"--version\" ]; then if [ -f \"{}\" ]; then read version < \"{}\"; else \
562 version='1.0.0-old'; fi; printf 'gemini %s\\n' \"$version\"; exit 0; fi\nexit 1\n",
563 version_path.display(),
564 version_path.display(),
565 ),
566 )
567 .expect("failed to create Gemini executable");
568 fs::write(
569 &npm_path,
570 format!(
571 "#!/bin/sh\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ] && [ \"$3\" = \
572 \"@google/gemini-cli@latest\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
573 0; fi\nexit 1\n",
574 version_path.display(),
575 ),
576 )
577 .expect("failed to create npm executable");
578 fs::set_permissions(&gemini_package_path, fs::Permissions::from_mode(0o755))
579 .expect("failed to mark Gemini executable");
580 fs::set_permissions(&npm_path, fs::Permissions::from_mode(0o755))
581 .expect("failed to mark npm executable");
582 symlink(&gemini_package_path, &gemini_path).expect("failed to link Gemini executable");
583 let path_value = env::join_paths([&bin_directory]).expect("valid path");
584
585 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
587
588 assert_eq!(
590 available_agent_clis,
591 vec![AgentCliInfo::new(
592 AgentKind::Gemini,
593 Some("9.9.9-updated".to_string())
594 )]
595 );
596 assert_eq!(
597 fs::read_to_string(version_path).expect("updated Gemini version"),
598 "9.9.9-updated\n"
599 );
600 }
601
602 #[test]
603 fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
606 let temp_directory = tempdir().expect("failed to create temp dir");
608 let gemini_path = temp_directory.path().join("gemini");
609 let update_marker_path = temp_directory.path().join("gemini-update");
610 fs::write(
611 &gemini_path,
612 format!(
613 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then touch \"{}\"; exit 0; fi\nexit 1\n",
614 update_marker_path.display(),
615 ),
616 )
617 .expect("failed to create Gemini executable");
618 fs::set_permissions(&gemini_path, fs::Permissions::from_mode(0o755))
619 .expect("failed to mark Gemini executable");
620
621 let did_update = run_agent_cli_update_with_timeout(
623 AgentKind::Gemini,
624 &gemini_path,
625 None,
626 Duration::from_millis(100),
627 );
628
629 assert!(!did_update);
631 assert!(!update_marker_path.exists());
632 }
633
634 #[test]
635 fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
637 let temp_directory = tempdir().expect("failed to create temp dir");
639 let codex_path = temp_directory.path().join("codex");
640 fs::write(
641 &codex_path,
642 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then i=0; while [ \"$i\" -lt 4096 ]; do \
643 printf \
644 '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\n'; \
645 printf \
646 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\\n' \
647 >&2; i=$((i + 1)); done; exit 0; fi\nexit 1\n",
648 )
649 .expect("failed to create noisy codex executable");
650 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
651 .expect("failed to mark codex executable");
652
653 let did_finish = run_agent_cli_update_with_timeout(
655 AgentKind::Codex,
656 &codex_path,
657 None,
658 Duration::from_secs(10),
659 );
660
661 assert!(did_finish);
663 }
664
665 #[test]
666 fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
669 let temp_directory = tempdir().expect("failed to create temp dir");
671 let codex_path = temp_directory.path().join("codex");
672 fs::write(&codex_path, "#!/bin/sh\nwhile :; do :; done\n")
673 .expect("failed to create hanging codex executable");
674 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
675 .expect("failed to mark codex executable");
676
677 let detected_version =
679 detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
680
681 assert_eq!(detected_version, None);
683 }
684
685 #[test]
686 fn test_parse_agent_cli_version_output_falls_back_to_line() {
688 let output = "Claude Code development build\n";
690
691 let parsed_version = parse_agent_cli_version_output(output);
693
694 assert_eq!(
696 parsed_version,
697 Some("Claude Code development build".to_string())
698 );
699 }
700
701 #[test]
702 fn test_real_agent_availability_probe_ignores_non_executable_files() {
705 let temp_directory = tempdir().expect("failed to create temp dir");
707 let codex_path = temp_directory.path().join("codex");
708 fs::write(&codex_path, "").expect("failed to create codex file");
709 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
710 .expect("failed to mark codex non-executable");
711 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
712
713 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
715
716 assert!(available_agent_kinds.is_empty());
718 }
719}