Skip to main content

harn_vm/a2a/
mod.rs

1use std::error::Error as _;
2
3use reqwest::Url;
4use serde_json::Value;
5use tokio::sync::broadcast;
6
7use crate::triggers::TriggerEvent;
8
9const A2A_AGENT_CARD_PATH: &str = ".well-known/a2a-agent";
10const A2A_PROTOCOL_VERSION: &str = "1.0.0";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ResolvedA2aEndpoint {
14    pub card_url: String,
15    pub rpc_url: String,
16    pub agent_id: Option<String>,
17    pub target_agent: String,
18}
19
20#[derive(Clone, Debug, PartialEq)]
21pub enum DispatchAck {
22    InlineResult {
23        task_id: String,
24        result: Value,
25    },
26    PendingTask {
27        task_id: String,
28        state: String,
29        handle: Value,
30    },
31}
32
33#[derive(Debug)]
34pub enum A2aClientError {
35    InvalidTarget(String),
36    Discovery(String),
37    Protocol(String),
38    Cancelled(String),
39}
40
41impl std::fmt::Display for A2aClientError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::InvalidTarget(message)
45            | Self::Discovery(message)
46            | Self::Protocol(message)
47            | Self::Cancelled(message) => f.write_str(message),
48        }
49    }
50}
51
52impl std::error::Error for A2aClientError {}
53
54#[derive(Debug)]
55enum AgentCardFetchError {
56    Cancelled(String),
57    Discovery(String),
58    ConnectRefused(String),
59}
60
61pub async fn dispatch_trigger_event(
62    raw_target: &str,
63    allow_cleartext: bool,
64    binding_id: &str,
65    binding_key: &str,
66    event: &TriggerEvent,
67    cancel_rx: &mut broadcast::Receiver<()>,
68) -> Result<(ResolvedA2aEndpoint, DispatchAck), A2aClientError> {
69    let target = parse_target(raw_target)?;
70    let endpoint = resolve_endpoint(&target, allow_cleartext, cancel_rx).await?;
71    let message_id = format!("{}.{}", event.trace_id.0, event.id.0);
72    let envelope = serde_json::json!({
73        "kind": "harn.trigger.dispatch",
74        "message_id": message_id,
75        "trace_id": event.trace_id.0,
76        "event_id": event.id.0,
77        "trigger_id": binding_id,
78        "binding_key": binding_key,
79        "target_agent": endpoint.target_agent,
80        "event": event,
81    });
82    let text = serde_json::to_string(&envelope)
83        .map_err(|error| A2aClientError::Protocol(format!("serialize A2A envelope: {error}")))?;
84    let request = crate::jsonrpc::request(
85        message_id.clone(),
86        "a2a.SendMessage",
87        serde_json::json!({
88            "contextId": event.trace_id.0,
89            "message": {
90                "messageId": message_id,
91                "role": "user",
92                "parts": [{
93                    "type": "text",
94                    "text": text,
95                }],
96                "metadata": {
97                    "kind": "harn.trigger.dispatch",
98                    "trace_id": event.trace_id.0,
99                    "event_id": event.id.0,
100                    "trigger_id": binding_id,
101                    "binding_key": binding_key,
102                    "target_agent": endpoint.target_agent,
103                },
104            },
105        }),
106    );
107
108    let body = send_jsonrpc(&endpoint.rpc_url, &request, cancel_rx).await?;
109    let result = body.get("result").cloned().ok_or_else(|| {
110        if let Some(error) = body.get("error") {
111            let message = error
112                .get("message")
113                .and_then(Value::as_str)
114                .unwrap_or("unknown A2A error");
115            A2aClientError::Protocol(format!("A2A task dispatch failed: {message}"))
116        } else {
117            A2aClientError::Protocol("A2A task dispatch response missing result".to_string())
118        }
119    })?;
120
121    let task_id = result
122        .get("id")
123        .and_then(Value::as_str)
124        .filter(|value| !value.is_empty())
125        .ok_or_else(|| A2aClientError::Protocol("A2A task response missing result.id".to_string()))?
126        .to_string();
127    let state = task_state(&result)?.to_string();
128
129    if state == "completed" {
130        let inline = extract_inline_result(&result);
131        return Ok((
132            endpoint,
133            DispatchAck::InlineResult {
134                task_id,
135                result: inline,
136            },
137        ));
138    }
139
140    Ok((
141        endpoint.clone(),
142        DispatchAck::PendingTask {
143            task_id: task_id.clone(),
144            state: state.clone(),
145            handle: serde_json::json!({
146                "kind": "a2a_task_handle",
147                "task_id": task_id,
148                "state": state,
149                "target_agent": endpoint.target_agent,
150                "rpc_url": endpoint.rpc_url,
151                "card_url": endpoint.card_url,
152                "agent_id": endpoint.agent_id,
153            }),
154        },
155    ))
156}
157
158pub fn target_agent_label(raw_target: &str) -> String {
159    parse_target(raw_target)
160        .map(|target| target.target_agent_label())
161        .unwrap_or_else(|_| raw_target.to_string())
162}
163
164#[derive(Clone, Debug)]
165struct ParsedTarget {
166    authority: String,
167    target_agent: String,
168}
169
170impl ParsedTarget {
171    fn target_agent_label(&self) -> String {
172        if self.target_agent.is_empty() {
173            self.authority.clone()
174        } else {
175            self.target_agent.clone()
176        }
177    }
178}
179
180fn parse_target(raw_target: &str) -> Result<ParsedTarget, A2aClientError> {
181    let parsed = Url::parse(&format!("http://{raw_target}")).map_err(|error| {
182        A2aClientError::InvalidTarget(format!(
183            "invalid a2a dispatch target '{raw_target}': {error}"
184        ))
185    })?;
186    let host = parsed.host_str().ok_or_else(|| {
187        A2aClientError::InvalidTarget(format!(
188            "invalid a2a dispatch target '{raw_target}': missing host"
189        ))
190    })?;
191    let authority = if let Some(port) = parsed.port() {
192        format!("{host}:{port}")
193    } else {
194        host.to_string()
195    };
196    Ok(ParsedTarget {
197        authority,
198        target_agent: parsed.path().trim_start_matches('/').to_string(),
199    })
200}
201
202async fn resolve_endpoint(
203    target: &ParsedTarget,
204    allow_cleartext: bool,
205    cancel_rx: &mut broadcast::Receiver<()>,
206) -> Result<ResolvedA2aEndpoint, A2aClientError> {
207    let mut last_error = None;
208    for scheme in card_resolution_schemes(allow_cleartext) {
209        let card_url = format!("{scheme}://{}/{A2A_AGENT_CARD_PATH}", target.authority);
210        match fetch_agent_card(&card_url, cancel_rx).await {
211            Ok(card) => {
212                return endpoint_from_card(
213                    card_url,
214                    allow_cleartext,
215                    &target.authority,
216                    target.target_agent.clone(),
217                    &card,
218                );
219            }
220            Err(AgentCardFetchError::Cancelled(message)) => {
221                return Err(A2aClientError::Cancelled(message));
222            }
223            Err(error) => {
224                let message = agent_card_fetch_error_message(&error);
225                last_error = Some(message);
226                if should_try_cleartext_fallback(scheme, allow_cleartext, &error, &target.authority)
227                {
228                    continue;
229                }
230                break;
231            }
232        }
233    }
234    Err(A2aClientError::Discovery(format!(
235        "could not resolve A2A agent card for '{}': {}",
236        target.authority,
237        last_error.unwrap_or_else(|| "unknown discovery error".to_string())
238    )))
239}
240
241async fn fetch_agent_card(
242    card_url: &str,
243    cancel_rx: &mut broadcast::Receiver<()>,
244) -> Result<Value, AgentCardFetchError> {
245    let response = tokio::select! {
246        response = crate::llm::shared_utility_client().get(card_url).send() => {
247            match response {
248                Ok(response) => Ok(response),
249                Err(error) if is_connect_refused(&error) => Err(AgentCardFetchError::ConnectRefused(
250                    format!("A2A HTTP request failed: {error}")
251                )),
252                Err(error) => Err(AgentCardFetchError::Discovery(
253                    format!("A2A HTTP request failed: {error}")
254                )),
255            }
256        }
257        _ = recv_cancel(cancel_rx) => Err(AgentCardFetchError::Cancelled(
258            "A2A agent-card fetch cancelled".to_string()
259        )),
260    }?;
261    if !response.status().is_success() {
262        return Err(AgentCardFetchError::Discovery(format!(
263            "GET {card_url} returned HTTP {}",
264            response.status()
265        )));
266    }
267    response
268        .json::<Value>()
269        .await
270        .map_err(|error| AgentCardFetchError::Discovery(format!("parse {card_url}: {error}")))
271}
272
273fn endpoint_from_card(
274    card_url: String,
275    allow_cleartext: bool,
276    requested_authority: &str,
277    target_agent: String,
278    card: &Value,
279) -> Result<ResolvedA2aEndpoint, A2aClientError> {
280    let base_url = card
281        .get("url")
282        .and_then(Value::as_str)
283        .ok_or_else(|| A2aClientError::Discovery("A2A agent card missing url".to_string()))?;
284    let base_url = Url::parse(base_url).map_err(|error| {
285        A2aClientError::Discovery(format!("invalid A2A card url '{base_url}': {error}"))
286    })?;
287    ensure_cleartext_allowed(&base_url, allow_cleartext, "agent card")?;
288    let card_authority = url_authority(&base_url)?;
289    if !authorities_equivalent(&card_authority, requested_authority) {
290        return Err(A2aClientError::Discovery(format!(
291            "A2A agent card url authority mismatch: requested '{requested_authority}', card returned '{card_authority}'"
292        )));
293    }
294    let interfaces = card
295        .get("interfaces")
296        .and_then(Value::as_array)
297        .ok_or_else(|| {
298            A2aClientError::Discovery("A2A agent card missing interfaces".to_string())
299        })?;
300    let jsonrpc_interfaces: Vec<&Value> = interfaces
301        .iter()
302        .filter(|entry| entry.get("protocol").and_then(Value::as_str) == Some("jsonrpc"))
303        .collect();
304    if jsonrpc_interfaces.len() != 1 {
305        return Err(A2aClientError::Discovery(format!(
306            "A2A agent card must expose exactly one jsonrpc interface, found {}",
307            jsonrpc_interfaces.len()
308        )));
309    }
310    let interface_url = jsonrpc_interfaces[0]
311        .get("url")
312        .and_then(Value::as_str)
313        .ok_or_else(|| {
314            A2aClientError::Discovery("A2A jsonrpc interface missing url".to_string())
315        })?;
316    let rpc_url = base_url.join(interface_url).map_err(|error| {
317        A2aClientError::Discovery(format!(
318            "invalid A2A interface url '{interface_url}': {error}"
319        ))
320    })?;
321    ensure_cleartext_allowed(&rpc_url, allow_cleartext, "jsonrpc interface")?;
322    Ok(ResolvedA2aEndpoint {
323        card_url,
324        rpc_url: rpc_url.to_string(),
325        agent_id: card.get("id").and_then(Value::as_str).map(str::to_string),
326        target_agent,
327    })
328}
329
330fn card_resolution_schemes(allow_cleartext: bool) -> &'static [&'static str] {
331    if allow_cleartext {
332        &["https", "http"]
333    } else {
334        &["https"]
335    }
336}
337
338/// Decide whether an HTTPS discovery failure should fall through to cleartext.
339///
340/// External targets only fall back on `ConnectionRefused` — the common "HTTPS
341/// port isn't listening" case. TLS handshake failures to an external host MUST
342/// NOT silently downgrade to HTTP, because an active network attacker can
343/// forge TLS errors to trigger a downgrade.
344///
345/// Loopback targets (`127.0.0.0/8`, `::1`, `localhost`) fall back on any
346/// discovery-style error. They cover the standard local-dev case where
347/// `harn serve` binds HTTP-only on `127.0.0.1:PORT`, and the SSRF threat
348/// model for loopback is already bounded — any attacker who can reach the
349/// local loopback already has code execution on the box.
350fn should_try_cleartext_fallback(
351    scheme: &str,
352    allow_cleartext: bool,
353    error: &AgentCardFetchError,
354    authority: &str,
355) -> bool {
356    if !allow_cleartext || scheme != "https" {
357        return false;
358    }
359    match error {
360        AgentCardFetchError::Cancelled(_) => false,
361        AgentCardFetchError::ConnectRefused(_) => true,
362        AgentCardFetchError::Discovery(_) => is_loopback_authority(authority),
363    }
364}
365
366fn ensure_cleartext_allowed(
367    url: &Url,
368    allow_cleartext: bool,
369    label: &str,
370) -> Result<(), A2aClientError> {
371    if allow_cleartext || url.scheme() != "http" {
372        return Ok(());
373    }
374    Err(A2aClientError::Discovery(format!(
375        "cleartext A2A {label} '{url}' requires `allow_cleartext = true` on the trigger binding"
376    )))
377}
378
379fn is_loopback_authority(authority: &str) -> bool {
380    let (host, _) = split_authority(authority);
381    if host.eq_ignore_ascii_case("localhost") {
382        return true;
383    }
384    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
385        return ip.is_loopback();
386    }
387    false
388}
389
390/// Return true when two authority strings refer to the same A2A endpoint.
391///
392/// Exact string equality is the default — an agent card that reports a
393/// different host than the one the client asked for is a security-relevant
394/// discrepancy (see harn#248 SSRF hardening). The one well-defined exception
395/// is loopback: `localhost`, `127.0.0.1`, `::1`, and the rest of
396/// `127.0.0.0/8` are all the same socket on this machine, and `harn serve`
397/// hardcodes `http://localhost:PORT` in its agent card even when a caller
398/// dials `127.0.0.1:PORT`. Treating both sides as loopback avoids a spurious
399/// mismatch in that case without widening the external-host trust boundary.
400fn authorities_equivalent(card_authority: &str, requested_authority: &str) -> bool {
401    if card_authority == requested_authority {
402        return true;
403    }
404    let (_, card_port) = split_authority(card_authority);
405    let (_, requested_port) = split_authority(requested_authority);
406    if card_port != requested_port {
407        return false;
408    }
409    is_loopback_authority(card_authority) && is_loopback_authority(requested_authority)
410}
411
412/// Split an authority into `(host, port_or_empty)`. Strips IPv6 brackets so
413/// `[::1]:8080` becomes `("::1", "8080")`.
414fn split_authority(authority: &str) -> (&str, &str) {
415    let (host_raw, port) = if authority.starts_with('[') {
416        // IPv6 bracketed form: "[addr]:port" or "[addr]".
417        if let Some(end) = authority.rfind(']') {
418            let host = &authority[..=end];
419            let rest = &authority[end + 1..];
420            let port = rest.strip_prefix(':').unwrap_or("");
421            (host, port)
422        } else {
423            (authority, "")
424        }
425    } else {
426        match authority.rsplit_once(':') {
427            Some((host, port)) => (host, port),
428            None => (authority, ""),
429        }
430    };
431    let host = host_raw.trim_start_matches('[').trim_end_matches(']');
432    (host, port)
433}
434
435fn agent_card_fetch_error_message(error: &AgentCardFetchError) -> String {
436    match error {
437        AgentCardFetchError::Cancelled(message)
438        | AgentCardFetchError::Discovery(message)
439        | AgentCardFetchError::ConnectRefused(message) => message.clone(),
440    }
441}
442
443fn is_connect_refused(error: &reqwest::Error) -> bool {
444    if !error.is_connect() {
445        return false;
446    }
447    let mut source = error.source();
448    while let Some(cause) = source {
449        if let Some(io_error) = cause.downcast_ref::<std::io::Error>() {
450            if io_error.kind() == std::io::ErrorKind::ConnectionRefused {
451                return true;
452            }
453        }
454        source = cause.source();
455    }
456    false
457}
458
459fn url_authority(url: &Url) -> Result<String, A2aClientError> {
460    let host = url
461        .host_str()
462        .ok_or_else(|| A2aClientError::Discovery(format!("A2A card url '{url}' missing host")))?;
463    Ok(if let Some(port) = url.port() {
464        format!("{host}:{port}")
465    } else {
466        host.to_string()
467    })
468}
469
470async fn send_jsonrpc(
471    rpc_url: &str,
472    request: &Value,
473    cancel_rx: &mut broadcast::Receiver<()>,
474) -> Result<Value, A2aClientError> {
475    let response = send_http(
476        crate::llm::shared_blocking_client()
477            .post(rpc_url)
478            .header(reqwest::header::CONTENT_TYPE, "application/json")
479            .header("A2A-Version", A2A_PROTOCOL_VERSION)
480            .json(request),
481        cancel_rx,
482        "A2A task dispatch cancelled",
483    )
484    .await?;
485    if !response.status().is_success() {
486        return Err(A2aClientError::Protocol(format!(
487            "A2A task dispatch returned HTTP {}",
488            response.status()
489        )));
490    }
491    response
492        .json::<Value>()
493        .await
494        .map_err(|error| A2aClientError::Protocol(format!("parse A2A dispatch response: {error}")))
495}
496
497async fn send_http(
498    request: reqwest::RequestBuilder,
499    cancel_rx: &mut broadcast::Receiver<()>,
500    cancelled_message: &'static str,
501) -> Result<reqwest::Response, A2aClientError> {
502    tokio::select! {
503        response = request.send() => response
504            .map_err(|error| A2aClientError::Protocol(format!("A2A HTTP request failed: {error}"))),
505        _ = recv_cancel(cancel_rx) => Err(A2aClientError::Cancelled(cancelled_message.to_string())),
506    }
507}
508
509fn task_state(task: &Value) -> Result<&str, A2aClientError> {
510    task.pointer("/status/state")
511        .and_then(Value::as_str)
512        .filter(|value| !value.is_empty())
513        .ok_or_else(|| {
514            A2aClientError::Protocol("A2A task response missing result.status.state".to_string())
515        })
516}
517
518fn extract_inline_result(task: &Value) -> Value {
519    let text = task
520        .get("history")
521        .and_then(Value::as_array)
522        .and_then(|history| {
523            history.iter().rev().find_map(|message| {
524                let role = message.get("role").and_then(Value::as_str)?;
525                if role != "agent" {
526                    return None;
527                }
528                message
529                    .get("parts")
530                    .and_then(Value::as_array)
531                    .and_then(|parts| {
532                        parts.iter().find_map(|part| {
533                            if part.get("type").and_then(Value::as_str) == Some("text") {
534                                part.get("text").and_then(Value::as_str).map(str::trim_end)
535                            } else {
536                                None
537                            }
538                        })
539                    })
540            })
541        });
542    match text {
543        Some(text) if !text.is_empty() => {
544            serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.to_string()))
545        }
546        _ => task.clone(),
547    }
548}
549
550async fn recv_cancel(cancel_rx: &mut broadcast::Receiver<()>) {
551    let _ = cancel_rx.recv().await;
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn target_agent_label_prefers_path() {
560        assert_eq!(target_agent_label("reviewer.prod/triage"), "triage");
561        assert_eq!(target_agent_label("reviewer.prod"), "reviewer.prod");
562    }
563
564    #[test]
565    fn extract_inline_result_parses_json_text() {
566        let task = serde_json::json!({
567            "history": [
568                {"role": "user", "parts": [{"type": "text", "text": "ignored"}]},
569                {"role": "agent", "parts": [{"type": "text", "text": "{\"trace_id\":\"trace_123\"}\n"}]},
570            ]
571        });
572        assert_eq!(
573            extract_inline_result(&task),
574            serde_json::json!({"trace_id": "trace_123"})
575        );
576    }
577
578    #[test]
579    fn discovery_prefers_https_before_http() {
580        assert_eq!(card_resolution_schemes(false), ["https"]);
581        assert_eq!(card_resolution_schemes(true), ["https", "http"]);
582    }
583
584    #[test]
585    fn cleartext_fallback_only_after_https_connect_refused() {
586        assert!(should_try_cleartext_fallback(
587            "https",
588            true,
589            &AgentCardFetchError::ConnectRefused("connect refused".to_string()),
590            "reviewer.example:443",
591        ));
592        assert!(!should_try_cleartext_fallback(
593            "http",
594            true,
595            &AgentCardFetchError::ConnectRefused("connect refused".to_string()),
596            "reviewer.example:443",
597        ));
598        assert!(!should_try_cleartext_fallback(
599            "https",
600            true,
601            &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
602            "reviewer.example:443",
603        ));
604    }
605
606    #[test]
607    fn cleartext_fallback_requires_opt_in_even_for_loopback_authorities() {
608        for authority in [
609            "127.0.0.1:8080",
610            "localhost:8080",
611            "[::1]:8080",
612            "127.1.2.3:9000",
613        ] {
614            assert!(
615                !should_try_cleartext_fallback(
616                    "https",
617                    false,
618                    &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
619                    authority,
620                ),
621                "cleartext fallback must stay disabled without opt-in for '{authority}'"
622            );
623        }
624    }
625
626    #[test]
627    fn cleartext_fallback_allows_loopback_after_opt_in() {
628        // Local dev: harn serve is HTTP-only, so TLS handshake fails but we
629        // still need the HTTP fallback to succeed.
630        for authority in [
631            "127.0.0.1:8080",
632            "localhost:8080",
633            "[::1]:8080",
634            "127.1.2.3:9000",
635        ] {
636            assert!(
637                should_try_cleartext_fallback(
638                    "https",
639                    true,
640                    &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
641                    authority,
642                ),
643                "expected cleartext fallback for loopback authority '{authority}'"
644            );
645        }
646    }
647
648    #[test]
649    fn cleartext_fallback_denies_external_tls_failures() {
650        // External target + TLS handshake failure must not downgrade — an
651        // attacker able to forge TLS errors shouldn't force cleartext.
652        for authority in [
653            "reviewer.example:443",
654            "8.8.8.8:443",
655            "192.168.1.10:8080",
656            "10.0.0.5:8443",
657        ] {
658            assert!(
659                !should_try_cleartext_fallback(
660                    "https",
661                    true,
662                    &AgentCardFetchError::Discovery("tls handshake failed".to_string()),
663                    authority,
664                ),
665                "cleartext fallback must be denied for external authority '{authority}'"
666            );
667        }
668    }
669
670    #[test]
671    fn is_loopback_authority_recognises_loopback_forms() {
672        assert!(is_loopback_authority("127.0.0.1:8080"));
673        assert!(is_loopback_authority("localhost:8080"));
674        assert!(is_loopback_authority("LOCALHOST:9000"));
675        assert!(is_loopback_authority("[::1]:8080"));
676        assert!(is_loopback_authority("127.5.5.5:1234"));
677        assert!(!is_loopback_authority("8.8.8.8:443"));
678        assert!(!is_loopback_authority("192.168.1.10:8080"));
679        assert!(!is_loopback_authority("example.com:443"));
680        assert!(!is_loopback_authority("reviewer.prod"));
681    }
682
683    #[test]
684    fn endpoint_from_card_rejects_card_url_authority_mismatch() {
685        let error = endpoint_from_card(
686            "https://trusted.example/.well-known/a2a-agent".to_string(),
687            false,
688            "trusted.example",
689            "triage".to_string(),
690            &serde_json::json!({
691                "url": "https://evil.example",
692                "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
693            }),
694        )
695        .unwrap_err();
696        assert_eq!(
697            error.to_string(),
698            "A2A agent card url authority mismatch: requested 'trusted.example', card returned 'evil.example'"
699        );
700    }
701
702    #[test]
703    fn endpoint_from_card_rejects_cleartext_without_opt_in() {
704        let error = endpoint_from_card(
705            "https://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
706            false,
707            "127.0.0.1:8080",
708            "triage".to_string(),
709            &serde_json::json!({
710                "url": "http://localhost:8080",
711                "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
712            }),
713        )
714        .expect_err("cleartext card should require explicit opt-in");
715        assert!(error
716            .to_string()
717            .contains("requires `allow_cleartext = true`"));
718    }
719
720    #[test]
721    fn endpoint_from_card_accepts_loopback_alias_pairs_when_cleartext_opted_in() {
722        // harn serve reports `http://localhost:PORT` in its card, but clients
723        // commonly dial `127.0.0.1:PORT`. Both refer to the same socket, so
724        // the authority check must not spuriously reject the pair.
725        let card = serde_json::json!({
726            "url": "http://localhost:8080",
727            "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
728        });
729        let endpoint = endpoint_from_card(
730            "http://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
731            true,
732            "127.0.0.1:8080",
733            "triage".to_string(),
734            &card,
735        )
736        .expect("loopback alias pair should be accepted");
737        assert_eq!(endpoint.rpc_url, "http://localhost:8080/rpc");
738
739        // IPv6 loopback `[::1]` also aliases to `127.0.0.1` / `localhost`.
740        let card_v6 = serde_json::json!({
741            "url": "http://[::1]:8080",
742            "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
743        });
744        let endpoint_v6 = endpoint_from_card(
745            "http://localhost:8080/.well-known/a2a-agent".to_string(),
746            true,
747            "localhost:8080",
748            "triage".to_string(),
749            &card_v6,
750        )
751        .expect("IPv6 loopback alias should be accepted");
752        assert_eq!(endpoint_v6.rpc_url, "http://[::1]:8080/rpc");
753
754        // Port mismatch is still rejected even on loopback.
755        let card_wrong_port = serde_json::json!({
756            "url": "http://localhost:9000",
757            "interfaces": [{"protocol": "jsonrpc", "url": "/rpc"}],
758        });
759        let error = endpoint_from_card(
760            "http://127.0.0.1:8080/.well-known/a2a-agent".to_string(),
761            true,
762            "127.0.0.1:8080",
763            "triage".to_string(),
764            &card_wrong_port,
765        )
766        .expect_err("mismatched ports must still be rejected even on loopback");
767        assert!(error
768            .to_string()
769            .contains("A2A agent card url authority mismatch"));
770    }
771
772    #[test]
773    fn authorities_equivalent_rejects_non_loopback_host_mismatch() {
774        assert!(!authorities_equivalent(
775            "internal.corp.example:443",
776            "trusted.example:443",
777        ));
778        assert!(!authorities_equivalent("10.0.0.5:8080", "127.0.0.1:8080",));
779        assert!(authorities_equivalent(
780            "trusted.example:443",
781            "trusted.example:443",
782        ));
783    }
784}