car-registry 0.16.0

File-based agent registry + lifecycle supervisor for Common Agent Runtime.
Documentation
//! `manifest.toml` integration for the supervisor
//! (Parslee-ai/car#182).
//!
//! Phase 2 moved the manifest types + canonicalization + ed25519
//! signing into the dedicated `car-bundle` crate. This module now
//! re-exports the public surface and holds the supervisor-side
//! helpers — loading from a directory, projecting to the
//! supervisor's flat [`AgentSpec`], and verifying signatures when
//! present.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

pub use car_bundle::{
    AgentIdentity, AgentManifest, BundleError, CapabilityDeclarations, ExternalProcessTransport,
    LifecyclePolicy, PublisherInfo, RestartPolicy as BundleRestartPolicy, RuntimeRequirements,
    TransportSpec,
};

use crate::supervisor::{AgentSpec, RestartPolicy, SupervisorError};

/// Convert from the bundle crate's RestartPolicy (which lives in
/// `car-bundle` so it can stay standalone) to the supervisor's
/// in-memory enum. They have the same variants by design; this
/// function exists to make the boundary explicit and to catch
/// any future divergence at compile time.
fn map_restart_policy(p: BundleRestartPolicy) -> RestartPolicy {
    match p {
        BundleRestartPolicy::Never => RestartPolicy::Never,
        BundleRestartPolicy::OnFailure => RestartPolicy::OnFailure,
        BundleRestartPolicy::Always => RestartPolicy::Always,
    }
}

fn unmap_restart_policy(p: RestartPolicy) -> BundleRestartPolicy {
    match p {
        RestartPolicy::Never => BundleRestartPolicy::Never,
        RestartPolicy::OnFailure => BundleRestartPolicy::OnFailure,
        RestartPolicy::Always => BundleRestartPolicy::Always,
    }
}

/// Build an [`AgentManifest`] from a legacy [`AgentSpec`].
pub fn from_legacy_spec(spec: &AgentSpec) -> AgentManifest {
    AgentManifest {
        agent: AgentIdentity {
            id: spec.id.clone(),
            name: spec.name.clone(),
            namespace: None,
            version: None,
            description: None,
            license: None,
            homepage: None,
        },
        publisher: None,
        runtime: None,
        lifecycle: None,
        transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
            command: Some(spec.command.clone()),
            binary_url: None,
            sha256: None,
            health_url: None,
            args: spec.args.clone(),
            cwd: spec.cwd.clone(),
            env: spec.env.clone(),
            restart: unmap_restart_policy(spec.restart),
            max_restarts: spec.max_restarts,
            backoff_secs: spec.backoff_secs,
            auto_start: spec.auto_start,
            token: spec.token.clone(),
        }),
        capabilities: None,
    }
}

/// Project an [`AgentManifest`] back to the supervisor's
/// in-memory [`AgentSpec`]. Phase 2: same projection rules as
/// phase 1; pure_data + health_url-only manifests still can't be
/// projected (the supervisor doesn't spawn them).
pub fn to_agent_spec(manifest: &AgentManifest) -> Result<AgentSpec, SupervisorError> {
    match &manifest.transport {
        TransportSpec::PureData => Err(SupervisorError::Other(format!(
            "agent `{}` is a pure_data bundle; supervisor cannot spawn it. \
             Pure-data agents are loaded by the runtime in a later phase.",
            manifest.agent.id
        ))),
        TransportSpec::ExternalProcess(t) => {
            let command = t.command.clone().ok_or_else(|| {
                SupervisorError::Other(format!(
                    "agent `{}` has transport.kind=external_process but no \
                     `command`; health_url-only entries are tracked but \
                     not spawned by the supervisor in this phase.",
                    manifest.agent.id
                ))
            })?;
            Ok(AgentSpec {
                id: manifest.agent.id.clone(),
                name: manifest.agent.name.clone(),
                command,
                args: t.args.clone(),
                cwd: t.cwd.clone(),
                env: t.env.clone(),
                restart: map_restart_policy(t.restart),
                max_restarts: t.max_restarts,
                backoff_secs: t.backoff_secs,
                auto_start: t.auto_start,
                token: t.token.clone(),
            })
        }
    }
}

/// Read every `<dir>/<id>/manifest.toml` under `dir`. Skips
/// malformed files with a `tracing::warn!`. Optionally verifies
/// signatures when present — verification failures log a warning
/// but do NOT remove the manifest from the returned list in phase
/// 2 (warn-but-not-reject keeps existing setups working while
/// operators sign their agents; phase 3 makes verification
/// strict).
pub fn load_manifest_dir(dir: &Path) -> Result<Vec<AgentManifest>, SupervisorError> {
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let manifest_path = path.join("manifest.toml");
        if !manifest_path.is_file() {
            continue;
        }
        let text = match std::fs::read_to_string(&manifest_path) {
            Ok(t) => t,
            Err(e) => {
                tracing::warn!(
                    manifest = %manifest_path.display(),
                    error = %e,
                    "skipping unreadable manifest.toml"
                );
                continue;
            }
        };
        let m = match AgentManifest::from_toml_str(&text) {
            Ok(m) => m,
            Err(e) => {
                tracing::warn!(
                    manifest = %manifest_path.display(),
                    error = %e,
                    "skipping malformed manifest.toml"
                );
                continue;
            }
        };
        if m.publisher.is_some() {
            if let Err(e) = car_bundle::verify_signature(&m) {
                tracing::warn!(
                    manifest = %manifest_path.display(),
                    agent_id = %m.agent.id,
                    error = %e,
                    "manifest signature did not verify (phase 2 warn-only; \
                     phase 3 will reject)"
                );
            }
        }
        out.push(m);
    }
    out.sort_by(|a, b| a.agent.id.cmp(&b.agent.id));
    Ok(out)
}

/// Write a single manifest.toml atomically. Creates the
/// `<dir>/<id>/` directory if needed.
pub fn write_manifest(
    agents_dir: &Path,
    manifest: &AgentManifest,
) -> Result<PathBuf, SupervisorError> {
    let agent_dir = agents_dir.join(&manifest.agent.id);
    std::fs::create_dir_all(&agent_dir)?;
    let manifest_path = agent_dir.join("manifest.toml");
    let toml_text = manifest
        .to_toml_string()
        .map_err(|e| SupervisorError::Other(format!("serialize manifest: {e}")))?;
    let tmp = agent_dir.join(".manifest.toml.tmp");
    std::fs::write(&tmp, toml_text)?;
    std::fs::rename(&tmp, &manifest_path)?;
    Ok(manifest_path)
}

/// Reap manifest dirs whose ids are not in `keep`. Returns the
/// ids that were removed. Only deletes directories that look like
/// supervised-agent layouts (must contain a `manifest.toml`).
pub fn reap_stale(agents_dir: &Path, keep: &HashSet<String>) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(agents_dir) else {
        return Vec::new();
    };
    let mut removed = Vec::new();
    for entry in entries.flatten() {
        let p = entry.path();
        if !p.is_dir() {
            continue;
        }
        let Some(name) = p.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if keep.contains(name) {
            continue;
        }
        if p.join("manifest.toml").is_file() {
            if let Err(e) = std::fs::remove_dir_all(&p) {
                tracing::warn!(
                    dir = %p.display(),
                    error = %e,
                    "reaping stale manifest dir failed"
                );
                continue;
            }
            removed.push(name.to_string());
        }
    }
    removed
}

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

    fn legacy_spec() -> AgentSpec {
        AgentSpec {
            id: "ui-improver".into(),
            name: "UI Improvement".into(),
            command: "/usr/local/bin/ui-improver".into(),
            args: vec!["--mode".into(), "a2ui".into()],
            cwd: None,
            env: BTreeMap::from([("RUST_LOG".to_string(), "info".to_string())]),
            restart: RestartPolicy::OnFailure,
            max_restarts: 7,
            backoff_secs: 3,
            auto_start: false,
            token: "tok-abc".into(),
        }
    }

    #[test]
    fn from_legacy_round_trips_external_process_fields() {
        let spec = legacy_spec();
        let manifest = from_legacy_spec(&spec);
        let round = to_agent_spec(&manifest).unwrap();
        assert_eq!(round.id, spec.id);
        assert_eq!(round.command, spec.command);
        assert_eq!(round.args, spec.args);
        assert_eq!(round.env, spec.env);
        assert_eq!(round.restart, spec.restart);
        assert_eq!(round.max_restarts, spec.max_restarts);
        assert_eq!(round.token, spec.token);
    }

    #[test]
    fn pure_data_manifest_cannot_project_to_agent_spec() {
        let m = AgentManifest {
            agent: AgentIdentity {
                id: "pure-bundle".into(),
                name: "Pure Data".into(),
                namespace: None,
                version: None,
                description: None,
                license: None,
                homepage: None,
            },
            publisher: None,
            runtime: None,
            lifecycle: None,
            transport: TransportSpec::PureData,
            capabilities: None,
        };
        assert!(m.is_pure_data());
        assert!(!m.is_remote_service());
        assert!(to_agent_spec(&m).is_err());
    }

    #[test]
    fn health_url_manifest_cannot_project_to_agent_spec() {
        let m = AgentManifest {
            agent: AgentIdentity {
                id: "remote-svc".into(),
                name: "Remote Service".into(),
                namespace: None,
                version: None,
                description: None,
                license: None,
                homepage: None,
            },
            publisher: None,
            runtime: None,
            lifecycle: None,
            transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
                command: None,
                binary_url: None,
                sha256: None,
                health_url: Some("https://svc.example.com/.well-known/a2a/agent.json".into()),
                args: vec![],
                cwd: None,
                env: BTreeMap::new(),
                restart: BundleRestartPolicy::default(),
                max_restarts: 10,
                backoff_secs: 5,
                auto_start: false,
                token: String::new(),
            }),
            capabilities: None,
        };
        assert!(m.is_remote_service());
        assert!(to_agent_spec(&m).is_err());
    }

    #[test]
    fn write_and_load_manifest_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let m = from_legacy_spec(&legacy_spec());
        let written = write_manifest(dir.path(), &m).unwrap();
        assert!(written.exists());
        let loaded = load_manifest_dir(dir.path()).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].agent.id, m.agent.id);
        let spec = to_agent_spec(&loaded[0]).unwrap();
        assert_eq!(spec.command, "/usr/local/bin/ui-improver");
        assert_eq!(spec.token, "tok-abc");
    }

    #[test]
    fn load_manifest_dir_skips_malformed_files() {
        let dir = tempfile::tempdir().unwrap();
        let good = from_legacy_spec(&legacy_spec());
        write_manifest(dir.path(), &good).unwrap();
        let bad_dir = dir.path().join("malformed");
        std::fs::create_dir_all(&bad_dir).unwrap();
        std::fs::write(bad_dir.join("manifest.toml"), "this is not valid toml === ").unwrap();
        let loaded = load_manifest_dir(dir.path()).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].agent.id, "ui-improver");
    }

    #[test]
    fn load_manifest_dir_is_empty_when_path_missing() {
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("does-not-exist");
        let loaded = load_manifest_dir(&missing).unwrap();
        assert!(loaded.is_empty());
    }

    #[test]
    fn signed_manifest_loads_when_signature_valid() {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;

        let dir = tempfile::tempdir().unwrap();
        let mut m = from_legacy_spec(&legacy_spec());
        let key = SigningKey::generate(&mut OsRng);
        car_bundle::sign_manifest(&mut m, &key).unwrap();
        write_manifest(dir.path(), &m).unwrap();
        // Loads + verifies; would warn on failure but the manifest
        // is still returned regardless. The success path leaves the
        // signature intact.
        let loaded = load_manifest_dir(dir.path()).unwrap();
        assert_eq!(loaded.len(), 1);
        assert!(loaded[0].publisher.is_some());
        // The freshly-loaded manifest still verifies.
        car_bundle::verify_signature(&loaded[0]).expect("signature should verify");
    }

    #[test]
    fn signed_but_tampered_manifest_still_loads_in_phase_2_with_warning() {
        use ed25519_dalek::SigningKey;
        use rand_core::OsRng;

        let dir = tempfile::tempdir().unwrap();
        let mut m = from_legacy_spec(&legacy_spec());
        let key = SigningKey::generate(&mut OsRng);
        car_bundle::sign_manifest(&mut m, &key).unwrap();
        // Tamper after signing.
        if let TransportSpec::ExternalProcess(ref mut t) = m.transport {
            t.command = Some("/tmp/tampered".into());
        }
        write_manifest(dir.path(), &m).unwrap();
        // Phase 2 warn-but-not-reject: load still returns the entry
        // even though verify will fail. Phase 3 will reject.
        let loaded = load_manifest_dir(dir.path()).unwrap();
        assert_eq!(loaded.len(), 1);
        // But the signature does NOT verify when checked
        // independently.
        assert!(car_bundle::verify_signature(&loaded[0]).is_err());
    }
}