Skip to main content

ag_agent/agent/
availability.rs

1//! Machine-scoped agent executable discovery.
2
3use std::env;
4use std::ffi::OsStr;
5use std::os::unix::fs::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
12/// Maximum time spent waiting for one provider CLI `--version` command.
13const AGENT_CLI_VERSION_TIMEOUT: Duration = Duration::from_secs(2);
14/// Maximum time spent waiting for one provider CLI `update` command.
15const AGENT_CLI_UPDATE_TIMEOUT: Duration = Duration::from_mins(5);
16/// Poll interval used while waiting for one bounded provider CLI subprocess.
17const AGENT_CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
18/// Canonical npm-global path segment for the Gemini CLI package.
19const GEMINI_NPM_PACKAGE_PATH: &str = "/lib/node_modules/@google/gemini-cli/";
20/// npm package spec used to refresh a globally installed Gemini CLI.
21const GEMINI_NPM_PACKAGE_SPEC: &str = "@google/gemini-cli@latest";
22
23/// Executable plus arguments for one provider CLI startup update.
24struct AgentCliUpdateCommand {
25    args: &'static [&'static str],
26    executable_path: PathBuf,
27}
28
29impl AgentCliUpdateCommand {
30    /// Creates one provider update command.
31    fn new(executable_path: PathBuf, args: &'static [&'static str]) -> Self {
32        Self {
33            args,
34            executable_path,
35        }
36    }
37}
38
39/// Detects which provider CLIs are locally runnable on the current machine.
40#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
41pub trait AgentAvailabilityProbe: Send + Sync {
42    /// Returns the agent kinds whose backing CLI executable is available.
43    fn available_agent_kinds(&self) -> Vec<AgentKind>;
44
45    /// Returns available agent CLI executables and their refreshed versions.
46    fn available_agent_clis(&self) -> Vec<AgentCliInfo> {
47        AgentCliInfo::from_kinds(&self.available_agent_kinds())
48    }
49}
50
51/// Production availability probe backed by `PATH` executable discovery.
52pub 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
64/// Availability probe that returns one caller-provided snapshot.
65pub struct StaticAgentAvailabilityProbe {
66    /// Agent kinds reported as available by the static probe.
67    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/// Returns the CLI executable name used by the provided agent kind.
77#[must_use]
78pub fn executable_name(agent_kind: AgentKind) -> &'static str {
79    agent_kind.executable_name()
80}
81
82/// Returns available agent CLI metadata from one `PATH` value.
83fn 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
99/// Returns agent kinds whose executables are present on one `PATH` value.
100fn 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
110/// Returns the first executable path matching one command name on `PATH`.
111fn 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
120/// Returns the candidate filesystem path for one executable name within a
121/// single `PATH` entry.
122fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
123    path_entry.join(executable_name)
124}
125
126/// Returns whether the candidate path is a regular file with at least one
127/// execute bit set.
128fn 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
140/// Runs one available CLI's update command, then extracts the installed
141/// version token from a fresh version probe.
142fn 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
152/// Refreshes all available CLI versions concurrently while preserving
153/// provider display order.
154fn 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
179/// Runs one available CLI's best-effort provider or package-manager update.
180fn 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
189/// Runs one available CLI's best-effort update with a caller-provided timeout.
190fn 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
204/// Builds the supported startup update command for one provider CLI.
205fn 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
220/// Builds Gemini's supported npm-global update command when the discovered
221/// executable resolves into the global Gemini CLI package.
222///
223/// Canonicalization failure is treated as an unknown installation because
224/// Agentty cannot safely prove that npm owns the executable.
225fn 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
243/// Runs one available CLI's version command and extracts the installed
244/// version token from its output.
245fn detect_agent_cli_version(executable_path: &Path) -> Option<String> {
246    detect_agent_cli_version_with_timeout(executable_path, AGENT_CLI_VERSION_TIMEOUT)
247}
248
249/// Runs one available CLI's version command with a caller-provided timeout.
250fn 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
265/// Runs one provider CLI `--version` command and stops waiting once the
266/// timeout expires.
267fn version_command_output(executable_path: &Path, timeout: Duration) -> Option<Output> {
268    command_output_with_timeout(executable_path, &["--version"], timeout)
269}
270
271/// Runs one provider CLI command with output discarded and stops waiting once
272/// the timeout expires.
273fn 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
290/// Runs one provider CLI command and stops waiting once the timeout expires.
291fn 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
308/// Waits for one child process to exit, killing it when the timeout expires.
309fn 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
330/// Parses a provider CLI version from the first useful `--version` output
331/// line.
332fn 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    /// Ensures executable names stay aligned with provider command names.
370    fn test_executable_name_matches_agent_cli_names() {
371        // Arrange / Act / Assert
372        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    /// Ensures the production probe reports only agent kinds whose
380    /// executables are present on the current `PATH`.
381    fn test_real_agent_availability_probe_filters_missing_executables() {
382        // Arrange
383        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        // Act
395        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
396
397        // Assert
398        assert_eq!(
399            available_agent_kinds,
400            vec![AgentKind::Antigravity, AgentKind::Codex]
401        );
402    }
403
404    #[test]
405    /// Ensures available CLI metadata includes parsed command versions.
406    fn test_available_agent_clis_from_path_includes_versions() {
407        // Arrange
408        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        // Act
417        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
418
419        // Assert
420        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    /// Ensures the startup CLI refresh runs `update` before probing the
431    /// visible version.
432    fn test_available_agent_clis_from_path_updates_before_version_probe() {
433        // Arrange
434        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        // Act
452        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
453
454        // Assert
455        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    /// Ensures CLI refreshes start independently so one slow provider does
467    /// not delay every following provider.
468    fn test_refresh_agent_cli_versions_runs_providers_concurrently() {
469        // Arrange
470        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        // Act
505        let agent_clis = refresh_agent_cli_versions(executable_agent_clis, refresh_cli_version);
506
507        // Assert
508        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    /// Ensures failed CLI updates do not prevent the post-update version
519    /// probe from refreshing the row.
520    fn test_refresh_agent_cli_version_probes_version_when_update_fails() {
521        // Arrange
522        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        // Act
534        let detected_version = refresh_agent_cli_version(AgentKind::Codex, &codex_path, None);
535
536        // Assert
537        assert_eq!(detected_version, Some("1.2.3".to_string()));
538    }
539
540    #[test]
541    /// Ensures npm-global Gemini installations update through npm instead of
542    /// treating `update` as an interactive Gemini query.
543    fn test_available_agent_clis_from_path_updates_npm_global_gemini() {
544        // Arrange
545        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        // Act
586        let available_agent_clis = available_agent_clis_from_path(Some(path_value.as_os_str()));
587
588        // Assert
589        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    /// Ensures Gemini installations with an unknown owner do not launch the
604    /// removed native update command.
605    fn test_run_agent_cli_update_skips_unknown_gemini_installation() {
606        // Arrange
607        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        // Act
622        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
630        assert!(!did_update);
631        assert!(!update_marker_path.exists());
632    }
633
634    #[test]
635    /// Ensures noisy CLI update commands cannot block on unread pipe buffers.
636    fn test_run_agent_cli_update_discards_output_without_pipe_backpressure() {
637        // Arrange
638        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        // Act
654        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
662        assert!(did_finish);
663    }
664
665    #[test]
666    /// Ensures unresponsive CLI version commands time out without returning a
667    /// version.
668    fn test_detect_agent_cli_version_with_timeout_handles_hanging_commands() {
669        // Arrange
670        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        // Act
678        let detected_version =
679            detect_agent_cli_version_with_timeout(&codex_path, Duration::from_millis(50));
680
681        // Assert
682        assert_eq!(detected_version, None);
683    }
684
685    #[test]
686    /// Ensures non-version text falls back to the first useful output line.
687    fn test_parse_agent_cli_version_output_falls_back_to_line() {
688        // Arrange
689        let output = "Claude Code development build\n";
690
691        // Act
692        let parsed_version = parse_agent_cli_version_output(output);
693
694        // Assert
695        assert_eq!(
696            parsed_version,
697            Some("Claude Code development build".to_string())
698        );
699    }
700
701    #[test]
702    /// Ensures probe discovery ignores non-executable files even when their
703    /// names match supported agent CLIs.
704    fn test_real_agent_availability_probe_ignores_non_executable_files() {
705        // Arrange
706        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        // Act
714        let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
715
716        // Assert
717        assert!(available_agent_kinds.is_empty());
718    }
719}