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);
18
19#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
21pub trait AgentAvailabilityProbe: Send + Sync {
22 fn available_agent_kinds(&self) -> Vec<AgentKind>;
24
25 fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
27 AgentCliInfo::from_kinds(&self.available_agent_kinds())
28 }
29}
30
31pub struct RealAgentAvailabilityProbe;
33
34impl AgentAvailabilityProbe for RealAgentAvailabilityProbe {
35 fn available_agent_kinds(&self) -> Vec<AgentKind> {
36 available_agent_kinds_from_path(env::var_os("PATH").as_deref())
37 }
38
39 fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
40 available_agent_clis_from_path(env::var_os("PATH").as_deref())
41 }
42}
43
44pub struct StaticAgentAvailabilityProbe {
46 pub available_agent_kinds: Vec<AgentKind>,
48}
49
50impl AgentAvailabilityProbe for StaticAgentAvailabilityProbe {
51 fn available_agent_kinds(&self) -> Vec<AgentKind> {
52 self.available_agent_kinds.clone()
53 }
54}
55
56#[must_use]
58pub fn executable_name(agent_kind: AgentKind) -> &'static str {
59 agent_kind.executable_name()
60}
61
62fn available_agent_clis_from_path(path_value: Option<&OsStr>) -> Vec<AgentCliInfo> {
64 let executable_agent_clis = AgentKind::ALL
65 .iter()
66 .copied()
67 .filter_map(|agent_kind| {
68 let executable_path = executable_path_on_path(path_value, executable_name(agent_kind))?;
69
70 Some((agent_kind, executable_path))
71 })
72 .collect();
73
74 refresh_agent_cli_versions(executable_agent_clis, refresh_agent_cli_version)
75}
76
77fn available_agent_kinds_from_path(path_value: Option<&OsStr>) -> Vec<AgentKind> {
79 AgentKind::ALL
80 .iter()
81 .copied()
82 .filter(|agent_kind| {
83 executable_path_on_path(path_value, executable_name(*agent_kind)).is_some()
84 })
85 .collect()
86}
87
88fn executable_path_on_path(path_value: Option<&OsStr>, executable_name: &str) -> Option<PathBuf> {
90 path_value
91 .map(env::split_paths)
92 .into_iter()
93 .flatten()
94 .map(|path_entry| candidate_path_for_executable_name(&path_entry, executable_name))
95 .find(|candidate_path| is_executable_file(candidate_path))
96}
97
98fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
101 path_entry.join(executable_name)
102}
103
104fn is_executable_file(candidate_path: &Path) -> bool {
107 let Ok(metadata) = candidate_path.metadata() else {
108 return false;
109 };
110
111 if !metadata.is_file() {
112 return false;
113 }
114
115 metadata.permissions().mode() & 0o111 != 0
116}
117
118fn refresh_agent_cli_version(executable_path: &Path) -> Option<String> {
121 run_agent_cli_update(executable_path);
122
123 detect_agent_cli_version(executable_path)
124}
125
126fn refresh_agent_cli_versions(
129 executable_agent_clis: Vec<(AgentKind, PathBuf)>,
130 refresh_cli_version: impl Fn(&Path) -> Option<String> + Sync,
131) -> Vec<AgentCliInfo> {
132 std::thread::scope(|scope| {
133 let refresh_cli_version = &refresh_cli_version;
134 let refresh_handles = executable_agent_clis
135 .into_iter()
136 .map(|(agent_kind, executable_path)| {
137 (
138 agent_kind,
139 scope.spawn(move || refresh_cli_version(&executable_path)),
140 )
141 })
142 .collect::<Vec<_>>();
143
144 refresh_handles
145 .into_iter()
146 .map(|(agent_kind, refresh_handle)| {
147 AgentCliInfo::new(agent_kind, refresh_handle.join().unwrap_or(None))
148 })
149 .collect()
150 })
151}
152
153fn run_agent_cli_update(executable_path: &Path) {
155 let _ = run_agent_cli_update_with_timeout(executable_path, AGENT_CLI_UPDATE_TIMEOUT);
156}
157
158fn run_agent_cli_update_with_timeout(executable_path: &Path, timeout: Duration) -> bool {
161 command_status_with_timeout(executable_path, &["update"], timeout).is_some()
162}
163
164fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
167 detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
168}
169
170fn detect_agent_cli_version_with_timeout(
172 executable_path: &Path,
173 timeout: Duration,
174) -> Option<String> {
175 let output = version_command_output(executable_path, timeout)?;
176 if !output.status.success() {
177 return None;
178 }
179
180 let stdout_text = String::from_utf8_lossy(&output.stdout);
181 let stderr_text = String::from_utf8_lossy(&output.stderr);
182 parse_agent_cli_version_output(&stdout_text)
183 .or_else(|| parse_agent_cli_version_output(&stderr_text))
184}
185
186fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
189 command_output_with_timeout(executable_path, &["--version"], timeout)
190}
191
192fn command_status_with_timeout(
195 executable_path: &Path,
196 args: &[&str],
197 timeout: Duration,
198) -> Option<()> {
199 let mut child = Command::new(executable_path)
200 .args(args)
201 .stdin(Stdio::null())
202 .stdout(Stdio::null())
203 .stderr(Stdio::null())
204 .spawn()
205 .ok()?;
206 wait_for_child_exit(&mut child, timeout)?;
207 let _ = child.wait().ok()?;
208
209 Some(())
210}
211
212fn command_output_with_timeout(
214 executable_path: &Path,
215 args: &[&str],
216 timeout: Duration,
217) -> Option<Output> {
218 let mut child = Command::new(executable_path)
219 .args(args)
220 .stdin(Stdio::null())
221 .stdout(Stdio::piped())
222 .stderr(Stdio::piped())
223 .spawn()
224 .ok()?;
225 wait_for_child_exit(&mut child, timeout)?;
226
227 child.wait_with_output().ok()
228}
229
230fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Option<()> {
232 let started_at = Instant::now();
233
234 loop {
235 if child.try_wait().ok()?.is_some() {
236 return Some(());
237 }
238
239 if started_at.elapsed() >= timeout {
240 let _ = child.kill();
241 let _ = child.wait();
242
243 return None;
244 }
245
246 std::thread::sleep(
247 AGENT_CLI_COMMAND_POLL_INTERVAL.min(timeout.saturating_sub(started_at.elapsed())),
248 );
249 }
250}
251
252fn parse_agent_cli_version_output(output: &str) -> Option<String> {
255 let line = output
256 .lines()
257 .map(str::trim)
258 .find(|line| !line.is_empty())?;
259 let version_token = line
260 .split_whitespace()
261 .map(|token| {
262 token.trim_matches(|character: char| {
263 matches!(character, ',' | ';' | ':' | '(' | ')' | '[' | ']')
264 })
265 })
266 .find(|token| {
267 let normalized = token.strip_prefix('v').unwrap_or(token);
268
269 normalized
270 .chars()
271 .next()
272 .is_some_and(|character| character.is_ascii_digit())
273 && normalized.contains('.')
274 });
275
276 Some(version_token.unwrap_or(line).to_string())
277}
278
279#[cfg(test)]
280mod tests {
281 use std::fs;
282 use std::os::unix::fs::PermissionsExt;
283 use std::sync::Arc;
284 use std::sync::atomic::{AtomicBool, Ordering};
285
286 use tempfile::tempdir;
287
288 use super::*;
289
290 #[test]
291 fn test_executable_name_matches_agent_cli_names() {
293 assert_eq!(executable_name(AgentKind::Antigravity), "agy");
295 assert_eq!(executable_name(AgentKind::Claude), "claude");
296 assert_eq!(executable_name(AgentKind::Codex), "codex");
297 }
298
299 #[test]
300 fn test_real_agent_availability_probe_filters_missing_executables() {
303 let temp_directory = tempdir().expect("failed to create temp dir");
305 let antigravity_path = temp_directory.path().join("agy");
306 let codex_path = temp_directory.path().join("codex");
307 fs::write(&antigravity_path, "").expect("failed to create agy executable");
308 fs::write(&codex_path, "").expect("failed to create codex executable");
309 fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
310 .expect("failed to mark agy executable");
311 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
312 .expect("failed to mark codex executable");
313 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
314
315 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
317
318 assert_eq!(
320 available_agent_kinds,
321 vec![AgentKind::Antigravity, AgentKind::Codex]
322 );
323 }
324
325 #[test]
326 fn test_available_agent_clis_from_path_includes_versions() {
328 let temp_directory = tempdir().expect("failed to create temp dir");
330 let codex_path = temp_directory.path().join("codex");
331 fs::write(&codex_path, "#!/bin/sh\nprintf 'codex-cli 1.2.3\\n'\n")
332 .expect("failed to create codex executable");
333 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
334 .expect("failed to mark codex executable");
335 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
336
337 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
339
340 assert_eq!(
342 available_agent_clis,
343 vec![AgentCliInfo::new(
344 AgentKind::Codex,
345 Some("1.2.3".to_string())
346 )]
347 );
348 }
349
350 #[test]
351 fn test_available_agent_clis_from_path_updates_before_version_probe() {
354 let temp_directory = tempdir().expect("failed to create temp dir");
356 let codex_path = temp_directory.path().join("codex");
357 let version_path = temp_directory.path().join("codex-version");
358 let script = format!(
359 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then printf '9.9.9-updated\\n' > \"{}\"; exit \
360 0; fi\nif [ \"$1\" = \"--version\" ]; then if [ -f \"{}\" ]; then read version < \
361 \"{}\"; else version='1.0.0-old'; fi; printf 'codex-cli %s\\n' \"$version\"; exit 0; \
362 fi\nexit 1\n",
363 version_path.display(),
364 version_path.display(),
365 version_path.display(),
366 );
367 fs::write(&codex_path, script).expect("failed to create codex executable");
368 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
369 .expect("failed to mark codex executable");
370 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
371
372 let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
374
375 assert_eq!(
377 available_agent_clis,
378 vec![AgentCliInfo::new(
379 AgentKind::Codex,
380 Some("9.9.9-updated".to_string())
381 )]
382 );
383 assert!(version_path.exists());
384 }
385
386 #[test]
387 fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
390 let codex_started = Arc::new(AtomicBool::new(false));
392 let refresh_cli_version = {
393 let codex_started = Arc::clone(&codex_started);
394
395 move |executable_path: &Path| {
396 if executable_path.file_name() == Some(OsStr::new("agy")) {
397 let started_at = Instant::now();
398 while !codex_started.load(Ordering::SeqCst)
399 && started_at.elapsed() < Duration::from_millis(200)
400 {
401 std::thread::sleep(Duration::from_millis(1));
402 }
403
404 return if codex_started.load(Ordering::SeqCst) {
405 Some("agy-concurrent".to_string())
406 } else {
407 Some("agy-sequential".to_string())
408 };
409 }
410
411 if executable_path.file_name() == Some(OsStr::new("codex")) {
412 codex_started.store(true, Ordering::SeqCst);
413
414 return Some("codex-current".to_string());
415 }
416
417 None
418 }
419 };
420 let executable_agent_clis = vec![
421 (AgentKind::Antigravity, PathBuf::from("agy")),
422 (AgentKind::Codex, PathBuf::from("codex")),
423 ];
424
425 let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
427
428 assert_eq!(
430 agent_clis,
431 vec![
432 AgentCliInfo::new(AgentKind::Antigravity, Some("agy-concurrent".to_string())),
433 AgentCliInfo::new(AgentKind::Codex, Some("codex-current".to_string())),
434 ]
435 );
436 }
437
438 #[test]
439 fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
442 let temp_directory = tempdir().expect("failed to create temp dir");
444 let codex_path = temp_directory.path().join("codex");
445 fs::write(
446 &codex_path,
447 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then exit 1; fi\nif [ \"$1\" = \"--version\" \
448 ]; then printf 'codex-cli 1.2.3\\n'; exit 0; fi\nexit 1\n",
449 )
450 .expect("failed to create codex executable");
451 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
452 .expect("failed to mark codex executable");
453
454 let detected_version = refresh_agent_cli_version(&codex_path);
456
457 assert_eq!(detected_version, Some("1.2.3".to_string()));
459 }
460
461 #[test]
462 fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
464 let temp_directory = tempdir().expect("failed to create temp dir");
466 let codex_path = temp_directory.path().join("codex");
467 fs::write(
468 &codex_path,
469 "#!/bin/sh\nif [ \"$1\" = \"update\" ]; then i=0; while [ \"$i\" -lt 4096 ]; do \
470 printf \
471 '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\n'; \
472 printf \
473 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\\n' \
474 >&2; i=$((i + 1)); done; exit 0; fi\nexit 1\n",
475 )
476 .expect("failed to create noisy codex executable");
477 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
478 .expect("failed to mark codex executable");
479
480 let did_finish = run_agent_cli_update_with_timeout(&codex_path, Duration::from_secs(2));
482
483 assert!(did_finish);
485 }
486
487 #[test]
488 fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
491 let temp_directory = tempdir().expect("failed to create temp dir");
493 let codex_path = temp_directory.path().join("codex");
494 fs::write(&codex_path, "#!/bin/sh\nwhile :; do :; done\n")
495 .expect("failed to create hanging codex executable");
496 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
497 .expect("failed to mark codex executable");
498
499 let detected_version =
501 detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
502
503 assert_eq!(detected_version, None);
505 }
506
507 #[test]
508 fn test_parse_agent_cli_version_output_falls_back_to_line() {
510 let output = "Claude Code development build\n";
512
513 let parsed_version = parse_agent_cli_version_output(output);
515
516 assert_eq!(
518 parsed_version,
519 Some("Claude Code development build".to_string())
520 );
521 }
522
523 #[test]
524 fn test_real_agent_availability_probe_ignores_non_executable_files() {
527 let temp_directory = tempdir().expect("failed to create temp dir");
529 let codex_path = temp_directory.path().join("codex");
530 fs::write(&codex_path, "").expect("failed to create codex file");
531 fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
532 .expect("failed to mark codex non-executable");
533 let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
534
535 let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
537
538 assert!(available_agent_kinds.is_empty());
540 }
541}