Skip to main content

agentmux_srv/backend/
tower_peers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Other AgentMux computers on the LAN in Tower
5//! (SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md §8.3): this computer pairs as
6//! a viewer device of the other one, the way a phone does, and reads its
7//! processes from the viewer listener.
8//!
9//! - **Pairing:** the other computer's pairing link (`agentmux://pair?…`,
10//!   its "Pair a device" panel) carries its address, viewer port, certificate
11//!   fingerprint and a one-time code. [`pair`] redeems the code for a viewer
12//!   token over TLS pinned to that fingerprint (`viewer::client`).
13//! - **What is kept:** the peer's address, port and fingerprint in
14//!   `tower-peers.json` in the data directory; the token in the secret store
15//!   (the OS keychain), never in the file.
16//! - **Reading:** `GET /agentmux/viewer/procs` with the token. The other
17//!   computer answers only if its user turned sharing on, and its user can
18//!   revoke this device from their paired-device list.
19//! - **A moved peer:** the fingerprint, not the address, is what is trusted,
20//!   so when the stored address doesn't answer, the address LAN discovery
21//!   now has for that hostname is tried, and kept if it answers.
22
23use std::path::PathBuf;
24use std::sync::Mutex;
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28
29use crate::backend::rpc_types::{TowerPeerInfo, TowerSnapshot};
30use crate::backend::viewer::client::pinned_client;
31
32/// `TowerSampleReq::connection` for a paired computer: `peer:<id>`.
33pub const PEER_PREFIX: &str = "peer:";
34
35const FILE: &str = "tower-peers.json";
36const TIMEOUT: Duration = Duration::from_secs(8);
37
38/// One paired computer, as kept on disk (no secret here).
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Peer {
41    pub id: String,
42    pub hostname: String,
43    pub host: String,
44    pub port: u16,
45    pub fingerprint: String,
46    /// This computer's device id on the peer (what its user revokes).
47    pub device_id: String,
48    pub paired_ms: i64,
49}
50
51impl Peer {
52    fn info(&self) -> TowerPeerInfo {
53        TowerPeerInfo {
54            connection: format!("{PEER_PREFIX}{}", self.id),
55            hostname: self.hostname.clone(),
56            address: format!("{}:{}", self.host, self.port),
57            paired_ms: self.paired_ms,
58        }
59    }
60
61    fn secret_key(&self) -> String {
62        format!("tower-peer:{}", self.id)
63    }
64
65    fn url(&self, host: &str, path: &str) -> String {
66        url_at(host, self.port, path)
67    }
68}
69
70fn url_at(host: &str, port: u16, path: &str) -> String {
71    // An IPv6 address needs brackets in a URL.
72    let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() };
73    format!("https://{host}:{port}{path}")
74}
75
76/// The viewer ports `host`'s AgentMux instances advertise now, from the LAN
77/// discovery probe a phone sends (UDP 47891): the answering instance's, then
78/// its siblings' (other channels on that host). Empty when nothing answers.
79async fn probe_viewer_ports(host: &str) -> Vec<u16> {
80    use crate::backend::lan_discovery::{UDP_DISCOVERY_PORT, UDP_PROBE_TYPE, UDP_PROTOCOL_VERSION};
81    let Ok(ip) = host.parse::<std::net::IpAddr>() else { return Vec::new() };
82    let bind = if ip.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
83    let Ok(sock) = tokio::net::UdpSocket::bind(bind).await else { return Vec::new() };
84    let probe = serde_json::json!({ "type": UDP_PROBE_TYPE, "v": UDP_PROTOCOL_VERSION }).to_string();
85    if sock.send_to(probe.as_bytes(), (ip, UDP_DISCOVERY_PORT)).await.is_err() {
86        return Vec::new();
87    }
88    let mut buf = vec![0u8; 8192];
89    match tokio::time::timeout(Duration::from_millis(1500), sock.recv_from(&mut buf)).await {
90        Ok(Ok((n, _))) => viewer_ports_of(&buf[..n]),
91        _ => Vec::new(),
92    }
93}
94
95/// The viewer ports in a discovery reply: its own, then its siblings'.
96fn viewer_ports_of(reply: &[u8]) -> Vec<u16> {
97    let Ok(v) = serde_json::from_slice::<serde_json::Value>(reply) else { return Vec::new() };
98    let port = |x: &serde_json::Value| x.get("viewer_port").and_then(|p| p.as_u64()).and_then(|p| u16::try_from(p).ok());
99    let mut ports: Vec<u16> = port(&v).into_iter().collect();
100    for s in v.get("siblings").and_then(|s| s.as_array()).into_iter().flatten() {
101        if let Some(p) = port(s).filter(|p| !ports.contains(p)) {
102            ports.push(p);
103        }
104    }
105    ports
106}
107
108/// What a pairing link says.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Link {
111    pub host: String,
112    pub port: u16,
113    pub fingerprint: String,
114    pub code: String,
115    pub hostname: String,
116}
117
118/// Parse an `agentmux://pair?v=1&host=…&port=…&fp=…&code=…&hostname=…` link.
119pub fn parse_link(link: &str) -> Result<Link, String> {
120    let bad = |why: &str| format!("That isn't a pairing link ({why}). Copy it from the other computer's \u{201c}Pair a device\u{201d} panel.");
121    let url = url::Url::parse(link.trim()).map_err(|_| bad("not a link"))?;
122    if url.scheme() != "agentmux" || url.host_str() != Some("pair") {
123        return Err(bad("not an agentmux://pair link"));
124    }
125    let get = |k: &str| url.query_pairs().find(|(key, _)| key == k).map(|(_, v)| v.into_owned());
126    if get("v").as_deref() != Some("1") {
127        return Err(bad("an unknown version"));
128    }
129    let fingerprint = get("fp").filter(|f| f.len() == 64 && f.bytes().all(|b| b.is_ascii_hexdigit())).ok_or_else(|| bad("no fingerprint"))?;
130    let host = get("host").filter(|h| h.parse::<std::net::IpAddr>().is_ok()).ok_or_else(|| bad("no address"))?;
131    Ok(Link {
132        host,
133        port: get("port").and_then(|p| p.parse().ok()).filter(|&p| p > 0).ok_or_else(|| bad("no port"))?,
134        fingerprint: fingerprint.to_ascii_lowercase(),
135        code: get("code").filter(|c| !c.is_empty()).ok_or_else(|| bad("no code"))?,
136        hostname: get("hostname").unwrap_or_default(),
137    })
138}
139
140/// Where peers' tokens are kept: the secret store (the OS keychain) in the
141/// app; in memory in tests, which must not touch the user's keychain.
142pub trait Tokens: Send + Sync {
143    fn put(&self, key: &str, token: &str) -> Result<(), String>;
144    fn get(&self, key: &str) -> Result<String, String>;
145    fn delete(&self, key: &str) -> Result<(), String>;
146}
147
148struct SecretStore;
149
150impl Tokens for SecretStore {
151    fn put(&self, key: &str, token: &str) -> Result<(), String> {
152        crate::identity::secret_store::put(key, token)
153    }
154    fn get(&self, key: &str) -> Result<String, String> {
155        crate::identity::secret_store::get(key).map(|t| t.as_str().to_string())
156    }
157    fn delete(&self, key: &str) -> Result<(), String> {
158        crate::identity::secret_store::delete(key)
159    }
160}
161
162/// The kept peers, from `dir` (the data directory).
163pub struct Peers {
164    dir: Option<PathBuf>,
165    lock: Mutex<()>,
166    tokens: Box<dyn Tokens>,
167}
168
169impl Peers {
170    pub fn new(dir: Option<PathBuf>) -> Self {
171        Self::with_tokens(dir, Box::new(SecretStore))
172    }
173
174    pub fn with_tokens(dir: Option<PathBuf>, tokens: Box<dyn Tokens>) -> Self {
175        Self { dir, lock: Mutex::new(()), tokens }
176    }
177
178    pub fn global() -> &'static Peers {
179        static GLOBAL: std::sync::OnceLock<Peers> = std::sync::OnceLock::new();
180        GLOBAL.get_or_init(|| Peers::new(Some(crate::backend::base::get_mux_data_dir())))
181    }
182
183    fn path(&self) -> Option<PathBuf> {
184        self.dir.as_ref().map(|d| d.join(FILE))
185    }
186
187    fn load(&self) -> Vec<Peer> {
188        self.path()
189            .and_then(|p| std::fs::read(p).ok())
190            .and_then(|b| serde_json::from_slice(&b).ok())
191            .unwrap_or_default()
192    }
193
194    fn save(&self, peers: &[Peer]) -> Result<(), String> {
195        let path = self.path().ok_or("no data directory")?;
196        let tmp = path.with_extension("json.tmp");
197        std::fs::write(&tmp, serde_json::to_vec_pretty(peers).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
198        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
199    }
200
201    pub fn list(&self) -> Vec<TowerPeerInfo> {
202        let _g = self.lock.lock().unwrap();
203        self.load().iter().map(Peer::info).collect()
204    }
205
206    pub fn get(&self, id: &str) -> Option<Peer> {
207        let _g = self.lock.lock().unwrap();
208        self.load().into_iter().find(|p| p.id == id)
209    }
210
211    fn put(&self, peer: Peer) -> Result<(), String> {
212        let _g = self.lock.lock().unwrap();
213        let mut all = self.load();
214        all.retain(|p| p.id != peer.id);
215        all.push(peer);
216        self.save(&all)
217    }
218
219    /// Forget a peer here (its token too). The other computer still lists
220    /// this device until its user revokes it.
221    pub fn forget(&self, id: &str) -> Result<(), String> {
222        let Some(peer) = self.get(id) else { return Ok(()) };
223        // The token first, with no lock held: a keychain delete may wait on
224        // the OS asking the user, and must not stall every listing meanwhile.
225        // If it fails the entry stays, so Forget can be tried again rather
226        // than leaving a token nothing points to.
227        self.tokens
228            .delete(&peer.secret_key())
229            .map_err(|e| format!("Couldn't remove the pairing with {} from the keychain: {e}", peer.hostname))?;
230        let _g = self.lock.lock().unwrap();
231        let mut all = self.load();
232        all.retain(|p| p.id != id);
233        self.save(&all)
234    }
235
236    /// Record that a peer now answers at `host:port`, if it is still kept (it
237    /// may have been forgotten while the request that found it was in
238    /// flight: then nothing is written).
239    fn moved_to(&self, id: &str, host: &str, port: u16) {
240        let _g = self.lock.lock().unwrap();
241        let mut all = self.load();
242        let Some(peer) = all.iter_mut().find(|p| p.id == id) else { return };
243        (peer.host, peer.port) = (host.to_string(), port);
244        let _ = self.save(&all);
245    }
246}
247
248#[derive(Deserialize)]
249struct PairReply {
250    token: String,
251    device_id: String,
252    #[serde(default)]
253    hostname: String,
254}
255
256/// Pair with the computer the link names, as `device_name` (this computer).
257pub async fn pair(peers: &'static Peers, link: &str, device_name: &str) -> Result<TowerPeerInfo, String> {
258    let link = parse_link(link)?;
259    let client = pinned_client(&link.fingerprint, TIMEOUT)?;
260    let probe = Peer {
261        id: uuid::Uuid::new_v4().to_string(),
262        hostname: link.hostname.clone(),
263        host: link.host.clone(),
264        port: link.port,
265        fingerprint: link.fingerprint.clone(),
266        device_id: String::new(),
267        paired_ms: agentmux_common::time::now_ms(),
268    };
269    let resp = client
270        .post(probe.url(&link.host, "/agentmux/viewer/pair"))
271        .json(&serde_json::json!({ "code": link.code, "device_name": device_name }))
272        .send()
273        .await
274        .map_err(|e| unreachable(&probe, &e))?;
275    let status = resp.status();
276    if status == reqwest::StatusCode::UNAUTHORIZED {
277        return Err("The code was refused: it works once, for two minutes. Show a new one on the other computer.".into());
278    }
279    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
280        return Err("Too many wrong codes: wait a minute and try again.".into());
281    }
282    if !status.is_success() {
283        return Err(format!("The other computer refused to pair ({status})."));
284    }
285    let reply: PairReply = resp.json().await.map_err(|e| format!("The other computer answered oddly: {e}"))?;
286    let peer = Peer {
287        hostname: if reply.hostname.is_empty() { link.hostname } else { reply.hostname },
288        device_id: reply.device_id,
289        ..probe
290    };
291    // The keychain write may wait on the OS asking the user's consent, which
292    // can't be cancelled: never on an async worker.
293    let info = peer.info();
294    let token = reply.token;
295    tokio::task::spawn_blocking(move || {
296        peers.tokens.put(&peer.secret_key(), &token)?;
297        if let Err(e) = peers.put(peer.clone()) {
298            if let Err(d) = peers.tokens.delete(&peer.secret_key()) {
299                tracing::warn!(error = %d, "tower: could not remove a token after a failed pairing");
300            }
301            return Err(e);
302        }
303        Ok(())
304    })
305    .await
306    .map_err(|e| e.to_string())??;
307    Ok(info)
308}
309
310fn unreachable(peer: &Peer, e: &reqwest::Error) -> String {
311    let why = if e.is_timeout() {
312        "it didn't answer".to_string()
313    } else if format!("{e:?}").contains("fingerprint") {
314        "its certificate isn't the one it was paired with".to_string()
315    } else {
316        "the connection failed".to_string()
317    };
318    format!("Can't reach {} at {}:{} ({why}).", if peer.hostname.is_empty() { "the other computer" } else { &peer.hostname }, peer.host, peer.port)
319}
320
321/// A paired computer's snapshot. `moved`: the addresses LAN discovery has for
322/// its hostname, tried in turn when the kept one doesn't answer.
323pub async fn sample(peers: &'static Peers, id: &str, filter: &str, moved: Vec<String>) -> Result<TowerSnapshot, String> {
324    let mut peer = peers.get(id).ok_or("That computer is no longer paired.")?;
325    // A keychain read (bounded, but blocking): off the async workers.
326    let key = peer.secret_key();
327    let token = tokio::task::spawn_blocking(move || peers.tokens.get(&key))
328        .await
329        .map_err(|e| e.to_string())?
330        .map_err(|e| format!("Couldn't read the pairing with {}: {e}", peer.hostname))?;
331    let client = pinned_client(&peer.fingerprint, TIMEOUT)?;
332    let mut hosts = vec![peer.host.clone()];
333    hosts.extend(moved.into_iter().filter(|h| *h != peer.host));
334    // The kept port at each address first; only if none answers, the ports the
335    // addresses advertise now (a restarted peer may have another). The pinned
336    // fingerprint decides which of them is really this peer.
337    let mut candidates: Vec<(String, u16)> = hosts.iter().map(|h| (h.clone(), peer.port)).collect();
338    let mut probed = false;
339    let mut next = 0;
340    let mut last_err = String::new();
341    loop {
342        if next == candidates.len() {
343            if probed {
344                break;
345            }
346            probed = true;
347            for h in &hosts {
348                for p in probe_viewer_ports(h).await {
349                    if !candidates.contains(&(h.clone(), p)) {
350                        candidates.push((h.clone(), p));
351                    }
352                }
353            }
354            if next == candidates.len() {
355                break;
356            }
357        }
358        let (host, port) = candidates[next].clone();
359        next += 1;
360        let resp = match client
361            .get(url_at(&host, port, "/agentmux/viewer/procs"))
362            .query(&[("filter", filter)])
363            .bearer_auth(&token)
364            .send()
365            .await
366        {
367            Ok(r) => r,
368            Err(e) => {
369                last_err = unreachable(&Peer { host: host.clone(), port, ..peer.clone() }, &e);
370                continue;
371            }
372        };
373        match resp.status() {
374            s if s.is_success() => {
375                if host != peer.host || port != peer.port {
376                    (peer.host, peer.port) = (host.clone(), port);
377                    let id = peer.id.clone();
378                    let _ = tokio::task::spawn_blocking(move || peers.moved_to(&id, &host, port)).await;
379                }
380                let mut snap: TowerSnapshot = resp.json().await.map_err(|e| format!("{} answered oddly: {e}", peer.hostname))?;
381                snap.remote = true;
382                return Ok(snap);
383            }
384            reqwest::StatusCode::UNAUTHORIZED => {
385                return Err(format!("{} no longer accepts this computer (it was unpaired there). Forget it and pair again.", peer.hostname))
386            }
387            reqwest::StatusCode::FORBIDDEN => {
388                return Err(format!("{} doesn't share its processes. Turn on \u{201c}Share this computer's processes with paired devices\u{201d} in its Tower.", peer.hostname))
389            }
390            s => return Err(format!("{} refused ({s}).", peer.hostname)),
391        }
392    }
393    Err(last_err)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    const FP: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
401
402    #[test]
403    fn a_pairing_link_is_read_and_anything_else_refused() {
404        let link = format!("agentmux://pair?v=1&host=198.51.100.20&port=47900&fp={FP}&code=ABCDEFGHJK&hostname=studio&channel=stable");
405        assert_eq!(
406            parse_link(&link).unwrap(),
407            Link { host: "198.51.100.20".into(), port: 47900, fingerprint: FP.into(), code: "ABCDEFGHJK".into(), hostname: "studio".into() }
408        );
409        assert!(parse_link("https://example.com/pair?v=1").is_err());
410        assert!(parse_link(&link.replace("v=1", "v=9")).is_err(), "unknown version");
411        assert!(parse_link(&link.replace(FP, "abc")).is_err(), "short fingerprint");
412        assert!(parse_link(&link.replace("198.51.100.20", "evil.example")).is_err(), "an address, not a name");
413        assert!(parse_link(&link.replace("&code=ABCDEFGHJK", "")).is_err());
414    }
415
416    /// Tokens in memory: tests never touch the user's keychain.
417    #[derive(Default)]
418    struct MemTokens(std::sync::Mutex<std::collections::HashMap<String, String>>);
419
420    impl Tokens for std::sync::Arc<MemTokens> {
421        fn put(&self, key: &str, token: &str) -> Result<(), String> {
422            self.0.lock().unwrap().insert(key.into(), token.into());
423            Ok(())
424        }
425        fn get(&self, key: &str) -> Result<String, String> {
426            self.0.lock().unwrap().get(key).cloned().ok_or_else(|| "no token".into())
427        }
428        fn delete(&self, key: &str) -> Result<(), String> {
429            self.0.lock().unwrap().remove(key);
430            Ok(())
431        }
432    }
433
434    /// Peers for one test, kept for the test binary's life (`pair` and
435    /// `sample` take `'static`, as the app's `Peers::global()` is).
436    fn peers_in(dir: &std::path::Path) -> (&'static Peers, std::sync::Arc<MemTokens>) {
437        let tokens = std::sync::Arc::new(MemTokens::default());
438        (Box::leak(Box::new(Peers::with_tokens(Some(dir.to_path_buf()), Box::new(tokens.clone())))), tokens)
439    }
440
441    #[test]
442    fn a_discovery_reply_gives_its_viewer_port_then_its_siblings() {
443        let reply = br#"{"type":"agentmux_discover_response","v":1,"viewer_port":29702,
444            "siblings":[{"channel":"dev","viewer_port":29703},{"channel":"x"},{"viewer_port":29702}]}"#;
445        assert_eq!(viewer_ports_of(reply), vec![29702, 29703]);
446        assert!(viewer_ports_of(b"not json").is_empty());
447        assert!(viewer_ports_of(br#"{"type":"agentmux_discover_response"}"#).is_empty(), "no viewer listener up");
448    }
449
450    #[test]
451    fn peers_are_kept_without_their_token_and_forgotten() {
452        let dir = tempfile::tempdir().unwrap();
453        let (peers, _) = peers_in(dir.path());
454        let peer = Peer {
455            id: "p1".into(),
456            hostname: "studio".into(),
457            host: "2001:db8::1".into(),
458            port: 47900,
459            fingerprint: FP.into(),
460            device_id: "d1".into(),
461            paired_ms: 5,
462        };
463        peers.put(peer.clone()).unwrap();
464        let list = peers.list();
465        assert_eq!(list.len(), 1);
466        assert_eq!(list[0].connection, "peer:p1");
467        assert_eq!(peer.url("2001:db8::1", "/x"), "https://[2001:db8::1]:47900/x");
468        let on_disk = std::fs::read_to_string(dir.path().join(FILE)).unwrap();
469        assert!(!on_disk.contains("amxv_"), "no token on disk");
470        peers.forget("p1").unwrap();
471        assert!(peers.list().is_empty());
472        // A request that found it at a new address after it was forgotten
473        // doesn't bring it back.
474        peers.moved_to("p1", "198.51.100.30", 47901);
475        assert!(peers.list().is_empty());
476    }
477
478    /// Nothing listening: an error that says so, and nothing kept.
479    #[tokio::test]
480    async fn pairing_with_nothing_listening_fails_closed() {
481        let dir = tempfile::tempdir().unwrap();
482        let (peers, _) = peers_in(dir.path());
483        // Nothing listens on this port; the error is about reaching it.
484        let link = format!("agentmux://pair?v=1&host=127.0.0.1&port=9&fp={FP}&code=ABCDEFGHJK&hostname=studio");
485        let err = pair(peers, &link, "me").await.unwrap_err();
486        assert!(err.contains("Can't reach studio"), "{err}");
487        assert!(peers.list().is_empty(), "nothing kept");
488    }
489
490    /// End to end against a real viewer listener over TLS: pair with the
491    /// link, get refused until the user shares, then read the snapshot; a
492    /// link pinning another certificate never pairs; a revoked device is
493    /// told so.
494    #[tokio::test(flavor = "multi_thread")]
495    async fn a_peer_pairs_over_pinned_tls_and_reads_once_sharing_is_on() {
496        let viewer_dir = tempfile::tempdir().unwrap();
497        let mut state = crate::server::tests::test_state();
498        let broker = crate::backend::mps::Broker::new();
499        state.viewer = crate::backend::viewer::ViewerService::new(Some(viewer_dir.path().to_path_buf()), &broker);
500        let tls = state.viewer.tls().unwrap();
501        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
502        listener.set_nonblocking(true).unwrap();
503        let port = listener.local_addr().unwrap().port();
504        let token = tokio_util::sync::CancellationToken::new();
505        crate::backend::lan_listeners::LanListenerSupervisor::serve_tls(
506            crate::server::build_viewer_router(state.clone()),
507            listener,
508            tokio_rustls::TlsAcceptor::from(tls.server_config().unwrap()),
509            token.clone(),
510        );
511        let link = |fp: &str| {
512            let (code, _) = state.viewer.pairing.start();
513            format!("agentmux://pair?v=1&host=127.0.0.1&port={port}&fp={fp}&code={code}&hostname=studio")
514        };
515
516        let dir = tempfile::tempdir().unwrap();
517        let (peers, tokens) = peers_in(dir.path());
518        // Another certificate's fingerprint: the handshake fails, no pairing.
519        let err = pair(peers, &link(FP), "laptop (Tower)").await.unwrap_err();
520        assert!(err.contains("Can't reach studio"), "{err}");
521        assert!(peers.list().is_empty());
522
523        let info = pair(peers, &link(&tls.fingerprint), "laptop (Tower)").await.unwrap();
524        let id = info.connection.strip_prefix(PEER_PREFIX).unwrap().to_string();
525        assert_eq!(tokens.0.lock().unwrap().len(), 1, "the token went to the token store");
526        assert!(!std::fs::read_to_string(dir.path().join(FILE)).unwrap().contains("amxv_"));
527
528        let err = sample(peers, &id, "", vec![]).await.unwrap_err();
529        assert!(err.contains("doesn't share"), "{err}");
530
531        let mut settings = state.config_watcher.get_settings();
532        settings.extra.insert("tower:sharewithpaired".to_string(), serde_json::json!(true));
533        state.config_watcher.update_settings(settings);
534        let snap = sample(peers, &id, "", vec![]).await.unwrap();
535        assert!(snap.remote);
536        assert!(snap.host.unwrap().total > 0);
537
538        // The other computer's user revokes this device.
539        let device = peers.get(&id).unwrap().device_id;
540        state.mstore.viewer_device_delete(&device).unwrap();
541        let err = sample(peers, &id, "", vec![]).await.unwrap_err();
542        assert!(err.contains("no longer accepts"), "{err}");
543        token.cancel();
544    }
545}