use std::sync::Arc;
use crate::core::Egress;
use super::card::{AgentCard, CardInterface, WELL_KNOWN_PATH};
use super::card_sig::{CardSignatureError, CardVerifier};
pub const JSONRPC: &str = "JSONRPC";
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("this client may not connect: {0}")]
Refused(String),
#[error("the card could not be fetched: {0}")]
Unreachable(String),
#[error("the card at '{url}' is not a valid Agent Card: {detail}")]
Malformed { url: String, detail: String },
#[error("the card's signature was not acceptable: {0}")]
Signature(#[from] CardSignatureError),
#[error(
"the agent at '{url}' offers no {binding} interface speaking A2A {version} — \
it advertises: {offered}"
)]
NoUsableInterface {
url: String,
binding: String,
version: String,
offered: String,
},
}
#[derive(Debug, Clone)]
pub struct CardClient {
egress: Option<Egress>,
verifier: Option<Arc<dyn CardVerifier>>,
timeout: std::time::Duration,
http: std::sync::OnceLock<reqwest::Client>,
#[cfg(feature = "testkit")]
loopback: bool,
}
impl Default for CardClient {
fn default() -> Self {
Self {
egress: None,
verifier: None,
timeout: Self::DEFAULT_TIMEOUT,
http: std::sync::OnceLock::new(),
#[cfg(feature = "testkit")]
loopback: false,
}
}
}
impl CardClient {
pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(feature = "testkit")]
#[must_use]
pub const fn allow_loopback(mut self) -> Self {
self.loopback = true;
self
}
fn reach(&self) -> crate::netguard::Reach {
if self.loopback_allowed() {
crate::netguard::Reach::PublicOrLoopbackName
} else {
crate::netguard::Reach::Public
}
}
fn http(&self) -> Result<&reqwest::Client, DiscoveryError> {
if let Some(client) = self.http.get() {
return Ok(client);
}
let client = crate::netguard::guarded_client(self.reach())
.timeout(self.timeout)
.build()
.map_err(|e| DiscoveryError::Unreachable(e.to_string()))?;
Ok(self.http.get_or_init(|| client))
}
#[allow(clippy::unused_self)]
const fn loopback_allowed(&self) -> bool {
#[cfg(feature = "testkit")]
{
self.loopback
}
#[cfg(not(feature = "testkit"))]
{
false
}
}
#[must_use]
pub const fn timeout(mut self, timeout: std::time::Duration) -> Self {
assert!(
!timeout.is_zero(),
"a card-fetch timeout of zero refuses every card; configure no discovery instead"
);
self.timeout = timeout;
self
}
#[must_use]
pub fn egress(mut self, egress: Egress) -> Self {
self.egress = Some(egress);
self
}
#[must_use]
pub fn verifying_with(mut self, verifier: Arc<dyn CardVerifier>) -> Self {
self.verifier = Some(verifier);
self
}
pub async fn discover(&self, origin: &str) -> Result<AgentCard, DiscoveryError> {
let url = format!("{}{WELL_KNOWN_PATH}", origin.trim_end_matches('/'));
self.fetch(&url).await
}
pub async fn fetch(&self, url: &str) -> Result<AgentCard, DiscoveryError> {
let parsed = reqwest::Url::parse(url).map_err(|e| DiscoveryError::Malformed {
url: url.to_owned(),
detail: e.to_string(),
})?;
let host = parsed
.host_str()
.ok_or_else(|| DiscoveryError::Malformed {
url: url.to_owned(),
detail: "the card URL names no host".to_owned(),
})?
.to_owned();
if parsed.scheme() != "https"
&& !(self.loopback_allowed()
&& crate::netguard::is_loopback_name(&host.to_ascii_lowercase()))
{
return Err(DiscoveryError::Refused(format!(
"the card URL '{url}' is not https — a plaintext card is whatever \
the network says it is, and its interface URL steers the calls \
that follow"
)));
}
if let Some(egress) = &self.egress {
if let Err(e) = egress.permits(Some(host.as_str())) {
return Err(DiscoveryError::Refused(e.to_string()));
}
}
let lookup = host
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(&host)
.to_owned();
let port = parsed.port_or_known_default().unwrap_or(443);
let resolved = tokio::net::lookup_host((lookup.as_str(), port))
.await
.map_err(|e| DiscoveryError::Unreachable(format!("DNS for '{host}': {e}")))?;
crate::netguard::judge(self.reach(), &host, resolved).map_err(|e| match e {
crate::netguard::NetGuardError::NoAddresses { .. } => {
DiscoveryError::Unreachable(e.to_string())
}
crate::netguard::NetGuardError::Forbidden { .. } => {
DiscoveryError::Refused(e.to_string())
}
})?;
let client = self.http()?;
let response = client
.get(url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| DiscoveryError::Unreachable(crate::netguard::transport_text(&e)))?;
if !response.status().is_success() {
return Err(DiscoveryError::Unreachable(format!(
"{url} answered {}",
response.status()
)));
}
let body = crate::netguard::intake::read(response, crate::netguard::intake::METADATA)
.await
.map_err(|e| DiscoveryError::Malformed {
url: url.to_owned(),
detail: e.to_string(),
})?;
let card: AgentCard =
serde_json::from_slice(&body).map_err(|e| DiscoveryError::Malformed {
url: url.to_owned(),
detail: e.to_string(),
})?;
if let Some(verifier) = &self.verifier {
card.verify(verifier.as_ref())?;
}
Ok(card)
}
}
impl AgentCard {
#[must_use]
pub fn select_interface(&self, binding: &str, version: &str) -> Option<&CardInterface> {
let want = super::protocol_major_minor(version)?;
self.supported_interfaces.iter().find(|i| {
i.protocol_binding == binding
&& super::protocol_major_minor(&i.protocol_version) == Some(want)
})
}
#[cfg(feature = "a2a")]
pub fn endpoint(&self) -> Result<super::a2a::Endpoint, DiscoveryError> {
let version = super::PROTOCOL_VERSION;
let iface = self.select_interface(JSONRPC, version).ok_or_else(|| {
DiscoveryError::NoUsableInterface {
url: self.name.clone(),
binding: JSONRPC.to_owned(),
version: version.to_owned(),
offered: self
.supported_interfaces
.iter()
.map(|i| format!("{} {}", i.protocol_binding, i.protocol_version))
.collect::<Vec<_>>()
.join(", "),
}
})?;
let endpoint = super::a2a::Endpoint::new(iface.url.clone());
Ok(match &iface.tenant {
Some(t) => endpoint.for_tenant(t.clone()),
None => endpoint,
})
}
}