car-external-agents 0.47.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! Wire-shape types for external-agent detection.
//!
//! Identical between the in-process FFI singleton and the daemon WS
//! surface, per the `car-ffi-common::supervisor` precedent — a host
//! can swap transports without reshaping payloads.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Stable identifier for each supported external agent. Mirrors the
/// canonical CLI binary name; downstream tooling keys on this string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AdapterId {
    /// Anthropic Claude Code (`claude` binary).
    ClaudeCode,
    /// OpenAI Codex CLI (`codex` binary).
    Codex,
    /// Google Gemini CLI (`gemini` binary).
    Gemini,
}

impl AdapterId {
    /// Stable string id used in JSON payloads and as the key downstream
    /// callers reference when invoking an external agent.
    pub fn as_str(self) -> &'static str {
        match self {
            AdapterId::ClaudeCode => "claude-code",
            AdapterId::Codex => "codex",
            AdapterId::Gemini => "gemini",
        }
    }

    /// Human-readable label for UI surfaces.
    pub fn display_name(self) -> &'static str {
        match self {
            AdapterId::ClaudeCode => "Claude Code",
            AdapterId::Codex => "Codex CLI",
            AdapterId::Gemini => "Gemini CLI",
        }
    }

    /// Every adapter the runtime knows about. Detection iterates this.
    pub fn all() -> &'static [AdapterId] {
        &[AdapterId::ClaudeCode, AdapterId::Codex, AdapterId::Gemini]
    }
}

/// How an installed CLI is authenticated (heuristic, never verified).
///
/// Detection inspects credential file *shape* — never contents — so
/// the result is advisory. Upstream tools change credential layouts
/// without notice; treat `Unknown` as the safe default and don't make
/// trust decisions on this field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthKind {
    /// OAuth-backed login against the vendor's subscription product
    /// (Claude Pro/Max, ChatGPT Plus/Pro, etc.). Routing decisions
    /// can prefer this when capability fits — invocations don't burn
    /// API credits.
    Subscription,
    /// API key in the credential file. Per-request consumption billing.
    ApiKey,
    /// Cred file present but shape doesn't match either pattern.
    /// Common when the upstream tool ships a new auth flow.
    #[default]
    Unknown,
    /// No credential file found. Tool is installed but the user
    /// hasn't logged in yet.
    Unauthenticated,
}

/// Static capability set per adapter — what its JSON stdio protocol
/// advertises. Phase 1 ships these as advisory metadata; Phase 2's
/// invocation path uses them to decide which features are wired up.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Capabilities {
    /// Tool use (function calling) is available.
    #[serde(default)]
    pub tool_use: bool,
    /// MCP server registration is supported.
    #[serde(default)]
    pub mcp: bool,
    /// Hooks (pre/post-tool, pre-prompt, etc.) are supported.
    #[serde(default)]
    pub hooks: bool,
    /// Multi-turn sessions with stable id are supported.
    #[serde(default)]
    pub sessions: bool,
    /// Token-level streaming output is supported.
    #[serde(default)]
    pub streaming: bool,
    /// Image input (vision) is supported — the CLI accepts images
    /// attached to the prompt (Claude Code via stdin image blocks,
    /// Codex via `--image`, Gemini via `@path`).
    #[serde(default)]
    pub images: bool,
}

/// Whether this spec's `binary_path` can be executed at all.
///
/// **Owned by detection, and by nothing else** (car#746). This is the fact
/// that separates "the tool is installed but not signed in" from "the OS
/// refuses to run this file". It is established once, when detection probes
/// the binary, and no later probe may revise it — a status command cannot run
/// on a binary the OS won't exec, so a probe that tries learns nothing and
/// must not be allowed to report `Unknown` over the diagnosis.
///
/// It lived on [`crate::health::HealthStatus`] before, whose contract is
/// "whoever probed last, writes". Putting an immutable detection-time fact in
/// a mutable slot made it destroyable by construction, and three separate
/// merge sites destroyed it. Splitting the axis makes the overwrite
/// unrepresentable rather than merely discouraged.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ExecutableStatus {
    /// Detection was able to execute the binary (or had no reason to doubt it).
    #[default]
    Runnable,
    /// The OS refused to execute it. Always carries a reason naming the path,
    /// because the user's next action is to fix or remove that specific file.
    Unusable {
        reason: String,
        /// UNIX seconds when detection established this.
        checked_at: u64,
    },
}

impl ExecutableStatus {
    /// `Some(reason)` when the binary cannot be executed.
    pub fn unusable_reason(&self) -> Option<&str> {
        match self {
            ExecutableStatus::Runnable => None,
            ExecutableStatus::Unusable { reason, .. } => Some(reason.as_str()),
        }
    }

    pub fn is_runnable(&self) -> bool {
        matches!(self, ExecutableStatus::Runnable)
    }
}

/// Detection result for one installed adapter. Empty when the binary
/// isn't on `$PATH`. Wire shape is stable across FFI and WS surfaces.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalAgentSpec {
    /// Adapter identifier (`"claude-code"`, `"codex"`, `"gemini"`).
    pub id: String,
    /// Human-readable label.
    pub display_name: String,
    /// Resolved absolute path to the binary. Stored at detection time
    /// so future invocations don't consult `$PATH` again — closes the
    /// PATH-injection variant per the 2026-05 audit's reasoning.
    pub binary_path: PathBuf,
    /// Version string parsed from `<bin> --version`. `None` when the
    /// version probe failed or timed out — entry still useful, the
    /// binary is on disk.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// **Deprecated since Phase 2 stage 1.** Heuristic auth-kind
    /// derived from credential file shape — see [`AuthKind`]. Kept
    /// for backwards compatibility with consumers that haven't
    /// migrated to the `health` field. Modern macOS / Linux /
    /// Windows builds use OS keystores, so this field falls through
    /// to `Unknown` for the most common installs. Prefer `health`.
    #[serde(default)]
    pub auth_kind: AuthKind,
    /// Static capability advertisement.
    pub capabilities: Capabilities,
    /// UNIX seconds when this entry was last refreshed.
    #[serde(default)]
    pub detected_at: u64,
    /// Ground-truth health bucket from the tool's own auth-status
    /// command, populated when detection runs with health checks
    /// enabled. `None` when health wasn't requested — call
    /// `agents.health_external` (or `detect_with_health()`) to
    /// populate. Replaces `auth_kind` as the primary "is this tool
    /// ready to invoke" signal.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub health: Option<crate::health::ExternalAgentHealth>,
    /// Whether the binary can be executed at all — see [`ExecutableStatus`].
    /// Written by detection; never by a health refresher.
    ///
    /// `#[serde(default)]` keeps the wire backward compatible: an entry from
    /// before this field existed reads as `Runnable`, which is what an absent
    /// diagnosis meant.
    #[serde(default)]
    pub execution: ExecutableStatus,
}

impl ExternalAgentSpec {
    /// `Some(reason)` when detection proved this spec's `binary_path`
    /// cannot be executed — see [`HealthStatus::NotExecutable`].
    ///
    /// The spec is still returned by detection (a user searching for a
    /// broken install needs to be able to find it), so **spawning code
    /// must consult this first**. `invoke` does; anything reaching
    /// `binary_path` directly must too, or it will hand the OS a file
    /// the OS has already refused to run.
    ///
    /// [`HealthStatus::NotExecutable`]: crate::health::HealthStatus::NotExecutable
    pub fn unusable_reason(&self) -> Option<&str> {
        // `execution` is authoritative. The `health` fallback below is the
        // compatibility window (car#746 migration step 2): specs deserialized
        // from a pre-split producer carry the verdict only in `health`, and
        // dropping the fallback before those are gone would silently downgrade
        // a known-broken binary to "fine". Removed together with the
        // `not_executable` emission once the window closes.
        if let Some(reason) = self.execution.unusable_reason() {
            return Some(reason);
        }
        let health = self.health.as_ref()?;
        if health.status != crate::health::HealthStatus::NotExecutable {
            return None;
        }
        Some(
            health
                .reason
                .as_deref()
                .unwrap_or("binary is not executable"),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::health::{ExternalAgentHealth, HealthStatus};

    fn spec_with(status: Option<HealthStatus>) -> ExternalAgentSpec {
        ExternalAgentSpec {
            id: "codex".to_string(),
            display_name: "Codex CLI".to_string(),
            binary_path: "/opt/homebrew/bin/codex".into(),
            version: None,
            auth_kind: AuthKind::Unknown,
            capabilities: Capabilities::default(),
            detected_at: 0,
            health: status.map(|s| ExternalAgentHealth {
                id: "codex".to_string(),
                status: s,
                details: serde_json::Value::Object(Default::default()),
                reason: Some("killed by signal 9".to_string()),
                checked_at: 0,
            }),
            execution: ExecutableStatus::Runnable,
        }
    }

    #[test]
    fn unusable_reason_only_fires_for_not_executable() {
        assert_eq!(
            spec_with(Some(HealthStatus::NotExecutable)).unusable_reason(),
            Some("killed by signal 9")
        );
        // Every other bucket describes auth state on a binary that
        // runs fine — none of them may block invocation.
        for ok in [
            HealthStatus::Ready,
            HealthStatus::NotConfigured,
            HealthStatus::Expired,
            HealthStatus::NetworkError,
            HealthStatus::Unknown,
        ] {
            assert_eq!(spec_with(Some(ok)).unusable_reason(), None, "{ok:?}");
        }
        // Unprobed health must not be read as broken.
        assert_eq!(spec_with(None).unusable_reason(), None);
    }
}

#[cfg(test)]
mod execution_axis_tests {
    use super::*;
    use crate::health::{ExternalAgentHealth, HealthStatus};

    fn base() -> ExternalAgentSpec {
        ExternalAgentSpec {
            id: "codex".to_string(),
            display_name: "Codex CLI".to_string(),
            binary_path: "/opt/homebrew/bin/codex".into(),
            version: None,
            auth_kind: AuthKind::default(),
            capabilities: Capabilities::default(),
            detected_at: 0,
            health: None,
            execution: ExecutableStatus::Runnable,
        }
    }

    /// The whole point of the split: a health refresher rewriting `health`
    /// cannot erase detection's verdict, because it no longer lives there.
    #[test]
    fn a_health_refresh_cannot_overwrite_the_execution_verdict() {
        let mut spec = base();
        spec.execution = ExecutableStatus::Unusable {
            reason: "quarantined at /opt/homebrew/bin/codex".into(),
            checked_at: 42,
        };

        // Exactly what the three merge sites did: stamp whatever the probe
        // returned over the spec's health.
        spec.health = Some(ExternalAgentHealth {
            id: "codex".into(),
            status: HealthStatus::Unknown,
            details: serde_json::json!({}),
            reason: None,
            checked_at: 99,
        });

        assert_eq!(
            spec.unusable_reason(),
            Some("quarantined at /opt/homebrew/bin/codex"),
            "a health probe must not be able to erase the execution verdict"
        );
    }

    #[test]
    fn a_runnable_binary_with_auth_problems_is_still_runnable() {
        // The other half: auth state is not executability. A signed-out tool
        // runs fine and must not be filtered out of spawn paths.
        let mut spec = base();
        for status in [
            HealthStatus::Unknown,
            HealthStatus::NotConfigured,
            HealthStatus::Expired,
            HealthStatus::Ready,
        ] {
            spec.health = Some(ExternalAgentHealth {
                id: "codex".into(),
                status,
                details: serde_json::json!({}),
                reason: Some("some auth detail".into()),
                checked_at: 1,
            });
            assert_eq!(
                spec.unusable_reason(),
                None,
                "{status:?} is not an exec fault"
            );
        }
    }

    /// Migration step 2: a spec produced before the split carries the verdict
    /// only in `health`. Dropping that fallback early would silently downgrade
    /// a known-broken binary to "fine".
    #[test]
    fn a_pre_split_spec_is_still_understood() {
        let json = serde_json::json!({
            "id": "codex",
            "display_name": "Codex CLI",
            "binary_path": "/opt/homebrew/bin/codex",
            "auth_kind": "unknown",
            "capabilities": {},
            "detected_at": 0,
            "health": {
                "id": "codex",
                "status": "not_executable",
                "details": {},
                "reason": "quarantined at /opt/homebrew/bin/codex",
                "checked_at": 7
            }
        });
        let spec: ExternalAgentSpec =
            serde_json::from_value(json).expect("pre-split spec must deserialize");
        assert!(
            spec.execution.is_runnable(),
            "absent `execution` reads as Runnable"
        );
        assert_eq!(
            spec.unusable_reason(),
            Some("quarantined at /opt/homebrew/bin/codex"),
            "the compatibility fallback must still honour the old shape"
        );
    }

    #[test]
    fn execution_defaults_to_runnable_and_adds_no_bytes_for_the_common_case() {
        let spec = base();
        let wire = serde_json::to_value(&spec).unwrap();
        assert_eq!(wire["execution"]["state"], "runnable");
        assert!(spec.execution.is_runnable());
    }

    #[test]
    fn unusable_round_trips_on_the_wire() {
        let mut spec = base();
        spec.execution = ExecutableStatus::Unusable {
            reason: "bad".into(),
            checked_at: 5,
        };
        let wire = serde_json::to_value(&spec).unwrap();
        assert_eq!(wire["execution"]["state"], "unusable");
        assert_eq!(wire["execution"]["reason"], "bad");
        let back: ExternalAgentSpec = serde_json::from_value(wire).unwrap();
        assert_eq!(back.execution, spec.execution);
    }
}