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
338fn 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
390fn 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
412fn split_authority(authority: &str) -> (&str, &str) {
415 let (host_raw, port) = if authority.starts_with('[') {
416 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 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 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 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 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 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}