car-external-agents 0.23.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
Documentation
//! Detection — locate installed adapter binaries on `$PATH`, probe
//! version and auth state, build [`ExternalAgentSpec`] entries.
//!
//! Detection is best-effort and idempotent. Failure of any sub-probe
//! (version timeout, missing cred file, malformed JSON) degrades the
//! affected field rather than dropping the entry — knowing "claude
//! is on disk but I can't tell you the version" is more useful than
//! a silent omission.

use crate::adapters::{self, Adapter};
use crate::types::ExternalAgentSpec;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(2);

/// Exclude world-writable scratch directories so a binary staged
/// under `/tmp` is never the one detection picks up. Matches
/// `car_registry::supervisor::validate_command`'s denylist for the
/// same reason — the 2026-05 audit walked an exploit chain that
/// staged under `/tmp` before lifecycle-spawning, and detection
/// should not be a backdoor around that.
const SCRATCH_PREFIXES: &[&str] = &["/tmp/", "/private/tmp/", "/var/tmp/", "/dev/shm/"];

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Resolve `bin_name` against the supplied `$PATH`-style search list
/// (`:`-separated on POSIX, `;` on Windows). Returns the first
/// executable match outside scratch directories. `None` when the
/// binary isn't found or every match is in a denied prefix.
///
/// `scratch_prefixes` is the list of path prefixes to refuse; in
/// production this is [`SCRATCH_PREFIXES`]. Tests pass `&[]` so
/// `tempfile`-created scratch dirs (which land under `/tmp/` on
/// Linux CI) work as PATH entries — the production denylist still
/// has unit coverage via [`tests::detect_rejects_scratch_dir_binaries`].
fn resolve_in_path(bin_name: &str, path_var: &str, scratch_prefixes: &[&str]) -> Option<PathBuf> {
    let separator = if cfg!(windows) { ';' } else { ':' };
    for dir in path_var.split(separator) {
        if dir.is_empty() {
            continue;
        }
        let candidate = Path::new(dir).join(bin_name);
        // Reject scratch dirs before any FS work — the lossy
        // string conversion is fine for prefix matching.
        let candidate_str = candidate.to_string_lossy();
        if scratch_prefixes
            .iter()
            .any(|prefix| candidate_str.starts_with(prefix))
        {
            continue;
        }
        if !candidate.exists() {
            continue;
        }
        let Ok(meta) = std::fs::metadata(&candidate) else {
            continue;
        };
        if !meta.is_file() {
            continue;
        }
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if meta.permissions().mode() & 0o111 == 0 {
                continue;
            }
        }
        return Some(candidate);
    }
    None
}

/// Run `<bin> --version` with a 2s timeout, return the trimmed stdout
/// if the probe succeeded with a zero exit code. Stderr is dropped —
/// some tools emit deprecation warnings there that pollute the parse
/// shape.
async fn probe_version(bin: &Path) -> Option<String> {
    use tokio::process::Command;
    let mut cmd = Command::new(bin);
    cmd.arg("--version");
    cmd.stdin(std::process::Stdio::null());
    cmd.stdout(std::process::Stdio::piped());
    cmd.stderr(std::process::Stdio::null());
    cmd.kill_on_drop(true);
    let child = cmd.spawn().ok()?;
    let output = match tokio::time::timeout(VERSION_PROBE_TIMEOUT, child.wait_with_output()).await {
        Ok(Ok(out)) => out,
        _ => return None,
    };
    if !output.status.success() {
        return None;
    }
    let stdout = String::from_utf8(output.stdout).ok()?;
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
}

/// Build a spec for one adapter, or `None` when the binary isn't
/// installed.
async fn detect_one(
    adapter: &Adapter,
    path_var: &str,
    home: &Path,
    scratch_prefixes: &[&str],
) -> Option<ExternalAgentSpec> {
    let binary_path = resolve_in_path(adapter.bin_name, path_var, scratch_prefixes)?;
    let version_raw = probe_version(&binary_path).await;
    let version = version_raw.and_then(|raw| (adapter.parse_version)(&raw));
    let auth_kind = (adapter.probe_auth)(home);
    Some(ExternalAgentSpec {
        id: adapter.id.as_str().to_string(),
        display_name: adapter.id.display_name().to_string(),
        binary_path,
        version,
        auth_kind,
        capabilities: adapter.capabilities.clone(),
        detected_at: now_secs(),
        health: None,
    })
}

/// Run detection for every known adapter against the current process
/// environment. Returns specs for installed adapters only — uninstalled
/// adapters are simply omitted from the list. Sorted by `id` for
/// deterministic UI ordering.
///
/// Best-effort: a failed sub-probe degrades the affected field rather
/// than dropping the entry. Only a missing binary causes omission.
pub async fn detect() -> Vec<ExternalAgentSpec> {
    let path_var = std::env::var("PATH").unwrap_or_default();
    let Some(home) = home_dir() else {
        // No HOME → can't probe auth state. Still detect binaries on
        // PATH; auth_kind defaults to Unknown.
        return detect_with_paths(&path_var, Path::new("/")).await;
    };
    detect_with_paths(&path_var, &home).await
}

/// Same as [`detect`] but with explicit `$PATH` and `$HOME` overrides.
/// Production scratch denylist applies. Used by tests; not part of the
/// public API.
pub(crate) async fn detect_with_paths(path_var: &str, home: &Path) -> Vec<ExternalAgentSpec> {
    detect_with_paths_filtered(path_var, home, SCRATCH_PREFIXES).await
}

/// Underlying implementation of [`detect_with_paths`] with an
/// overridable scratch-prefix list. Tests pass `&[]` so binaries
/// staged in `tempfile`-created dirs (which land under `/tmp/` on
/// Linux CI) survive the resolver. The production scratch denylist
/// keeps full unit coverage via [`tests::detect_rejects_scratch_dir_binaries`].
pub(crate) async fn detect_with_paths_filtered(
    path_var: &str,
    home: &Path,
    scratch_prefixes: &[&str],
) -> Vec<ExternalAgentSpec> {
    let mut specs: Vec<ExternalAgentSpec> = Vec::new();
    for adapter in adapters::all() {
        if let Some(spec) = detect_one(adapter, path_var, home, scratch_prefixes).await {
            specs.push(spec);
        }
    }
    specs.sort_by(|a, b| a.id.cmp(&b.id));
    specs
}

/// Run detection and immediately populate each spec's `health` field
/// with the ground-truth result from each tool's auth-status command.
/// Slower than plain [`detect`] (subprocess spawn per tool) but gives
/// host UIs a one-stop call for "what's installed AND ready to use."
///
/// Pass `force = true` to bypass the 30s per-tool health-check TTL
/// cache.
pub async fn detect_with_health(force: bool) -> Vec<ExternalAgentSpec> {
    let mut specs = detect().await;
    let healths = crate::health::check_all(&specs, force).await;
    let by_id: std::collections::HashMap<&str, &crate::health::ExternalAgentHealth> =
        healths.iter().map(|h| (h.id.as_str(), h)).collect();
    for spec in specs.iter_mut() {
        if let Some(h) = by_id.get(spec.id.as_str()) {
            spec.health = Some((*h).clone());
        }
    }
    specs
}

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

    /// Build a fake executable at `dir/name` and return its path.
    /// Skips on Windows since the perm-bit check is no-op there.
    fn make_fake_bin(dir: &Path, name: &str, version_output: &str) -> PathBuf {
        let path = dir.join(name);
        let script = format!("#!/bin/sh\necho '{version_output}'\n");
        std::fs::write(&path, script).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        path
    }

    #[tokio::test]
    async fn detect_finds_fake_binary_on_path() {
        let bin_dir = tempfile::TempDir::new().unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        make_fake_bin(bin_dir.path(), "claude", "1.0.51 (Claude Code)");

        let path_var = bin_dir.path().to_string_lossy().to_string();
        // Empty scratch denylist — `tempfile` lands under /tmp/ on
        // Linux CI, which the production list rejects. The
        // production-denylist behavior is covered by
        // `detect_rejects_scratch_dir_binaries` below.
        let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;

        let claude = specs.iter().find(|s| s.id == "claude-code");
        assert!(claude.is_some(), "expected claude-code in {specs:?}");
        let claude = claude.unwrap();
        assert_eq!(claude.version.as_deref(), Some("1.0.51"));
        // No cred file → Unauthenticated, not Unknown.
        assert!(matches!(
            claude.auth_kind,
            crate::types::AuthKind::Unauthenticated
        ));
    }

    #[tokio::test]
    async fn detect_omits_uninstalled_binaries() {
        let bin_dir = tempfile::TempDir::new().unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        // Empty PATH dir — no binaries installed.
        let path_var = bin_dir.path().to_string_lossy().to_string();
        let specs = detect_with_paths(&path_var, home_dir.path()).await;
        assert!(specs.is_empty(), "expected no detections, got {specs:?}");
    }

    #[tokio::test]
    async fn detect_picks_subscription_when_oauth_creds_present() {
        let bin_dir = tempfile::TempDir::new().unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        make_fake_bin(bin_dir.path(), "claude", "1.0.51");

        // Write a fake credential file with an oauth-shaped key.
        let claude_dir = home_dir.path().join(".claude");
        std::fs::create_dir_all(&claude_dir).unwrap();
        std::fs::write(
            claude_dir.join(".credentials.json"),
            r#"{"oauthAccount": {"email": "[email protected]"}}"#,
        )
        .unwrap();

        let path_var = bin_dir.path().to_string_lossy().to_string();
        let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
        let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
        assert!(matches!(
            claude.auth_kind,
            crate::types::AuthKind::Subscription
        ));
    }

    #[tokio::test]
    async fn detect_picks_apikey_when_only_apikey_present() {
        let bin_dir = tempfile::TempDir::new().unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        make_fake_bin(bin_dir.path(), "claude", "1.0.51");

        let claude_dir = home_dir.path().join(".claude");
        std::fs::create_dir_all(&claude_dir).unwrap();
        std::fs::write(
            claude_dir.join(".credentials.json"),
            r#"{"apiKey": "sk-ant-..."}"#,
        )
        .unwrap();

        let path_var = bin_dir.path().to_string_lossy().to_string();
        let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
        let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
        assert!(matches!(claude.auth_kind, crate::types::AuthKind::ApiKey));
    }

    #[tokio::test]
    async fn detect_rejects_scratch_dir_binaries() {
        // Skip on platforms where TMPDIR doesn't land under /tmp
        // (macOS resolves to /var/folders/... — outside the denylist
        // by design).
        let tmp = std::env::temp_dir();
        if !tmp.starts_with("/tmp") && !tmp.starts_with("/private/tmp") {
            return;
        }
        let bin_dir = tempfile::TempDir::new_in("/tmp").unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        make_fake_bin(bin_dir.path(), "claude", "1.0.51");

        let path_var = bin_dir.path().to_string_lossy().to_string();
        let specs = detect_with_paths(&path_var, home_dir.path()).await;
        assert!(
            specs.iter().all(|s| s.id != "claude-code"),
            "scratch-dir binary must be rejected, got {specs:?}"
        );
    }

    #[tokio::test]
    async fn detect_keeps_entry_when_version_probe_fails() {
        let bin_dir = tempfile::TempDir::new().unwrap();
        let home_dir = tempfile::TempDir::new().unwrap();
        // A binary that exits non-zero — version probe returns None.
        let path = bin_dir.path().join("claude");
        std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
        }

        let path_var = bin_dir.path().to_string_lossy().to_string();
        let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
        let claude = specs.iter().find(|s| s.id == "claude-code");
        assert!(claude.is_some(), "entry must survive failed version probe");
        assert!(claude.unwrap().version.is_none());
    }
}