car-a2a 0.24.1

Bridge between Common Agent Runtime and the Linux Foundation Agent2Agent (A2A) v1.0 protocol
//! Registry of remote A2A peers CAR can discover and call.
//!
//! A2A is otherwise ad-hoc — `a2a.send` dials a caller-supplied endpoint. To
//! *discover* across peers (resolve a need into a remote agent's skill), CAR
//! needs a persisted list of peers it knows about. This module is that list:
//! file-backed at `~/.car/a2a-peers.json` (`CAR_A2A_PEERS_PATH` overrides),
//! atomic write-through, mirroring the other `~/.car` registries' hygiene.
//!
//! Each peer carries a stable, identifier-safe `slug` (derived from its host)
//! so the discovery layer can name a peer's skill
//! `agentdns://<slug>/skill/<skill-id>` and a caller can map back to the
//! endpoint via [`PeerRegistry::list`].
//!
//! **Trust:** registering a peer means CAR will fetch its agent card (an
//! outbound GET) during discovery. The *caller-facing* gate lives in the
//! `a2a.peers.add` handler (loopback-only unless an explicit opt-in, mirroring
//! `a2a.send`); this module only validates URL shape. Note the A2A client used
//! for the fetch follows redirects (a property shared with `a2a.send`), so a
//! trusted peer can still redirect the fetch elsewhere — registering a
//! non-loopback peer is a deliberate trust decision.

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

/// A registered remote A2A peer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PeerEntry {
    /// Stable, identifier-safe key derived from the peer's host. Unique within
    /// the registry; used as the `organization` segment of skill identifiers.
    pub slug: String,
    /// The peer's base URL (its agent card is at `<url>/.well-known/agent-card.json`).
    pub url: String,
    /// Optional human label.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

/// Slugify a host into the `agentdns://` identifier charset (alnum, `-`, `.`):
/// lowercase, replace anything else (incl. the `:` of a `host:port`) with `-`,
/// trim leading/trailing separators. Never empty.
fn slugify_host(host: &str) -> String {
    let mapped: String = host
        .to_ascii_lowercase()
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
                c
            } else {
                '-'
            }
        })
        .collect();
    let trimmed = mapped.trim_matches(['-', '.'].as_ref());
    if trimmed.is_empty() {
        "peer".to_string()
    } else {
        trimmed.to_string()
    }
}

/// Pick a slug not already taken, appending `-2`, `-3`, … on collision.
fn unique_slug(base: &str, existing: &[PeerEntry]) -> String {
    if !existing.iter().any(|p| p.slug == base) {
        return base.to_string();
    }
    (2..)
        .map(|n| format!("{base}-{n}"))
        .find(|s| !existing.iter().any(|p| &p.slug == s))
        .expect("infinite range yields a free slug")
}

/// File-backed registry of remote A2A peers. See module docs.
pub struct PeerRegistry {
    path: PathBuf,
}

impl PeerRegistry {
    /// `~/.car/a2a-peers.json` (or `CAR_A2A_PEERS_PATH`).
    pub fn user_default() -> Result<Self, String> {
        if let Some(p) = std::env::var_os("CAR_A2A_PEERS_PATH") {
            return Ok(Self { path: PathBuf::from(p) });
        }
        let home = std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
        Ok(Self {
            path: PathBuf::from(home).join(".car").join("a2a-peers.json"),
        })
    }

    pub fn at(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    fn read_all(&self) -> Vec<PeerEntry> {
        std::fs::read_to_string(&self.path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    fn write_all(&self, peers: &[PeerEntry]) -> Result<(), String> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("create {}: {e}", parent.display()))?;
        }
        let json = serde_json::to_string_pretty(peers).map_err(|e| e.to_string())?;
        let tmp = self.path.with_extension("json.tmp");
        std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
        std::fs::rename(&tmp, &self.path)
            .map_err(|e| format!("rename into {}: {e}", self.path.display()))
    }

    /// Register a peer by base URL. Validates the URL is http(s), derives a
    /// unique slug from its host, and rejects a duplicate URL.
    pub fn add(&self, url: &str, label: Option<String>) -> Result<PeerEntry, String> {
        let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid url: {e}"))?;
        if !matches!(parsed.scheme(), "http" | "https") {
            return Err("peer url must be http or https".to_string());
        }
        let host = parsed.host_str().ok_or("peer url has no host")?.to_string();
        // Canonicalize so trailing-slash / default-port variants of the same URL
        // dedup (and don't double-fetch as distinct peers). Trim a trailing `/`
        // so the client's `<url>/.well-known/agent-card.json` stays single-slash.
        let canonical = parsed.as_str().trim_end_matches('/').to_string();
        let mut all = self.read_all();
        if all.iter().any(|p| p.url == canonical) {
            return Err(format!("peer '{canonical}' is already registered"));
        }
        let entry = PeerEntry {
            slug: unique_slug(&slugify_host(&host), &all),
            url: canonical,
            label,
        };
        all.push(entry.clone());
        self.write_all(&all)?;
        Ok(entry)
    }

    pub fn list(&self) -> Vec<PeerEntry> {
        self.read_all()
    }

    /// Remove a peer by slug. Returns whether one was removed.
    pub fn remove(&self, slug: &str) -> Result<bool, String> {
        let mut all = self.read_all();
        let before = all.len();
        all.retain(|p| p.slug != slug);
        let removed = all.len() != before;
        if removed {
            self.write_all(&all)?;
        }
        Ok(removed)
    }
}

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

    #[test]
    fn slugify_handles_host_port_and_specials() {
        assert_eq!(slugify_host("peer.example.com"), "peer.example.com");
        assert_eq!(slugify_host("peer.example.com:8080"), "peer.example.com-8080");
        assert_eq!(slugify_host("UPPER_Case"), "upper-case");
    }

    #[test]
    fn add_derives_slug_and_rejects_dupes() {
        let dir = tempfile::tempdir().unwrap();
        let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
        let e = reg.add("https://peer.example.com/a2a", Some("Peer".into())).unwrap();
        assert_eq!(e.slug, "peer.example.com");
        assert_eq!(reg.list().len(), 1);
        // Duplicate URL rejected.
        assert!(reg.add("https://peer.example.com/a2a", None).is_err());
    }

    #[test]
    fn add_uniquifies_slug_on_host_collision() {
        let dir = tempfile::tempdir().unwrap();
        let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
        let a = reg.add("https://peer.example.com/one", None).unwrap();
        let b = reg.add("https://peer.example.com/two", None).unwrap();
        assert_eq!(a.slug, "peer.example.com");
        assert_eq!(b.slug, "peer.example.com-2"); // same host, distinct slug
    }

    #[test]
    fn canonicalizes_url_so_trailing_slash_dedups() {
        let dir = tempfile::tempdir().unwrap();
        let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
        let e = reg.add("https://x.example.com", None).unwrap();
        assert_eq!(e.url, "https://x.example.com"); // trailing slash trimmed
        // The trailing-slash variant is the same peer.
        assert!(reg.add("https://x.example.com/", None).is_err());
        assert_eq!(reg.list().len(), 1);
    }

    #[test]
    fn rejects_non_http_scheme() {
        let dir = tempfile::tempdir().unwrap();
        let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
        assert!(reg.add("ftp://peer.example.com", None).is_err());
        assert!(reg.add("not a url", None).is_err());
    }

    #[test]
    fn remove_by_slug() {
        let dir = tempfile::tempdir().unwrap();
        let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
        reg.add("https://a.example.com", None).unwrap();
        assert!(reg.remove("a.example.com").unwrap());
        assert!(!reg.remove("a.example.com").unwrap()); // already gone
        assert!(reg.list().is_empty());
    }
}