openlatch-client 0.6.23

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! `<cli> --version`, bounded: what an agent CLI says its version is, asked
//! without ever waiting on it.
//!
//! Only ever a CLI: an agent's GUI binary is never run, since `--version` on
//! an Electron app launches the app.

use std::path::Path;
use std::time::Duration;

/// How long a CLI gets to answer `--version`.
pub const TIMEOUT: Duration = Duration::from_secs(5);

/// The first line `binary --version` prints, with `envs` set.
///
/// The deadline covers the read as well as the exit. The first line of stdout
/// is read on its own thread: a wrapper that starts a background child and
/// exits leaves that child holding the pipe, and a read to EOF on the caller's
/// thread would hang the caller with it. A reader still blocked at the
/// deadline is abandoned — it ends when the pipe closes. Windowless, since the
/// daemon has no console to lend.
pub fn first_line_within(
    binary: &Path,
    envs: &[(&str, &str)],
    timeout: Duration,
) -> Result<String, String> {
    use std::io::BufRead;
    let name = binary
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    let mut command = std::process::Command::new(binary);
    command
        .arg("--version")
        .envs(envs.iter().copied())
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null());
    crate::core::process_compat::no_console_window(&mut command);
    let mut child = command.spawn().map_err(|e| e.to_string())?;
    let deadline = std::time::Instant::now() + timeout;
    let (tx, rx) = std::sync::mpsc::channel();
    if let Some(stdout) = child.stdout.take() {
        std::thread::spawn(move || {
            let mut line = String::new();
            let _ = std::io::BufReader::new(stdout).read_line(&mut line);
            let _ = tx.send(line);
        });
    }
    let late = || format!("`{name} --version` did not answer in time");
    let status = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status,
            Ok(None) if std::time::Instant::now() < deadline => {
                std::thread::sleep(Duration::from_millis(25));
            }
            Ok(None) => {
                let _ = child.kill();
                let _ = child.wait();
                return Err(late());
            }
            Err(e) => return Err(e.to_string()),
        }
    };
    if !status.success() {
        return Err(format!("`{name} --version` exited with {status}"));
    }
    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
    rx.recv_timeout(remaining).map_err(|_| late())
}

/// The version `binary --version` names: the first word of its first line
/// that looks like one, a leading `v` dropped. `codex-cli 0.130.0`,
/// `2.1.304 (Claude Code)` and `v1.18.35` all answer.
pub fn version_of(binary: &Path) -> Option<String> {
    version_in(&first_line_within(binary, &[], TIMEOUT).ok()?)
}

/// [`version_of`]'s parse, on a line already read.
pub fn version_in(line: &str) -> Option<String> {
    line.split_whitespace()
        .map(|word| word.strip_prefix('v').unwrap_or(word))
        .find(|word| super::agent_versions::looks_like_version(word))
        .map(str::to_string)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_version_word_is_found_in_each_cli_s_own_wording() {
        assert_eq!(
            version_in("codex-cli 0.130.0\n").as_deref(),
            Some("0.130.0")
        );
        assert_eq!(
            version_in("2.1.304 (Claude Code)").as_deref(),
            Some("2.1.304")
        );
        assert_eq!(version_in("v1.18.35").as_deref(), Some("1.18.35"));
        assert_eq!(version_in("cline/3.40.1 darwin-arm64").as_deref(), None);
        assert_eq!(version_in("unknown"), None);
        assert_eq!(version_in(""), None);
    }

    /// A wrapper that exits while a descendant still holds stdout never hangs
    /// the probe: the version line is read, and a silent one is cut at the
    /// deadline.
    #[cfg(unix)]
    #[test]
    fn the_probe_is_bounded_when_a_descendant_holds_stdout() {
        use std::os::unix::fs::PermissionsExt as _;
        let dir = tempfile::tempdir().expect("dir");
        let script = |name: &str, body: &str| {
            let path = dir.path().join(name);
            std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("script");
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
            path
        };
        let answers = script("answers", "(sleep 30) &\necho codex-cli 0.130.0");
        let started = std::time::Instant::now();
        let line = first_line_within(&answers, &[], Duration::from_secs(3)).expect("line");
        assert_eq!(version_in(&line).as_deref(), Some("0.130.0"));
        assert!(started.elapsed() < Duration::from_secs(3));

        let silent = script("silent", "(sleep 30) &\nexit 0");
        let started = std::time::Instant::now();
        assert!(first_line_within(&silent, &[], Duration::from_millis(500)).is_err());
        assert!(started.elapsed() < Duration::from_secs(3));
    }
}