1use std::fs::File;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use crate::ipc::{SendListener, SendStream};
15use crate::machine::{
16 load_device_bearer, lock_store, save_device_bearer, KEYCHAIN_DIR_ENV,
17};
18use crate::push::PushLink;
19use crate::send::{send_sock_path_named, SendReply, SendRequest};
20use crate::{
21 nonempty_var, OpenedStore, SessionConfig, SessionWake, ShellError, DEVICE_TOKEN_ENV,
22 LEADER_SOCK_ENV, STORE_ROOT_ENV,
23};
24
25pub const NODE_DEFAULT_SOCK_NAME: &str = "node-client.sock";
27
28const SEND_JOIN_WAIT_SECS: u64 = 120;
30
31pub struct NodeClient {
34 store: OpenedStore,
35 push: PushLink,
36 send_listener: Option<SendListener>,
37 send_sock: Option<PathBuf>,
38 send_queue: Vec<PendingSend>,
39 last_full_drive: Instant,
40 store_root: PathBuf,
41 _lock: File,
42}
43
44struct PendingSend {
45 stream: SendStream,
46 request: SendRequest,
47 started: Instant,
48 room: Option<String>,
49 peer: Option<String>,
50}
51
52impl Drop for NodeClient {
53 fn drop(&mut self) {
54 if let Some(path) = self.send_sock.take() {
55 let _ = std::fs::remove_file(path);
56 }
57 }
58}
59
60#[derive(Debug, Default)]
63pub struct NodeTickReport {
64 pub pushed: Vec<String>,
66 pub joined: Vec<String>,
68 pub errors: Vec<String>,
70 pub sent: Vec<(String, String, SendReply)>,
72 pub wake_note: Option<String>,
74}
75
76impl NodeClient {
77 pub fn from_env() -> Result<Self, ShellError> {
83 let wake = SessionWake::node_cli()?;
84 let sock = wake.leader_sock.as_ref().ok_or_else(|| {
85 ShellError::SessionList(format!(
86 "node client requires {LEADER_SOCK_ENV} (ACP leader.sock)"
87 ))
88 })?;
89 if !mail4agent_grok::leader_is_listening(sock) {
90 return Err(ShellError::SessionList(
91 "the leader is not listening; start grok with [cli] use_leader = true".to_string(),
92 ));
93 }
94
95 if nonempty_var(DEVICE_TOKEN_ENV).is_none() {
97 if let (Some(dir), Some(session_id)) = (
98 nonempty_var(KEYCHAIN_DIR_ENV).map(PathBuf::from),
99 nonempty_var(crate::SESSION_ID_ENV),
100 ) {
101 if let Some(token) = load_device_bearer(&dir, &session_id) {
102 std::env::set_var(DEVICE_TOKEN_ENV, token);
103 }
104 }
105 }
106
107 let config = SessionConfig::from_env()?;
108 let lock = lock_store(&config.store_dir())?;
109 let store = OpenedStore::connect_with_wake(&config, wake)?;
110
111 if let Some(dir) = nonempty_var(KEYCHAIN_DIR_ENV).map(PathBuf::from) {
112 save_device_bearer(&dir, config.session_id(), store.device_bearer());
113 }
114
115 let push = PushLink::open(config.homeserver_url(), vec![store.device_bearer().to_string()], config.is_product())?;
116 let store_root = PathBuf::from(
117 nonempty_var(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?,
118 );
119
120 Ok(Self {
121 store,
122 push,
123 send_listener: None,
124 send_sock: None,
125 send_queue: Vec::new(),
126 last_full_drive: Instant::now(),
127 store_root,
128 _lock: lock,
129 })
130 }
131
132 pub fn nick(&self) -> Option<&str> {
134 self.store.nick()
135 }
136
137 pub fn session_id(&self) -> &str {
139 self.store.session_id()
140 }
141
142 pub fn user_id(&self) -> &str {
144 self.store.user_id()
145 }
146
147 pub fn store_root(&self) -> &Path {
149 &self.store_root
150 }
151
152 pub fn store(&self) -> &OpenedStore {
154 &self.store
155 }
156
157 pub fn store_mut(&mut self) -> &mut OpenedStore {
159 &mut self.store
160 }
161
162 pub fn wake_log(&self) -> &[crate::WakeAttempt] {
165 self.store.wake_log()
166 }
167
168 pub fn wake_note(&self) -> Option<&str> {
170 self.store.wake_note()
171 }
172
173 pub fn listen_for_sends(&mut self, path: &Path) -> Result<(), ShellError> {
178 let listener = SendListener::bind(path).map_err(|err| {
179 if err.kind() == std::io::ErrorKind::AlreadyExists {
180 ShellError::SessionList(
181 "another client already listens on the send socket".to_string(),
182 )
183 } else {
184 ShellError::Io(err)
185 }
186 })?;
187 listener.set_nonblocking(true)?;
188 self.send_listener = Some(listener);
189 self.send_sock = Some(path.to_path_buf());
190 Ok(())
191 }
192
193 pub fn listen_for_sends_from_env(&mut self) -> Result<PathBuf, ShellError> {
196 let path = send_sock_path_named(
197 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
198 &self.store_root,
199 NODE_DEFAULT_SOCK_NAME,
200 );
201 self.listen_for_sends(&path)?;
202 Ok(path)
203 }
204
205 pub fn tick(&mut self, now_ms: i64, full_drive_secs: u64) -> NodeTickReport {
212 let mut report = NodeTickReport::default();
213 let me = self.store.user_id().to_string();
214 let mut pushed = false;
215 for (recipient, event) in self.push.drain() {
216 if recipient != me {
217 continue;
218 }
219 report.pushed.push(event.event_id.clone());
220 self.store.record_push(event);
221 pushed = true;
222 }
223
224 let full = self.last_full_drive.elapsed().as_secs() >= full_drive_secs;
225 if full {
226 self.last_full_drive = Instant::now();
227 }
228
229 if pushed || full {
230 if let Err(err) = self.store.drive(now_ms, pushed) {
231 report.errors.push(err.to_string());
232 } else {
233 match self.store.accept_direct_invites(now_ms) {
234 Ok(joined) => report.joined.extend(joined),
235 Err(err) => report.errors.push(err.to_string()),
236 }
237 }
238 }
239
240 self.serve_sends(now_ms, &mut report);
241 report.wake_note = self.store.wake_note().map(str::to_string);
242 report
243 }
244
245 pub fn send_blocking(
249 &mut self,
250 to: &str,
251 text: &str,
252 wait: Duration,
253 ) -> SendReply {
254 let as_nick = self
255 .store
256 .nick()
257 .unwrap_or("")
258 .to_string();
259 let started = Instant::now();
260 let mut room = None;
261 let mut peer = None;
262 loop {
263 let now = now_ms();
264 match self.try_send(&as_nick, to, text, now, &mut room, &mut peer) {
265 Some(reply) => return reply,
266 None if started.elapsed() >= wait => {
267 return SendReply {
268 room,
269 ..SendReply::failed(format!("{to} has not joined the DM yet"))
270 }
271 }
272 None => {
273 let _ = self.store.drive(now, false);
274 std::thread::sleep(Duration::from_millis(500));
275 }
276 }
277 }
278 }
279
280 fn try_send(
281 &mut self,
282 as_nick: &str,
283 to: &str,
284 text: &str,
285 now_ms: i64,
286 room: &mut Option<String>,
287 peer: &mut Option<String>,
288 ) -> Option<SendReply> {
289 let own = self.store.nick().unwrap_or("");
290 if !own.eq_ignore_ascii_case(as_nick) {
291 return Some(SendReply::failed(format!(
292 "{as_nick} is not this node client session"
293 )));
294 }
295 if peer.is_none() {
296 let mut last_err = None;
297 for attempt in 0..3 {
298 if attempt > 0 {
299 let _ = self.store.drive(now_ms, false);
300 }
301 match self.store.find_nick(to, now_ms) {
302 Ok(found) => {
303 *peer = Some(found.user_id);
304 last_err = None;
305 break;
306 }
307 Err(err) => last_err = Some(err),
308 }
309 }
310 if let Some(err) = last_err {
311 return Some(SendReply::failed(format!("find {to}: {err}")));
312 }
313 }
314 if room.is_none() {
315 match self.store.ensure_dm(to, now_ms) {
316 Ok(room_id) => *room = Some(room_id),
317 Err(err) => return Some(SendReply::failed(format!("open DM: {err}"))),
318 }
319 }
320 let (room_id, peer_id) = (room.clone()?, peer.clone()?);
321 if !self.store.member_joined(&room_id, &peer_id) {
322 return None;
323 }
324 match self.store.write_to_nick(to, text, now_ms) {
325 Ok(room_id) => {
326 let event_id = self
327 .store
328 .texts()
329 .into_iter()
330 .rev()
331 .find(|row| row.room_id == room_id && row.body == text)
332 .and_then(|row| row.event_id);
333 Some(SendReply {
334 ok: true,
335 room: Some(room_id),
336 event_id,
337 error: None,
338 })
339 }
340 Err(err) => Some(SendReply {
341 room: Some(room_id),
342 ..SendReply::failed(format!("send: {err}"))
343 }),
344 }
345 }
346
347 fn serve_sends(&mut self, now_ms: i64, report: &mut NodeTickReport) {
348 if let Some(listener) = &self.send_listener {
349 loop {
350 match listener.accept() {
351 Ok(mut stream) => match crate::send::read_request(&mut stream) {
352 Ok(request) => self.send_queue.push(PendingSend {
353 stream,
354 request,
355 started: Instant::now(),
356 room: None,
357 peer: None,
358 }),
359 Err(err) => {
360 crate::send::write_reply(&mut stream, &SendReply::failed(err))
361 }
362 },
363 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
364 Err(_) => break,
365 }
366 }
367 }
368 let queue = std::mem::take(&mut self.send_queue);
369 for mut pending in queue {
370 let (as_nick, to, text) = (
371 pending.request.as_nick.clone(),
372 pending.request.to.clone(),
373 pending.request.text.clone(),
374 );
375 let outcome = self.try_send(
376 &as_nick,
377 &to,
378 &text,
379 now_ms,
380 &mut pending.room,
381 &mut pending.peer,
382 );
383 let reply = match outcome {
384 Some(reply) => reply,
385 None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
386 SendReply {
387 room: pending.room.clone(),
388 ..SendReply::failed(format!("{to} has not joined the DM yet"))
389 }
390 }
391 None => {
392 self.send_queue.push(pending);
393 continue;
394 }
395 };
396 crate::send::write_reply(&mut pending.stream, &reply);
397 report.sent.push((as_nick, to, reply));
398 }
399 }
400}
401
402fn now_ms() -> i64 {
403 std::time::SystemTime::now()
404 .duration_since(std::time::UNIX_EPOCH)
405 .map(|elapsed| elapsed.as_millis() as i64)
406 .unwrap_or(0)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::{ROUTINE_BEARER_ENV, ROUTINE_URL_ENV, LEADER_CWD_ENV};
413
414 #[test]
415 fn node_wake_refuses_routine_env_and_keeps_leader_only() {
416 match SessionWake::node_from_lookup(|key| match key {
417 ROUTINE_URL_ENV => Some("http://127.0.0.1/hook".into()),
418 LEADER_SOCK_ENV => Some("/tmp/leader.sock".into()),
419 _ => None,
420 }) {
421 Ok(_) => panic!("routine url accepted"),
422 Err(err) => {
423 assert!(matches!(err, ShellError::NodeRoutine));
424 let text = err.to_string();
425 assert!(!text.contains("127.0.0.1"));
426 assert!(!text.contains("hook"));
427 }
428 }
429
430 match SessionWake::node_from_lookup(|key| match key {
431 ROUTINE_BEARER_ENV => Some("secret-bearer".into()),
432 _ => None,
433 }) {
434 Ok(_) => panic!("routine bearer accepted"),
435 Err(err) => {
436 assert!(matches!(err, ShellError::NodeRoutine));
437 assert!(!err.to_string().contains("secret-bearer"));
438 }
439 }
440
441 let wake = match SessionWake::node_from_lookup(|key| match key {
442 LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".into()),
443 LEADER_CWD_ENV => Some("/tmp/work".into()),
444 _ => None,
445 }) {
446 Ok(wake) => wake,
447 Err(err) => panic!("leader only refused: {err}"),
448 };
449 assert!(wake.routine_url.is_none());
450 assert!(wake.routine_bearer.is_none());
451 assert_eq!(
452 wake.leader_sock.as_deref(),
453 Some(Path::new("/tmp/node-leader.sock"))
454 );
455 assert_eq!(wake.leader_cwd.as_deref(), Some("/tmp/work"));
456 }
457
458 #[test]
459 fn node_default_sock_name_differs_from_web() {
460 assert_ne!(NODE_DEFAULT_SOCK_NAME, crate::DEFAULT_SOCK_NAME);
461 assert_eq!(NODE_DEFAULT_SOCK_NAME, "node-client.sock");
462 }
463
464 #[cfg(unix)]
465 #[test]
466 fn fake_acp_peer_answers_session_prompt_for_wake_framing() {
467 use std::os::unix::net::UnixListener;
468 use std::sync::{Arc, Mutex};
469 use std::thread;
470
471 use serde_json::{json, Value};
472 use tokio::io::{AsyncReadExt, AsyncWriteExt};
473 use tokio::net::UnixStream;
474
475 fn encode(bytes: &[u8]) -> Vec<u8> {
476 mail4agent_grok::encode_frame(bytes).expect("frame")
477 }
478
479 let dir = std::env::temp_dir().join(format!(
480 "m4a-node-fake-acp-{}-{}",
481 std::process::id(),
482 std::time::SystemTime::now()
483 .duration_since(std::time::UNIX_EPOCH)
484 .expect("clock")
485 .as_nanos()
486 ));
487 std::fs::create_dir_all(&dir).expect("dir");
488 let path = dir.join("leader.sock");
489 let listener = UnixListener::bind(&path).expect("bind");
490 let prompts = Arc::new(Mutex::new(Vec::<String>::new()));
491 let recorded = Arc::clone(&prompts);
492 let server = thread::spawn(move || {
493 let (sock, _) = listener.accept().expect("accept");
494 let runtime = tokio::runtime::Builder::new_current_thread()
495 .enable_all()
496 .build()
497 .expect("rt");
498 runtime.block_on(async move {
499 sock.set_nonblocking(true).expect("nb");
501 let mut stream = UnixStream::from_std(sock).expect("tokio");
502 async fn read_value(stream: &mut UnixStream) -> Value {
503 let mut len_buf = [0u8; 4];
504 stream.read_exact(&mut len_buf).await.expect("len");
505 let len = u32::from_be_bytes(len_buf) as usize;
506 let mut buf = vec![0u8; len];
507 stream.read_exact(&mut buf).await.expect("body");
508 serde_json::from_slice(&buf).expect("json")
509 }
510 async fn write_value(stream: &mut UnixStream, value: &Value) {
511 let bytes = serde_json::to_vec(value).expect("json");
512 let frame = encode(&bytes);
513 stream.write_all(&frame).await.expect("write");
514 stream.flush().await.expect("flush");
515 }
516 let register = read_value(&mut stream).await;
517 assert_eq!(register["type"], "register");
518 write_value(&mut stream, &json!({"type": "registered", "ready": true})).await;
519 loop {
520 let value = read_value(&mut stream).await;
521 if value.get("type").and_then(Value::as_str) == Some("disconnect") {
522 break;
523 }
524 if value.get("type").and_then(Value::as_str) != Some("acp") {
525 continue;
526 }
527 let payload = value["payload"].as_str().expect("payload");
528 let inner: Value = serde_json::from_str(payload).expect("inner");
529 if inner["method"].as_str() == Some("session/prompt") {
530 let text = inner["params"]["prompt"][0]["text"]
531 .as_str()
532 .unwrap_or("")
533 .to_string();
534 recorded.lock().expect("p").push(text);
535 }
536 let id = inner["id"].clone();
537 let body = json!({"jsonrpc":"2.0","id": id, "result": {}}).to_string();
538 write_value(&mut stream, &json!({"type":"acp","payload": body})).await;
539 }
540 });
541 });
542
543 mail4agent_grok::wake_decrypted_room_blocking(
544 &path,
545 "local-session-id",
546 "/tmp",
547 "hello-from-node-test",
548 )
549 .expect("fake peer answered");
550 server.join().expect("server");
551 let got = prompts.lock().expect("prompts");
552 assert_eq!(got.as_slice(), ["hello-from-node-test"]);
553 let _ = std::fs::remove_dir_all(&dir);
554 }
555}