1use std::sync::Arc;
29
30use crate::backend::storage::store::Store;
31
32const MAX_RELAY_MESSAGE_BYTES: usize = 10240;
35
36const RELAY_TIMEOUT_SECS: u64 = 10;
40
41pub(crate) fn rest_base_url() -> String {
49 std::env::var("AGENTMUX_MUXBUS_REST_URL")
50 .ok()
51 .filter(|v| !v.is_empty())
52 .unwrap_or_else(|| crate::muxbus::cloud_subscriber::MUXBUS_REST_URL.to_string())
53}
54
55pub(crate) const USER_AGENT: &str = concat!("agentmux/", env!("CARGO_PKG_VERSION"));
58
59pub(crate) const MAX_BATCH_AGENTS: usize = 50;
61
62const BATCH_ROUTES_RETRY: std::time::Duration = std::time::Duration::from_secs(600);
65
66static BATCH_ROUTES_MISSING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> =
67 std::sync::LazyLock::new(Default::default);
68
69pub(crate) fn batch_routes_missing(base: &str) -> bool {
73 BATCH_ROUTES_MISSING
74 .lock()
75 .unwrap_or_else(|e| e.into_inner())
76 .get(base)
77 .is_some_and(|until| *until > std::time::Instant::now())
78}
79
80pub(crate) fn note_batch_routes_missing(base: &str) {
82 tracing::info!("relay has no batch routes yet: asking per agent for now");
83 BATCH_ROUTES_MISSING
84 .lock()
85 .unwrap_or_else(|e| e.into_inner())
86 .insert(base.to_string(), std::time::Instant::now() + BATCH_ROUTES_RETRY);
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
94pub(crate) struct WanCarried {
95 pub wan_sig: String,
96 pub wan_msg_id: String,
97 pub wan_ts_secs: i64,
98 pub wan_source_agent: String,
99 pub wan_target_agent: String,
100 pub wan_source_host: String,
101 pub wan_source_channel: String,
102 pub wan_key_fp: String,
103}
104
105const MAX_CARRIED_SIG_CHARS: usize = 128;
107const MAX_CARRIED_ID_CHARS: usize = 256;
108
109pub(crate) fn wan_carry_gate(
128 req: &crate::backend::reactive::types::InjectionRequest,
129 wan: Option<&crate::backend::storage::wan_identity::WanIdentityStore>,
130 directory: Option<&str>,
131 local_channel: &str,
132) -> Result<WanCarried, &'static str> {
133 use crate::backend::reactive::sanitize::validate_agent_id;
134
135 let wan = wan.ok_or("no wan.db")?;
136 let sig = req.wan_sig.as_deref().ok_or("unsigned")?;
137 if req.sig_verified != Some(true) {
138 return Err("sender not host-verified");
139 }
140 let source = req.source_agent.as_deref().ok_or("no source agent")?;
141 let msg_id = req.request_id.as_deref().ok_or("no msgid")?;
142 let ts_secs = req.ts_secs.ok_or("no signed timestamp")?;
143 let host = req.wan_source_host.as_deref().ok_or("no instance")?;
144 let channel = req.source_channel.as_deref().ok_or("no channel")?;
145
146 let instance = wan
148 .instance_ensure(&crate::backend::reactive::registry::local_host_label())
149 .map_err(|_| "no instance")?;
150 if host != instance.instance_id {
151 return Err("signed for another instance");
152 }
153 if channel != local_channel {
154 return Err("signed for another channel");
155 }
156 let key = wan.agent_key_load(source).ok().flatten().ok_or("no key in wan.db")?;
158 let public = key.public_key_bytes().ok_or("malformed key")?;
159 if !agentmux_common::jekt_sign::verify_wan_jekt(
160 &public,
161 msg_id,
162 source,
163 host,
164 channel,
165 &req.target_agent,
166 ts_secs,
167 &req.message,
168 sig,
169 ) {
170 return Err("signature does not verify locally");
171 }
172 let directory = directory.ok_or("no account to publish under")?;
174 if !wan.agent_key_is_published_to(source, &key, directory).unwrap_or(false) {
175 return Err("key not published to this directory");
176 }
177 if !validate_agent_id(source) || !validate_agent_id(&req.target_agent) {
179 return Err("malformed agent id");
180 }
181 if sig.len() > MAX_CARRIED_SIG_CHARS
182 || [msg_id, host, channel].iter().any(|s| s.len() > MAX_CARRIED_ID_CHARS)
183 || ts_secs <= 0
184 {
185 return Err("over the cloud's caps");
186 }
187 Ok(WanCarried {
188 wan_sig: sig.to_string(),
189 wan_msg_id: msg_id.to_string(),
190 wan_ts_secs: ts_secs,
191 wan_source_agent: source.to_string(),
192 wan_target_agent: req.target_agent.clone(),
193 wan_source_host: host.to_string(),
194 wan_source_channel: channel.to_string(),
195 wan_key_fp: agentmux_common::jekt_sign::wan_key_fingerprint(&public),
196 })
197}
198
199#[derive(Debug)]
205pub(crate) enum RelayOutcome {
206 Queued {
209 injection_id: Option<String>,
210 target_in_account: Option<bool>,
214 },
215 Failed(String),
217}
218
219#[allow(clippy::too_many_arguments)]
237pub(crate) async fn relay_inject(
238 base_url: &str,
239 http: &reqwest::Client,
240 token: &str,
241 source_agent: &str,
242 target_agent: &str,
243 message: &str,
244 priority: &str,
245 wan: Option<&WanCarried>,
246) -> RelayOutcome {
247 if message.len() > MAX_RELAY_MESSAGE_BYTES {
248 return RelayOutcome::Failed(format!(
249 "message is {} bytes; the cloud relay accepts at most {}",
250 message.len(),
251 MAX_RELAY_MESSAGE_BYTES
252 ));
253 }
254
255 let url = format!("{}/reactive/inject", base_url.trim_end_matches('/'));
256 let mut body = serde_json::json!({
257 "target_agent": target_agent,
258 "message": message,
259 "priority": priority,
260 });
261 if let (Some(wan), Some(obj)) = (wan, body.as_object_mut()) {
264 if let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(wan) {
265 obj.extend(fields);
266 }
267 }
268 let resp = http
269 .post(&url)
270 .header("Authorization", format!("Bearer {token}"))
271 .header("X-Agent-ID", source_agent)
272 .header("X-Client-Wrapped", "true")
273 .timeout(std::time::Duration::from_secs(RELAY_TIMEOUT_SECS))
274 .json(&body)
275 .send()
276 .await;
277
278 let resp = match resp {
279 Ok(r) => r,
280 Err(e) => return RelayOutcome::Failed(format!("cloud relay unreachable: {e}")),
281 };
282
283 let status = resp.status();
284 if status.is_success() {
285 let body: serde_json::Value = resp.json().await.unwrap_or_default();
286 if body.get("success").and_then(|v| v.as_bool()) != Some(true) {
290 return RelayOutcome::Failed(format!(
291 "cloud relay returned {status} without success:true"
292 ));
293 }
294 return RelayOutcome::Queued {
295 injection_id: body
296 .get("injection_id")
297 .and_then(|v| v.as_str())
298 .map(str::to_string),
299 target_in_account: body.get("target_in_account").and_then(|v| v.as_bool()),
300 };
301 }
302
303 let detail = resp.text().await.unwrap_or_default();
307 RelayOutcome::Failed(format!("cloud relay rejected: HTTP {status} — {detail}"))
308}
309
310pub(crate) async fn relay_token(
342 source_agent: &str,
343 store: &Arc<Store>,
344 http: &reqwest::Client,
345) -> Option<RelayCredential> {
346 let scheduler = crate::broker::get_global()?;
347 let shared = crate::muxbus::cloud_subscriber::load_valid_token(store, &scheduler).await?;
348 let account_sub = crate::muxbus::pkce::token_sub(&shared);
349
350 let token = match crate::muxbus::agent_credentials::ensure_agent_credential(source_agent, store, http).await {
351 Some(per_agent) => per_agent,
352 None => shared,
353 };
354 let after = crate::muxbus::cloud_subscriber::load_valid_token(store, &scheduler)
360 .await
361 .map(|t| crate::muxbus::pkce::token_sub(&t));
362 Some(RelayCredential { token, account_sub: settled_account(account_sub, after) })
363}
364
365fn settled_account(before: String, after: Option<String>) -> String {
368 if after.as_deref() == Some(before.as_str()) {
369 before
370 } else {
371 String::new()
372 }
373}
374
375pub(crate) struct RelayCredential {
379 pub token: String,
380 pub account_sub: String,
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 struct Captured {
390 agent_id: Option<String>,
391 client_wrapped: Option<String>,
392 authorization: Option<String>,
393 body: serde_json::Value,
394 }
395
396 async fn stub_relay(
397 status: axum::http::StatusCode,
398 response_body: &'static str,
399 ) -> (
400 String,
401 std::sync::Arc<std::sync::Mutex<Option<Captured>>>,
402 tokio_util::sync::DropGuard,
403 ) {
404 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
405 let sink = seen.clone();
406 let app = axum::Router::new().route(
407 "/reactive/inject",
408 axum::routing::post(
409 move |headers: axum::http::HeaderMap, body: axum::Json<serde_json::Value>| {
410 let sink = sink.clone();
411 async move {
412 let get = |k: &str| {
413 headers
414 .get(k)
415 .and_then(|v| v.to_str().ok())
416 .map(str::to_string)
417 };
418 *sink.lock().unwrap() = Some(Captured {
419 agent_id: get("x-agent-id"),
420 client_wrapped: get("x-client-wrapped"),
421 authorization: get("authorization"),
422 body: body.0,
423 });
424 (
425 status,
426 [(axum::http::header::CONTENT_TYPE, "application/json")],
427 response_body,
428 )
429 }
430 },
431 ),
432 );
433 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
434 let addr = listener.local_addr().unwrap();
435 let token = tokio_util::sync::CancellationToken::new();
436 let child = token.clone();
437 tokio::spawn(async move {
438 let _ = axum::serve(listener, app)
439 .with_graceful_shutdown(async move { child.cancelled().await })
440 .await;
441 });
442 (format!("http://{addr}"), seen, token.drop_guard())
443 }
444
445 #[tokio::test]
446 async fn a_queued_injection_returns_its_id() {
447 let (url, _seen, _g) = stub_relay(
448 axum::http::StatusCode::OK,
449 r#"{"success":true,"injection_id":"inj-42"}"#,
450 )
451 .await;
452 let out = relay_inject(
453 &url,
454 &reqwest::Client::new(),
455 "tok",
456 "agent2",
457 "clare",
458 "hello",
459 "normal",
460 None,
461 )
462 .await;
463 match out {
464 RelayOutcome::Queued { injection_id, target_in_account } => {
465 assert_eq!(injection_id.as_deref(), Some("inj-42"));
466 assert_eq!(target_in_account, None)
468 }
469 other => panic!("expected Queued, got {other:?}"),
470 }
471 }
472
473 #[tokio::test]
477 async fn the_sender_travels_as_a_header_and_wrapping_is_declined() {
478 let (url, seen, _g) =
479 stub_relay(axum::http::StatusCode::OK, r#"{"success":true}"#).await;
480 let _ = relay_inject(
481 &url,
482 &reqwest::Client::new(),
483 "tok",
484 "agent2",
485 "clare",
486 "hello",
487 "urgent",
488 None,
489 )
490 .await;
491
492 let c = seen.lock().unwrap();
493 let c = c.as_ref().expect("stub relay saw no request");
494 assert_eq!(c.agent_id.as_deref(), Some("agent2"), "X-Agent-ID is required by the route");
495 assert_eq!(
496 c.client_wrapped.as_deref(),
497 Some("true"),
498 "without this the cloud wraps the marker and the receiver wraps it again"
499 );
500 assert_eq!(c.authorization.as_deref(), Some("Bearer tok"));
501 assert!(c.body.get("source_agent").is_none());
503 assert_eq!(c.body.get("target_agent").and_then(|v| v.as_str()), Some("clare"));
504 assert_eq!(c.body.get("message").and_then(|v| v.as_str()), Some("hello"));
505 assert_eq!(c.body.get("priority").and_then(|v| v.as_str()), Some("urgent"));
506 }
507
508 #[tokio::test]
509 async fn a_quota_rejection_surfaces_the_clouds_own_message() {
510 let (url, _seen, _g) = stub_relay(
511 axum::http::StatusCode::PAYMENT_REQUIRED,
512 r#"{"error":"quota_exceeded","upgrade_url":"https://cloud.agentmux.ai/billing"}"#,
513 )
514 .await;
515 let out = relay_inject(
516 &url,
517 &reqwest::Client::new(),
518 "tok",
519 "agent2",
520 "clare",
521 "hello",
522 "normal",
523 None,
524 )
525 .await;
526 match out {
527 RelayOutcome::Failed(e) => {
528 assert!(e.contains("402"), "status should be visible: {e}");
529 assert!(e.contains("upgrade_url"), "cloud's own body should pass through: {e}");
530 }
531 other => panic!("expected Failed, got {other:?}"),
532 }
533 }
534
535 #[tokio::test]
539 async fn a_queued_injection_carries_whether_the_target_is_the_senders() {
540 for (body, want) in [
541 (r#"{"success":true,"injection_id":"inj-7","target_in_account":false}"#, Some(false)),
542 (r#"{"success":true,"injection_id":"inj-8","target_in_account":true}"#, Some(true)),
543 ] {
544 let (url, _seen, _g) = stub_relay(axum::http::StatusCode::OK, body).await;
545 let out = relay_inject(&url, &reqwest::Client::new(), "tok", "agent2", "agnetx", "hello", "normal", None).await;
546 match out {
547 RelayOutcome::Queued { target_in_account, .. } => assert_eq!(target_in_account, want, "{body}"),
548 other => panic!("expected Queued, got {other:?}"),
549 }
550 }
551 }
552
553 #[tokio::test]
557 async fn a_2xx_without_success_true_is_a_failure() {
558 let (url, _seen, _g) =
559 stub_relay(axum::http::StatusCode::OK, r#"{"injection_id":"inj-1"}"#).await;
560 let out = relay_inject(
561 &url,
562 &reqwest::Client::new(),
563 "tok",
564 "agent2",
565 "clare",
566 "hello",
567 "normal",
568 None,
569 )
570 .await;
571 assert!(matches!(out, RelayOutcome::Failed(_)), "got {out:?}");
572 }
573
574 #[tokio::test]
577 async fn an_oversized_message_is_rejected_before_any_request() {
578 let big = "x".repeat(MAX_RELAY_MESSAGE_BYTES + 1);
579 let out = relay_inject(
580 "http://127.0.0.1:1", &reqwest::Client::new(),
582 "tok",
583 "agent2",
584 "clare",
585 &big,
586 "normal",
587 None,
588 )
589 .await;
590 match out {
591 RelayOutcome::Failed(e) => assert!(e.contains("10240"), "{e}"),
592 other => panic!("expected Failed, got {other:?}"),
593 }
594 }
595
596 #[tokio::test]
597 async fn an_unreachable_relay_fails_rather_than_hanging() {
598 let url = {
599 let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
600 format!("http://{}", l.local_addr().unwrap())
601 };
602 let out = relay_inject(
603 &url,
604 &reqwest::Client::new(),
605 "tok",
606 "agent2",
607 "clare",
608 "hello",
609 "normal",
610 None,
611 )
612 .await;
613 assert!(matches!(out, RelayOutcome::Failed(_)), "got {out:?}");
614 }
615
616 use crate::backend::reactive::types::InjectionRequest;
619 use crate::backend::storage::wan_identity::WanIdentityStore;
620
621 const TEST_DIR: &str = "https://relay.test#acct";
624
625 #[test]
626 fn a_send_is_bound_to_an_account_only_if_it_held_through_resolution() {
627 assert_eq!(settled_account("acct-a".into(), Some("acct-a".into())), "acct-a");
631 assert_eq!(settled_account("acct-a".into(), Some("acct-b".into())), "");
632 assert_eq!(settled_account("acct-a".into(), None), "", "logged out mid-way");
633 }
634
635 fn signed_request() -> (tempfile::TempDir, WanIdentityStore, InjectionRequest) {
636 use base64::Engine as _;
637 let dir = tempfile::tempdir().unwrap();
638 let wan = WanIdentityStore::open(&dir.path().join("wan.db")).unwrap();
639 let instance = wan.instance_ensure("narko").unwrap();
640 let key = wan.agent_key_ensure("camper", None).unwrap();
641 wan.agent_key_mark_published("camper", &key.public_key, TEST_DIR).unwrap();
642 let private = base64::engine::general_purpose::STANDARD.decode(&key.private_key).unwrap();
643 let sig = agentmux_common::jekt_sign::sign_wan_jekt(
644 &private, "msg-1", "camper", &instance.instance_id, "stable", "agent2", 1_790_000_000, "hello",
645 )
646 .unwrap();
647 let req = InjectionRequest {
648 target_agent: "agent2".into(),
649 message: "hello".into(),
650 source_agent: Some("camper".into()),
651 request_id: Some("msg-1".into()),
652 ts_secs: Some(1_790_000_000),
653 sig_verified: Some(true),
654 wan_sig: Some(sig),
655 wan_source_host: Some(instance.instance_id.clone()),
656 source_channel: Some("stable".into()),
657 ..Default::default()
658 };
659 (dir, wan, req)
660 }
661
662 #[test]
663 fn a_signed_host_verified_published_message_is_carried_as_signed() {
664 let (_dir, wan, req) = signed_request();
665 let carried = wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").expect("every condition holds");
666 let key = wan.agent_key_load("camper").unwrap().unwrap();
667 assert_eq!(carried.wan_sig, req.wan_sig.clone().unwrap());
668 assert_eq!(carried.wan_msg_id, "msg-1");
669 assert_eq!(carried.wan_ts_secs, 1_790_000_000);
670 assert_eq!(carried.wan_source_agent, "camper");
671 assert_eq!(carried.wan_target_agent, "agent2");
672 assert_eq!(carried.wan_source_channel, "stable");
673 assert_eq!(
674 carried.wan_key_fp,
675 agentmux_common::jekt_sign::wan_key_fingerprint(&key.public_key_bytes().unwrap())
676 );
677 let instance = wan.instance_ensure("narko").unwrap();
679 let record = crate::muxbus::wan_publish::certify(&instance, "camper", "stable", &key).unwrap();
680 let view = agentmux_common::jekt_sign::WanCarried {
681 sig: &carried.wan_sig,
682 msg_id: &carried.wan_msg_id,
683 ts_secs: carried.wan_ts_secs,
684 source_agent: &carried.wan_source_agent,
685 target_agent: &carried.wan_target_agent,
686 source_host: &carried.wan_source_host,
687 source_channel: &carried.wan_source_channel,
688 key_fp: &carried.wan_key_fp,
689 };
690 assert_eq!(agentmux_common::jekt_sign::verify_wan_against_record(&view, &record, "hello"), Ok(()));
691 }
692
693 #[test]
694 fn each_carry_condition_failing_in_turn_sends_the_message_unsigned() {
695 type Mutation = Box<dyn Fn(&mut InjectionRequest, &WanIdentityStore)>;
696 let cases: Vec<(&str, Mutation)> = vec![
697 ("unsigned", Box::new(|r, _| r.wan_sig = None)),
698 ("not host-verified", Box::new(|r, _| r.sig_verified = None)),
700 ("host verification failed", Box::new(|r, _| r.sig_verified = Some(false))),
701 ("hostname instead of instance id", Box::new(|r, _| r.wan_source_host = Some("narko".into()))),
703 ("another channel", Box::new(|r, _| r.source_channel = Some("beta".into()))),
704 ("tampered message", Box::new(|r, _| r.message = "hello!".into())),
706 ("retargeted", Box::new(|r, _| r.target_agent = "lark".into())),
707 ("key purged by an agent delete", Box::new(|_, w| {
708 w.agent_keys_delete(&["camper".to_string()]).unwrap();
709 })),
710 ("key not published", Box::new(|r, w| {
712 use base64::Engine as _;
713 w.agent_keys_delete(&["camper".to_string()]).unwrap();
714 let key = w.agent_key_ensure("camper", None).unwrap();
715 let private = base64::engine::general_purpose::STANDARD.decode(&key.private_key).unwrap();
716 r.wan_sig = agentmux_common::jekt_sign::sign_wan_jekt(
717 &private, "msg-1", "camper", r.wan_source_host.as_deref().unwrap(), "stable", "agent2",
718 1_790_000_000, "hello",
719 );
720 })),
721 ("no signed timestamp", Box::new(|r, _| r.ts_secs = None)),
722 ("no msgid", Box::new(|r, _| r.request_id = None)),
723 ];
724 for (name, mutate) in cases {
725 let (_dir, wan, mut req) = signed_request();
726 mutate(&mut req, &wan);
727 assert!(wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").is_err(), "{name} was carried");
728 }
729 let (_dir, _wan, req) = signed_request();
731 assert_eq!(wan_carry_gate(&req, None, Some(TEST_DIR), "stable"), Err("no wan.db"));
732 let (_dir, wan, req) = signed_request();
735 assert_eq!(
736 wan_carry_gate(&req, Some(&wan), Some("https://relay.test#another-account"), "stable"),
737 Err("key not published to this directory")
738 );
739 assert_eq!(wan_carry_gate(&req, Some(&wan), None, "stable"), Err("no account to publish under"));
740 }
741
742 #[tokio::test]
743 async fn the_carried_tuple_rides_the_body_and_its_absence_keeps_the_old_shape() {
744 let (url, seen, _g) = stub_relay(axum::http::StatusCode::OK, r#"{"success":true}"#).await;
745 let (_dir, wan, req) = signed_request();
746 let carried = wan_carry_gate(&req, Some(&wan), Some(TEST_DIR), "stable").unwrap();
747 let _ = relay_inject(&url, &reqwest::Client::new(), "tok", "camper", "agent2", "hello", "normal", Some(&carried))
748 .await;
749 {
750 let c = seen.lock().unwrap();
751 let body = &c.as_ref().unwrap().body;
752 for field in [
753 "wan_sig", "wan_msg_id", "wan_ts_secs", "wan_source_agent", "wan_target_agent",
754 "wan_source_host", "wan_source_channel", "wan_key_fp",
755 ] {
756 assert!(body.get(field).is_some(), "{field} missing from the relay body");
757 }
758 assert_eq!(body["wan_ts_secs"], serde_json::json!(1_790_000_000));
759 }
760
761 let _ = relay_inject(&url, &reqwest::Client::new(), "tok", "camper", "agent2", "hello", "normal", None).await;
762 let c = seen.lock().unwrap();
763 let keys: std::collections::BTreeSet<_> =
764 c.as_ref().unwrap().body.as_object().unwrap().keys().cloned().collect();
765 assert_eq!(
766 keys,
767 ["message", "priority", "target_agent"].into_iter().map(String::from).collect(),
768 "unsigned relays keep exactly the old body"
769 );
770 }
771}