Skip to main content

agent_engine/events/
socket.rs

1// src/events/socket.rs
2// Per-session event delivery listener.
3// Unix: a Unix domain socket at `socket_path`.
4// Windows: a named pipe whose name is derived deterministically from
5// `socket_path` (see `pipe_name_for`), so clients holding the registry's
6// socket_path can connect without any registry format change.
7
8use std::sync::{
9    atomic::{AtomicBool, Ordering},
10    Arc,
11};
12
13use tokio::io::AsyncReadExt;
14#[cfg(unix)]
15use tokio::net::UnixListener;
16
17use super::queue::EventQueue;
18use super::types::Event;
19
20const MAX_PAYLOAD: usize = 256 * 1024; // 256KB
21
22/// Derive the Windows named-pipe name for a session socket path. Both the
23/// engine (server) and `synaps send` (client) call this on the registry's
24/// `socket_path`, so the two sides agree without changing the registry format.
25/// The path is hashed because pipe names live in a flat namespace and must
26/// not contain path separators.
27#[cfg(windows)]
28pub fn pipe_name_for(socket_path: &str) -> String {
29    use sha2::{Digest, Sha256};
30    let digest = Sha256::digest(socket_path.as_bytes());
31    // 16 bytes of the hash is plenty for uniqueness and keeps the name short.
32    let hex: String = digest[..16].iter().map(|b| format!("{:02x}", b)).collect();
33    format!(r"\\.\pipe\synaps-session-{}", hex)
34}
35
36/// Remove socket file if it exists. Best-effort, never panics.
37/// Sockets now live in ~/.synaps-cli/run/ (mode 0700), so symlink
38/// attacks from other users are not possible. Still refuse symlinks
39/// as defense-in-depth.
40pub fn cleanup_socket(socket_path: &str) {
41    #[cfg(unix)]
42    {
43        use std::os::unix::fs::FileTypeExt;
44        let path = std::path::Path::new(socket_path);
45        if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_socket()) {
46            let _ = std::fs::remove_file(path);
47        }
48    }
49    #[cfg(not(unix))]
50    let _ = socket_path;
51}
52
53/// Captured at bind, never inferred later from an untrusted card.
54pub(crate) struct SocketIdentity {
55    #[cfg(unix)]
56    path: String,
57    #[cfg(unix)]
58    inode: (u64, u64),
59}
60impl SocketIdentity {
61    pub(crate) fn remove(&self) {
62        #[cfg(unix)]
63        {
64            use std::os::unix::fs::{FileTypeExt, MetadataExt};
65            if std::fs::symlink_metadata(&self.path)
66                .is_ok_and(|m| m.file_type().is_socket() && (m.dev(), m.ino()) == self.inode)
67            {
68                let _ = std::fs::remove_file(&self.path);
69            }
70        }
71    }
72}
73
74/// Client end of a per-session endpoint: a stream on the Unix socket at
75/// `socket_path`, or on Windows the named pipe derived from it.
76#[cfg(unix)]
77pub type SessionClient = tokio::net::UnixStream;
78#[cfg(windows)]
79pub type SessionClient = tokio::net::windows::named_pipe::NamedPipeClient;
80
81/// Connect to the session endpoint a registration publishes as
82/// `socket_path`, so callers (`synaps send`, tests) never hard-code the
83/// platform transport.
84pub async fn connect_session_socket(socket_path: &str) -> std::io::Result<SessionClient> {
85    #[cfg(unix)]
86    {
87        tokio::net::UnixStream::connect(socket_path).await
88    }
89    #[cfg(windows)]
90    {
91        // Like the daemon pipe: refuse a server run by another account, so
92        // nobody else can squat a session's name and receive what we send.
93        let client = tokio::net::windows::named_pipe::ClientOptions::new()
94            .open(pipe_name_for(socket_path))?;
95        crate::ipc::verify_pipe_server(&client)?;
96        Ok(client)
97    }
98}
99
100/// Deliver one payload to a session endpoint: connect, write, close. The
101/// listener reads to EOF, and a named pipe has no half-close (`shutdown`
102/// only flushes), so EOF reaches it only when the handle is dropped. Doing
103/// all three here means no caller can hold the pipe open by accident.
104pub async fn send_to_session_socket(socket_path: &str, payload: &[u8]) -> std::io::Result<()> {
105    use tokio::io::AsyncWriteExt;
106    let mut client = connect_session_socket(socket_path).await?;
107    client.write_all(payload).await?;
108    client.shutdown().await?;
109    drop(client);
110    Ok(())
111}
112
113#[cfg(unix)]
114pub(crate) type SessionListener = UnixListener;
115#[cfg(windows)]
116pub(crate) type SessionListener = tokio::net::windows::named_pipe::NamedPipeServer;
117
118pub(crate) fn bind_session_socket(
119    path: &str,
120) -> std::io::Result<(SessionListener, SocketIdentity)> {
121    #[cfg(unix)]
122    {
123        use std::os::unix::fs::{MetadataExt, PermissionsExt};
124        // Never unlink before binding: an existing endpoint belongs to someone else.
125        let listener = UnixListener::bind(path)?;
126        let meta = std::fs::symlink_metadata(path)?;
127        let identity = SocketIdentity {
128            path: path.into(),
129            inode: (meta.dev(), meta.ino()),
130        };
131        if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
132            identity.remove();
133            return Err(e);
134        }
135        Ok((listener, identity))
136    }
137    #[cfg(windows)]
138    {
139        // Owner-only DACL and no remote clients, like the daemon pipe (the
140        // default pipe DACL also grants Everyone read).
141        let server = crate::ipc::create_owner_only_pipe(&pipe_name_for(path), true)?;
142        Ok((server, SocketIdentity {}))
143    }
144}
145
146/// Compatibility listener API. Binding is synchronous and refuses occupied paths.
147/// Registry users should use SessionEndpoint for atomic bind + publication.
148pub fn listen_session_socket(
149    path: String,
150    queue: Arc<EventQueue>,
151    shutdown: Arc<AtomicBool>,
152) -> tokio::task::JoinHandle<()> {
153    match bind_session_socket(&path) {
154        Ok((listener, identity)) => serve_session_socket_inner(
155            listener,
156            path,
157            queue,
158            shutdown,
159            Some(LegacyCleanup(identity)),
160        ),
161        Err(e) => {
162            tracing::error!("socket: bind failed: {}", e);
163            tokio::spawn(async {})
164        }
165    }
166}
167
168struct LegacyCleanup(SocketIdentity);
169impl Drop for LegacyCleanup {
170    fn drop(&mut self) {
171        #[cfg(unix)]
172        {
173            // Prefer coordination, but never strand a closed legacy socket
174            // solely because another registry operation owns the lock. The
175            // captured inode check preserves any replacement already bound.
176            let _lock = std::path::Path::new(&self.0.path)
177                .parent()
178                .and_then(|dir| super::registry::lock_dir(dir).ok());
179            self.0.remove();
180        }
181    }
182}
183
184pub(crate) fn serve_session_socket(
185    listener: SessionListener,
186    path: String,
187    queue: Arc<EventQueue>,
188    shutdown: Arc<AtomicBool>,
189) -> tokio::task::JoinHandle<()> {
190    serve_session_socket_inner(listener, path, queue, shutdown, None)
191}
192
193/// Bind a Unix socket at `socket_path`, accept connections, parse incoming
194/// events, push to `queue`. Runs until `shutdown` is set.
195///
196/// Protocol: client connects → sends full JSON event → closes connection.
197/// One event per connection. Max payload 256KB.
198#[cfg(unix)]
199fn serve_session_socket_inner(
200    listener: SessionListener,
201    socket_path: String,
202    queue: Arc<EventQueue>,
203    shutdown: Arc<AtomicBool>,
204    cleanup: Option<LegacyCleanup>,
205) -> tokio::task::JoinHandle<()> {
206    tokio::spawn(async move {
207        let _cleanup = cleanup;
208        tracing::info!("socket: listening on {}", socket_path);
209
210        loop {
211            if shutdown.load(Ordering::Acquire) {
212                break;
213            }
214
215            // Poll accept with a timeout so we can check shutdown periodically
216            let accept =
217                tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
218
219            match accept.await {
220                Ok(Ok((mut stream, _addr))) => {
221                    let queue = queue.clone();
222                    tokio::spawn(async move {
223                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
224                        let _ = tokio::time::timeout(
225                            std::time::Duration::from_secs(5),
226                            handle_connection(&mut stream, &queue),
227                        )
228                        .await;
229                    });
230                }
231                Ok(Err(e)) => {
232                    tracing::warn!("socket: accept error: {}", e);
233                }
234                Err(_) => {
235                    // Timeout — loop around and check shutdown flag
236                }
237            }
238        }
239
240        tracing::info!("socket: shut down {}", socket_path);
241    })
242}
243
244/// Windows implementation: a named pipe server. Same protocol and shutdown
245/// semantics as the Unix listener. The `socket_path` is never created on
246/// disk — it only seeds the pipe name via `pipe_name_for`.
247#[cfg(windows)]
248fn serve_session_socket_inner(
249    mut server: SessionListener,
250    socket_path: String,
251    queue: Arc<EventQueue>,
252    shutdown: Arc<AtomicBool>,
253    cleanup: Option<LegacyCleanup>,
254) -> tokio::task::JoinHandle<()> {
255    tokio::spawn(async move {
256        let _cleanup = cleanup;
257        let pipe_name = pipe_name_for(&socket_path);
258        tracing::info!("socket: listening on {}", pipe_name);
259
260        loop {
261            if shutdown.load(Ordering::Acquire) {
262                break;
263            }
264
265            // Poll connect with a timeout so we can check shutdown
266            // periodically (mirrors the Unix accept loop). connect() is
267            // resumable after a timeout drop on the same server instance.
268            let accept =
269                tokio::time::timeout(std::time::Duration::from_millis(500), server.connect());
270
271            match accept.await {
272                Ok(Ok(())) => {
273                    // Spin up the next pipe instance before handing this
274                    // connection off, so new clients can connect immediately.
275                    let next = match crate::ipc::create_owner_only_pipe(&pipe_name, false) {
276                        Ok(s) => s,
277                        Err(e) => {
278                            tracing::error!("socket: failed to recreate pipe: {}", e);
279                            break;
280                        }
281                    };
282                    let mut connected = std::mem::replace(&mut server, next);
283                    let queue = queue.clone();
284                    tokio::spawn(async move {
285                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
286                        let _ = tokio::time::timeout(
287                            std::time::Duration::from_secs(5),
288                            handle_connection(&mut connected, &queue),
289                        )
290                        .await;
291                    });
292                }
293                Ok(Err(e)) => {
294                    tracing::warn!("socket: accept error: {}", e);
295                }
296                Err(_) => {
297                    // Timeout — loop around and check shutdown flag
298                }
299            }
300        }
301
302        tracing::info!("socket: shut down {}", pipe_name);
303    })
304}
305
306async fn handle_connection<S: tokio::io::AsyncRead + Unpin>(stream: &mut S, queue: &EventQueue) {
307    // Read up to MAX_PAYLOAD + 1 so we can detect oversized payloads
308    let mut buf = Vec::with_capacity(4096);
309    let mut chunk = [0u8; 8192];
310
311    loop {
312        match stream.read(&mut chunk).await {
313            Ok(0) => break, // EOF — client closed connection
314            // Named pipes surface client disconnect as BrokenPipe, not Ok(0)
315            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => break,
316            Ok(n) => {
317                if buf.len() + n > MAX_PAYLOAD {
318                    tracing::warn!(
319                        "socket: payload exceeds {}KB limit, dropping connection",
320                        MAX_PAYLOAD / 1024
321                    );
322                    return;
323                }
324                buf.extend_from_slice(&chunk[..n]);
325            }
326            Err(e) => {
327                tracing::warn!("socket: read error: {}", e);
328                return;
329            }
330        }
331    }
332
333    if buf.is_empty() {
334        return;
335    }
336
337    match serde_json::from_slice::<Event>(&buf) {
338        Ok(mut event) => {
339            if let Some(rejected) = event.normalize_intake_id() {
340                tracing::warn!(
341                    "socket: event id rejected ({} bytes, not a plain token), replaced by {}",
342                    rejected.len(),
343                    event.id
344                );
345            }
346            tracing::info!(
347                "socket: event {} from {}",
348                event.id,
349                event.source.source_type
350            );
351            if let Err(e) = queue.push(event) {
352                tracing::warn!("socket: queue push failed: {}", e);
353            }
354        }
355        Err(e) => {
356            tracing::warn!("socket: invalid JSON payload: {}", e);
357        }
358    }
359}
360
361#[cfg(all(test, unix))]
362mod tests {
363    use super::*;
364    use crate::events::{Event, Severity};
365    use std::sync::atomic::AtomicBool;
366    use tokio::io::AsyncWriteExt;
367    use tokio::net::UnixStream;
368
369    async fn wait_for_socket(path: &str) {
370        for _ in 0..50 {
371            if std::path::Path::new(path).exists() {
372                return;
373            }
374            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
375        }
376        panic!("socket never appeared at {}", path);
377    }
378
379    #[tokio::test]
380    async fn legacy_abort_before_first_poll_closes_listener_and_cleans_inode() {
381        let tmp = tempfile::tempdir().unwrap();
382        let path = tmp
383            .path()
384            .join("session.sock")
385            .to_string_lossy()
386            .into_owned();
387        let handle = listen_session_socket(
388            path.clone(),
389            Arc::new(EventQueue::new(10)),
390            Arc::new(AtomicBool::new(false)),
391        );
392        assert!(std::path::Path::new(&path).exists());
393        handle.abort();
394        let _ = handle.await;
395        assert!(!std::path::Path::new(&path).exists());
396        assert!(UnixStream::connect(&path).await.is_err());
397    }
398
399    #[tokio::test]
400    async fn legacy_abort_cleans_inode_even_while_registry_lock_is_held() {
401        let tmp = tempfile::tempdir().unwrap();
402        let path = tmp
403            .path()
404            .join("contended.sock")
405            .to_string_lossy()
406            .into_owned();
407        let handle = listen_session_socket(
408            path.clone(),
409            Arc::new(EventQueue::new(10)),
410            Arc::new(AtomicBool::new(false)),
411        );
412        let lock = crate::events::registry::lock_dir(tmp.path()).unwrap();
413        handle.abort();
414        let _ = handle.await;
415        assert!(!std::path::Path::new(&path).exists());
416        // The old listener no longer accepts, and its pathname is reusable.
417        let rebound = tokio::net::UnixListener::bind(&path).unwrap();
418        drop(rebound);
419        cleanup_socket(&path);
420        drop(lock);
421    }
422
423    #[tokio::test]
424    async fn legacy_old_listener_exit_keeps_replacement_inode_connectable() {
425        let tmp = tempfile::tempdir().unwrap();
426        let path = tmp
427            .path()
428            .join("session.sock")
429            .to_string_lossy()
430            .into_owned();
431        let queue = Arc::new(EventQueue::new(10));
432        let handle = listen_session_socket(
433            path.clone(),
434            queue.clone(),
435            Arc::new(AtomicBool::new(false)),
436        );
437        std::fs::remove_file(&path).unwrap();
438        let replacement =
439            listen_session_socket(path.clone(), queue, Arc::new(AtomicBool::new(false)));
440        handle.abort();
441        let _ = handle.await;
442        let _client = UnixStream::connect(&path).await.unwrap();
443        replacement.abort();
444        let _ = replacement.await;
445    }
446
447    #[tokio::test]
448    async fn delivers_event_to_queue() {
449        let tmp = tempfile::tempdir().unwrap();
450        let path = tmp
451            .path()
452            .join("session.sock")
453            .to_string_lossy()
454            .into_owned();
455        let queue = Arc::new(EventQueue::new(10));
456        let shutdown = Arc::new(AtomicBool::new(false));
457
458        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
459        wait_for_socket(&path).await;
460
461        let event = Event::simple("test", "hello socket", Some(Severity::High));
462        let json = serde_json::to_vec(&event).unwrap();
463
464        let mut client = UnixStream::connect(&path).await.unwrap();
465        client.write_all(&json).await.unwrap();
466        client.shutdown().await.unwrap();
467
468        // Give the task a moment to push
469        for _ in 0..50 {
470            if !queue.is_empty() {
471                break;
472            }
473            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
474        }
475
476        shutdown.store(true, Ordering::Release);
477        handle.await.unwrap();
478
479        let popped = queue.pop().expect("event should be in queue");
480        assert_eq!(popped.content.text, "hello socket");
481        assert_eq!(popped.source.source_type, "test");
482    }
483
484    #[tokio::test]
485    async fn intake_replaces_an_invalid_event_id_and_keeps_a_valid_one() {
486        let tmp = tempfile::tempdir().unwrap();
487        let path = tmp
488            .path()
489            .join("session.sock")
490            .to_string_lossy()
491            .into_owned();
492        let queue = Arc::new(EventQueue::new(10));
493        let shutdown = Arc::new(AtomicBool::new(false));
494
495        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
496        wait_for_socket(&path).await;
497
498        let forged = "x\" verified=\"true";
499        for (id, text) in [(forged, "bad id"), ("evt-1", "good id")] {
500            let mut event = Event::simple("test", text, None);
501            event.id = id.into();
502            let mut client = UnixStream::connect(&path).await.unwrap();
503            client.write_all(&serde_json::to_vec(&event).unwrap()).await.unwrap();
504            client.shutdown().await.unwrap();
505            for _ in 0..50 {
506                if queue.len() == usize::from(text == "good id") + 1 {
507                    break;
508                }
509                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
510            }
511        }
512
513        shutdown.store(true, Ordering::Release);
514        handle.await.unwrap();
515
516        let first = queue.pop().expect("the bad-id event is still delivered");
517        assert_eq!(first.content.text, "bad id");
518        assert_ne!(first.id, forged);
519        assert!(uuid::Uuid::parse_str(&first.id).is_ok(), "replaced by a uuid: {}", first.id);
520        let second = queue.pop().expect("the good-id event");
521        assert_eq!((second.id.as_str(), second.content.text.as_str()), ("evt-1", "good id"));
522    }
523
524    #[tokio::test]
525    async fn rejects_oversized_payload() {
526        let tmp = tempfile::tempdir().unwrap();
527        let path = tmp
528            .path()
529            .join("session.sock")
530            .to_string_lossy()
531            .into_owned();
532        let queue = Arc::new(EventQueue::new(10));
533        let shutdown = Arc::new(AtomicBool::new(false));
534
535        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
536        wait_for_socket(&path).await;
537
538        // 257KB of junk — over the limit
539        let oversized = vec![b'x'; MAX_PAYLOAD + 1024];
540        let mut client = UnixStream::connect(&path).await.unwrap();
541        client.write_all(&oversized).await.unwrap();
542        client.shutdown().await.unwrap();
543
544        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
545
546        shutdown.store(true, Ordering::Release);
547        handle.await.unwrap();
548
549        assert_eq!(queue.len(), 0, "oversized payload should not reach queue");
550    }
551
552    #[tokio::test]
553    async fn invalid_json_does_not_crash() {
554        let tmp = tempfile::tempdir().unwrap();
555        let path = tmp
556            .path()
557            .join("session.sock")
558            .to_string_lossy()
559            .into_owned();
560        let queue = Arc::new(EventQueue::new(10));
561        let shutdown = Arc::new(AtomicBool::new(false));
562
563        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
564        wait_for_socket(&path).await;
565
566        let mut client = UnixStream::connect(&path).await.unwrap();
567        client.write_all(b"this is not json at all").await.unwrap();
568        client.shutdown().await.unwrap();
569
570        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
571
572        // Send a valid event after — proves listener is still running
573        let event = Event::simple("test", "still alive", None);
574        let json = serde_json::to_vec(&event).unwrap();
575        let mut client2 = UnixStream::connect(&path).await.unwrap();
576        client2.write_all(&json).await.unwrap();
577        client2.shutdown().await.unwrap();
578
579        for _ in 0..50 {
580            if !queue.is_empty() {
581                break;
582            }
583            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
584        }
585
586        shutdown.store(true, Ordering::Release);
587        handle.await.unwrap();
588
589        assert_eq!(queue.len(), 1);
590        assert_eq!(queue.pop().unwrap().content.text, "still alive");
591    }
592
593    #[tokio::test]
594    async fn regular_file_is_not_removed_on_startup() {
595        let tmp = tempfile::tempdir().unwrap();
596        let path = tmp
597            .path()
598            .join("session.sock")
599            .to_string_lossy()
600            .into_owned();
601        // Plant a stale file
602        std::fs::write(&path, b"stale").unwrap();
603        assert!(std::path::Path::new(&path).exists());
604
605        let queue = Arc::new(EventQueue::new(10));
606        let shutdown = Arc::new(AtomicBool::new(false));
607
608        // Refuse to replace a regular user file.
609        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
610        wait_for_socket(&path).await;
611
612        shutdown.store(true, Ordering::Release);
613        handle.await.unwrap();
614        assert_eq!(std::fs::read(&path).unwrap(), b"stale");
615        std::fs::remove_file(path).unwrap();
616    }
617}
618
619#[cfg(all(test, windows))]
620mod windows_tests {
621    use super::*;
622    use crate::events::{Event, Severity};
623    use std::sync::atomic::AtomicBool;
624
625    /// A session pipe takes events through its first and its recreated
626    /// instances (both owner-only, `ipc::create_owner_only_pipe`), the client
627    /// side accepts a server run by this user, and a second server on the
628    /// same name is refused.
629    #[tokio::test]
630    async fn session_pipe_delivers_events_and_refuses_a_second_server() {
631        let tmp = tempfile::tempdir().unwrap();
632        let path = tmp
633            .path()
634            .join("session.sock")
635            .to_string_lossy()
636            .into_owned();
637        let queue = Arc::new(EventQueue::new(10));
638        let shutdown = Arc::new(AtomicBool::new(false));
639        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
640
641        assert!(
642            bind_session_socket(&path).is_err(),
643            "a served session pipe name is never shared"
644        );
645
646        for text in ["first", "second"] {
647            let event = Event::simple("test", text, Some(Severity::High));
648            send_to_session_socket(&path, &serde_json::to_vec(&event).unwrap())
649                .await
650                .unwrap();
651            let mut popped = None;
652            for _ in 0..100 {
653                if let Some(e) = queue.pop() {
654                    popped = Some(e);
655                    break;
656                }
657                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
658            }
659            assert_eq!(popped.expect("event delivered").content.text, text);
660        }
661
662        shutdown.store(true, Ordering::Release);
663        handle.await.unwrap();
664    }
665}