agentpal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2097 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ net::SocketAddr,
4
+ sync::Arc,
5
+ };
6
+
7
+ use agentpal_protocol::{
8
+ DeviceId, HistoryPage, HistoryRequest, HostId, HostStatus, PairClaimAccepted, PairClaimRequest,
9
+ PairCreateRequest, PairId, PairingPayload, PickerRegistry, RelayClientMessage, RelayClientRole,
10
+ RelayServerMessage, SessionEvent, SessionEventEnvelope, SessionSummary, WorkspaceSnapshot,
11
+ };
12
+ use anyhow::Context;
13
+ use async_trait::async_trait;
14
+ use axum::{
15
+ Json, Router,
16
+ extract::{
17
+ State,
18
+ ws::{Message, WebSocket, WebSocketUpgrade},
19
+ },
20
+ response::IntoResponse,
21
+ routing::get,
22
+ };
23
+ use clap::Parser;
24
+ use futures_util::{SinkExt, StreamExt};
25
+ use redis::aio::ConnectionManager;
26
+ use serde::{Deserialize, Serialize};
27
+ use sha2::{Digest, Sha256};
28
+ use time::{Duration as TimeDuration, OffsetDateTime};
29
+ use tokio::sync::{RwLock, mpsc};
30
+ use tracing::{info, warn};
31
+ use uuid::Uuid;
32
+
33
+ #[derive(Debug, Parser)]
34
+ #[command(
35
+ name = "agentpal-relay",
36
+ version,
37
+ about = "AgentPal local relay prototype"
38
+ )]
39
+ struct Args {
40
+ #[arg(long, default_value = "127.0.0.1")]
41
+ host: String,
42
+
43
+ #[arg(long, default_value_t = 8790)]
44
+ port: u16,
45
+
46
+ #[arg(long, env = "OAP_REDIS_URL")]
47
+ redis_url: Option<String>,
48
+
49
+ #[arg(long, env = "OAP_REDIS_KEY_PREFIX", default_value = "agentpal:relay")]
50
+ redis_key_prefix: String,
51
+
52
+ #[arg(long, env = "OAP_RELAY_REQUIRE_PAIRING", default_value_t = false)]
53
+ require_pairing: bool,
54
+ }
55
+
56
+ #[derive(Clone)]
57
+ struct AppState {
58
+ snapshot: Arc<RwLock<RelaySnapshot>>,
59
+ connections: Arc<RwLock<ConnectionRegistry>>,
60
+ store: Arc<dyn PairingStore>,
61
+ require_pairing: bool,
62
+ }
63
+
64
+ #[derive(Default)]
65
+ struct RelaySnapshot {
66
+ hosts: HashMap<String, HostStatus>,
67
+ sessions: HashMap<String, SessionSummary>,
68
+ session_hosts: HashMap<String, HostId>,
69
+ events: HashMap<String, Vec<SessionEventEnvelope>>,
70
+ picker_registries: HashMap<String, PickerRegistry>,
71
+ workspace_snapshots: HashMap<String, WorkspaceSnapshot>,
72
+ }
73
+
74
+ #[derive(Default)]
75
+ struct ConnectionRegistry {
76
+ clients: HashMap<String, mpsc::UnboundedSender<RelayServerMessage>>,
77
+ hosts: HashMap<HostId, String>,
78
+ mobiles: HashMap<String, MobileConnection>,
79
+ host_mobiles: HashMap<HostId, Vec<String>>,
80
+ }
81
+
82
+ #[derive(Clone)]
83
+ struct MobileConnection {
84
+ connection_id: String,
85
+ host_id: Option<HostId>,
86
+ device_id: Option<DeviceId>,
87
+ device_token_hash: Option<String>,
88
+ }
89
+
90
+ #[derive(Clone, Debug, Serialize, Deserialize)]
91
+ struct DeviceBinding {
92
+ host_id: HostId,
93
+ device_id: DeviceId,
94
+ }
95
+
96
+ #[derive(Clone, Debug, Serialize, Deserialize)]
97
+ struct StoredPairSession {
98
+ pair_id: PairId,
99
+ relay_url: String,
100
+ host_id: HostId,
101
+ host_name: String,
102
+ pair_token_hash: String,
103
+ #[serde(default, with = "time::serde::rfc3339::option")]
104
+ expires_at: Option<OffsetDateTime>,
105
+ }
106
+
107
+ enum PairClaimOutcome {
108
+ Claimed(StoredPairSession),
109
+ NotFound,
110
+ TokenRejected,
111
+ Expired,
112
+ }
113
+
114
+ #[async_trait]
115
+ trait PairingStore: Send + Sync {
116
+ async fn put_pair_session(
117
+ &self,
118
+ session: StoredPairSession,
119
+ ttl_seconds: Option<u64>,
120
+ ) -> anyhow::Result<()>;
121
+ async fn claim_pair_session(
122
+ &self,
123
+ pair_id: &str,
124
+ pair_token: &str,
125
+ now: OffsetDateTime,
126
+ ) -> anyhow::Result<PairClaimOutcome>;
127
+ async fn put_device_binding(
128
+ &self,
129
+ device_token_hash: String,
130
+ binding: DeviceBinding,
131
+ ) -> anyhow::Result<()>;
132
+ async fn get_device_binding(
133
+ &self,
134
+ device_token_hash: &str,
135
+ ) -> anyhow::Result<Option<DeviceBinding>>;
136
+ async fn mark_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<()>;
137
+ async fn is_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<bool>;
138
+ }
139
+
140
+ #[derive(Default)]
141
+ struct MemoryPairingStore {
142
+ pairings: RwLock<HashMap<PairId, StoredPairSession>>,
143
+ device_bindings: RwLock<HashMap<String, DeviceBinding>>,
144
+ cloud_pair_hosts: RwLock<HashSet<HostId>>,
145
+ }
146
+
147
+ #[async_trait]
148
+ impl PairingStore for MemoryPairingStore {
149
+ async fn put_pair_session(
150
+ &self,
151
+ session: StoredPairSession,
152
+ _ttl_seconds: Option<u64>,
153
+ ) -> anyhow::Result<()> {
154
+ self.pairings
155
+ .write()
156
+ .await
157
+ .insert(session.pair_id.clone(), session);
158
+ Ok(())
159
+ }
160
+
161
+ async fn claim_pair_session(
162
+ &self,
163
+ pair_id: &str,
164
+ pair_token: &str,
165
+ now: OffsetDateTime,
166
+ ) -> anyhow::Result<PairClaimOutcome> {
167
+ let mut pairings = self.pairings.write().await;
168
+ let Some(session) = pairings.get(pair_id).cloned() else {
169
+ return Ok(PairClaimOutcome::NotFound);
170
+ };
171
+ if let Some(expires_at) = session.expires_at {
172
+ if expires_at < now {
173
+ pairings.remove(pair_id);
174
+ return Ok(PairClaimOutcome::Expired);
175
+ }
176
+ }
177
+ if session.pair_token_hash != token_hash(pair_token) {
178
+ return Ok(PairClaimOutcome::TokenRejected);
179
+ }
180
+ pairings.remove(pair_id);
181
+ Ok(PairClaimOutcome::Claimed(session))
182
+ }
183
+
184
+ async fn put_device_binding(
185
+ &self,
186
+ device_token_hash: String,
187
+ binding: DeviceBinding,
188
+ ) -> anyhow::Result<()> {
189
+ self.device_bindings
190
+ .write()
191
+ .await
192
+ .insert(device_token_hash, binding);
193
+ Ok(())
194
+ }
195
+
196
+ async fn get_device_binding(
197
+ &self,
198
+ device_token_hash: &str,
199
+ ) -> anyhow::Result<Option<DeviceBinding>> {
200
+ Ok(self
201
+ .device_bindings
202
+ .read()
203
+ .await
204
+ .get(device_token_hash)
205
+ .cloned())
206
+ }
207
+
208
+ async fn mark_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<()> {
209
+ self.cloud_pair_hosts
210
+ .write()
211
+ .await
212
+ .insert(host_id.to_owned());
213
+ Ok(())
214
+ }
215
+
216
+ async fn is_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<bool> {
217
+ Ok(self.cloud_pair_hosts.read().await.contains(host_id))
218
+ }
219
+ }
220
+
221
+ #[derive(Clone)]
222
+ struct RedisPairingStore {
223
+ connection: ConnectionManager,
224
+ key_prefix: String,
225
+ }
226
+
227
+ impl RedisPairingStore {
228
+ fn pair_key(&self, pair_id: &str) -> String {
229
+ format!("{}:pair:{pair_id}", self.key_prefix)
230
+ }
231
+
232
+ fn device_key(&self, device_token_hash: &str) -> String {
233
+ format!("{}:device:{device_token_hash}", self.key_prefix)
234
+ }
235
+
236
+ fn cloud_host_key(&self, host_id: &str) -> String {
237
+ format!("{}:cloud-host:{host_id}", self.key_prefix)
238
+ }
239
+ }
240
+
241
+ #[async_trait]
242
+ impl PairingStore for RedisPairingStore {
243
+ async fn put_pair_session(
244
+ &self,
245
+ session: StoredPairSession,
246
+ ttl_seconds: Option<u64>,
247
+ ) -> anyhow::Result<()> {
248
+ let key = self.pair_key(&session.pair_id);
249
+ let payload = serde_json::to_string(&session)?;
250
+ let expires_at_unix = session
251
+ .expires_at
252
+ .map(|expires_at| expires_at.unix_timestamp().to_string())
253
+ .unwrap_or_default();
254
+ let mut connection = self.connection.clone();
255
+ let _: usize = redis::cmd("HSET")
256
+ .arg(&key)
257
+ .arg("payload")
258
+ .arg(payload)
259
+ .arg("tokenHash")
260
+ .arg(&session.pair_token_hash)
261
+ .arg("expiresAtUnix")
262
+ .arg(expires_at_unix)
263
+ .query_async(&mut connection)
264
+ .await?;
265
+ if let Some(ttl_seconds) = ttl_seconds {
266
+ let _: bool = redis::cmd("EXPIRE")
267
+ .arg(&key)
268
+ .arg(ttl_seconds.max(1))
269
+ .query_async(&mut connection)
270
+ .await?;
271
+ }
272
+ Ok(())
273
+ }
274
+
275
+ async fn claim_pair_session(
276
+ &self,
277
+ pair_id: &str,
278
+ pair_token: &str,
279
+ now: OffsetDateTime,
280
+ ) -> anyhow::Result<PairClaimOutcome> {
281
+ let key = self.pair_key(pair_id);
282
+ let script = r#"
283
+ local token_hash = redis.call("HGET", KEYS[1], "tokenHash")
284
+ if not token_hash then
285
+ return {0, ""}
286
+ end
287
+ if token_hash ~= ARGV[1] then
288
+ return {2, ""}
289
+ end
290
+ local expires_at = redis.call("HGET", KEYS[1], "expiresAtUnix")
291
+ if expires_at and expires_at ~= "" and tonumber(expires_at) < tonumber(ARGV[2]) then
292
+ redis.call("DEL", KEYS[1])
293
+ return {3, ""}
294
+ end
295
+ local payload = redis.call("HGET", KEYS[1], "payload")
296
+ redis.call("DEL", KEYS[1])
297
+ return {1, payload}
298
+ "#;
299
+ let mut connection = self.connection.clone();
300
+ let (code, payload): (i64, String) = redis::cmd("EVAL")
301
+ .arg(script)
302
+ .arg(1)
303
+ .arg(&key)
304
+ .arg(token_hash(pair_token))
305
+ .arg(now.unix_timestamp())
306
+ .query_async(&mut connection)
307
+ .await?;
308
+ match code {
309
+ 0 => Ok(PairClaimOutcome::NotFound),
310
+ 1 => Ok(PairClaimOutcome::Claimed(serde_json::from_str(&payload)?)),
311
+ 2 => Ok(PairClaimOutcome::TokenRejected),
312
+ 3 => Ok(PairClaimOutcome::Expired),
313
+ _ => anyhow::bail!("unexpected redis pair claim outcome: {code}"),
314
+ }
315
+ }
316
+
317
+ async fn put_device_binding(
318
+ &self,
319
+ device_token_hash: String,
320
+ binding: DeviceBinding,
321
+ ) -> anyhow::Result<()> {
322
+ let key = self.device_key(&device_token_hash);
323
+ let payload = serde_json::to_string(&binding)?;
324
+ let mut connection = self.connection.clone();
325
+ let _: () = redis::cmd("SET")
326
+ .arg(key)
327
+ .arg(payload)
328
+ .query_async(&mut connection)
329
+ .await?;
330
+ Ok(())
331
+ }
332
+
333
+ async fn get_device_binding(
334
+ &self,
335
+ device_token_hash: &str,
336
+ ) -> anyhow::Result<Option<DeviceBinding>> {
337
+ let key = self.device_key(device_token_hash);
338
+ let mut connection = self.connection.clone();
339
+ let payload: Option<String> = redis::cmd("GET")
340
+ .arg(key)
341
+ .query_async(&mut connection)
342
+ .await?;
343
+ payload
344
+ .map(|payload| serde_json::from_str(&payload).map_err(Into::into))
345
+ .transpose()
346
+ }
347
+
348
+ async fn mark_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<()> {
349
+ let key = self.cloud_host_key(host_id);
350
+ let mut connection = self.connection.clone();
351
+ let _: () = redis::cmd("SET")
352
+ .arg(key)
353
+ .arg("1")
354
+ .query_async(&mut connection)
355
+ .await?;
356
+ Ok(())
357
+ }
358
+
359
+ async fn is_cloud_pair_host(&self, host_id: &str) -> anyhow::Result<bool> {
360
+ let key = self.cloud_host_key(host_id);
361
+ let mut connection = self.connection.clone();
362
+ let exists: bool = redis::cmd("EXISTS")
363
+ .arg(key)
364
+ .query_async(&mut connection)
365
+ .await?;
366
+ Ok(exists)
367
+ }
368
+ }
369
+
370
+ #[derive(Debug, Serialize)]
371
+ #[serde(rename_all = "camelCase")]
372
+ struct Health {
373
+ ok: bool,
374
+ service: &'static str,
375
+ version: &'static str,
376
+ }
377
+
378
+ #[tokio::main]
379
+ async fn main() -> anyhow::Result<()> {
380
+ tracing_subscriber::fmt()
381
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
382
+ .init();
383
+
384
+ let args = Args::parse();
385
+ let store = build_pairing_store(&args).await?;
386
+ let state = Arc::new(AppState {
387
+ snapshot: Arc::new(RwLock::new(RelaySnapshot::default())),
388
+ connections: Arc::new(RwLock::new(ConnectionRegistry::default())),
389
+ store,
390
+ require_pairing: args.require_pairing,
391
+ });
392
+
393
+ let app = Router::new()
394
+ .route("/healthz", get(healthz))
395
+ .route("/ws", get(ws_handler))
396
+ .with_state(state);
397
+
398
+ let addr: SocketAddr = format!("{}:{}", args.host, args.port)
399
+ .parse()
400
+ .context("invalid host/port")?;
401
+ let listener = tokio::net::TcpListener::bind(addr).await?;
402
+ info!(%addr, "agentpal relay listening");
403
+ axum::serve(listener, app).await?;
404
+ Ok(())
405
+ }
406
+
407
+ async fn build_pairing_store(args: &Args) -> anyhow::Result<Arc<dyn PairingStore>> {
408
+ if let Some(redis_url) = &args.redis_url {
409
+ let client = redis::Client::open(redis_url.as_str())
410
+ .with_context(|| "failed to create redis client")?;
411
+ let connection = client
412
+ .get_connection_manager()
413
+ .await
414
+ .with_context(|| "failed to connect to redis")?;
415
+ info!(
416
+ key_prefix = %args.redis_key_prefix,
417
+ require_pairing = args.require_pairing,
418
+ "agentpal relay using redis pairing store"
419
+ );
420
+ return Ok(Arc::new(RedisPairingStore {
421
+ connection,
422
+ key_prefix: args.redis_key_prefix.clone(),
423
+ }));
424
+ }
425
+
426
+ if args.require_pairing {
427
+ warn!("OAP_RELAY_REQUIRE_PAIRING is enabled without Redis; restart loses device bindings");
428
+ }
429
+ info!(
430
+ require_pairing = args.require_pairing,
431
+ "agentpal relay using in-memory pairing store"
432
+ );
433
+ Ok(Arc::new(MemoryPairingStore::default()))
434
+ }
435
+
436
+ async fn healthz() -> Json<Health> {
437
+ Json(Health {
438
+ ok: true,
439
+ service: "agentpal-relay",
440
+ version: env!("CARGO_PKG_VERSION"),
441
+ })
442
+ }
443
+
444
+ async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
445
+ ws.on_upgrade(move |socket| handle_socket(socket, state))
446
+ }
447
+
448
+ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
449
+ let (mut sender, mut receiver) = socket.split();
450
+ let (client_tx, mut client_rx) = mpsc::unbounded_channel::<RelayServerMessage>();
451
+ let connection_id = Uuid::new_v4().to_string();
452
+ state
453
+ .connections
454
+ .write()
455
+ .await
456
+ .clients
457
+ .insert(connection_id.clone(), client_tx);
458
+
459
+ let snapshot = RelayServerMessage::Snapshot {
460
+ hosts: Vec::new(),
461
+ sessions: Vec::new(),
462
+ picker_registries: Vec::new(),
463
+ workspace_snapshots: Vec::new(),
464
+ };
465
+ if send_json(&mut sender, &snapshot).await.is_err() {
466
+ cleanup_connection(&state, &connection_id).await;
467
+ return;
468
+ }
469
+
470
+ let outbound = tokio::spawn(async move {
471
+ loop {
472
+ tokio::select! {
473
+ Some(payload) = client_rx.recv() => {
474
+ if send_json(&mut sender, &payload).await.is_err() {
475
+ break;
476
+ }
477
+ }
478
+ else => break,
479
+ }
480
+ }
481
+ });
482
+
483
+ while let Some(message) = receiver.next().await {
484
+ match message {
485
+ Ok(Message::Text(text)) => {
486
+ let incoming: RelayClientMessage = match serde_json::from_str(&text) {
487
+ Ok(message) => message,
488
+ Err(error) => {
489
+ route_to_connection(
490
+ &state,
491
+ &connection_id,
492
+ RelayServerMessage::Error {
493
+ message: format!("invalid relay message: {error}"),
494
+ },
495
+ )
496
+ .await;
497
+ continue;
498
+ }
499
+ };
500
+ handle_client_message(incoming, &state, &connection_id).await;
501
+ }
502
+ Ok(Message::Close(_)) => break,
503
+ Ok(Message::Binary(bytes)) => {
504
+ route_to_connection(
505
+ &state,
506
+ &connection_id,
507
+ RelayServerMessage::RelayNotice {
508
+ message: format!("ignored binary websocket message: {} bytes", bytes.len()),
509
+ },
510
+ )
511
+ .await;
512
+ }
513
+ Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
514
+ Err(error) => {
515
+ warn!(%error, "websocket receive error");
516
+ break;
517
+ }
518
+ }
519
+ }
520
+
521
+ outbound.abort();
522
+ cleanup_connection(&state, &connection_id).await;
523
+ }
524
+
525
+ async fn handle_client_message(message: RelayClientMessage, state: &AppState, connection_id: &str) {
526
+ match message {
527
+ RelayClientMessage::Register {
528
+ role,
529
+ client_id,
530
+ host_id,
531
+ device_id,
532
+ device_token,
533
+ } => {
534
+ if role == RelayClientRole::Host {
535
+ if let Some(host_id) = &host_id {
536
+ let existing = {
537
+ let connections = state.connections.read().await;
538
+ connections.hosts.get(host_id).cloned()
539
+ };
540
+ if existing.as_deref().is_some_and(|id| id != connection_id) {
541
+ route_to_connection(
542
+ state,
543
+ connection_id,
544
+ RelayServerMessage::Error {
545
+ message: format!("host is already connected: {host_id}"),
546
+ },
547
+ )
548
+ .await;
549
+ return;
550
+ }
551
+ }
552
+ }
553
+ let verified_binding = match (&host_id, &device_id, &device_token) {
554
+ (Some(host_id), Some(device_id), Some(device_token)) => {
555
+ let device_token_hash = token_hash(device_token);
556
+ match state.store.get_device_binding(&device_token_hash).await {
557
+ Ok(Some(binding))
558
+ if &binding.host_id == host_id && &binding.device_id == device_id =>
559
+ {
560
+ Some((binding, device_token_hash))
561
+ }
562
+ Ok(_) => None,
563
+ Err(error) => {
564
+ warn!(%error, "failed to verify mobile device binding");
565
+ None
566
+ }
567
+ }
568
+ }
569
+ _ => None,
570
+ };
571
+ let fallback_host: Option<HostId> = match (&verified_binding, &host_id) {
572
+ (None, Some(host_id)) if !state.require_pairing => {
573
+ match state.store.is_cloud_pair_host(host_id).await {
574
+ Ok(false) => Some(host_id.to_owned()),
575
+ Ok(true) => None,
576
+ Err(error) => {
577
+ warn!(%error, "failed to check cloud-pair host marker");
578
+ None
579
+ }
580
+ }
581
+ }
582
+ _ => None,
583
+ };
584
+ {
585
+ let mut connections = state.connections.write().await;
586
+ match role {
587
+ RelayClientRole::Host => {
588
+ if let Some(host_id) = &host_id {
589
+ connections
590
+ .hosts
591
+ .insert(host_id.clone(), connection_id.to_owned());
592
+ }
593
+ }
594
+ RelayClientRole::Mobile => {
595
+ let mut verified_host_id = None;
596
+ let mut device_token_hash = None;
597
+ if let Some((binding, token_hash)) = &verified_binding {
598
+ verified_host_id = Some(binding.host_id.clone());
599
+ bind_mobile_to_host(&mut connections, &binding.host_id, &client_id);
600
+ device_token_hash = Some(token_hash.clone());
601
+ } else if let Some(host_id) = &fallback_host {
602
+ verified_host_id = Some(host_id.clone());
603
+ bind_mobile_to_host(&mut connections, host_id, &client_id);
604
+ }
605
+ let mobile = MobileConnection {
606
+ connection_id: connection_id.to_owned(),
607
+ host_id: verified_host_id,
608
+ device_id: device_id.clone(),
609
+ device_token_hash,
610
+ };
611
+ connections.mobiles.insert(client_id.clone(), mobile);
612
+ }
613
+ }
614
+ }
615
+ if role == RelayClientRole::Mobile {
616
+ if let Some(host_id) = verified_binding
617
+ .as_ref()
618
+ .map(|(binding, _)| binding.host_id.clone())
619
+ .or(fallback_host)
620
+ {
621
+ route_to_connection(
622
+ state,
623
+ connection_id,
624
+ scoped_snapshot(state, &host_id).await,
625
+ )
626
+ .await;
627
+ }
628
+ }
629
+ }
630
+ RelayClientMessage::PairCreate { request } => {
631
+ handle_pair_create(request, state, connection_id).await;
632
+ }
633
+ RelayClientMessage::PairClaim { request } => {
634
+ handle_pair_claim(request, state, connection_id).await;
635
+ }
636
+ RelayClientMessage::HostStatus { status } => {
637
+ if !is_host_connection(state, connection_id, &status.host_id).await {
638
+ reject_host_origin(state, connection_id, &status.host_id, "host status").await;
639
+ return;
640
+ }
641
+ let host_id = status.host_id.clone();
642
+ state
643
+ .snapshot
644
+ .write()
645
+ .await
646
+ .hosts
647
+ .insert(status.host_id.clone(), status.clone());
648
+ route_host_update(state, &host_id, RelayServerMessage::HostStatus { status }).await;
649
+ }
650
+ RelayClientMessage::SessionEvent { envelope } => {
651
+ if !is_host_connection(state, connection_id, &envelope.host_id).await {
652
+ reject_host_origin(state, connection_id, &envelope.host_id, "session event").await;
653
+ return;
654
+ }
655
+ if let Some(session_id) = &envelope.session_id {
656
+ let mut snapshot = state.snapshot.write().await;
657
+ match &envelope.payload {
658
+ SessionEvent::SessionStarted { summary } => {
659
+ snapshot
660
+ .session_hosts
661
+ .insert(summary.session_id.clone(), envelope.host_id.clone());
662
+ snapshot
663
+ .sessions
664
+ .insert(summary.session_id.clone(), summary.clone());
665
+ }
666
+ SessionEvent::StateChanged { state } => {
667
+ if let Some(summary) = snapshot.sessions.get_mut(session_id) {
668
+ summary.state = state.clone();
669
+ summary.updated_at = envelope.created_at;
670
+ }
671
+ }
672
+ SessionEvent::ApprovalRequested { .. } => {
673
+ if let Some(summary) = snapshot.sessions.get_mut(session_id) {
674
+ summary.pending_approvals = summary.pending_approvals.saturating_add(1);
675
+ summary.updated_at = envelope.created_at;
676
+ }
677
+ }
678
+ SessionEvent::ApprovalResolved { .. } => {
679
+ if let Some(summary) = snapshot.sessions.get_mut(session_id) {
680
+ summary.pending_approvals = summary.pending_approvals.saturating_sub(1);
681
+ summary.updated_at = envelope.created_at;
682
+ }
683
+ }
684
+ _ => {}
685
+ }
686
+ snapshot
687
+ .events
688
+ .entry(session_id.clone())
689
+ .or_default()
690
+ .push(envelope.clone());
691
+ }
692
+ route_host_update(
693
+ state,
694
+ &envelope.host_id.clone(),
695
+ RelayServerMessage::SessionEvent { envelope },
696
+ )
697
+ .await;
698
+ }
699
+ RelayClientMessage::ClientCommand { command } => {
700
+ let host_id = command.host_id.clone();
701
+ route_mobile_to_host(
702
+ state,
703
+ connection_id,
704
+ &host_id,
705
+ RelayServerMessage::ClientCommand { command },
706
+ )
707
+ .await;
708
+ }
709
+ RelayClientMessage::HistoryRequest { request } => {
710
+ if is_mobile_authorized_for_host(state, connection_id, &request.host_id).await {
711
+ let page = history_page(&request, state).await;
712
+ route_to_connection(
713
+ state,
714
+ connection_id,
715
+ RelayServerMessage::HistoryPage { page },
716
+ )
717
+ .await;
718
+ route_to_host(
719
+ state,
720
+ &request.host_id.clone(),
721
+ RelayServerMessage::HistoryRequest { request },
722
+ )
723
+ .await;
724
+ } else {
725
+ reject_unpaired_mobile(state, connection_id, &request.host_id).await;
726
+ }
727
+ }
728
+ RelayClientMessage::WorkspaceRequest { request } => {
729
+ route_mobile_to_host(
730
+ state,
731
+ connection_id,
732
+ &request.host_id.clone(),
733
+ RelayServerMessage::WorkspaceRequest { request },
734
+ )
735
+ .await;
736
+ }
737
+ RelayClientMessage::WorkspaceSnapshot { snapshot } => {
738
+ if !is_host_connection(state, connection_id, &snapshot.host_id).await {
739
+ reject_host_origin(
740
+ state,
741
+ connection_id,
742
+ &snapshot.host_id,
743
+ "workspace snapshot",
744
+ )
745
+ .await;
746
+ return;
747
+ }
748
+ state.snapshot.write().await.workspace_snapshots.insert(
749
+ workspace_snapshot_key(&snapshot.host_id, &snapshot.workspace),
750
+ snapshot.clone(),
751
+ );
752
+ route_host_update(
753
+ state,
754
+ &snapshot.host_id.clone(),
755
+ RelayServerMessage::WorkspaceSnapshot { snapshot },
756
+ )
757
+ .await;
758
+ }
759
+ RelayClientMessage::FilePreviewRequest { request } => {
760
+ route_mobile_to_host(
761
+ state,
762
+ connection_id,
763
+ &request.host_id.clone(),
764
+ RelayServerMessage::FilePreviewRequest { request },
765
+ )
766
+ .await;
767
+ }
768
+ RelayClientMessage::FilePreview { preview } => {
769
+ if !is_host_connection(state, connection_id, &preview.host_id).await {
770
+ reject_host_origin(state, connection_id, &preview.host_id, "file preview").await;
771
+ return;
772
+ }
773
+ route_host_update(
774
+ state,
775
+ &preview.host_id.clone(),
776
+ RelayServerMessage::FilePreview { preview },
777
+ )
778
+ .await;
779
+ }
780
+ RelayClientMessage::PickerRegistry { registry } => {
781
+ if !is_host_connection(state, connection_id, &registry.host_id).await {
782
+ reject_host_origin(state, connection_id, &registry.host_id, "picker registry")
783
+ .await;
784
+ return;
785
+ }
786
+ state
787
+ .snapshot
788
+ .write()
789
+ .await
790
+ .picker_registries
791
+ .insert(registry.session_id.clone(), registry.clone());
792
+ route_host_update(
793
+ state,
794
+ &registry.host_id.clone(),
795
+ RelayServerMessage::PickerRegistry { registry },
796
+ )
797
+ .await;
798
+ }
799
+ }
800
+ }
801
+
802
+ fn workspace_snapshot_key(host_id: &str, workspace: &str) -> String {
803
+ format!("{host_id}:{workspace}")
804
+ }
805
+
806
+ fn token_hash(value: &str) -> String {
807
+ let digest = Sha256::digest(value.as_bytes());
808
+ digest.iter().map(|byte| format!("{byte:02x}")).collect()
809
+ }
810
+
811
+ async fn handle_pair_create(request: PairCreateRequest, state: &AppState, connection_id: &str) {
812
+ let registered_host = {
813
+ let connections = state.connections.read().await;
814
+ connections.hosts.get(&request.host_id).cloned()
815
+ };
816
+ if registered_host.as_deref() != Some(connection_id) {
817
+ route_to_connection(
818
+ state,
819
+ connection_id,
820
+ RelayServerMessage::Error {
821
+ message: format!(
822
+ "pair create rejected because this connection is not host: {}",
823
+ request.host_id
824
+ ),
825
+ },
826
+ )
827
+ .await;
828
+ return;
829
+ }
830
+
831
+ let pair_id = request
832
+ .pair_id
833
+ .unwrap_or_else(|| format!("pair_{}", Uuid::new_v4()));
834
+ let pair_token = request
835
+ .pair_token
836
+ .unwrap_or_else(|| Uuid::new_v4().to_string());
837
+ let ttl_seconds = request.expires_in_seconds.or(Some(120));
838
+ let expires_at = ttl_seconds.map(|seconds| {
839
+ OffsetDateTime::now_utc() + TimeDuration::seconds(seconds.min(i64::MAX as u64) as i64)
840
+ });
841
+ let session = StoredPairSession {
842
+ pair_id: pair_id.clone(),
843
+ relay_url: request.relay_url.clone(),
844
+ host_id: request.host_id.clone(),
845
+ host_name: request.host_name.clone(),
846
+ pair_token_hash: token_hash(&pair_token),
847
+ expires_at,
848
+ };
849
+ let pairing = PairingPayload {
850
+ version: 1,
851
+ relay_url: request.relay_url,
852
+ pair_id: Some(pair_id.clone()),
853
+ host_id: request.host_id,
854
+ host_name: request.host_name,
855
+ pair_token,
856
+ device_id: None,
857
+ device_token: None,
858
+ expires_at,
859
+ };
860
+
861
+ if let Err(error) = state.store.put_pair_session(session, ttl_seconds).await {
862
+ route_to_connection(
863
+ state,
864
+ connection_id,
865
+ RelayServerMessage::Error {
866
+ message: format!("pair create failed: {error}"),
867
+ },
868
+ )
869
+ .await;
870
+ return;
871
+ }
872
+ if let Err(error) = state.store.mark_cloud_pair_host(&pairing.host_id).await {
873
+ route_to_connection(
874
+ state,
875
+ connection_id,
876
+ RelayServerMessage::Error {
877
+ message: format!("pair create failed: {error}"),
878
+ },
879
+ )
880
+ .await;
881
+ return;
882
+ }
883
+ route_to_connection(
884
+ state,
885
+ connection_id,
886
+ RelayServerMessage::PairCreated { pairing },
887
+ )
888
+ .await;
889
+ }
890
+
891
+ async fn handle_pair_claim(request: PairClaimRequest, state: &AppState, connection_id: &str) {
892
+ let outcome = match state
893
+ .store
894
+ .claim_pair_session(
895
+ &request.pair_id,
896
+ &request.pair_token,
897
+ OffsetDateTime::now_utc(),
898
+ )
899
+ .await
900
+ {
901
+ Ok(outcome) => outcome,
902
+ Err(error) => {
903
+ route_to_connection(
904
+ state,
905
+ connection_id,
906
+ RelayServerMessage::Error {
907
+ message: format!("pair claim failed: {error}"),
908
+ },
909
+ )
910
+ .await;
911
+ return;
912
+ }
913
+ };
914
+ let session = match outcome {
915
+ PairClaimOutcome::Claimed(session) => session,
916
+ PairClaimOutcome::NotFound => {
917
+ route_to_connection(
918
+ state,
919
+ connection_id,
920
+ RelayServerMessage::Error {
921
+ message: "pair session not found or already claimed".to_owned(),
922
+ },
923
+ )
924
+ .await;
925
+ return;
926
+ }
927
+ PairClaimOutcome::TokenRejected => {
928
+ route_to_connection(
929
+ state,
930
+ connection_id,
931
+ RelayServerMessage::Error {
932
+ message: "pair token rejected".to_owned(),
933
+ },
934
+ )
935
+ .await;
936
+ return;
937
+ }
938
+ PairClaimOutcome::Expired => {
939
+ route_to_connection(
940
+ state,
941
+ connection_id,
942
+ RelayServerMessage::Error {
943
+ message: "pair session expired".to_owned(),
944
+ },
945
+ )
946
+ .await;
947
+ return;
948
+ }
949
+ };
950
+
951
+ let device_id = request
952
+ .device_id
953
+ .unwrap_or_else(|| format!("mobile_{}", Uuid::new_v4()));
954
+ let device_token = Uuid::new_v4().to_string();
955
+ let device_token_hash = token_hash(&device_token);
956
+ if let Err(error) = state
957
+ .store
958
+ .put_device_binding(
959
+ device_token_hash.clone(),
960
+ DeviceBinding {
961
+ host_id: session.host_id.clone(),
962
+ device_id: device_id.clone(),
963
+ },
964
+ )
965
+ .await
966
+ {
967
+ route_to_connection(
968
+ state,
969
+ connection_id,
970
+ RelayServerMessage::Error {
971
+ message: format!("pair claim failed: {error}"),
972
+ },
973
+ )
974
+ .await;
975
+ return;
976
+ }
977
+ let claim = PairClaimAccepted {
978
+ pair_id: request.pair_id,
979
+ host_id: session.host_id.clone(),
980
+ host_name: session.host_name.clone(),
981
+ mobile_client_id: request.mobile_client_id.clone(),
982
+ device_id: device_id.clone(),
983
+ device_token: device_token.clone(),
984
+ };
985
+
986
+ {
987
+ let mut connections = state.connections.write().await;
988
+ bind_mobile_to_host(
989
+ &mut connections,
990
+ &session.host_id,
991
+ &request.mobile_client_id,
992
+ );
993
+ connections
994
+ .mobiles
995
+ .entry(request.mobile_client_id.clone())
996
+ .and_modify(|mobile| {
997
+ mobile.connection_id = connection_id.to_owned();
998
+ mobile.host_id = Some(session.host_id.clone());
999
+ mobile.device_id = Some(device_id.clone());
1000
+ mobile.device_token_hash = Some(device_token_hash.clone());
1001
+ })
1002
+ .or_insert_with(|| MobileConnection {
1003
+ connection_id: connection_id.to_owned(),
1004
+ host_id: Some(session.host_id.clone()),
1005
+ device_id: Some(device_id.clone()),
1006
+ device_token_hash: Some(device_token_hash.clone()),
1007
+ });
1008
+ }
1009
+
1010
+ route_to_connection(
1011
+ state,
1012
+ connection_id,
1013
+ RelayServerMessage::PairClaimed {
1014
+ claim: claim.clone(),
1015
+ },
1016
+ )
1017
+ .await;
1018
+ route_to_host(
1019
+ state,
1020
+ &session.host_id,
1021
+ RelayServerMessage::PairClaimed { claim },
1022
+ )
1023
+ .await;
1024
+ }
1025
+
1026
+ fn bind_mobile_to_host(
1027
+ connections: &mut ConnectionRegistry,
1028
+ host_id: &str,
1029
+ mobile_client_id: &str,
1030
+ ) {
1031
+ let items = connections
1032
+ .host_mobiles
1033
+ .entry(host_id.to_owned())
1034
+ .or_default();
1035
+ if !items.iter().any(|item| item == mobile_client_id) {
1036
+ items.push(mobile_client_id.to_owned());
1037
+ }
1038
+ }
1039
+
1040
+ async fn route_to_host(state: &AppState, host_id: &str, payload: RelayServerMessage) {
1041
+ let target = {
1042
+ let connections = state.connections.read().await;
1043
+ connections
1044
+ .hosts
1045
+ .get(host_id)
1046
+ .and_then(|connection_id| connections.clients.get(connection_id))
1047
+ .cloned()
1048
+ };
1049
+ if let Some(tx) = target {
1050
+ let _ = tx.send(payload);
1051
+ return;
1052
+ }
1053
+ }
1054
+
1055
+ async fn is_host_connection(state: &AppState, connection_id: &str, host_id: &str) -> bool {
1056
+ let connections = state.connections.read().await;
1057
+ connections.hosts.get(host_id).map(String::as_str) == Some(connection_id)
1058
+ }
1059
+
1060
+ async fn reject_host_origin(
1061
+ state: &AppState,
1062
+ connection_id: &str,
1063
+ host_id: &str,
1064
+ message_kind: &str,
1065
+ ) {
1066
+ route_to_connection(
1067
+ state,
1068
+ connection_id,
1069
+ RelayServerMessage::Error {
1070
+ message: format!(
1071
+ "{message_kind} rejected because this connection is not host: {host_id}"
1072
+ ),
1073
+ },
1074
+ )
1075
+ .await;
1076
+ }
1077
+
1078
+ async fn reject_unpaired_mobile(state: &AppState, connection_id: &str, host_id: &str) {
1079
+ route_to_connection(
1080
+ state,
1081
+ connection_id,
1082
+ RelayServerMessage::Error {
1083
+ message: format!("mobile is not paired with host: {host_id}"),
1084
+ },
1085
+ )
1086
+ .await;
1087
+ }
1088
+
1089
+ async fn is_mobile_authorized_for_host(
1090
+ state: &AppState,
1091
+ connection_id: &str,
1092
+ host_id: &str,
1093
+ ) -> bool {
1094
+ let mobile_auth = {
1095
+ let connections = state.connections.read().await;
1096
+ let mobile = connections.mobiles.iter().find_map(|(client_id, mobile)| {
1097
+ (mobile.connection_id == connection_id && mobile.host_id.as_deref() == Some(host_id))
1098
+ .then_some((client_id, mobile))
1099
+ });
1100
+ mobile.map(|(client_id, mobile)| {
1101
+ let listed = connections
1102
+ .host_mobiles
1103
+ .get(host_id)
1104
+ .is_some_and(|items| items.iter().any(|item| item == client_id));
1105
+ (
1106
+ listed,
1107
+ mobile.device_id.clone(),
1108
+ mobile.device_token_hash.clone(),
1109
+ )
1110
+ })
1111
+ };
1112
+ match mobile_auth {
1113
+ None => false,
1114
+ Some((listed, device_id, device_token_hash)) => {
1115
+ let cloud_pair_host = match state.store.is_cloud_pair_host(host_id).await {
1116
+ Ok(value) => value,
1117
+ Err(error) => {
1118
+ warn!(%error, "failed to check cloud-pair host marker");
1119
+ true
1120
+ }
1121
+ };
1122
+ if !state.require_pairing && !cloud_pair_host {
1123
+ listed
1124
+ } else {
1125
+ match (device_id, device_token_hash) {
1126
+ (Some(device_id), Some(device_token_hash)) => {
1127
+ match state.store.get_device_binding(&device_token_hash).await {
1128
+ Ok(Some(binding)) => {
1129
+ binding.host_id == host_id && binding.device_id == device_id
1130
+ }
1131
+ Ok(None) => false,
1132
+ Err(error) => {
1133
+ warn!(%error, "failed to verify device binding during route");
1134
+ false
1135
+ }
1136
+ }
1137
+ }
1138
+ _ => false,
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ async fn route_host_update(state: &AppState, host_id: &str, payload: RelayServerMessage) {
1146
+ let connection_ids: Vec<String> = {
1147
+ let connections = state.connections.read().await;
1148
+ connections
1149
+ .mobiles
1150
+ .values()
1151
+ .filter(|mobile| mobile.host_id.as_deref() == Some(host_id))
1152
+ .map(|mobile| mobile.connection_id.clone())
1153
+ .collect()
1154
+ };
1155
+ for connection_id in connection_ids {
1156
+ if is_mobile_authorized_for_host(state, &connection_id, host_id).await {
1157
+ route_to_connection(state, &connection_id, payload.clone()).await;
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ async fn scoped_snapshot(state: &AppState, host_id: &str) -> RelayServerMessage {
1163
+ let snapshot = state.snapshot.read().await;
1164
+ RelayServerMessage::Snapshot {
1165
+ hosts: snapshot.hosts.get(host_id).cloned().into_iter().collect(),
1166
+ sessions: snapshot
1167
+ .sessions
1168
+ .iter()
1169
+ .filter(|(session_id, _)| {
1170
+ snapshot
1171
+ .session_hosts
1172
+ .get(*session_id)
1173
+ .is_some_and(|session_host| session_host == host_id)
1174
+ })
1175
+ .map(|(_, summary)| summary.clone())
1176
+ .collect(),
1177
+ picker_registries: snapshot
1178
+ .picker_registries
1179
+ .values()
1180
+ .filter(|registry| registry.host_id == host_id)
1181
+ .cloned()
1182
+ .collect(),
1183
+ workspace_snapshots: snapshot
1184
+ .workspace_snapshots
1185
+ .values()
1186
+ .filter(|workspace_snapshot| workspace_snapshot.host_id == host_id)
1187
+ .cloned()
1188
+ .collect(),
1189
+ }
1190
+ }
1191
+
1192
+ async fn route_mobile_to_host(
1193
+ state: &AppState,
1194
+ connection_id: &str,
1195
+ host_id: &str,
1196
+ payload: RelayServerMessage,
1197
+ ) {
1198
+ if is_mobile_authorized_for_host(state, connection_id, host_id).await {
1199
+ route_to_host(state, host_id, payload).await;
1200
+ } else {
1201
+ reject_unpaired_mobile(state, connection_id, host_id).await;
1202
+ }
1203
+ }
1204
+
1205
+ async fn route_to_connection(state: &AppState, connection_id: &str, payload: RelayServerMessage) {
1206
+ let target = {
1207
+ let connections = state.connections.read().await;
1208
+ connections.clients.get(connection_id).cloned()
1209
+ };
1210
+ if let Some(tx) = target {
1211
+ let _ = tx.send(payload);
1212
+ }
1213
+ }
1214
+
1215
+ async fn cleanup_connection(state: &AppState, connection_id: &str) {
1216
+ let mut connections = state.connections.write().await;
1217
+ connections.clients.remove(connection_id);
1218
+ let removed_hosts: Vec<HostId> = connections
1219
+ .hosts
1220
+ .iter()
1221
+ .filter_map(|(host_id, id)| (id == connection_id).then_some(host_id.clone()))
1222
+ .collect();
1223
+ for host_id in removed_hosts {
1224
+ connections.hosts.remove(&host_id);
1225
+ connections.host_mobiles.remove(&host_id);
1226
+ }
1227
+ let removed_mobiles: Vec<String> = connections
1228
+ .mobiles
1229
+ .iter()
1230
+ .filter_map(|(client_id, mobile)| {
1231
+ (mobile.connection_id == connection_id).then_some(client_id.clone())
1232
+ })
1233
+ .collect();
1234
+ for client_id in removed_mobiles {
1235
+ connections.mobiles.remove(&client_id);
1236
+ for clients in connections.host_mobiles.values_mut() {
1237
+ clients.retain(|item| item != &client_id);
1238
+ }
1239
+ }
1240
+ }
1241
+
1242
+ async fn history_page(request: &HistoryRequest, state: &AppState) -> HistoryPage {
1243
+ let limit = request.limit.clamp(1, 100) as usize;
1244
+ let snapshot = state.snapshot.read().await;
1245
+ let all_events = snapshot
1246
+ .events
1247
+ .get(&request.session_id)
1248
+ .map(Vec::as_slice)
1249
+ .unwrap_or(&[]);
1250
+
1251
+ let mut candidates: Vec<SessionEventEnvelope> = all_events
1252
+ .iter()
1253
+ .filter(|event| event.host_id == request.host_id)
1254
+ .filter(|event| match request.before_seq {
1255
+ Some(before_seq) => event.seq < before_seq,
1256
+ None => true,
1257
+ })
1258
+ .rev()
1259
+ .take(limit + 1)
1260
+ .cloned()
1261
+ .collect();
1262
+ let has_more = candidates.len() > limit;
1263
+ if has_more {
1264
+ candidates.truncate(limit);
1265
+ }
1266
+ candidates.reverse();
1267
+
1268
+ HistoryPage {
1269
+ request_id: request.request_id.clone(),
1270
+ host_id: request.host_id.clone(),
1271
+ session_id: request.session_id.clone(),
1272
+ oldest_seq: candidates.first().map(|event| event.seq),
1273
+ newest_seq: candidates.last().map(|event| event.seq),
1274
+ events: candidates,
1275
+ has_more,
1276
+ }
1277
+ }
1278
+
1279
+ async fn send_json(
1280
+ sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
1281
+ payload: &RelayServerMessage,
1282
+ ) -> Result<(), axum::Error> {
1283
+ let text = match serde_json::to_string(payload) {
1284
+ Ok(text) => text,
1285
+ Err(error) => {
1286
+ warn!(%error, "failed to serialize relay payload");
1287
+ return Ok(());
1288
+ }
1289
+ };
1290
+ sender.send(Message::Text(text.into())).await
1291
+ }
1292
+
1293
+ #[cfg(test)]
1294
+ mod tests {
1295
+ use super::*;
1296
+ use agentpal_protocol::{
1297
+ AgentKind, AgentPalEnvelope, ClientCommand, PairClaimRequest, PairCreateRequest,
1298
+ RelayClientMessage, RelayClientRole, SessionState,
1299
+ };
1300
+
1301
+ fn test_state(require_pairing: bool) -> AppState {
1302
+ AppState {
1303
+ snapshot: Arc::new(RwLock::new(RelaySnapshot::default())),
1304
+ connections: Arc::new(RwLock::new(ConnectionRegistry::default())),
1305
+ store: Arc::new(MemoryPairingStore::default()),
1306
+ require_pairing,
1307
+ }
1308
+ }
1309
+
1310
+ async fn register_client(
1311
+ state: &AppState,
1312
+ connection_id: &str,
1313
+ ) -> mpsc::UnboundedReceiver<RelayServerMessage> {
1314
+ let (tx, rx) = mpsc::unbounded_channel();
1315
+ state
1316
+ .connections
1317
+ .write()
1318
+ .await
1319
+ .clients
1320
+ .insert(connection_id.to_owned(), tx);
1321
+ rx
1322
+ }
1323
+
1324
+ fn session_started(host_id: &str, session_id: &str) -> RelayClientMessage {
1325
+ RelayClientMessage::SessionEvent {
1326
+ envelope: AgentPalEnvelope::new(
1327
+ host_id,
1328
+ Some(session_id.to_owned()),
1329
+ 1,
1330
+ SessionEvent::SessionStarted {
1331
+ summary: SessionSummary {
1332
+ session_id: session_id.to_owned(),
1333
+ agent_kind: AgentKind::Codex,
1334
+ workspace: ".".to_owned(),
1335
+ title: Some(session_id.to_owned()),
1336
+ state: SessionState::Idle,
1337
+ pending_approvals: 0,
1338
+ updated_at: OffsetDateTime::now_utc(),
1339
+ },
1340
+ },
1341
+ ),
1342
+ }
1343
+ }
1344
+
1345
+ #[tokio::test]
1346
+ async fn cloud_pair_claim_binds_mobile_and_routes_commands() {
1347
+ let state = test_state(true);
1348
+ let mut host_rx = register_client(&state, "host-conn").await;
1349
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1350
+
1351
+ handle_client_message(
1352
+ RelayClientMessage::Register {
1353
+ role: RelayClientRole::Host,
1354
+ client_id: "host-client".to_owned(),
1355
+ host_id: Some("host-a".to_owned()),
1356
+ device_id: None,
1357
+ device_token: None,
1358
+ },
1359
+ &state,
1360
+ "host-conn",
1361
+ )
1362
+ .await;
1363
+
1364
+ handle_client_message(
1365
+ RelayClientMessage::PairCreate {
1366
+ request: PairCreateRequest {
1367
+ host_id: "host-a".to_owned(),
1368
+ host_name: "Host A".to_owned(),
1369
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1370
+ pair_id: Some("pair-a".to_owned()),
1371
+ pair_token: Some("secret-a".to_owned()),
1372
+ expires_in_seconds: Some(120),
1373
+ },
1374
+ },
1375
+ &state,
1376
+ "host-conn",
1377
+ )
1378
+ .await;
1379
+
1380
+ let created = host_rx.recv().await.expect("host receives pair-created");
1381
+ let RelayServerMessage::PairCreated { pairing } = created else {
1382
+ panic!("expected pair-created");
1383
+ };
1384
+ assert_eq!(pairing.pair_id.as_deref(), Some("pair-a"));
1385
+
1386
+ handle_client_message(
1387
+ RelayClientMessage::Register {
1388
+ role: RelayClientRole::Mobile,
1389
+ client_id: "mobile-client".to_owned(),
1390
+ host_id: Some("host-a".to_owned()),
1391
+ device_id: None,
1392
+ device_token: None,
1393
+ },
1394
+ &state,
1395
+ "mobile-conn",
1396
+ )
1397
+ .await;
1398
+ handle_client_message(
1399
+ RelayClientMessage::ClientCommand {
1400
+ command: ClientCommand::input_submit(
1401
+ "cmd-rejected",
1402
+ "host-a",
1403
+ "agentpal-codex-local",
1404
+ "before claim",
1405
+ ),
1406
+ },
1407
+ &state,
1408
+ "mobile-conn",
1409
+ )
1410
+ .await;
1411
+ let rejected = mobile_rx.recv().await.expect("mobile receives rejection");
1412
+ assert!(matches!(rejected, RelayServerMessage::Error { .. }));
1413
+
1414
+ handle_client_message(
1415
+ RelayClientMessage::PairClaim {
1416
+ request: PairClaimRequest {
1417
+ pair_id: "pair-a".to_owned(),
1418
+ pair_token: "secret-a".to_owned(),
1419
+ mobile_client_id: "mobile-client".to_owned(),
1420
+ device_id: None,
1421
+ device_name: Some("test phone".to_owned()),
1422
+ },
1423
+ },
1424
+ &state,
1425
+ "mobile-conn",
1426
+ )
1427
+ .await;
1428
+ let claimed_for_mobile = mobile_rx.recv().await.expect("mobile receives claim");
1429
+ let RelayServerMessage::PairClaimed { claim } = claimed_for_mobile else {
1430
+ panic!("expected pair-claimed for mobile");
1431
+ };
1432
+ assert_eq!(claim.host_id, "host-a");
1433
+ assert!(!claim.device_token.is_empty());
1434
+ assert!(matches!(
1435
+ host_rx.recv().await.expect("host receives claim"),
1436
+ RelayServerMessage::PairClaimed { .. }
1437
+ ));
1438
+
1439
+ handle_client_message(
1440
+ RelayClientMessage::ClientCommand {
1441
+ command: ClientCommand::input_submit(
1442
+ "cmd-accepted",
1443
+ "host-a",
1444
+ "agentpal-codex-local",
1445
+ "after claim",
1446
+ ),
1447
+ },
1448
+ &state,
1449
+ "mobile-conn",
1450
+ )
1451
+ .await;
1452
+ let routed = host_rx.recv().await.expect("host receives client command");
1453
+ let RelayServerMessage::ClientCommand { command } = routed else {
1454
+ panic!("expected routed client-command");
1455
+ };
1456
+ assert_eq!(command.command_id, "cmd-accepted");
1457
+ }
1458
+
1459
+ #[tokio::test]
1460
+ async fn claimed_device_token_authorizes_mobile_reconnect() {
1461
+ let state = test_state(true);
1462
+ let mut host_rx = register_client(&state, "host-conn").await;
1463
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1464
+ let mut reconnect_rx = register_client(&state, "mobile-reconnect").await;
1465
+
1466
+ handle_client_message(
1467
+ RelayClientMessage::Register {
1468
+ role: RelayClientRole::Host,
1469
+ client_id: "host-client".to_owned(),
1470
+ host_id: Some("host-a".to_owned()),
1471
+ device_id: None,
1472
+ device_token: None,
1473
+ },
1474
+ &state,
1475
+ "host-conn",
1476
+ )
1477
+ .await;
1478
+ handle_client_message(
1479
+ RelayClientMessage::PairCreate {
1480
+ request: PairCreateRequest {
1481
+ host_id: "host-a".to_owned(),
1482
+ host_name: "Host A".to_owned(),
1483
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1484
+ pair_id: Some("pair-reconnect".to_owned()),
1485
+ pair_token: Some("secret-reconnect".to_owned()),
1486
+ expires_in_seconds: Some(120),
1487
+ },
1488
+ },
1489
+ &state,
1490
+ "host-conn",
1491
+ )
1492
+ .await;
1493
+ assert!(matches!(
1494
+ host_rx.recv().await.expect("host receives pair-created"),
1495
+ RelayServerMessage::PairCreated { .. }
1496
+ ));
1497
+
1498
+ handle_client_message(
1499
+ RelayClientMessage::PairClaim {
1500
+ request: PairClaimRequest {
1501
+ pair_id: "pair-reconnect".to_owned(),
1502
+ pair_token: "secret-reconnect".to_owned(),
1503
+ mobile_client_id: "mobile-client".to_owned(),
1504
+ device_id: Some("device-a".to_owned()),
1505
+ device_name: Some("test phone".to_owned()),
1506
+ },
1507
+ },
1508
+ &state,
1509
+ "mobile-conn",
1510
+ )
1511
+ .await;
1512
+ let RelayServerMessage::PairClaimed { claim } =
1513
+ mobile_rx.recv().await.expect("mobile receives claim")
1514
+ else {
1515
+ panic!("expected pair-claimed for mobile");
1516
+ };
1517
+ assert!(matches!(
1518
+ host_rx.recv().await.expect("host receives claim"),
1519
+ RelayServerMessage::PairClaimed { .. }
1520
+ ));
1521
+
1522
+ handle_client_message(
1523
+ RelayClientMessage::Register {
1524
+ role: RelayClientRole::Mobile,
1525
+ client_id: "mobile-client".to_owned(),
1526
+ host_id: Some("host-a".to_owned()),
1527
+ device_id: Some(claim.device_id.clone()),
1528
+ device_token: Some(claim.device_token.clone()),
1529
+ },
1530
+ &state,
1531
+ "mobile-reconnect",
1532
+ )
1533
+ .await;
1534
+ assert!(matches!(
1535
+ reconnect_rx
1536
+ .recv()
1537
+ .await
1538
+ .expect("mobile receives scoped snapshot"),
1539
+ RelayServerMessage::Snapshot { .. }
1540
+ ));
1541
+ handle_client_message(
1542
+ RelayClientMessage::ClientCommand {
1543
+ command: ClientCommand::input_submit(
1544
+ "cmd-reconnect",
1545
+ "host-a",
1546
+ "agentpal-codex-local",
1547
+ "after reconnect",
1548
+ ),
1549
+ },
1550
+ &state,
1551
+ "mobile-reconnect",
1552
+ )
1553
+ .await;
1554
+ let routed = host_rx
1555
+ .recv()
1556
+ .await
1557
+ .expect("host receives reconnect command");
1558
+ let RelayServerMessage::ClientCommand { command } = routed else {
1559
+ panic!("expected routed client-command");
1560
+ };
1561
+ assert_eq!(command.command_id, "cmd-reconnect");
1562
+ assert!(reconnect_rx.try_recv().is_err());
1563
+ }
1564
+
1565
+ #[tokio::test]
1566
+ async fn authorized_mobile_receives_only_scoped_snapshot() {
1567
+ let state = test_state(true);
1568
+ let mut host_a_rx = register_client(&state, "host-a-conn").await;
1569
+ let mut host_b_rx = register_client(&state, "host-b-conn").await;
1570
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1571
+
1572
+ handle_client_message(
1573
+ RelayClientMessage::Register {
1574
+ role: RelayClientRole::Host,
1575
+ client_id: "host-a-client".to_owned(),
1576
+ host_id: Some("host-a".to_owned()),
1577
+ device_id: None,
1578
+ device_token: None,
1579
+ },
1580
+ &state,
1581
+ "host-a-conn",
1582
+ )
1583
+ .await;
1584
+ handle_client_message(
1585
+ RelayClientMessage::Register {
1586
+ role: RelayClientRole::Host,
1587
+ client_id: "host-b-client".to_owned(),
1588
+ host_id: Some("host-b".to_owned()),
1589
+ device_id: None,
1590
+ device_token: None,
1591
+ },
1592
+ &state,
1593
+ "host-b-conn",
1594
+ )
1595
+ .await;
1596
+ handle_client_message(
1597
+ RelayClientMessage::HostStatus {
1598
+ status: HostStatus::local_codex("host-a", "Host A", "."),
1599
+ },
1600
+ &state,
1601
+ "host-a-conn",
1602
+ )
1603
+ .await;
1604
+ handle_client_message(
1605
+ RelayClientMessage::HostStatus {
1606
+ status: HostStatus::local_codex("host-b", "Host B", "."),
1607
+ },
1608
+ &state,
1609
+ "host-b-conn",
1610
+ )
1611
+ .await;
1612
+ handle_client_message(
1613
+ session_started("host-a", "session-a"),
1614
+ &state,
1615
+ "host-a-conn",
1616
+ )
1617
+ .await;
1618
+ handle_client_message(
1619
+ session_started("host-b", "session-b"),
1620
+ &state,
1621
+ "host-b-conn",
1622
+ )
1623
+ .await;
1624
+ handle_client_message(
1625
+ RelayClientMessage::PairCreate {
1626
+ request: PairCreateRequest {
1627
+ host_id: "host-a".to_owned(),
1628
+ host_name: "Host A".to_owned(),
1629
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1630
+ pair_id: Some("pair-a-scope".to_owned()),
1631
+ pair_token: Some("secret-a-scope".to_owned()),
1632
+ expires_in_seconds: Some(120),
1633
+ },
1634
+ },
1635
+ &state,
1636
+ "host-a-conn",
1637
+ )
1638
+ .await;
1639
+ assert!(matches!(
1640
+ host_a_rx
1641
+ .recv()
1642
+ .await
1643
+ .expect("host-a receives pair-created"),
1644
+ RelayServerMessage::PairCreated { .. }
1645
+ ));
1646
+ assert!(host_b_rx.try_recv().is_err());
1647
+
1648
+ handle_client_message(
1649
+ RelayClientMessage::PairClaim {
1650
+ request: PairClaimRequest {
1651
+ pair_id: "pair-a-scope".to_owned(),
1652
+ pair_token: "secret-a-scope".to_owned(),
1653
+ mobile_client_id: "mobile-client".to_owned(),
1654
+ device_id: Some("device-a-scope".to_owned()),
1655
+ device_name: None,
1656
+ },
1657
+ },
1658
+ &state,
1659
+ "mobile-conn",
1660
+ )
1661
+ .await;
1662
+ let RelayServerMessage::PairClaimed { claim } =
1663
+ mobile_rx.recv().await.expect("mobile receives claim")
1664
+ else {
1665
+ panic!("expected pair-claimed for mobile");
1666
+ };
1667
+ assert!(matches!(
1668
+ host_a_rx.recv().await.expect("host-a receives claim"),
1669
+ RelayServerMessage::PairClaimed { .. }
1670
+ ));
1671
+ handle_client_message(
1672
+ RelayClientMessage::Register {
1673
+ role: RelayClientRole::Mobile,
1674
+ client_id: "mobile-client".to_owned(),
1675
+ host_id: Some("host-a".to_owned()),
1676
+ device_id: Some(claim.device_id),
1677
+ device_token: Some(claim.device_token),
1678
+ },
1679
+ &state,
1680
+ "mobile-conn",
1681
+ )
1682
+ .await;
1683
+ let RelayServerMessage::Snapshot {
1684
+ hosts,
1685
+ sessions,
1686
+ picker_registries,
1687
+ workspace_snapshots,
1688
+ } = mobile_rx
1689
+ .recv()
1690
+ .await
1691
+ .expect("mobile receives scoped snapshot")
1692
+ else {
1693
+ panic!("expected scoped snapshot");
1694
+ };
1695
+ assert_eq!(
1696
+ hosts
1697
+ .iter()
1698
+ .map(|host| host.host_id.as_str())
1699
+ .collect::<Vec<_>>(),
1700
+ ["host-a"]
1701
+ );
1702
+ assert_eq!(
1703
+ sessions
1704
+ .iter()
1705
+ .map(|session| session.session_id.as_str())
1706
+ .collect::<Vec<_>>(),
1707
+ ["session-a"]
1708
+ );
1709
+ assert!(picker_registries.is_empty());
1710
+ assert!(workspace_snapshots.is_empty());
1711
+ assert!(host_b_rx.try_recv().is_err());
1712
+ }
1713
+
1714
+ #[tokio::test]
1715
+ async fn duplicate_host_id_registration_is_rejected() {
1716
+ let state = test_state(true);
1717
+ let mut first_host_rx = register_client(&state, "host-conn-a").await;
1718
+ let mut second_host_rx = register_client(&state, "host-conn-b").await;
1719
+
1720
+ handle_client_message(
1721
+ RelayClientMessage::Register {
1722
+ role: RelayClientRole::Host,
1723
+ client_id: "host-client-a".to_owned(),
1724
+ host_id: Some("host-a".to_owned()),
1725
+ device_id: None,
1726
+ device_token: None,
1727
+ },
1728
+ &state,
1729
+ "host-conn-a",
1730
+ )
1731
+ .await;
1732
+ handle_client_message(
1733
+ RelayClientMessage::Register {
1734
+ role: RelayClientRole::Host,
1735
+ client_id: "host-client-b".to_owned(),
1736
+ host_id: Some("host-a".to_owned()),
1737
+ device_id: None,
1738
+ device_token: None,
1739
+ },
1740
+ &state,
1741
+ "host-conn-b",
1742
+ )
1743
+ .await;
1744
+
1745
+ assert!(matches!(
1746
+ second_host_rx
1747
+ .recv()
1748
+ .await
1749
+ .expect("duplicate host receives rejection"),
1750
+ RelayServerMessage::Error { .. }
1751
+ ));
1752
+ handle_client_message(
1753
+ RelayClientMessage::PairCreate {
1754
+ request: PairCreateRequest {
1755
+ host_id: "host-a".to_owned(),
1756
+ host_name: "Host A".to_owned(),
1757
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1758
+ pair_id: Some("pair-original-host".to_owned()),
1759
+ pair_token: Some("secret-original-host".to_owned()),
1760
+ expires_in_seconds: Some(120),
1761
+ },
1762
+ },
1763
+ &state,
1764
+ "host-conn-a",
1765
+ )
1766
+ .await;
1767
+ assert!(matches!(
1768
+ first_host_rx
1769
+ .recv()
1770
+ .await
1771
+ .expect("original host can still create pair"),
1772
+ RelayServerMessage::PairCreated { .. }
1773
+ ));
1774
+ }
1775
+
1776
+ #[tokio::test]
1777
+ async fn unpaired_mobile_cannot_read_history() {
1778
+ let state = test_state(true);
1779
+ let _host_rx = register_client(&state, "host-conn").await;
1780
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1781
+
1782
+ handle_client_message(
1783
+ RelayClientMessage::Register {
1784
+ role: RelayClientRole::Host,
1785
+ client_id: "host-client".to_owned(),
1786
+ host_id: Some("host-a".to_owned()),
1787
+ device_id: None,
1788
+ device_token: None,
1789
+ },
1790
+ &state,
1791
+ "host-conn",
1792
+ )
1793
+ .await;
1794
+ handle_client_message(session_started("host-a", "session-a"), &state, "host-conn").await;
1795
+ handle_client_message(
1796
+ RelayClientMessage::Register {
1797
+ role: RelayClientRole::Mobile,
1798
+ client_id: "mobile-client".to_owned(),
1799
+ host_id: Some("host-a".to_owned()),
1800
+ device_id: None,
1801
+ device_token: None,
1802
+ },
1803
+ &state,
1804
+ "mobile-conn",
1805
+ )
1806
+ .await;
1807
+ handle_client_message(
1808
+ RelayClientMessage::HistoryRequest {
1809
+ request: HistoryRequest {
1810
+ request_id: "history-unpaired".to_owned(),
1811
+ host_id: "host-a".to_owned(),
1812
+ session_id: "session-a".to_owned(),
1813
+ before_seq: None,
1814
+ limit: 20,
1815
+ },
1816
+ },
1817
+ &state,
1818
+ "mobile-conn",
1819
+ )
1820
+ .await;
1821
+
1822
+ assert!(matches!(
1823
+ mobile_rx
1824
+ .recv()
1825
+ .await
1826
+ .expect("mobile receives unpaired rejection"),
1827
+ RelayServerMessage::Error { .. }
1828
+ ));
1829
+ assert!(mobile_rx.try_recv().is_err());
1830
+ }
1831
+
1832
+ #[tokio::test]
1833
+ async fn host_origin_messages_require_registered_host_connection() {
1834
+ let state = test_state(true);
1835
+ let mut host_rx = register_client(&state, "host-conn").await;
1836
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1837
+
1838
+ handle_client_message(
1839
+ RelayClientMessage::Register {
1840
+ role: RelayClientRole::Host,
1841
+ client_id: "host-client".to_owned(),
1842
+ host_id: Some("host-a".to_owned()),
1843
+ device_id: None,
1844
+ device_token: None,
1845
+ },
1846
+ &state,
1847
+ "host-conn",
1848
+ )
1849
+ .await;
1850
+ handle_client_message(
1851
+ RelayClientMessage::Register {
1852
+ role: RelayClientRole::Mobile,
1853
+ client_id: "mobile-client".to_owned(),
1854
+ host_id: Some("host-a".to_owned()),
1855
+ device_id: None,
1856
+ device_token: None,
1857
+ },
1858
+ &state,
1859
+ "mobile-conn",
1860
+ )
1861
+ .await;
1862
+ handle_client_message(
1863
+ RelayClientMessage::HostStatus {
1864
+ status: HostStatus::local_codex("host-a", "spoofed", "."),
1865
+ },
1866
+ &state,
1867
+ "mobile-conn",
1868
+ )
1869
+ .await;
1870
+ handle_client_message(
1871
+ session_started("host-a", "spoofed-session"),
1872
+ &state,
1873
+ "mobile-conn",
1874
+ )
1875
+ .await;
1876
+
1877
+ assert!(matches!(
1878
+ mobile_rx
1879
+ .recv()
1880
+ .await
1881
+ .expect("mobile receives host-status rejection"),
1882
+ RelayServerMessage::Error { .. }
1883
+ ));
1884
+ assert!(matches!(
1885
+ mobile_rx
1886
+ .recv()
1887
+ .await
1888
+ .expect("mobile receives session-event rejection"),
1889
+ RelayServerMessage::Error { .. }
1890
+ ));
1891
+ assert!(host_rx.try_recv().is_err());
1892
+ let RelayServerMessage::Snapshot {
1893
+ hosts, sessions, ..
1894
+ } = scoped_snapshot(&state, "host-a").await
1895
+ else {
1896
+ panic!("expected snapshot");
1897
+ };
1898
+ assert!(hosts.is_empty());
1899
+ assert!(sessions.is_empty());
1900
+ }
1901
+
1902
+ #[tokio::test]
1903
+ async fn pair_claim_rejects_wrong_token_without_consuming_session() {
1904
+ let state = test_state(true);
1905
+ let mut host_rx = register_client(&state, "host-conn").await;
1906
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
1907
+
1908
+ handle_client_message(
1909
+ RelayClientMessage::Register {
1910
+ role: RelayClientRole::Host,
1911
+ client_id: "host-client".to_owned(),
1912
+ host_id: Some("host-a".to_owned()),
1913
+ device_id: None,
1914
+ device_token: None,
1915
+ },
1916
+ &state,
1917
+ "host-conn",
1918
+ )
1919
+ .await;
1920
+ handle_client_message(
1921
+ RelayClientMessage::PairCreate {
1922
+ request: PairCreateRequest {
1923
+ host_id: "host-a".to_owned(),
1924
+ host_name: "Host A".to_owned(),
1925
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1926
+ pair_id: Some("pair-token".to_owned()),
1927
+ pair_token: Some("secret-token".to_owned()),
1928
+ expires_in_seconds: Some(120),
1929
+ },
1930
+ },
1931
+ &state,
1932
+ "host-conn",
1933
+ )
1934
+ .await;
1935
+ assert!(matches!(
1936
+ host_rx.recv().await.expect("host receives pair-created"),
1937
+ RelayServerMessage::PairCreated { .. }
1938
+ ));
1939
+
1940
+ handle_client_message(
1941
+ RelayClientMessage::PairClaim {
1942
+ request: PairClaimRequest {
1943
+ pair_id: "pair-token".to_owned(),
1944
+ pair_token: "wrong-token".to_owned(),
1945
+ mobile_client_id: "mobile-client".to_owned(),
1946
+ device_id: None,
1947
+ device_name: None,
1948
+ },
1949
+ },
1950
+ &state,
1951
+ "mobile-conn",
1952
+ )
1953
+ .await;
1954
+ assert!(matches!(
1955
+ mobile_rx.recv().await.expect("mobile receives rejection"),
1956
+ RelayServerMessage::Error { .. }
1957
+ ));
1958
+
1959
+ handle_client_message(
1960
+ RelayClientMessage::PairClaim {
1961
+ request: PairClaimRequest {
1962
+ pair_id: "pair-token".to_owned(),
1963
+ pair_token: "secret-token".to_owned(),
1964
+ mobile_client_id: "mobile-client".to_owned(),
1965
+ device_id: None,
1966
+ device_name: None,
1967
+ },
1968
+ },
1969
+ &state,
1970
+ "mobile-conn",
1971
+ )
1972
+ .await;
1973
+ assert!(matches!(
1974
+ mobile_rx.recv().await.expect("mobile receives claim"),
1975
+ RelayServerMessage::PairClaimed { .. }
1976
+ ));
1977
+ }
1978
+
1979
+ #[tokio::test]
1980
+ async fn redis_pairing_store_claim_consumes_and_persists_device_binding() {
1981
+ let Ok(redis_url) = std::env::var("OAP_REDIS_TEST_URL") else {
1982
+ return;
1983
+ };
1984
+ let client = redis::Client::open(redis_url.as_str()).expect("redis url parses");
1985
+ let connection = client
1986
+ .get_connection_manager()
1987
+ .await
1988
+ .expect("redis test connection");
1989
+ let prefix = format!("agentpal:test:{}", Uuid::new_v4());
1990
+ let store = RedisPairingStore {
1991
+ connection,
1992
+ key_prefix: prefix,
1993
+ };
1994
+ let session = StoredPairSession {
1995
+ pair_id: "pair-redis".to_owned(),
1996
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
1997
+ host_id: "host-redis".to_owned(),
1998
+ host_name: "Redis Host".to_owned(),
1999
+ pair_token_hash: token_hash("secret-redis"),
2000
+ expires_at: Some(OffsetDateTime::now_utc() + TimeDuration::minutes(2)),
2001
+ };
2002
+ store
2003
+ .put_pair_session(session, Some(120))
2004
+ .await
2005
+ .expect("put redis pair session");
2006
+
2007
+ let wrong = store
2008
+ .claim_pair_session("pair-redis", "wrong", OffsetDateTime::now_utc())
2009
+ .await
2010
+ .expect("wrong token query succeeds");
2011
+ assert!(matches!(wrong, PairClaimOutcome::TokenRejected));
2012
+
2013
+ let claimed = store
2014
+ .claim_pair_session("pair-redis", "secret-redis", OffsetDateTime::now_utc())
2015
+ .await
2016
+ .expect("correct token query succeeds");
2017
+ assert!(matches!(claimed, PairClaimOutcome::Claimed(_)));
2018
+
2019
+ let consumed = store
2020
+ .claim_pair_session("pair-redis", "secret-redis", OffsetDateTime::now_utc())
2021
+ .await
2022
+ .expect("consumed token query succeeds");
2023
+ assert!(matches!(consumed, PairClaimOutcome::NotFound));
2024
+
2025
+ let device_token_hash = token_hash("device-secret");
2026
+ store
2027
+ .put_device_binding(
2028
+ device_token_hash.clone(),
2029
+ DeviceBinding {
2030
+ host_id: "host-redis".to_owned(),
2031
+ device_id: "device-redis".to_owned(),
2032
+ },
2033
+ )
2034
+ .await
2035
+ .expect("put redis device binding");
2036
+ let binding = store
2037
+ .get_device_binding(&device_token_hash)
2038
+ .await
2039
+ .expect("get redis device binding")
2040
+ .expect("redis device binding exists");
2041
+ assert_eq!(binding.host_id, "host-redis");
2042
+ assert_eq!(binding.device_id, "device-redis");
2043
+ store
2044
+ .mark_cloud_pair_host("host-redis")
2045
+ .await
2046
+ .expect("mark redis cloud host");
2047
+ assert!(
2048
+ store
2049
+ .is_cloud_pair_host("host-redis")
2050
+ .await
2051
+ .expect("check redis cloud host")
2052
+ );
2053
+
2054
+ let mut cleanup = store.connection.clone();
2055
+ let _: usize = redis::cmd("DEL")
2056
+ .arg(store.device_key(&device_token_hash))
2057
+ .arg(store.cloud_host_key("host-redis"))
2058
+ .query_async(&mut cleanup)
2059
+ .await
2060
+ .expect("cleanup redis test keys");
2061
+ }
2062
+
2063
+ #[tokio::test]
2064
+ async fn pair_create_requires_registered_host_connection() {
2065
+ let state = test_state(true);
2066
+ let mut mobile_rx = register_client(&state, "mobile-conn").await;
2067
+
2068
+ handle_client_message(
2069
+ RelayClientMessage::PairCreate {
2070
+ request: PairCreateRequest {
2071
+ host_id: "host-a".to_owned(),
2072
+ host_name: "Host A".to_owned(),
2073
+ relay_url: "ws://127.0.0.1:8790/ws".to_owned(),
2074
+ pair_id: Some("pair-a".to_owned()),
2075
+ pair_token: Some("secret-a".to_owned()),
2076
+ expires_in_seconds: Some(120),
2077
+ },
2078
+ },
2079
+ &state,
2080
+ "mobile-conn",
2081
+ )
2082
+ .await;
2083
+
2084
+ let rejected = mobile_rx
2085
+ .recv()
2086
+ .await
2087
+ .expect("requester receives rejection");
2088
+ assert!(matches!(rejected, RelayServerMessage::Error { .. }));
2089
+ assert!(
2090
+ state
2091
+ .store
2092
+ .claim_pair_session("pair-a", "secret-a", OffsetDateTime::now_utc())
2093
+ .await
2094
+ .is_ok_and(|outcome| matches!(outcome, PairClaimOutcome::NotFound))
2095
+ );
2096
+ }
2097
+ }