pub(crate) mod auth;
pub(crate) mod core;
pub(crate) mod grpc;
pub(crate) mod jsonrpc;
pub mod proto;
pub(crate) mod server;
pub(crate) mod state;
pub(crate) use proto::lf::a2a::v1;
pub use v1::a2a_service_client::A2aServiceClient;
pub use v1::a2a_service_server::{A2aService, A2aServiceServer};
#[derive(Debug, Clone)]
pub struct A2aServeOptions {
pub addr: std::net::SocketAddr,
pub name: Option<String>,
pub description: Option<String>,
pub token: Option<String>,
pub token_file: Option<std::path::PathBuf>,
pub tls_cert: Option<std::path::PathBuf>,
pub tls_key: Option<std::path::PathBuf>,
}
pub fn run_server(opts: A2aServeOptions) -> std::io::Result<()> {
let mut card = state::AgentCardInfo::default();
if let Some(name) = opts.name {
card.name = name;
}
if let Some(description) = opts.description {
card.description = description;
}
let tls = resolve_tls_config(opts.tls_cert.as_deref(), opts.tls_key.as_deref())?;
let scheme = if tls.is_some() { "https" } else { "http" };
let url = format!("{scheme}://{}", opts.addr);
card.http_url.clone_from(&url);
card.grpc_url = url;
let auth_token: Option<std::sync::Arc<str>> = if let Some(token) = opts.token.as_deref() {
let token = token.trim();
if token.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--token must not be empty",
));
}
Some(std::sync::Arc::from(token))
} else if let Some(path) = opts.token_file.as_deref() {
let token = auth::load_or_create_token(path)?;
tracing::info!(path = %path.display(), "A2A bearer auth enabled (token file)");
Some(std::sync::Arc::from(token.as_str()))
} else {
None
};
if auth_token.is_none() && !opts.addr.ip().is_loopback() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to bind non-loopback address {} without auth; pass --token or --token-file",
opts.addr
),
));
}
if auth_token.is_some() {
tracing::info!("A2A bearer auth required on all routes except the public agent card");
}
let app_state = state::A2aState::new(card).with_auth_token(auth_token);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(async move {
let cancel = tokio_util::sync::CancellationToken::new();
let signal_cancel = cancel.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
tracing::info!("Ctrl-C received; shutting down A2A server");
signal_cancel.cancel();
}
});
server::serve(app_state, opts.addr, cancel, tls).await
})
}
#[derive(Debug, Clone)]
pub(crate) struct TlsPaths {
pub(crate) cert: std::path::PathBuf,
pub(crate) key: std::path::PathBuf,
}
pub(crate) fn resolve_tls_config(
cert: Option<&std::path::Path>,
key: Option<&std::path::Path>,
) -> std::io::Result<Option<TlsPaths>> {
match (cert, key) {
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--tls-cert and --tls-key must be supplied together (both or neither)",
)),
(Some(cert), Some(key)) => {
ensure_readable(cert)?;
ensure_readable(key)?;
ensure_key_not_group_readable(key)?;
Ok(Some(TlsPaths {
cert: cert.to_path_buf(),
key: key.to_path_buf(),
}))
}
}
}
fn ensure_readable(path: &std::path::Path) -> std::io::Result<()> {
std::fs::File::open(path)
.map(|_| ())
.map_err(|err| std::io::Error::new(err.kind(), format!("{}: {err}", path.display())))
}
#[cfg(unix)]
fn ensure_key_not_group_readable(key: &std::path::Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(key)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"TLS key '{}' is group/other accessible (mode {mode:o}); expected 0600",
key.display()
),
));
}
Ok(())
}
#[cfg(not(unix))]
fn ensure_key_not_group_readable(_key: &std::path::Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::resolve_tls_config;
use std::io::Write as _;
use std::path::Path;
fn temp_file(bytes: &[u8]) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("create temp file");
file.write_all(bytes).expect("write temp file");
file.flush().expect("flush temp file");
file
}
#[test]
fn neither_cert_nor_key_yields_plaintext() {
let resolved = resolve_tls_config(None, None).expect("neither is valid");
assert!(
resolved.is_none(),
"no TLS pair must map to the plaintext path"
);
}
#[test]
fn both_readable_paths_yield_a_config() {
let cert = temp_file(b"-----BEGIN CERTIFICATE-----\n");
let key = temp_file(b"-----BEGIN PRIVATE KEY-----\n");
let resolved = resolve_tls_config(Some(cert.path()), Some(key.path()))
.expect("both readable must validate");
let paths = resolved.expect("both supplied must yield Some");
assert_eq!(paths.cert, cert.path());
assert_eq!(paths.key, key.path());
}
#[cfg(unix)]
#[test]
fn group_readable_key_is_rejected() {
use std::os::unix::fs::PermissionsExt as _;
let cert = temp_file(b"-----BEGIN CERTIFICATE-----\n");
let key = temp_file(b"-----BEGIN PRIVATE KEY-----\n");
std::fs::set_permissions(key.path(), std::fs::Permissions::from_mode(0o644))
.expect("chmod 0644");
let err = resolve_tls_config(Some(cert.path()), Some(key.path()))
.expect_err("group/other-readable key must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn cert_without_key_is_rejected() {
let cert = temp_file(b"x");
let err = resolve_tls_config(Some(cert.path()), None)
.expect_err("exactly-one (cert only) must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn key_without_cert_is_rejected() {
let key = temp_file(b"x");
let err = resolve_tls_config(None, Some(key.path()))
.expect_err("exactly-one (key only) must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn missing_cert_file_is_rejected() {
let key = temp_file(b"x");
let missing = Path::new("/nonexistent/basemind-a2a-tls/cert.pem");
let err = resolve_tls_config(Some(missing), Some(key.path()))
.expect_err("unreadable cert must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
assert!(
err.to_string().contains("cert.pem"),
"error must name the offending path: {err}"
);
}
#[test]
fn missing_key_file_is_rejected() {
let cert = temp_file(b"x");
let missing = Path::new("/nonexistent/basemind-a2a-tls/key.pem");
let err = resolve_tls_config(Some(cert.path()), Some(missing))
.expect_err("unreadable key must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
}