1use crate::bridges::{Bridge, BridgeError, BridgeKind};
4
5#[derive(Clone, Debug)]
6pub struct A2AAgentCard {
7 pub agent_id: String,
8 pub display_name: Option<String>,
9 pub public_key_b64: Option<String>,
10 pub public_key_algorithm: Option<String>,
11 pub capabilities: Vec<A2ACapability>,
12 pub trust_domain: String,
13}
14
15#[derive(Clone, Debug)]
16pub struct A2ACapability {
17 pub name: String,
18 pub description: Option<String>,
19 pub risk: Option<String>,
20}
21
22#[derive(Clone, Debug)]
23pub struct A2ABridgeConfig {
24 pub bridge_id: String,
25 pub trust_domain: String,
26 pub default_risk: Option<String>,
27}
28
29pub struct A2ABridge {
30 cfg: A2ABridgeConfig,
31}
32
33impl Bridge for A2ABridge {
34 fn bridge_id(&self) -> &str {
35 &self.cfg.bridge_id
36 }
37 fn kind(&self) -> BridgeKind {
38 BridgeKind::A2a
39 }
40 fn trust_domain(&self) -> &str {
41 &self.cfg.trust_domain
42 }
43}
44
45#[derive(Clone, Debug)]
46pub struct ProjectedActor {
47 pub actor_id: String,
48 pub algorithm: String,
49 pub public_key: String,
50 pub capabilities: Vec<(String, String)>, }
52
53impl A2ABridge {
54 pub fn new(cfg: A2ABridgeConfig) -> Self {
55 Self { cfg }
56 }
57
58 pub fn accept_agent_card(&self, card: &A2AAgentCard) -> Result<ProjectedActor, BridgeError> {
59 if card.agent_id.is_empty() || card.trust_domain.is_empty() {
60 return Err(BridgeError::InvalidInput(
61 "AgentCard missing agent_id or trust_domain".into(),
62 ));
63 }
64 let actor_id = format!("tf:actor:agent:{}/{}", card.trust_domain, card.agent_id);
65 let (algorithm, public_key) = match (&card.public_key_b64, &card.public_key_algorithm) {
66 (Some(pk), Some(alg)) => (alg.clone(), pk.clone()),
67 (Some(pk), None) => ("ed25519".to_string(), pk.clone()),
68 _ => (
69 "external-attestation".to_string(),
70 format!("agent-card:{}", card.agent_id),
71 ),
72 };
73 let mut caps = Vec::with_capacity(card.capabilities.len());
74 for c in &card.capabilities {
75 let action = a2a_normalise_capability(&c.name, None);
76 if !is_valid_action_name(&action) {
77 return Err(BridgeError::Rejected(format!(
78 "A2A capability {} does not normalise to a valid action name (got {})",
79 c.name, action
80 )));
81 }
82 let risk = c
83 .risk
84 .clone()
85 .or_else(|| self.cfg.default_risk.clone())
86 .unwrap_or_else(|| "R2".to_string());
87 caps.push((action, risk));
88 }
89 Ok(ProjectedActor {
90 actor_id,
91 algorithm,
92 public_key,
93 capabilities: caps,
94 })
95 }
96}
97
98pub fn a2a_normalise_capability(name: &str, prefix: Option<&str>) -> String {
102 let mut buf = String::with_capacity(name.len());
103 let mut last_underscore = false;
104 for c in name.chars() {
105 if c.is_ascii_alphanumeric() {
106 buf.push(c.to_ascii_lowercase());
107 last_underscore = false;
108 } else if !last_underscore {
109 buf.push('_');
110 last_underscore = true;
111 }
112 }
113 let scrubbed = buf.trim_matches('_').to_string();
114 let with_prefix = match prefix {
115 Some(p) => format!("{}.{}", p, scrubbed),
116 None => scrubbed,
117 };
118 if with_prefix.contains('.') {
119 with_prefix
120 } else {
121 format!("a2a.{}", with_prefix)
122 }
123}
124
125fn is_valid_action_name(s: &str) -> bool {
126 let mut segs = s.split('.');
127 let first = match segs.next() {
128 Some(x) => x,
129 None => return false,
130 };
131 if !is_valid_action_segment(first) {
132 return false;
133 }
134 let mut count = 1;
135 for seg in segs {
136 if !is_valid_action_segment(seg) {
137 return false;
138 }
139 count += 1;
140 }
141 count >= 2
142}
143
144fn is_valid_action_segment(s: &str) -> bool {
145 let mut chars = s.chars();
146 let first = match chars.next() {
147 Some(c) => c,
148 None => return false,
149 };
150 if !first.is_ascii_lowercase() {
151 return false;
152 }
153 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn normalise_basic() {
162 assert_eq!(
164 a2a_normalise_capability("filesystem.read", None),
165 "a2a.filesystem_read"
166 );
167 assert_eq!(a2a_normalise_capability("ping", None), "a2a.ping");
168 assert_eq!(
169 a2a_normalise_capability("Read File!", None),
170 "a2a.read_file"
171 );
172 assert_eq!(
173 a2a_normalise_capability("system-info", Some("tools")),
174 "tools.system_info"
175 );
176 }
177
178 #[test]
179 fn accept_agent_card_round_trip() {
180 let bridge = A2ABridge::new(A2ABridgeConfig {
181 bridge_id: "tf-a2a".into(),
182 trust_domain: "example.com".into(),
183 default_risk: Some("R2".into()),
184 });
185 let card = A2AAgentCard {
186 agent_id: "code-helper".into(),
187 display_name: None,
188 public_key_b64: Some("AAAA".into()),
189 public_key_algorithm: Some("ed25519".into()),
190 capabilities: vec![A2ACapability {
191 name: "fs.read".into(),
192 description: None,
193 risk: None,
194 }],
195 trust_domain: "example.com".into(),
196 };
197 let p = bridge.accept_agent_card(&card).expect("project");
198 assert_eq!(p.actor_id, "tf:actor:agent:example.com/code-helper");
199 assert_eq!(p.capabilities[0].0, "a2a.fs_read");
200 assert_eq!(p.capabilities[0].1, "R2");
201 }
202
203 #[test]
204 fn missing_agent_id_rejected() {
205 let bridge = A2ABridge::new(A2ABridgeConfig {
206 bridge_id: "tf-a2a".into(),
207 trust_domain: "example.com".into(),
208 default_risk: None,
209 });
210 let card = A2AAgentCard {
211 agent_id: "".into(),
212 display_name: None,
213 public_key_b64: None,
214 public_key_algorithm: None,
215 capabilities: vec![],
216 trust_domain: "example.com".into(),
217 };
218 assert!(bridge.accept_agent_card(&card).is_err());
219 }
220}