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.
- package/Cargo.lock +2155 -0
- package/Cargo.toml +30 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/bin/agentpal.mjs +256 -0
- package/crates/host/Cargo.toml +20 -0
- package/crates/host/src/codex.rs +2486 -0
- package/crates/host/src/main.rs +61 -0
- package/crates/protocol/Cargo.toml +11 -0
- package/crates/protocol/src/lib.rs +576 -0
- package/crates/relay/Cargo.toml +22 -0
- package/crates/relay/src/main.rs +2097 -0
- package/package.json +46 -0
|
@@ -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, ®istry.host_id).await {
|
|
782
|
+
reject_host_origin(state, connection_id, ®istry.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
|
+
®istry.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
|
+
}
|