use std::path::Path;
use serde::Deserialize;
pub const A2A_ROUTES_ENTRY: &str = "assets/a2a-routes.json";
pub const A2A_ENV_SEGMENT: &str = "default";
pub const A2A_TOKEN_PREFIX: &str = "a2a_token__";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct PackA2aRoute {
pub agent_id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub auth_header_name: Option<String>,
#[serde(default)]
pub auth_team: Option<String>,
#[serde(default)]
pub requires_auth: bool,
}
#[must_use]
pub fn routes_from_pack(pack_path: &Path) -> Vec<PackA2aRoute> {
let Ok(file) = std::fs::File::open(pack_path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let Ok(entry) = archive.by_name(A2A_ROUTES_ENTRY) else {
return Vec::new();
};
match serde_json::from_reader::<_, Vec<PackA2aRoute>>(entry) {
Ok(routes) => routes,
Err(error) => {
tracing::warn!(
error = %error,
entry = A2A_ROUTES_ENTRY,
"ignoring malformed A2A route sidecar"
);
Vec::new()
}
}
}
#[must_use]
pub fn a2a_secret_uri(tenant: &str, team: Option<&str>, agent_id: &str) -> String {
let team = greentic_secrets_lib::normalize_team(team)
.unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string());
format!("secrets://{A2A_ENV_SEGMENT}/{tenant}/{team}/a2a/{agent_id}")
}
#[must_use]
pub fn secret_team(route: &PackA2aRoute, configured_team: Option<&str>) -> String {
let chosen = route
.auth_team
.as_deref()
.filter(|team| !team.trim().is_empty())
.or(configured_team);
greentic_secrets_lib::normalize_team(chosen)
.unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string())
}
#[must_use]
pub fn token_question_id(agent_id: &str) -> String {
format!("{A2A_TOKEN_PREFIX}{agent_id}")
}
#[must_use]
pub fn agent_id_from_token_question(question_id: &str) -> Option<&str> {
question_id.strip_prefix(A2A_TOKEN_PREFIX)
}
pub fn augment_with_a2a_routes(mut form: qa_spec::FormSpec, pack_path: &Path) -> qa_spec::FormSpec {
for route in routes_from_pack(pack_path)
.into_iter()
.filter(|route| route.requires_auth)
{
let id = token_question_id(&route.agent_id);
if form.questions.iter().any(|q| q.id == id) {
continue;
}
let label = route.name.clone().unwrap_or_else(|| route.agent_id.clone());
let header = match route.auth_header_name.as_deref() {
Some(name) if !name.trim().is_empty() => format!("`{name}` header"),
_ => "`Authorization: Bearer` header".to_string(),
};
form.questions.push(qa_spec::QuestionSpec {
id,
kind: qa_spec::QuestionType::String,
title: format!("A2A agent '{label}' credential"),
title_i18n: None,
description: Some(format!(
"Sent in the {header}. Leave blank if the credential is already \
provisioned for this tenant."
)),
description_i18n: None,
required: false,
choices: None,
default_value: None,
secret: true,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: Default::default(),
computed_overridable: false,
});
}
form
}
pub async fn persist_a2a_secrets(
store: &greentic_secrets_lib::DevStore,
tenant: &str,
team: Option<&str>,
config: &serde_json::Value,
pack_path: Option<&Path>,
) -> anyhow::Result<Vec<String>> {
let Some(map) = config.as_object() else {
return Ok(Vec::new());
};
let routes = pack_path.map(routes_from_pack).unwrap_or_default();
let mut entries = Vec::new();
let mut written = Vec::new();
for (key, value) in map {
let Some(agent_id) = agent_id_from_token_question(key) else {
continue;
};
let text = value.as_str().unwrap_or_default().trim();
if text.is_empty() {
continue;
}
let team_segment = match routes.iter().find(|route| route.agent_id == agent_id) {
Some(route) => secret_team(route, team),
None => greentic_secrets_lib::normalize_team(team)
.unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string()),
};
let uri = a2a_secret_uri(tenant, Some(&team_segment), agent_id);
tracing::info!(
uri = %uri,
value_len = text.len(),
agent_id,
"setup secret WRITE (a2a)"
);
entries.push(greentic_secrets_lib::SeedEntry {
uri,
format: greentic_secrets_lib::SecretFormat::Text,
value: greentic_secrets_lib::SeedValue::Text {
text: text.to_string(),
},
description: Some(format!("A2A credential for agent {agent_id}")),
});
written.push(agent_id.to_string());
}
if entries.is_empty() {
return Ok(written);
}
let report = greentic_secrets_lib::apply_seed(
store,
&greentic_secrets_lib::SeedDoc { entries },
greentic_secrets_lib::ApplyOptions::default(),
)
.await;
if !report.failed.is_empty() {
anyhow::bail!(
"failed to persist {} A2A credential(s): {:?}",
report.failed.len(),
report.failed
);
}
Ok(written)
}
#[cfg(test)]
mod tests;