1use std::collections::HashSet;
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::Duration;
30
31use futures_util::{SinkExt, StreamExt};
32use serde::{Deserialize, Serialize};
33use tokio::sync::mpsc;
34use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message};
35
36use crate::backend::reactive::handler::get_global_handler;
37use crate::backend::reactive::types::InjectionRequest;
38use crate::backend::storage::store::Store;
39use crate::broker::RefreshErrorKind;
40use crate::muxbus::delivery_status;
41
42mod delivery;
43use delivery::{claim_and_deliver, PendingResp};
44
45pub(crate) const MUXBUS_WS_URL: &str = "wss://muxbus-ws.agentmux.ai";
50pub(crate) const MUXBUS_REST_URL: &str = "https://muxbus.agentmux.ai";
51const RECONNECT_DELAY_SECS: u64 = 5;
52const MAX_RECONNECT_DELAY_SECS: u64 = 60;
53const STALE_RECHECK_SECS: u64 = 60;
57const CLIENT_PING_INTERVAL_SECS: u64 = 240;
65const BROKER_SWEEP_INTERVAL_SECS: u64 = 60;
73
74const REAGENT_SIG_MAX_AGE_SECS: i64 = 600;
81
82use agentmux_common::time::now_ms;
83use agentmux_common::time::now_secs as now_unix_secs;
84
85fn reagent_sig_is_fresh(ts_secs: i64, now: i64) -> bool {
92 ts_secs > 0 && (now - ts_secs).abs() <= REAGENT_SIG_MAX_AGE_SECS
93}
94
95#[derive(Serialize)]
98#[serde(tag = "type", rename_all = "snake_case")]
99enum ClientMsg {
100 Subscribe { agents: Vec<String> },
101 #[serde(rename = "subscribe:add")]
102 SubscribeAdd { agents: Vec<String> },
103 #[serde(rename = "subscribe:remove")]
104 SubscribeRemove { agents: Vec<String> },
105 Ping,
110}
111
112#[derive(Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114enum ServerMsg {
115 InjectAvailable,
118 Subscribed {
119 #[allow(dead_code)]
120 agents: Vec<String>,
121 },
122 Pong,
123 Error {
124 message: String,
125 },
126 Evicted {
127 #[allow(dead_code)]
128 agents: Vec<String>,
129 },
130 #[serde(other)]
131 Unknown,
132}
133
134enum CtrlMsg {
137 AddAgent(String),
138 RemoveAgent(String),
139 ReloadToken,
140}
141
142pub struct CloudSubscriber {
145 ctrl_tx: mpsc::UnboundedSender<CtrlMsg>,
146 agents: Arc<Mutex<HashSet<String>>>,
149}
150
151static GLOBAL_SUBSCRIBER: OnceLock<CloudSubscriber> = OnceLock::new();
152
153pub fn get_global_subscriber() -> Option<&'static CloudSubscriber> {
154 GLOBAL_SUBSCRIBER.get()
155}
156
157impl CloudSubscriber {
158 pub fn init_global(mstore: Arc<Store>) {
161 let (ctrl_tx, ctrl_rx) = mpsc::unbounded_channel::<CtrlMsg>();
162 let agents = Arc::new(Mutex::new(HashSet::<String>::new()));
163 let handler = get_global_handler();
166 let registered: Vec<String> = handler
167 .list_agents()
168 .into_iter()
169 .map(|r| r.agent_id)
170 .collect();
171 let seed = agents_to_seed(®istered, |a| handler.has_live_name(a));
172 if !seed.is_empty() {
173 tracing::info!(
174 count = seed.len(),
175 "cloud_subscriber: subscribing agents that registered before it started"
176 );
177 agents.lock().unwrap().extend(seed);
178 }
179 let subscriber = CloudSubscriber {
180 ctrl_tx,
181 agents: agents.clone(),
182 };
183 if GLOBAL_SUBSCRIBER.set(subscriber).is_err() {
184 return; }
186 tokio::spawn(run_loop(mstore, agents, ctrl_rx));
187 }
188
189 pub fn add_agent(&self, agent_id: &str) {
193 let key = agent_id.to_lowercase();
194 let mut agents = self.agents.lock().unwrap();
195 if agents.contains(&key) {
196 return;
197 }
198 agents.insert(key.clone());
199 drop(agents);
200 let _ = self.ctrl_tx.send(CtrlMsg::AddAgent(key));
201 }
202
203 pub fn remove_agent(&self, agent_id: &str) {
205 let key = agent_id.to_lowercase();
206 self.agents.lock().unwrap().remove(&key);
207 let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key));
208 }
209
210 pub fn drop_if_orphaned(&self, agent_id: &str, is_live: impl Fn(&str) -> bool) -> bool {
220 let key = agent_id.to_lowercase();
221 let mut agents = self.agents.lock().unwrap();
222 if !agents.contains(&key) || is_live(&key) {
223 return false;
224 }
225 agents.remove(&key);
226 let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key));
227 true
228 }
229
230 async fn release_now(
239 &self,
240 agent_id: &str,
241 base: &str,
242 token: impl std::future::Future<Output = String>,
243 ) -> RelayRelease {
244 let key = agent_id.to_lowercase();
245 let was_subscribed = {
246 let mut agents = self.agents.lock().unwrap();
247 let removed = agents.remove(&key);
248 if removed {
249 let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key.clone()));
250 }
251 removed
252 };
253 let held = super::wan_lease::is_held(&key);
258 if !was_subscribed && !held {
259 super::wan_lease::release(base, &key, "", &reqwest::Client::new(), || true).await;
261 return RelayRelease::NotSubscribed;
262 }
263 let token = token.await;
264 let still_gone = || !self.agents.lock().unwrap().contains(&key);
265 if super::wan_lease::release(base, &key, &token, &reqwest::Client::new(), still_gone).await {
267 RelayRelease::Released
268 } else {
269 RelayRelease::Kept
270 }
271 }
272
273 pub fn reload_token(&self) {
276 let _ = self.ctrl_tx.send(CtrlMsg::ReloadToken);
277 }
278
279 pub fn subscribed_agents(&self) -> Vec<String> {
283 let mut v: Vec<String> = self.agents.lock().unwrap().iter().cloned().collect();
284 v.sort();
285 v
286 }
287}
288
289async fn run_loop(
292 mstore: Arc<Store>,
293 agents: Arc<Mutex<HashSet<String>>>,
294 mut ctrl_rx: mpsc::UnboundedReceiver<CtrlMsg>,
295) {
296 let http = reqwest::Client::builder()
297 .user_agent(super::relay::USER_AGENT)
298 .build()
299 .unwrap_or_default();
300 let mut delay_secs = RECONNECT_DELAY_SECS;
301
302 let scheduler = crate::broker::init_global(Duration::from_secs(BROKER_SWEEP_INTERVAL_SECS));
308 {
309 let mstore_fresh = mstore.clone();
310 let mstore_refresh = mstore.clone();
311 let http_refresh = http.clone();
312 scheduler
313 .register(
314 crate::muxbus::CREDENTIAL_ID,
315 move || {
334 let mstore = mstore_fresh.clone();
335 Box::pin(async move {
336 tokio::task::spawn_blocking(move || mstore.muxbus_is_fresh())
337 .await
338 .unwrap_or(false)
339 })
340 },
341 move || {
342 let mstore = mstore_refresh.clone();
343 let http = http_refresh.clone();
344 Box::pin(async move {
345 let lock_store = mstore.clone();
347 let _leader = tokio::task::spawn_blocking(move || lock_store.muxbus_refresh_lock())
348 .await
349 .map_err(|e| RefreshErrorKind::Transient(format!("refresh lock task: {e}")))?
350 .map_err(|e| RefreshErrorKind::Transient(e.to_string()))?;
351 let load_store = mstore.clone();
352 let current = tokio::task::spawn_blocking(move || load_store.muxbus_load())
353 .await
354 .map_err(|e| RefreshErrorKind::Transient(format!("muxbus_load task: {e}")))?
355 .map_err(|e| RefreshErrorKind::Transient(e.to_string()))?
356 .ok_or_else(|| {
360 RefreshErrorKind::PermanentAuthFailure(
361 "no muxbus credentials stored".to_string(),
362 )
363 })?;
364 if current.is_valid() && !current.nearly_expired() {
366 return Ok(());
367 }
368 if current.refresh_token.is_empty() {
369 return Err(RefreshErrorKind::PermanentAuthFailure(
372 "no refresh_token stored".to_string(),
373 ));
374 }
375 let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
379 if crate::muxbus::discovery::client_id_superseded(discovered.as_ref(), ¤t.client_id) {
380 return Err(RefreshErrorKind::PermanentAuthFailure(
381 "signed in to a user pool AgentMux Cloud no longer uses".to_string(),
382 ));
383 }
384 let refreshed = crate::muxbus::pkce::refresh_token(¤t, &http)
390 .await
391 .map_err(classify_refresh_token_error)?;
392 if !refreshed_same_account(¤t, &refreshed) {
393 return Err(RefreshErrorKind::PermanentAuthFailure(
394 "the refreshed sign-in belongs to another account".to_string(),
395 ));
396 }
397 let save_store = mstore.clone();
398 tokio::task::spawn_blocking(move || save_store.muxbus_save(&refreshed))
399 .await
400 .map_err(|e| RefreshErrorKind::Transient(format!("muxbus_save task: {e}")))?
401 .map_err(|e| RefreshErrorKind::Transient(e.to_string()))
402 })
403 },
404 )
405 .await;
406 }
407 crate::muxbus::wan_presence::sign_in_changed();
411
412 let mut stale_logged: Option<String> = None;
414
415 loop {
416 let has_stored_creds_load = {
436 let mstore = mstore.clone();
437 tokio::task::spawn_blocking(move || mstore.muxbus_load()).await
438 };
439 let nothing_stored = matches!(has_stored_creds_load, Ok(Ok(None)));
440 let (has_stored_creds, stale) = match has_stored_creds_load {
441 Ok(Ok(Some(c))) => {
442 if !c.access_token.is_empty() || !c.refresh_token.is_empty() {
445 delivery_status::saw_session(Some(c.user_email.clone()));
446 }
447 (usable_or_refreshable(&c), crate::muxbus::stale_sign_in(&c, &http).await)
448 }
449 Ok(Ok(None)) => (false, None),
450 Ok(Err(e)) => {
451 tracing::warn!(error = %e, "cloud_subscriber: muxbus_load failed — assuming credentials exist and retrying with backoff");
452 delivery_status::saw_session(None);
453 (true, None)
454 }
455 Err(e) => {
456 tracing::warn!(error = %e, "cloud_subscriber: muxbus_load task panicked — assuming credentials exist and retrying with backoff");
457 delivery_status::saw_session(None);
458 (true, None)
459 }
460 };
461 if let Some(reason) = stale {
467 delivery_status::set_link(delivery_status::Link::SignInStale(reason.clone()));
468 if stale_logged.as_deref() != Some(reason.as_str()) {
469 tracing::warn!(reason = %reason, "cloud_subscriber: AgentMux Cloud sign-in needs renewing — not reconnecting until the user signs in again");
470 stale_logged = Some(reason);
471 }
472 wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
473 delay_secs = RECONNECT_DELAY_SECS;
474 continue;
475 }
476 stale_logged = None;
477 if nothing_stored {
480 delivery_status::set_link(delivery_status::Link::NoSignIn);
481 wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
482 delay_secs = RECONNECT_DELAY_SECS;
483 continue;
484 }
485 let token = match load_valid_token(&mstore, &scheduler).await {
486 Some(t) => t,
487 None if !has_stored_creds => {
488 delivery_status::set_link(delivery_status::Link::NoSignIn);
491 wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
492 delay_secs = RECONNECT_DELAY_SECS;
493 continue;
494 }
495 None => {
496 tracing::warn!(
500 "cloud_subscriber: token refresh failed, retrying in {}s",
501 delay_secs
502 );
503 delivery_status::set_link(delivery_status::Link::Unreachable(
504 "couldn't refresh the sign-in".to_string(),
505 ));
506 let sleep = tokio::time::sleep(Duration::from_secs(delay_secs));
507 tokio::pin!(sleep);
508 'backoff: loop {
509 tokio::select! {
510 _ = &mut sleep => break 'backoff,
511 Some(msg) = ctrl_rx.recv() => match msg {
512 CtrlMsg::ReloadToken => {
513 delay_secs = RECONNECT_DELAY_SECS;
514 break 'backoff;
515 }
516 CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_) => {
517 }
519 }
520 }
521 }
522 delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
523 continue;
524 }
525 };
526
527 let session_start = std::time::Instant::now();
528
529 match connect_and_run(&token, agents.clone(), &mut ctrl_rx, &mstore, &http).await {
530 Ok(()) => {
531 if session_start.elapsed().as_secs() > 30 {
534 delay_secs = RECONNECT_DELAY_SECS;
535 }
536 tracing::info!("cloud_subscriber: disconnected cleanly");
537 delivery_status::set_link(delivery_status::Link::Unreachable("disconnected".to_string()));
538 }
539 Err(e) => {
540 if session_start.elapsed().as_secs() > 30 {
543 delay_secs = RECONNECT_DELAY_SECS;
544 }
545 tracing::warn!("cloud_subscriber: error: {e}, reconnecting in {}s", delay_secs);
546 delivery_status::set_link(delivery_status::Link::Unreachable(e));
547 }
548 }
549
550 let sleep = tokio::time::sleep(Duration::from_secs(delay_secs));
553 tokio::pin!(sleep);
554 'reconnect: loop {
555 tokio::select! {
556 _ = &mut sleep => break 'reconnect,
557 Some(msg) = ctrl_rx.recv() => match msg {
558 CtrlMsg::ReloadToken => {
559 delay_secs = RECONNECT_DELAY_SECS;
560 break 'reconnect;
561 }
562 CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_) => {
563 }
565 }
566 }
567 }
568 delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
569 }
570}
571
572fn usable_or_refreshable(c: &crate::muxbus::MuxBusCredentials) -> bool {
575 c.is_valid() || !c.refresh_token.is_empty()
576}
577
578fn refreshed_same_account(current: &crate::muxbus::MuxBusCredentials, refreshed: &crate::muxbus::MuxBusCredentials) -> bool {
582 if current.user_sub.is_empty() {
583 return true;
584 }
585 let sub = crate::muxbus::pkce::token_sub(&refreshed.id_token);
586 let sub = if sub.is_empty() { crate::muxbus::pkce::token_sub(&refreshed.access_token) } else { sub };
587 sub == current.user_sub
588}
589
590async fn wait_for_sign_in(ctrl_rx: &mut mpsc::UnboundedReceiver<CtrlMsg>, recheck: Duration) {
594 let sleep = tokio::time::sleep(recheck);
595 tokio::pin!(sleep);
596 loop {
597 tokio::select! {
598 _ = &mut sleep => return,
599 msg = ctrl_rx.recv() => match msg {
600 Some(CtrlMsg::ReloadToken) => return,
601 Some(CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_)) => {}
602 None => {
604 (&mut sleep).await;
605 return;
606 }
607 }
608 }
609 }
610}
611
612async fn connect_and_run(
613 token: &str,
614 agents: Arc<Mutex<HashSet<String>>>,
615 ctrl_rx: &mut mpsc::UnboundedReceiver<CtrlMsg>,
616 mstore: &Arc<Store>,
617 http: &reqwest::Client,
618) -> Result<(), String> {
619 use tokio_tungstenite::tungstenite::ClientRequestBuilder;
620
621 let base = super::relay::rest_base_url();
624 let base = base.as_str();
625
626 let ws_url = super::discovery::ws_url(http).await;
649 tracing::info!("cloud_subscriber: connecting to {}", ws_url);
650 let uri: tokio_tungstenite::tungstenite::http::Uri =
651 ws_url.parse().map_err(|e| format!("parse url: {e}"))?;
652 let request = ClientRequestBuilder::new(uri)
653 .with_header("Authorization", format!("Bearer {}", token));
654
655 let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None)
656 .await
657 .map_err(|e| format!("connect: {e}"))?;
658
659 tracing::info!("cloud_subscriber: WebSocket connected");
660 delivery_status::set_link(delivery_status::Link::Connected);
661 let (mut write, mut read) = ws_stream.split();
662
663 let initial_agents: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
665 let sub_msg = serde_json::to_string(&ClientMsg::Subscribe { agents: initial_agents })
666 .map_err(|e| format!("serialize subscribe: {e}"))?;
667 write.send(Message::Text(sub_msg.into()))
668 .await
669 .map_err(|e| format!("send subscribe: {e}"))?;
670
671 if catch_up(base, token, http, &agents, mstore).await {
676 return Ok(());
677 }
678
679 let mut ping_interval = tokio::time::interval(Duration::from_secs(CLIENT_PING_INTERVAL_SECS));
680 let mut lease_interval = tokio::time::interval(super::wan_lease::RENEW_EVERY);
683 lease_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
684 ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
685 ping_interval.tick().await; let mut catch_up_interval = tokio::time::interval(CATCH_UP_EVERY);
688 catch_up_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
689 catch_up_interval.tick().await; loop {
692 tokio::select! {
693 _ = lease_interval.tick() => {
701 let mut registered: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
702 let handler = get_global_handler();
708 let orphans = orphaned_subscriptions(®istered, |a| handler.has_live_name(a));
709 if !orphans.is_empty() {
710 if let Some(sub) = get_global_subscriber() {
711 for agent_id in &orphans {
712 if sub.drop_if_orphaned(agent_id, |a| handler.has_live_name(a)) {
713 tracing::warn!(
714 agent = %agent_id,
715 "cloud_subscriber: subscribed agent has no live pane — dropped it and released its WAN lease"
716 );
717 }
718 }
719 }
720 registered.retain(|a| !orphans.contains(a));
721 }
722 let subscribed = &agents;
723 let shared = shared_token_now(mstore, token).await;
727 let batched = super::wan_lease::renew_held_batch(base, ®istered, &shared, http, |a| {
728 subscribed.lock().unwrap().iter().any(|s| s.eq_ignore_ascii_case(a))
729 })
730 .await;
731 for (agent_id, outcome) in &batched {
732 if let super::wan_lease::Outcome::HeldElsewhere(where_) = outcome {
733 crate::backend::agent_admission::fence_agent_named(agent_id, where_);
734 }
735 }
736 registered.retain(|a| !batched.contains_key(&a.trim().to_lowercase()));
737 futures_util::future::join_all(registered.iter().map(|agent_id| async move {
738 let per_agent = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
739 let agent_token = match per_agent {
740 Some(t) => t,
741 None => shared_token_now(mstore, token).await,
742 };
743 let still_subscribed = || subscribed.lock().unwrap().contains(agent_id);
746 if let super::wan_lease::Outcome::HeldElsewhere(where_) =
747 super::wan_lease::ensure(base, agent_id, &agent_token, http, still_subscribed).await
748 {
749 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
750 }
751 }))
752 .await;
753 }
754
755 _ = catch_up_interval.tick() => {
756 if catch_up(base, token, http, &agents, mstore).await {
757 return Ok(());
758 }
759 }
760
761 _ = ping_interval.tick() => {
762 let ping_msg = serde_json::to_string(&ClientMsg::Ping)
763 .map_err(|e| format!("serialize ping: {e}"))?;
764 if let Err(e) = write.send(Message::Text(ping_msg.into())).await {
765 return Err(format!("send ping: {e}"));
766 }
767 }
768
769 msg = read.next() => {
771 match msg {
772 None => return Ok(()), Some(Err(e)) => return Err(format!("ws recv: {e}")),
774 Some(Ok(Message::Text(text))) => {
775 if let Ok(server_msg) = serde_json::from_str::<ServerMsg>(&text) {
776 match handle_server_msg(server_msg, base, token, http, &agents, mstore).await {
777 Ok(()) => {}
778 Err(ref e) if e.starts_with("reconnect:") => {
780 tracing::info!("cloud_subscriber: {e}, reconnecting");
781 return Ok(());
782 }
783 Err(e) => {
784 tracing::warn!("cloud_subscriber: handle msg error: {e}");
785 }
786 }
787 }
788 }
789 Some(Ok(Message::Ping(data))) => {
790 let _ = write.send(Message::Pong(data)).await;
791 }
792 Some(Ok(Message::Close(_))) => return Ok(()),
793 Some(Ok(_)) => {} }
795 }
796
797 ctrl = ctrl_rx.recv() => {
799 match ctrl {
800 None => return Ok(()), Some(CtrlMsg::AddAgent(id)) => {
802 let msg = serde_json::to_string(&ClientMsg::SubscribeAdd { agents: vec![id] })
803 .unwrap_or_default();
804 let _ = write.send(Message::Text(msg.into())).await;
805 if catch_up(base, token, http, &agents, mstore).await {
808 return Ok(());
809 }
810 }
811 Some(CtrlMsg::RemoveAgent(id)) => {
812 {
814 let (id, base, token, http, mstore, agents) =
815 (id.clone(), base.to_string(), token.to_string(), http.clone(), mstore.clone(), agents.clone());
816 tokio::spawn(async move {
817 let token = shared_token_now(&mstore, &token).await;
818 let still_gone = || !agents.lock().unwrap().contains(&id);
821 super::wan_lease::release(&base, &id, &token, &http, still_gone).await
822 });
823 }
824 let msg = serde_json::to_string(&ClientMsg::SubscribeRemove { agents: vec![id] })
825 .unwrap_or_default();
826 let _ = write.send(Message::Text(msg.into())).await;
827 }
828 Some(CtrlMsg::ReloadToken) => {
829 let _ = write.send(Message::Close(None)).await;
831 return Ok(());
832 }
833 }
834 }
835 }
836 }
837}
838
839const CATCH_UP_EVERY: Duration = Duration::from_secs(120);
848
849fn agents_to_seed(registered: &[String], is_live: impl Fn(&str) -> bool) -> Vec<String> {
856 let mut seed: Vec<String> = registered
857 .iter()
858 .map(|a| a.to_lowercase())
859 .filter(|a| is_live(a))
860 .collect();
861 seed.sort();
862 seed.dedup();
863 seed
864}
865
866async fn catch_up(
871 base: &str,
872 token: &str,
873 http: &reqwest::Client,
874 agents: &Arc<Mutex<HashSet<String>>>,
875 mstore: &Arc<Store>,
876) -> bool {
877 match handle_server_msg(ServerMsg::InjectAvailable, base, token, http, agents, mstore).await {
878 Ok(()) => false,
879 Err(e) if e.starts_with("reconnect:") => {
880 tracing::info!("cloud_subscriber: {e}, reconnecting");
881 true
882 }
883 Err(e) => {
884 tracing::warn!("cloud_subscriber: catch-up pull error: {e}");
885 false
886 }
887 }
888}
889
890async fn handle_server_msg(
894 msg: ServerMsg,
895 base: &str,
896 token: &str,
897 http: &reqwest::Client,
898 agents: &Arc<Mutex<HashSet<String>>>,
899 mstore: &Arc<Store>,
900) -> Result<(), String> {
901 match msg {
902 ServerMsg::InjectAvailable => {
903 let registered: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
905 let handler = get_global_handler();
906
907 let outcomes = match sync_agents_batched(base, ®istered, token, http, mstore, handler).await {
920 Some(outcomes) => outcomes,
921 None => {
922 futures_util::future::join_all(
923 registered
924 .iter()
925 .map(|agent_id| sync_agent_reactive(base, agent_id, token, http, mstore, handler)),
926 )
927 .await
928 }
929 };
930
931 if outcomes
932 .iter()
933 .any(|o| matches!(o, AgentSyncOutcome::ReconnectSharedTokenExpired))
934 {
935 return Err("reconnect:token_expired".to_string());
937 }
938 }
939 ServerMsg::Error { message } => {
940 tracing::warn!("cloud_subscriber: server error: {message}");
941 }
942 ServerMsg::Evicted { .. } => {
943 return Err("reconnect:evicted".to_string());
945 }
946 ServerMsg::Pong | ServerMsg::Subscribed { .. } | ServerMsg::Unknown => {}
947 }
948 Ok(())
949}
950
951enum AgentSyncOutcome {
952 Ok,
955 ReconnectSharedTokenExpired,
966}
967
968fn is_credential_rejected(status: reqwest::StatusCode) -> bool {
982 status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN
983}
984
985fn shared_token_rejection_outcome(status: reqwest::StatusCode, agent_id: &str) -> AgentSyncOutcome {
1008 if status == reqwest::StatusCode::FORBIDDEN {
1009 tracing::warn!(
1010 agent_id = %agent_id,
1011 "cloud_subscriber: shared token rejected (binding mismatch) for this agent — skipping this cycle, not reconnecting"
1012 );
1013 delivery_status::pull_failed(agent_id, "the relay refused this agent's messages (403)");
1015 return AgentSyncOutcome::Ok;
1016 }
1017 AgentSyncOutcome::ReconnectSharedTokenExpired
1018}
1019
1020async fn sync_agent_reactive(
1037 base: &str,
1038 agent_id: &str,
1039 token: &str,
1040 http: &reqwest::Client,
1041 mstore: &Arc<Store>,
1042 handler: &'static crate::backend::reactive::handler::ReactiveHandler,
1043) -> AgentSyncOutcome {
1044 let per_agent_token = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
1052 let mut agent_token = match per_agent_token.clone() {
1053 Some(t) => t,
1054 None => shared_token_now(mstore, token).await,
1055 };
1056 let mut using_per_agent = per_agent_token.is_some();
1057
1058 if let super::wan_lease::Outcome::HeldElsewhere(where_) =
1064 super::wan_lease::ensure(base, agent_id, &agent_token, http, || is_subscribed(agent_id)).await
1065 {
1066 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1067 return AgentSyncOutcome::Ok;
1068 }
1069
1070 let url = format!("{}/reactive/pending/{}", base, agent_id);
1071 let body: PendingResp = loop {
1072 let resp_started = std::time::Instant::now();
1074 let resp = match http
1075 .get(&url)
1076 .header("Authorization", format!("Bearer {}", agent_token))
1077 .header("X-Agent-ID", agent_id)
1078 .header("X-Agent-Instance", super::wan_lease::instance_id())
1079 .send()
1080 .await
1081 {
1082 Ok(r) => r,
1083 Err(e) => {
1084 tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: fetch pending failed");
1085 delivery_status::pull_failed(agent_id, "couldn't fetch messages from the relay");
1086 return AgentSyncOutcome::Ok;
1087 }
1088 };
1089
1090 if is_credential_rejected(resp.status()) {
1091 if using_per_agent {
1092 if resp.status() == reqwest::StatusCode::FORBIDDEN {
1104 crate::muxbus::agent_credentials::invalidate_binding_mismatched_credential(agent_id, mstore);
1105 } else {
1106 crate::muxbus::agent_credentials::invalidate_cached_token(agent_id, mstore);
1107 }
1108 tracing::warn!(
1109 agent_id = %agent_id,
1110 status = %resp.status(),
1111 "cloud_subscriber: per-agent credential rejected — invalidated, retrying with shared token",
1112 );
1113 agent_token = shared_token_now(mstore, token).await;
1114 using_per_agent = false;
1115 continue;
1116 }
1117 return shared_token_rejection_outcome(resp.status(), agent_id);
1118 }
1119 if resp.status() == reqwest::StatusCode::CONFLICT {
1120 let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
1122 if let Some(where_) = super::wan_lease::note_not_holder_since(agent_id, &body, resp_started) {
1123 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1124 }
1125 return AgentSyncOutcome::Ok;
1126 }
1127 if !resp.status().is_success() {
1128 tracing::warn!(
1129 status = %resp.status(),
1130 agent_id = %agent_id,
1131 "cloud_subscriber: fetch pending non-2xx"
1132 );
1133 delivery_status::pull_failed(agent_id, format!("the relay answered {} to a message fetch", resp.status().as_u16()));
1134 return AgentSyncOutcome::Ok;
1135 }
1136
1137 break match resp.json().await {
1138 Ok(b) => b,
1139 Err(e) => {
1140 tracing::warn!(error = %e, "cloud_subscriber: parse pending failed");
1141 delivery_status::pull_failed(agent_id, "the relay's message list couldn't be read");
1142 return AgentSyncOutcome::Ok;
1143 }
1144 };
1145 };
1146
1147 claim_and_deliver(base, agent_id, token, http, mstore, handler, body, agent_token, using_per_agent).await
1148}
1149
1150async fn sync_agents_batched(
1162 base: &str,
1163 registered: &[String],
1164 token: &str,
1165 http: &reqwest::Client,
1166 mstore: &Arc<Store>,
1167 handler: &'static crate::backend::reactive::handler::ReactiveHandler,
1168) -> Option<Vec<AgentSyncOutcome>> {
1169 if registered.len() < 2 || super::relay::batch_routes_missing(base) {
1170 return None;
1171 }
1172 let shared = shared_token_now(mstore, token).await;
1173
1174 let leases = futures_util::future::join_all(registered.iter().map(|agent_id| {
1178 let shared = &shared;
1179 async move { super::wan_lease::ensure(base, agent_id, shared, http, || is_subscribed(agent_id)).await }
1180 }))
1181 .await;
1182 let mut pull: Vec<&String> = Vec::new();
1183 for (agent_id, lease) in registered.iter().zip(leases) {
1184 match lease {
1185 super::wan_lease::Outcome::HeldElsewhere(where_) => {
1186 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1187 }
1188 _ => pull.push(agent_id),
1189 }
1190 }
1191
1192 let mut answers = serde_json::Map::new();
1193 let started = std::time::Instant::now();
1194 for chunk in pull.chunks(super::relay::MAX_BATCH_AGENTS) {
1195 let resp = http
1196 .post(format!("{base}/reactive/pending-batch"))
1197 .header("Authorization", format!("Bearer {shared}"))
1198 .header("X-Agent-Instance", super::wan_lease::instance_id())
1199 .json(&serde_json::json!({ "agent_ids": chunk }))
1200 .send()
1201 .await;
1202 match resp {
1203 Ok(r) if matches!(r.status().as_u16(), 404 | 405) && answers.is_empty() => {
1204 super::relay::note_batch_routes_missing(base);
1205 return None;
1206 }
1207 Ok(r) if r.status().is_success() => match r.json::<serde_json::Value>().await {
1208 Ok(v) => {
1209 if let Some(results) = v.get("results").and_then(|r| r.as_object()) {
1210 answers.extend(results.clone());
1211 }
1212 }
1213 Err(e) => tracing::warn!(error = %e, "cloud_subscriber: parse batch pull failed"),
1214 },
1215 Ok(r) => tracing::warn!(status = %r.status(), "cloud_subscriber: batch pull non-2xx; pulling those agents one by one"),
1216 Err(e) => tracing::warn!(error = %e, "cloud_subscriber: batch pull failed; pulling those agents one by one"),
1217 }
1218 }
1219
1220 let outcomes = futures_util::future::join_all(pull.into_iter().map(|agent_id| {
1221 let entry = answers.get(&agent_id.trim().to_lowercase()).cloned();
1222 let shared = &shared;
1223 async move {
1224 let Some(entry) = entry else {
1225 return sync_agent_reactive(base, agent_id, token, http, mstore, handler).await;
1226 };
1227 match entry.get("error").and_then(|e| e.as_str()) {
1228 Some("not_holder") => {
1229 if let Some(where_) = super::wan_lease::note_not_holder_since(agent_id, &entry, started) {
1231 crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1232 }
1233 AgentSyncOutcome::Ok
1234 }
1235 Some(_) => {
1236 delivery_status::pull_failed(agent_id, "the relay couldn't read this agent's messages just now");
1237 AgentSyncOutcome::Ok
1238 }
1239 None => match serde_json::from_value::<PendingResp>(entry) {
1240 Ok(body) if body.injections.is_empty() => {
1241 delivery_status::fetched(agent_id, 0, &body.expired);
1242 AgentSyncOutcome::Ok
1243 }
1244 Ok(body) => {
1245 let own = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
1248 let using_per_agent = own.is_some();
1249 let agent_token = own.unwrap_or_else(|| shared.clone());
1250 claim_and_deliver(base, agent_id, token, http, mstore, handler, body, agent_token, using_per_agent).await
1251 }
1252 Err(e) => {
1253 tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: parse pending failed");
1254 delivery_status::pull_failed(agent_id, "the relay's message list couldn't be read");
1255 AgentSyncOutcome::Ok
1256 }
1257 },
1258 }
1259 }
1260 }))
1261 .await;
1262 Some(outcomes)
1263}
1264
1265async fn fresh_shared_token<F, Fut>(fresh: F, connection_token: &str) -> String
1275where
1276 F: FnOnce() -> Fut,
1277 Fut: std::future::Future<Output = Option<String>>,
1278{
1279 fresh().await.unwrap_or_else(|| connection_token.to_string())
1280}
1281
1282pub(crate) fn drop_cloud_subscription_on_exit(registration_was_ours: bool, still_held: bool) -> bool {
1289 registration_was_ours || !still_held
1290}
1291
1292fn orphaned_subscriptions(registered: &[String], is_live: impl Fn(&str) -> bool) -> Vec<String> {
1297 registered.iter().filter(|a| !is_live(a)).cloned().collect()
1298}
1299
1300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306pub(crate) enum RelayRelease {
1307 NotSubscribed,
1309 Released,
1311 Kept,
1313}
1314
1315pub(crate) fn is_subscribed(agent: &str) -> bool {
1318 get_global_subscriber().is_none_or(|s| s.agents.lock().unwrap().contains(&agent.to_lowercase()))
1319}
1320
1321pub(crate) async fn take_over_lease_now(
1325 id_store: &Arc<Store>,
1326 http: &reqwest::Client,
1327 agent: &str,
1328) -> super::wan_lease::Outcome {
1329 if get_global_subscriber().is_none() || agent.is_empty() {
1330 return super::wan_lease::Outcome::Unknown;
1331 }
1332 let token = shared_token_now(id_store, "").await;
1333 if token.is_empty() {
1334 return super::wan_lease::Outcome::Unknown;
1335 }
1336 super::wan_lease::take_over(&super::relay::rest_base_url(), agent, &token, http).await
1337}
1338
1339pub(crate) async fn release_agent_now(id_store: &Arc<Store>, agent: &str) -> RelayRelease {
1340 let Some(sub) = get_global_subscriber() else { return RelayRelease::NotSubscribed };
1341 sub.release_now(agent, &super::relay::rest_base_url(), shared_token_now(id_store, "")).await
1342}
1343
1344async fn shared_token_now(mstore: &Arc<Store>, connection_token: &str) -> String {
1347 fresh_shared_token(
1348 || async {
1349 let scheduler = crate::broker::get_global()?;
1350 load_valid_token(mstore, &scheduler).await
1351 },
1352 connection_token,
1353 )
1354 .await
1355}
1356
1357pub(crate) async fn load_valid_token(
1368 mstore: &Arc<Store>,
1369 scheduler: &crate::broker::RefreshScheduler,
1370) -> Option<String> {
1371 if let Err(e) = scheduler.ensure_fresh(crate::muxbus::CREDENTIAL_ID).await {
1372 tracing::warn!(error = %e, "cloud_subscriber: token refresh failed");
1373 }
1374 let load_store = mstore.clone();
1383 let creds = tokio::task::spawn_blocking(move || load_store.muxbus_load())
1384 .await
1385 .ok()
1386 .and_then(|r| r.ok())
1387 .flatten();
1388 match creds {
1389 Some(c) if c.is_valid() => Some(c.access_token),
1390 _ => None,
1391 }
1392}
1393
1394fn classify_refresh_token_error(e: crate::muxbus::pkce::RefreshTokenError) -> RefreshErrorKind {
1403 use crate::muxbus::pkce::RefreshTokenError;
1404 match &e {
1405 RefreshTokenError::NoRefreshToken => RefreshErrorKind::PermanentAuthFailure(e.to_string()),
1406 RefreshTokenError::Rejected { status, .. }
1407 if (400..500).contains(status) && !matches!(status, 408 | 429) =>
1408 {
1409 RefreshErrorKind::PermanentAuthFailure(e.to_string())
1410 }
1411 RefreshTokenError::Rejected { .. }
1412 | RefreshTokenError::Network(_)
1413 | RefreshTokenError::ParseFailed(_) => RefreshErrorKind::Transient(e.to_string()),
1414 }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::classify_refresh_token_error;
1420 use super::is_credential_rejected;
1421 use super::shared_token_rejection_outcome;
1422 use super::AgentSyncOutcome;
1423 use super::{reagent_sig_is_fresh, REAGENT_SIG_MAX_AGE_SECS};
1424 use crate::broker::RefreshErrorKind;
1425 use crate::muxbus::pkce::RefreshTokenError;
1426 use tokio_tungstenite::tungstenite::client::IntoClientRequest;
1427 use tokio_tungstenite::tungstenite::ClientRequestBuilder;
1428
1429 fn jwt(sub: &str) -> String {
1430 use base64::Engine;
1431 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"{sub}"}}"#));
1432 format!("e30.{payload}.sig")
1433 }
1434
1435 #[test]
1438 fn a_sign_in_with_only_a_refresh_token_can_still_be_refreshed() {
1439 use crate::muxbus::MuxBusCredentials;
1440 let in_an_hour = agentmux_common::time::now_secs() + 3600;
1441 let usable = |access: &str, refresh: &str, expires_at: i64| {
1442 super::usable_or_refreshable(&MuxBusCredentials {
1443 access_token: access.into(),
1444 refresh_token: refresh.into(),
1445 expires_at,
1446 ..Default::default()
1447 })
1448 };
1449 assert!(usable("", "rt", 0), "torn: refresh token only");
1450 assert!(usable("at", "", in_an_hour), "valid access token");
1451 assert!(usable("at", "rt", 0), "expired, refreshable");
1452 assert!(!usable("at", "", 0), "expired, nothing to refresh with");
1453 assert!(!usable("", "", 0), "nothing");
1454 }
1455
1456 #[test]
1458 fn a_refresh_for_another_account_is_refused() {
1459 use crate::muxbus::MuxBusCredentials;
1460 let current = |sub: &str| MuxBusCredentials { user_sub: sub.into(), ..Default::default() };
1461 let refreshed = |id: String, access: String| MuxBusCredentials {
1462 id_token: id,
1463 access_token: access,
1464 ..Default::default()
1465 };
1466 assert!(super::refreshed_same_account(¤t("user-a"), &refreshed(jwt("user-a"), jwt("user-a"))));
1467 assert!(!super::refreshed_same_account(¤t("user-a"), &refreshed(jwt("user-b"), jwt("user-b"))));
1468 assert!(
1469 super::refreshed_same_account(¤t("user-a"), &refreshed(String::new(), jwt("user-a"))),
1470 "no id token: the access token's"
1471 );
1472 assert!(!super::refreshed_same_account(¤t("user-a"), &refreshed("x".into(), "y".into())), "unreadable");
1473 assert!(
1474 super::refreshed_same_account(¤t(""), &refreshed(jwt("user-b"), jwt("user-b"))),
1475 "an older row with no account recorded can't be checked"
1476 );
1477 }
1478
1479 #[tokio::test]
1486 async fn calls_during_a_connection_get_a_fresh_token_not_the_connections() {
1487 let token = super::fresh_shared_token(|| async { Some("fresh".to_string()) }, "stale-connection-token").await;
1488 assert_eq!(token, "fresh");
1489 }
1490
1491 #[test]
1496 fn an_exit_drops_the_subscription_when_nobody_holds_the_agent() {
1497 assert!(super::drop_cloud_subscription_on_exit(false, false), "no registration and no live holder");
1498 assert!(super::drop_cloud_subscription_on_exit(true, false));
1499 assert!(!super::drop_cloud_subscription_on_exit(false, true), "a newer spawn holds it: keep");
1500 }
1501
1502 #[tokio::test]
1506 async fn a_parked_loop_ignores_agent_traffic_and_waits_for_the_recheck() {
1507 use std::time::{Duration, Instant};
1508 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1509 for i in 0..20 {
1510 tx.send(super::CtrlMsg::AddAgent(format!("a{i}"))).unwrap();
1511 tx.send(super::CtrlMsg::RemoveAgent(format!("a{i}"))).unwrap();
1512 }
1513 let started = Instant::now();
1514 super::wait_for_sign_in(&mut rx, Duration::from_millis(300)).await;
1515 assert!(started.elapsed() >= Duration::from_millis(300), "woke before the recheck");
1516 }
1517
1518 #[tokio::test]
1519 async fn a_sign_in_wakes_a_parked_loop_at_once() {
1520 use std::time::Duration;
1521 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1522 tx.send(super::CtrlMsg::AddAgent("a".into())).unwrap();
1523 tx.send(super::CtrlMsg::ReloadToken).unwrap();
1524 tokio::time::timeout(Duration::from_secs(5), super::wait_for_sign_in(&mut rx, Duration::from_secs(3600)))
1525 .await
1526 .expect("ReloadToken must end the wait");
1527 }
1528
1529 fn test_subscriber(
1530 agents: &[&str],
1531 ) -> (super::CloudSubscriber, tokio::sync::mpsc::UnboundedReceiver<super::CtrlMsg>) {
1532 let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel();
1533 let set = agents.iter().map(|a| a.to_string()).collect::<std::collections::HashSet<_>>();
1534 (super::CloudSubscriber { ctrl_tx, agents: std::sync::Arc::new(std::sync::Mutex::new(set)) }, ctrl_rx)
1535 }
1536
1537 #[test]
1542 fn dropping_an_orphan_keeps_an_agent_that_registered_meanwhile() {
1543 let (sub, mut rx) = test_subscriber(&["opaz"]);
1544 assert!(!sub.drop_if_orphaned("Opaz", |_| true));
1546 assert!(sub.agents.lock().unwrap().contains("opaz"), "still subscribed");
1547 assert!(rx.try_recv().is_err(), "no RemoveAgent sent");
1548 }
1549
1550 #[test]
1555 fn an_add_racing_a_drop_is_queued_after_the_removal() {
1556 let (sub, mut rx) = test_subscriber(&["opaz"]);
1557 let sub = std::sync::Arc::new(sub);
1558 let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
1559 let adder = {
1560 let sub = sub.clone();
1561 std::thread::spawn(move || {
1562 entered_rx.recv().unwrap();
1563 sub.add_agent("Opaz");
1564 })
1565 };
1566 let dropped = sub.drop_if_orphaned("Opaz", |_| {
1567 entered_tx.send(()).unwrap();
1569 std::thread::sleep(std::time::Duration::from_millis(50));
1570 false
1571 });
1572 adder.join().unwrap();
1573 assert!(dropped);
1574 assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"), "removal first");
1575 assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::AddAgent(k)) if k == "opaz"), "then the re-add");
1576 assert!(sub.agents.lock().unwrap().contains("opaz"), "and it ends subscribed");
1577 }
1578
1579 #[tokio::test]
1584 async fn a_take_over_release_queues_its_removal_before_a_re_add() {
1585 let (sub, mut rx) = test_subscriber(&["opaz"]);
1586 let sub = std::sync::Arc::new(sub);
1587 let re_adder = sub.clone();
1588 let released = sub
1589 .release_now("Opaz", "http://127.0.0.1:9", async move {
1590 re_adder.add_agent("Opaz");
1592 String::new()
1593 })
1594 .await;
1595 assert_eq!(released, super::RelayRelease::Kept, "re-added meanwhile: the lease was kept");
1596 assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"), "removal first");
1597 assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::AddAgent(k)) if k == "opaz"), "then the re-add");
1598 assert!(sub.agents.lock().unwrap().contains("opaz"), "and it ends subscribed");
1599 }
1600
1601 #[tokio::test]
1608 async fn a_take_over_release_still_releases_a_lease_the_exit_left_behind() {
1609 let agent = format!("agent-{}", uuid::Uuid::new_v4());
1610 crate::muxbus::wan_lease::mark_held_for_test(&agent);
1611 let (sub, mut rx) = test_subscriber(&[]);
1612 let outcome = sub.release_now(&agent, "http://127.0.0.1:9", async { String::new() }).await;
1613 assert_eq!(outcome, super::RelayRelease::Released);
1614 assert!(!crate::muxbus::wan_lease::is_held(&agent), "the lease is let go before answering");
1615 assert!(rx.try_recv().is_err(), "no second RemoveAgent");
1616 }
1617
1618 #[tokio::test]
1619 async fn a_take_over_release_of_an_unsubscribed_agent_says_so() {
1620 let (sub, _rx) = test_subscriber(&[]);
1621 let outcome = sub.release_now("Opaz", "http://127.0.0.1:9", async { String::new() }).await;
1622 assert_eq!(outcome, super::RelayRelease::NotSubscribed);
1623 }
1624
1625 #[test]
1626 fn dropping_an_orphan_removes_it_and_tells_the_ws_loop() {
1627 let (sub, mut rx) = test_subscriber(&["opaz"]);
1628 assert!(sub.drop_if_orphaned("Opaz", |_| false));
1629 assert!(!sub.agents.lock().unwrap().contains("opaz"));
1630 assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"));
1631 assert!(!sub.drop_if_orphaned("Opaz", |_| false), "already gone");
1632 }
1633
1634 #[test]
1635 fn the_lease_tick_finds_subscriptions_no_live_block_holds() {
1636 let registered = vec!["opaz".to_string(), "maricon".to_string()];
1637 let orphans = super::orphaned_subscriptions(®istered, |name| name == "maricon");
1638 assert_eq!(orphans, vec!["opaz".to_string()]);
1639 assert!(super::orphaned_subscriptions(®istered, |_| true).is_empty());
1640 }
1641
1642 #[tokio::test]
1643 async fn calls_during_a_connection_fall_back_to_the_connections_token() {
1644 let token = super::fresh_shared_token(|| async { None }, "connection-token").await;
1645 assert_eq!(token, "connection-token");
1646 }
1647
1648 #[test]
1652 fn treats_401_as_credential_rejected() {
1653 assert!(is_credential_rejected(reqwest::StatusCode::UNAUTHORIZED));
1654 }
1655
1656 #[test]
1657 fn treats_403_as_credential_rejected() {
1658 assert!(is_credential_rejected(reqwest::StatusCode::FORBIDDEN));
1659 }
1660
1661 #[test]
1662 fn does_not_treat_other_client_errors_as_credential_rejected() {
1663 for status in [
1668 reqwest::StatusCode::BAD_REQUEST,
1669 reqwest::StatusCode::NOT_FOUND,
1670 reqwest::StatusCode::TOO_MANY_REQUESTS,
1671 ] {
1672 assert!(!is_credential_rejected(status), "{status} must not be treated as a credential rejection");
1673 }
1674 }
1675
1676 #[test]
1682 fn shared_token_403_does_not_reconnect() {
1683 let outcome = shared_token_rejection_outcome(reqwest::StatusCode::FORBIDDEN, "agent-x");
1684 assert!(matches!(outcome, AgentSyncOutcome::Ok));
1685 }
1686
1687 #[test]
1688 fn shared_token_401_still_reconnects() {
1689 let outcome = shared_token_rejection_outcome(reqwest::StatusCode::UNAUTHORIZED, "agent-x");
1690 assert!(matches!(outcome, AgentSyncOutcome::ReconnectSharedTokenExpired));
1691 }
1692
1693 #[test]
1694 fn does_not_treat_success_or_server_error_as_credential_rejected() {
1695 for status in [reqwest::StatusCode::OK, reqwest::StatusCode::INTERNAL_SERVER_ERROR] {
1696 assert!(!is_credential_rejected(status));
1697 }
1698 }
1699
1700 #[test]
1702 fn reagent_sig_exactly_now_is_fresh() {
1703 assert!(reagent_sig_is_fresh(1_000, 1_000));
1704 }
1705
1706 #[test]
1707 fn reagent_sig_within_the_window_is_fresh() {
1708 assert!(reagent_sig_is_fresh(1_000, 1_000 + REAGENT_SIG_MAX_AGE_SECS));
1709 assert!(reagent_sig_is_fresh(1_000, 1_000 - REAGENT_SIG_MAX_AGE_SECS));
1710 }
1711
1712 #[test]
1713 fn reagent_sig_just_past_the_window_is_not_fresh() {
1714 assert!(!reagent_sig_is_fresh(1_000, 1_000 + REAGENT_SIG_MAX_AGE_SECS + 1));
1715 assert!(!reagent_sig_is_fresh(1_000, 1_000 - REAGENT_SIG_MAX_AGE_SECS - 1));
1716 }
1717
1718 #[test]
1719 fn reagent_sig_far_in_the_past_is_not_fresh() {
1720 assert!(!reagent_sig_is_fresh(1_000, 1_000 + 3600));
1723 }
1724
1725 #[test]
1726 fn reagent_sig_zero_or_negative_timestamp_is_never_fresh() {
1727 assert!(!reagent_sig_is_fresh(0, 0));
1728 assert!(!reagent_sig_is_fresh(-1, 0));
1729 }
1730
1731 #[test]
1732 fn no_refresh_token_is_permanent() {
1733 assert!(matches!(
1734 classify_refresh_token_error(RefreshTokenError::NoRefreshToken),
1735 RefreshErrorKind::PermanentAuthFailure(_)
1736 ));
1737 }
1738
1739 #[test]
1740 fn a_4xx_rejection_is_permanent() {
1741 assert!(matches!(
1742 classify_refresh_token_error(RefreshTokenError::Rejected {
1743 status: 400,
1744 body: "invalid_grant".into(),
1745 }),
1746 RefreshErrorKind::PermanentAuthFailure(_)
1747 ));
1748 }
1749
1750 #[test]
1751 fn a_5xx_rejection_is_transient() {
1752 assert!(matches!(
1753 classify_refresh_token_error(RefreshTokenError::Rejected {
1754 status: 503,
1755 body: "unavailable".into(),
1756 }),
1757 RefreshErrorKind::Transient(_)
1758 ));
1759 }
1760
1761 #[test]
1762 fn rate_limit_and_timeout_rejections_are_transient_not_permanent() {
1763 assert!(matches!(
1767 classify_refresh_token_error(RefreshTokenError::Rejected {
1768 status: 429,
1769 body: "rate limited".into(),
1770 }),
1771 RefreshErrorKind::Transient(_)
1772 ));
1773 assert!(matches!(
1774 classify_refresh_token_error(RefreshTokenError::Rejected {
1775 status: 408,
1776 body: "request timeout".into(),
1777 }),
1778 RefreshErrorKind::Transient(_)
1779 ));
1780 }
1781
1782 #[test]
1783 fn network_and_parse_errors_are_transient() {
1784 assert!(matches!(
1785 classify_refresh_token_error(RefreshTokenError::Network("timeout".into())),
1786 RefreshErrorKind::Transient(_)
1787 ));
1788 assert!(matches!(
1789 classify_refresh_token_error(RefreshTokenError::ParseFailed("bad json".into())),
1790 RefreshErrorKind::Transient(_)
1791 ));
1792 }
1793
1794 #[test]
1808 fn ws_request_carries_every_header_the_handshake_requires() {
1809 let uri: tokio_tungstenite::tungstenite::http::Uri =
1810 "wss://muxbus-ws.agentmux.ai".parse().unwrap();
1811 let request = ClientRequestBuilder::new(uri)
1812 .with_header("Authorization", "Bearer test-token")
1813 .into_client_request()
1814 .expect("request should build");
1815
1816 let headers = request.headers();
1817 for required in ["Host", "Connection", "Upgrade", "Sec-WebSocket-Version", "Sec-WebSocket-Key"] {
1818 assert!(
1819 headers.contains_key(required),
1820 "missing required WebSocket handshake header: {required}"
1821 );
1822 }
1823 assert_eq!(headers.get("Authorization").unwrap(), "Bearer test-token");
1824 }
1825
1826 use std::sync::{Arc, Mutex};
1832
1833 type Seen = Arc<Mutex<Vec<String>>>;
1834
1835 async fn fake_relay(routes: Vec<(String, u16, serde_json::Value)>) -> (String, Seen) {
1840 let seen: Seen = Arc::default();
1841 let app = axum::Router::new().fallback({
1842 let seen = Arc::clone(&seen);
1843 move |req: axum::extract::Request| {
1844 let (seen, routes) = (Arc::clone(&seen), routes.clone());
1845 async move {
1846 let line = format!("{} {}", req.method(), req.uri().path());
1847 seen.lock().unwrap().push(line.clone());
1848 let (status, body) = routes
1849 .iter()
1850 .find(|(route, _, _)| *route == line)
1851 .map(|(_, status, body)| (*status, body.clone()))
1852 .unwrap_or((404, serde_json::Value::Null));
1853 (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
1854 }
1855 }
1856 });
1857 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1858 let addr = listener.local_addr().unwrap();
1859 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1860 (format!("http://{addr}"), seen)
1861 }
1862
1863 fn hold_locally(
1866 agent: &str,
1867 ) -> (tempfile::TempDir, crate::backend::agent_admission::HeldAgentLease, std::sync::mpsc::Receiver<String>) {
1868 let tmp = tempfile::tempdir().unwrap();
1869 let store = Arc::new(crate::registry::LeaseStore::open_with_ttl(tmp.path(), 60_000).unwrap());
1870 let (tx, rx) = std::sync::mpsc::channel();
1871 let tx = Mutex::new(tx);
1872 let held = crate::backend::agent_admission::acquire(
1873 &store,
1874 &format!("uid-{agent}"),
1875 agent,
1876 &Arc::from("boot-test"),
1877 "block-test",
1878 None,
1879 move |why| {
1880 let _ = tx.lock().unwrap().send(why);
1881 },
1882 )
1883 .unwrap();
1884 (tmp, held, rx)
1885 }
1886
1887 fn not_holder() -> serde_json::Value {
1888 serde_json::json!({
1889 "error": "not_holder",
1890 "held_by": { "host": "desk", "channel": "stable", "version": "0.58.0" },
1891 })
1892 }
1893
1894 fn lease_granted() -> (String, u16, serde_json::Value) {
1895 ("POST /agents/lease".into(), 200, serde_json::json!({ "ok": true, "epoch": 1 }))
1896 }
1897
1898 async fn sync(base: &str, agent: &str) {
1900 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1901 let handler = crate::backend::reactive::handler::get_global_handler();
1902 let outcome = super::sync_agent_reactive(base, agent, "test-token", &reqwest::Client::new(), &mstore, handler).await;
1903 assert!(matches!(outcome, AgentSyncOutcome::Ok));
1904 }
1905
1906 fn assert_fenced(agent: &str, rx: &std::sync::mpsc::Receiver<String>) {
1907 let why = rx.try_recv().expect("the local holder was fenced");
1908 assert!(why.contains("taken over") && why.contains("computer desk"), "{why}");
1909 assert_eq!(
1910 crate::muxbus::wan_lease::held_elsewhere(agent).as_deref(),
1911 Some("computer desk, channel stable, v0.58.0"),
1912 "remembered for the early admission check"
1913 );
1914 }
1915
1916 #[tokio::test]
1917 async fn a_lease_held_elsewhere_fences_the_local_holder_before_any_pull() {
1918 let agent = format!("agent-{}", uuid::Uuid::new_v4());
1919 let (_tmp, _held, rx) = hold_locally(&agent);
1920 let (base, seen) = fake_relay(vec![("POST /agents/lease".into(), 409, not_holder())]).await;
1921
1922 sync(&base, &agent).await;
1923
1924 assert_fenced(&agent, &rx);
1925 assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease"], "no pending pull");
1926 }
1927
1928 #[tokio::test]
1929 async fn a_409_on_the_pending_pull_fences_the_local_holder_and_claims_nothing() {
1930 let agent = format!("agent-{}", uuid::Uuid::new_v4());
1931 let (_tmp, _held, rx) = hold_locally(&agent);
1932 let pending = format!("GET /reactive/pending/{agent}");
1933 let (base, seen) = fake_relay(vec![lease_granted(), (pending.clone(), 409, not_holder())]).await;
1934
1935 sync(&base, &agent).await;
1936
1937 assert_fenced(&agent, &rx);
1938 assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease", pending.as_str()], "no ack");
1939 }
1940
1941 #[tokio::test]
1945 async fn a_409_on_the_ack_fences_the_local_holder_and_delivers_nothing() {
1946 let agent = format!("agent-{}", uuid::Uuid::new_v4());
1947 let (_tmp, _held, rx) = hold_locally(&agent);
1948 let pending = format!("GET /reactive/pending/{agent}");
1949 let (base, seen) = fake_relay(vec![
1950 lease_granted(),
1951 (pending.clone(), 200, serde_json::json!({ "injections": [{ "id": "inj-1", "message": "hello" }] })),
1952 ("POST /reactive/ack".into(), 409, not_holder()),
1953 ])
1954 .await;
1955
1956 sync(&base, &agent).await;
1957
1958 assert_fenced(&agent, &rx);
1959 assert_eq!(
1960 *seen.lock().unwrap(),
1961 ["POST /agents/lease", pending.as_str(), "POST /reactive/ack"],
1962 "nothing delivered, so nothing released"
1963 );
1964 }
1965
1966 async fn sync_batched(base: &str, agents: &[String]) -> Option<usize> {
1970 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1971 let handler = crate::backend::reactive::handler::get_global_handler();
1972 super::sync_agents_batched(base, agents, "test-token", &reqwest::Client::new(), &mstore, handler)
1973 .await
1974 .map(|outcomes| outcomes.len())
1975 }
1976
1977 #[tokio::test]
1978 async fn one_request_pulls_for_every_agent_and_only_agents_with_messages_are_claimed() {
1979 let [a, b, c, d] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
1980 let (_tmp, _held, rx) = hold_locally(&b);
1981 let results = serde_json::json!({ "results": {
1982 a.clone(): { "agent_id": a.clone(), "injections": [{ "id": "inj-1", "message": "hello" }], "count": 1, "expired": [] },
1983 b.clone(): not_holder(),
1984 c.clone(): { "agent_id": c.clone(), "injections": [], "count": 0, "expired": [] },
1985 }});
1987 let pd = format!("GET /reactive/pending/{d}");
1988 let (base, seen) = fake_relay(vec![
1989 lease_granted(),
1990 ("POST /reactive/pending-batch".into(), 200, results),
1991 ("POST /reactive/ack".into(), 200, serde_json::json!({ "acknowledged": [], "delivered_at": "2026-10-11T00:00:00Z" })),
1992 (pd.clone(), 200, serde_json::json!({ "injections": [] })),
1993 ])
1994 .await;
1995
1996 let agents = vec![a.clone(), b.clone(), c.clone(), d.clone()];
1997 assert_eq!(sync_batched(&base, &agents).await, Some(4));
1998
1999 assert_fenced(&b, &rx);
2000 let seen = seen.lock().unwrap();
2001 let count = |line: &str| seen.iter().filter(|s| *s == line).count();
2002 assert_eq!(count("POST /reactive/pending-batch"), 1, "{seen:?}");
2003 assert_eq!(count("POST /reactive/ack"), 1, "only {a} had messages: {seen:?}");
2004 assert_eq!(count(&pd), 1, "{d}, missing from the answer, pulled on its own: {seen:?}");
2005 assert!(
2006 !seen.iter().any(|s| s.starts_with("GET /reactive/pending/") && *s != pd),
2007 "no single pull for the agents the batch answered: {seen:?}"
2008 );
2009 }
2010
2011 #[tokio::test]
2012 async fn an_unavailable_entry_is_a_failed_pull_for_that_agent_only() {
2013 let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
2014 let results = serde_json::json!({ "results": {
2015 a.clone(): { "error": "unavailable" },
2016 b.clone(): { "agent_id": b.clone(), "injections": [], "count": 0, "expired": [] },
2017 }});
2018 let (base, seen) = fake_relay(vec![lease_granted(), ("POST /reactive/pending-batch".into(), 200, results)]).await;
2019
2020 assert_eq!(sync_batched(&base, &[a.clone(), b.clone()]).await, Some(2));
2021 assert!(
2022 !seen.lock().unwrap().iter().any(|s| s.starts_with("GET /reactive/pending/")),
2023 "retried on the next pull, not at once"
2024 );
2025 }
2026
2027 #[tokio::test]
2028 async fn a_relay_without_the_batch_route_is_pulled_one_agent_at_a_time() {
2029 let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
2030 let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2031 let (base, seen) = fake_relay(vec![
2032 lease_granted(),
2033 (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2034 (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2035 ])
2036 .await;
2037 let agents = Arc::new(Mutex::new(HashSet::from([a.clone(), b.clone()])));
2038 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2039
2040 assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2041 assert!(crate::muxbus::relay::batch_routes_missing(&base));
2042 assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2043
2044 let seen = seen.lock().unwrap();
2045 assert_eq!(seen.iter().filter(|s| *s == "POST /reactive/pending-batch").count(), 1, "asked once, then remembered: {seen:?}");
2046 assert_eq!(seen.iter().filter(|s| **s == pa || **s == pb).count(), 4, "both agents, both times: {seen:?}");
2047 }
2048
2049 #[tokio::test]
2050 async fn a_single_agent_is_pulled_with_the_single_route() {
2051 let a = format!("agent-{}", uuid::Uuid::new_v4());
2052 let (base, seen) = fake_relay(vec![lease_granted()]).await;
2053 assert_eq!(sync_batched(&base, &[a]).await, None);
2054 assert!(seen.lock().unwrap().is_empty());
2055 }
2056
2057 use super::{agents_to_seed, catch_up, CATCH_UP_EVERY};
2059 use std::collections::HashSet;
2060 use std::time::Duration;
2061
2062 #[test]
2066 fn the_seed_is_every_live_registered_agent_lowercased_and_deduplicated() {
2067 let registered: Vec<String> = ["Alice", "alice", "Bob", "Carol"].map(String::from).to_vec();
2068 let got = agents_to_seed(®istered, |a| a != "carol");
2069 assert_eq!(got, ["alice", "bob"], "Carol has no live pane; Alice appears once, lowercased");
2070 }
2071
2072 #[test]
2073 fn an_empty_registry_seeds_nothing() {
2074 assert!(agents_to_seed(&[], |_| true).is_empty());
2075 }
2076
2077 #[tokio::test]
2078 async fn a_catch_up_pulls_pending_for_every_registered_agent() {
2079 let (a, b) = (format!("agent-{}", uuid::Uuid::new_v4()), format!("agent-{}", uuid::Uuid::new_v4()));
2080 let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2081 let (base, seen) = fake_relay(vec![
2082 lease_granted(),
2083 (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2084 (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2085 ])
2086 .await;
2087 let agents = Arc::new(Mutex::new(HashSet::from([a.clone(), b.clone()])));
2088 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2089
2090 let reconnect = catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await;
2091
2092 assert!(!reconnect, "nothing went wrong, so no reconnect");
2093 let seen = seen.lock().unwrap();
2094 assert!(seen.contains(&pa), "pulled for {a}: {seen:?}");
2095 assert!(seen.contains(&pb), "pulled for {b}: {seen:?}");
2096 }
2097
2098 #[tokio::test]
2099 async fn a_catch_up_with_no_registered_agents_asks_the_relay_for_nothing() {
2100 let (base, seen) = fake_relay(vec![lease_granted()]).await;
2101 let agents = Arc::new(Mutex::new(HashSet::new()));
2102 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2103
2104 assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2105
2106 assert!(seen.lock().unwrap().is_empty());
2107 }
2108
2109 #[test]
2110 fn the_fallback_interval_is_short_enough_to_matter_and_long_enough_to_be_cheap() {
2111 assert!(CATCH_UP_EVERY >= Duration::from_secs(30), "faster than this is polling");
2112 assert!(CATCH_UP_EVERY <= Duration::from_secs(300), "slower than this is the 8m46s we are fixing");
2113 }
2114
2115 #[tokio::test]
2120 async fn a_session_pulls_what_is_waiting_on_connect_and_when_an_agent_is_added() {
2121 use futures_util::StreamExt;
2122 let _serial = crate::muxbus::pkce::TEST_SERIAL
2124 .lock()
2125 .unwrap_or_else(|e| e.into_inner());
2126
2127 let (a, b) = (format!("agent-{}", uuid::Uuid::new_v4()), format!("agent-{}", uuid::Uuid::new_v4()));
2128 let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2129 let (rest, seen) = fake_relay(vec![
2130 lease_granted(),
2131 (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2132 (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2133 ])
2134 .await;
2135
2136 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2139 let ws_url = format!("ws://{}", listener.local_addr().unwrap());
2140 let frames: Seen = Arc::default();
2141 let server = tokio::spawn({
2142 let frames = Arc::clone(&frames);
2143 async move {
2144 let (stream, _) = listener.accept().await.unwrap();
2145 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2146 while let Some(Ok(msg)) = ws.next().await {
2147 if let tokio_tungstenite::tungstenite::Message::Text(t) = msg {
2148 frames.lock().unwrap().push(t.to_string());
2149 }
2150 }
2151 }
2152 });
2153
2154 std::env::set_var("AGENTMUX_MUXBUS_REST_URL", &rest);
2155 std::env::set_var("AGENTMUX_MUXBUS_WS_URL", &ws_url);
2156
2157 let agents = Arc::new(Mutex::new(HashSet::from([a.clone()])));
2158 let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<super::CtrlMsg>();
2159 let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2160 let session = tokio::spawn({
2161 let (agents, mstore) = (Arc::clone(&agents), Arc::clone(&mstore));
2162 async move {
2163 super::connect_and_run("test-token", agents, &mut ctrl_rx, &mstore, &reqwest::Client::new()).await
2164 }
2165 });
2166
2167 async fn within_5s(what: &str, mut cond: impl FnMut() -> bool) {
2168 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2169 while !cond() {
2170 assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}");
2171 tokio::time::sleep(Duration::from_millis(20)).await;
2172 }
2173 }
2174
2175 within_5s(&pa, || seen.lock().unwrap().contains(&pa)).await;
2177 assert!(
2178 frames.lock().unwrap().iter().any(|f| f.contains("\"subscribe\"") && f.contains(&a)),
2179 "subscribed {a}: {:?}",
2180 frames.lock().unwrap()
2181 );
2182
2183 agents.lock().unwrap().insert(b.clone());
2185 ctrl_tx.send(super::CtrlMsg::AddAgent(b.clone())).unwrap();
2186 within_5s(&pb, || seen.lock().unwrap().contains(&pb)).await;
2187
2188 session.abort();
2189 server.abort();
2190 std::env::remove_var("AGENTMUX_MUXBUS_REST_URL");
2191 std::env::remove_var("AGENTMUX_MUXBUS_WS_URL");
2192 }
2193}