Skip to main content

agent_engine/events/
registry.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::config::base_dir;
6
7/// Discriminator written into every registration so the registry can tell
8/// its own files from other `*.json` sharing the run dir (`daemon.json`,
9/// tooling drops). Files missing it (older binaries) are treated as sessions.
10pub const REGISTRATION_KIND: &str = "session";
11
12fn default_kind() -> String {
13    REGISTRATION_KIND.to_string()
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionRegistration {
18    /// Always `"session"`; see [`REGISTRATION_KIND`].
19    #[serde(default = "default_kind")]
20    pub kind: String,
21    pub session_id: String,
22    pub name: Option<String>,
23    pub socket_path: String,
24    pub pid: u32,
25    pub started_at: DateTime<Utc>,
26}
27
28/// Returns the session runtime directory, creating it mode 0700 if needed.
29///
30/// `SYNAPS_RUNTIME_DIR` deliberately controls only ephemeral Unix sockets and
31/// registration files. Persistent state and plugin discovery remain rooted at
32/// `SYNAPS_BASE_DIR`; this avoids the 108-byte `sun_path` limit when the base
33/// directory is an EFS session path.
34pub fn registry_dir() -> PathBuf {
35    let dir = std::env::var_os("SYNAPS_RUNTIME_DIR")
36        .map(PathBuf::from)
37        .unwrap_or_else(|| base_dir().join("run"));
38    if let Err(e) = std::fs::create_dir_all(&dir) {
39        tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
40    }
41    #[cfg(unix)]
42    {
43        use std::os::unix::fs::PermissionsExt;
44        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
45    }
46    dir
47}
48
49/// Sanitize a session ID for safe use in filenames and socket paths.
50/// Rejects path separators, `..`, and non-printable characters.
51/// Returns the sanitized string (replaces unsafe chars with `_`).
52pub fn sanitize_session_id(raw: &str) -> String {
53    // Only allow alphanumeric, hyphens, and underscores. Dots are not needed
54    // in session IDs (format is {name}-{timestamp}-{pid}) and allowing them
55    // complicates path traversal prevention (single-pass ".." replace is
56    // incomplete for "..." inputs).
57    raw.chars()
58        .map(|c| {
59            if c.is_alphanumeric() || c == '-' || c == '_' {
60                c
61            } else {
62                '_'
63            }
64        })
65        .collect::<String>()
66}
67
68/// Returns the Unix domain socket path for a session.
69/// Sockets live in the registry dir — `$SYNAPS_RUNTIME_DIR` when set (e.g.
70/// `/run/user/<uid>/synaps`), otherwise `$SYNAPS_BASE_DIR/run` (default
71/// `~/.synaps-cli/run/`) — which is user-owned and mode 0700, avoiding /tmp
72/// symlink squatting and TOCTOU races.
73pub fn socket_path_for_session(session_id: &str) -> String {
74    socket_path_in_dir(&registry_dir(), session_id)
75}
76
77fn socket_path_in_dir(dir: &std::path::Path, session_id: &str) -> String {
78    let safe_id = sanitize_session_id(session_id);
79    dir.join(format!("{}.sock", safe_id))
80        .to_string_lossy()
81        .into_owned()
82}
83
84/// Write `{session_id}.json` atomically (tmp + rename). Chmod 0600 on Unix.
85pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
86    register_session_in(reg, &registry_dir())
87}
88
89fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
90    let safe_id = sanitize_session_id(&reg.session_id);
91    let path = dir.join(format!("{}.json", safe_id));
92    let tmp = path.with_extension("tmp");
93
94    let json = serde_json::to_string(reg).map_err(|e| format!("serialize error: {}", e))?;
95
96    std::fs::write(&tmp, &json).map_err(|e| format!("write error: {}", e))?;
97
98    #[cfg(unix)]
99    {
100        use std::os::unix::fs::PermissionsExt;
101        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
102    }
103
104    std::fs::rename(&tmp, &path).map_err(|e| format!("rename error: {}", e))?;
105
106    Ok(())
107}
108
109/// Rewrite `{session_id}.json` with a new `name` (rename / `saveas`). The
110/// rest of the record (socket, pid, started_at) is kept. Errors when there
111/// is no registration to update.
112pub fn update_session_name(session_id: &str, name: Option<&str>) -> Result<(), String> {
113    update_session_name_in(session_id, name, &registry_dir())
114}
115
116fn update_session_name_in(session_id: &str, name: Option<&str>, dir: &std::path::Path) -> Result<(), String> {
117    let safe_id = sanitize_session_id(session_id);
118    let path = dir.join(format!("{}.json", safe_id));
119    let content = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?;
120    let mut reg: SessionRegistration =
121        serde_json::from_str(&content).map_err(|e| format!("parse {}: {}", path.display(), e))?;
122    reg.name = name.map(str::to_string);
123    register_session_in(&reg, dir)
124}
125
126/// Remove the registration file. Best-effort — never panics.
127/// Also removes the socket file at `socket_path` if it exists.
128pub fn unregister_session(session_id: &str) {
129    unregister_session_in(session_id, &registry_dir());
130}
131
132fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
133    let safe_id = sanitize_session_id(session_id);
134    let path = dir.join(format!("{}.json", safe_id));
135
136    // Load first so we can clean up the socket.
137    if let Ok(content) = std::fs::read_to_string(&path) {
138        if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
139            let sock = std::path::Path::new(&reg.socket_path);
140            // Only delete if socket_path is inside the registry dir — prevents
141            // a crafted JSON from causing arbitrary file deletion.
142            if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
143                let _ = std::fs::remove_file(sock);
144            }
145        }
146    }
147
148    let _ = std::fs::remove_file(&path);
149}
150
151/// Returns true if a process with `pid` is alive (Unix: `kill(pid, 0)`).
152fn pid_is_alive(pid: u32) -> bool {
153    #[cfg(unix)]
154    {
155        // SAFETY: kill with signal 0 never sends a signal; it only checks
156        // whether the process exists and we have permission to signal it.
157        let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
158        result == 0
159    }
160    #[cfg(not(unix))]
161    {
162        let _ = pid;
163        true
164    }
165}
166
167/// Files in the run dir that are never session registrations, whatever
168/// their contents: the daemon's own `daemon.json` / `daemon-<profile>.json`.
169fn is_reserved_json(path: &std::path::Path) -> bool {
170    path.file_stem()
171        .and_then(|s| s.to_str())
172        .is_some_and(|stem| stem == "daemon" || stem.starts_with("daemon-"))
173}
174
175/// Read all registration files, prune stale ones (dead PID), return live set.
176///
177/// Only files this registry wrote are ever unlinked: a `*.json` that is not
178/// a `SessionRegistration` (parse failure, or `kind != "session"`) is logged
179/// and skipped — `daemon.json` lives in the same dir and `synaps send` must
180/// never destroy it.
181pub fn list_active_sessions() -> Vec<SessionRegistration> {
182    list_active_sessions_in(&registry_dir())
183}
184
185fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
186    let Ok(entries) = std::fs::read_dir(dir) else {
187        return Vec::new();
188    };
189
190    let mut live = Vec::new();
191
192    for entry in entries.flatten() {
193        let path = entry.path();
194        if !path.extension().is_some_and(|e| e == "json") || is_reserved_json(&path) {
195            continue;
196        }
197        let Ok(content) = std::fs::read_to_string(&path) else {
198            continue;
199        };
200        let reg = match serde_json::from_str::<SessionRegistration>(&content) {
201            Ok(reg) if reg.kind == REGISTRATION_KIND => reg,
202            Ok(reg) => {
203                tracing::debug!(path = %path.display(), kind = %reg.kind, "registry: not a session registration — skipped");
204                continue;
205            }
206            Err(e) => {
207                tracing::debug!(path = %path.display(), error = %e, "registry: unparseable json — skipped, not ours");
208                continue;
209            }
210        };
211
212        if pid_is_alive(reg.pid) {
213            live.push(reg);
214        } else {
215            // Dead owner: prune the registration and its socket, but only a
216            // socket inside our dir (a crafted file must not delete elsewhere).
217            let sock = std::path::Path::new(&reg.socket_path);
218            if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
219                let _ = std::fs::remove_file(sock);
220            }
221            let _ = std::fs::remove_file(&path);
222        }
223    }
224
225    live
226}
227
228/// Resolve a query to a registration. Resolution order:
229/// 1. Exact session_id
230/// 2. Name match
231/// 3. Partial session_id prefix (unambiguous)
232pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
233    find_session_registration_in(query, &registry_dir())
234}
235
236fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
237    let sessions = list_active_sessions_in(dir);
238
239    // 1. Exact ID
240    if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
241        return Some(reg.clone());
242    }
243
244    // 2. Name match
245    if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
246        return Some(reg.clone());
247    }
248
249    // 3. Partial prefix — only if unambiguous
250    let matches: Vec<_> = sessions
251        .iter()
252        .filter(|r| r.session_id.starts_with(query))
253        .collect();
254
255    if matches.len() == 1 {
256        Some(matches[0].clone())
257    } else {
258        None
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serial_test::serial;
266    use tempfile::TempDir;
267
268    fn tmp_registry() -> TempDir {
269        let dir = tempfile::tempdir().unwrap();
270        std::fs::create_dir_all(dir.path()).unwrap();
271        dir
272    }
273
274    fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
275        SessionRegistration {
276            kind: REGISTRATION_KIND.to_string(),
277            session_id: id.to_string(),
278            name: name.map(|s| s.to_string()),
279            socket_path: socket_path_for_session(id),
280            pid,
281            started_at: Utc::now(),
282        }
283    }
284
285    fn dir_buf(tmp: &TempDir) -> PathBuf {
286        tmp.path().to_path_buf()
287    }
288
289    #[test]
290    fn register_creates_file() {
291        let tmp = tmp_registry();
292        let dir = dir_buf(&tmp);
293        let reg = make_reg("abc-1234", None, std::process::id());
294        register_session_in(&reg, &dir).unwrap();
295        assert!(dir.join("abc-1234.json").exists());
296    }
297
298    #[test]
299    fn list_returns_live_sessions() {
300        let tmp = tmp_registry();
301        let dir = dir_buf(&tmp);
302        let pid = std::process::id();
303        let reg = make_reg("live-0001", Some("my-agent"), pid);
304        register_session_in(&reg, &dir).unwrap();
305
306        let sessions = list_active_sessions_in(&dir);
307        assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
308    }
309
310    #[test]
311    fn find_by_exact_id() {
312        let tmp = tmp_registry();
313        let dir = dir_buf(&tmp);
314        let reg = make_reg("find-exact-01", None, std::process::id());
315        register_session_in(&reg, &dir).unwrap();
316
317        let found = find_session_registration_in("find-exact-01", &dir);
318        assert!(found.is_some());
319        assert_eq!(found.unwrap().session_id, "find-exact-01");
320    }
321
322    #[test]
323    fn find_by_name() {
324        let tmp = tmp_registry();
325        let dir = dir_buf(&tmp);
326        let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
327        register_session_in(&reg, &dir).unwrap();
328
329        let found = find_session_registration_in("prod-agent", &dir);
330        assert!(found.is_some());
331        assert_eq!(found.unwrap().session_id, "named-session-01");
332    }
333
334    #[test]
335    fn find_by_partial_prefix() {
336        let tmp = tmp_registry();
337        let dir = dir_buf(&tmp);
338        let reg = make_reg("prefix-abcdef-01", None, std::process::id());
339        register_session_in(&reg, &dir).unwrap();
340
341        let found = find_session_registration_in("prefix-abc", &dir);
342        assert!(found.is_some());
343        assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
344    }
345
346    #[test]
347    fn ambiguous_prefix_returns_none() {
348        let tmp = tmp_registry();
349        let dir = dir_buf(&tmp);
350        let pid = std::process::id();
351        register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
352        register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
353
354        let found = find_session_registration_in("dup-aaaa", &dir);
355        assert!(found.is_none(), "ambiguous prefix should return None");
356    }
357
358    /// Bug: `synaps send` used to unlink every `run/*.json` it could not
359    /// parse as a registration — `daemon.json` included.
360    #[test]
361    fn foreign_json_is_never_unlinked() {
362        let tmp = tmp_registry();
363        let dir = dir_buf(&tmp);
364        let daemon = dir.join("daemon.json");
365        std::fs::write(
366            &daemon,
367            r#"{"pid":1,"version":"0.9.0","exe":"/usr/bin/synaps","socket":"/x/daemon.sock","protocol":2}"#,
368        )
369        .unwrap();
370        let garbage = dir.join("garbage.json");
371        std::fs::write(&garbage, "this is not json {").unwrap();
372        // Parses as a registration structurally but declares another kind.
373        let other = dir.join("other-kind.json");
374        std::fs::write(
375            &other,
376            r#"{"kind":"widget","session_id":"x","name":null,"socket_path":"/x.sock","pid":1,"started_at":"2024-01-01T00:00:00Z"}"#,
377        )
378        .unwrap();
379        // A legacy registration without `kind` still counts as a session.
380        let legacy = dir.join("legacy-0001.json");
381        std::fs::write(
382            &legacy,
383            format!(
384                r#"{{"session_id":"legacy-0001","name":"old","socket_path":"{}","pid":{},"started_at":"2024-01-01T00:00:00Z"}}"#,
385                dir.join("legacy-0001.sock").display(),
386                std::process::id()
387            ),
388        )
389        .unwrap();
390        let reg = make_reg("live-0002", Some("ambient"), std::process::id());
391        register_session_in(&reg, &dir).unwrap();
392
393        for _ in 0..2 {
394            let found = find_session_registration_in("ambient", &dir).expect("session resolved");
395            assert_eq!(found.session_id, "live-0002");
396            assert!(find_session_registration_in("old", &dir).is_some(), "legacy reg counts");
397            let ids: Vec<_> = list_active_sessions_in(&dir).into_iter().map(|r| r.session_id).collect();
398            assert!(!ids.contains(&"x".to_string()), "foreign kind not listed");
399            assert!(daemon.exists(), "daemon.json must survive");
400            assert!(garbage.exists(), "garbage.json must survive");
401            assert!(other.exists(), "other-kind.json must survive");
402            assert!(legacy.exists());
403        }
404        assert!(std::fs::read_to_string(&daemon).unwrap().contains("0.9.0"));
405    }
406
407    /// Bug: the name was frozen at create — `saveas` never reached the
408    /// registry, so `synaps send --session <name>` missed until a
409    /// `--continue`.
410    #[test]
411    fn update_name_rewrites_registration() {
412        let tmp = tmp_registry();
413        let dir = dir_buf(&tmp);
414        let reg = make_reg("rename-0001", None, std::process::id());
415        register_session_in(&reg, &dir).unwrap();
416        assert!(find_session_registration_in("ambient", &dir).is_none());
417
418        update_session_name_in("rename-0001", Some("ambient"), &dir).unwrap();
419        let found = find_session_registration_in("ambient", &dir).expect("resolves by new name");
420        assert_eq!(found.session_id, "rename-0001");
421        assert_eq!(found.socket_path, reg.socket_path, "rest of the record kept");
422
423        update_session_name_in("rename-0001", None, &dir).unwrap();
424        assert!(find_session_registration_in("ambient", &dir).is_none(), "cleared");
425        assert!(update_session_name_in("ghost", Some("x"), &dir).is_err(), "no reg → error");
426    }
427
428    #[test]
429    fn unregister_removes_file() {
430        let tmp = tmp_registry();
431        let dir = dir_buf(&tmp);
432        let reg = make_reg("unreg-0001", None, std::process::id());
433        register_session_in(&reg, &dir).unwrap();
434
435        let path = dir.join("unreg-0001.json");
436        assert!(path.exists());
437
438        unregister_session_in("unreg-0001", &dir);
439        assert!(!path.exists());
440    }
441
442    #[test]
443    fn unregister_is_idempotent() {
444        let tmp = tmp_registry();
445        let dir = dir_buf(&tmp);
446        // Should not panic even if the file was never registered
447        unregister_session_in("ghost-session-99", &dir);
448    }
449
450    #[test]
451    fn stale_pid_pruned() {
452        let tmp = tmp_registry();
453        let dir = dir_buf(&tmp);
454        // PID 999999 is effectively guaranteed to not exist
455        let reg = make_reg("stale-dead-pid", None, 999999);
456        register_session_in(&reg, &dir).unwrap();
457
458        let sessions = list_active_sessions_in(&dir);
459        assert!(
460            !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
461            "stale registration should have been pruned"
462        );
463
464        // File should also be gone
465        assert!(!dir.join("stale-dead-pid.json").exists());
466    }
467
468    #[test]
469    // Keyed to the same lock as every HOME/SYNAPS_BASE_DIR mutator: unkeyed
470    // `#[serial]` and `#[serial(synaps_base_dir)]` do NOT exclude each other,
471    // so this base-dir *reader* raced mutator tests that point
472    // SYNAPS_BASE_DIR at a tempdir under /tmp.
473    #[serial(synaps_base_dir)]
474    fn socket_path_format() {
475        let path = socket_path_for_session("20240101-120000-ab12");
476        // Sockets now live in the registry dir, not /tmp
477        assert!(
478            path.ends_with("/run/20240101-120000-ab12.sock"),
479            "got: {}",
480            path
481        );
482        assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
483    }
484
485    // Regression: an EFS-backed SYNAPS_BASE_DIR can exceed Linux's 108-byte
486    // sockaddr_un.sun_path limit. Runtime sockets must be independently rooted.
487    #[cfg(unix)]
488    #[test]
489    fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
490        use std::os::unix::net::UnixListener;
491        let long_session = "s".repeat(80);
492        // These model deterministic per-UID runtime roots created by guest-agent.
493        let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
494        let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
495        assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
496        assert_ne!(
497            path_a, path_b,
498            "per-user runtime roots must isolate same session ids"
499        );
500
501        // Bind under SHORT, /tmp-rooted dirs. The whole point is short socket
502        // paths; some platforms' tempdir() (e.g. macOS /var/folders/...) is long
503        // enough to blow the sun_path limit and flake this exact assertion, so we
504        // control the root length explicitly. Two sibling dirs model two per-uid
505        // runtime roots.
506        let pid = std::process::id();
507        let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
508        let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
509        std::fs::create_dir_all(&a).unwrap();
510        std::fs::create_dir_all(&b).unwrap();
511        let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
512        let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
513        let _ = std::fs::remove_dir_all(&a);
514        let _ = std::fs::remove_dir_all(&b);
515    }
516
517    #[cfg(unix)]
518    #[test]
519    fn registration_file_is_0600() {
520        use std::os::unix::fs::PermissionsExt;
521        let tmp = tmp_registry();
522        let dir = dir_buf(&tmp);
523        let reg = make_reg("perms-check-01", None, std::process::id());
524        register_session_in(&reg, &dir).unwrap();
525
526        let path = dir.join("perms-check-01.json");
527        let perms = std::fs::metadata(&path).unwrap().permissions();
528        assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
529    }
530}
531
532// ── daemon paths (Phase 2 B) ──────────────────────────────────────────────────
533
534/// Files the `synaps daemon` keeps under `registry_dir()` (0700). One daemon
535/// per profile: `daemon.{sock,lock,json,pid}` or `daemon-<P>.{…}`.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct DaemonPaths {
538    pub dir: PathBuf,
539    pub sock: PathBuf,
540    pub lock: PathBuf,
541    pub json: PathBuf,
542    pub pid: PathBuf,
543}
544
545/// Daemon file paths for a profile, under `registry_dir()`.
546pub fn daemon_paths(profile: Option<&str>) -> DaemonPaths {
547    daemon_paths_in(&registry_dir(), profile)
548}
549
550/// Same, rooted at an explicit dir (tests; `--socket` overrides only `.sock`).
551pub fn daemon_paths_in(dir: &std::path::Path, profile: Option<&str>) -> DaemonPaths {
552    let stem = match profile.map(sanitize_session_id).filter(|p| !p.is_empty()) {
553        Some(p) => format!("daemon-{p}"),
554        None => "daemon".to_string(),
555    };
556    DaemonPaths {
557        dir: dir.to_path_buf(),
558        sock: dir.join(format!("{stem}.sock")),
559        lock: dir.join(format!("{stem}.lock")),
560        json: dir.join(format!("{stem}.json")),
561        pid: dir.join(format!("{stem}.pid")),
562    }
563}
564
565#[cfg(test)]
566mod daemon_paths_tests {
567    use super::*;
568
569    #[test]
570    fn daemon_paths_are_profile_scoped_and_sanitised() {
571        let d = std::path::Path::new("/run/x");
572        let p = daemon_paths_in(d, None);
573        assert_eq!(p.sock, d.join("daemon.sock"));
574        assert_eq!(p.lock, d.join("daemon.lock"));
575        assert_eq!(p.json, d.join("daemon.json"));
576        assert_eq!(p.pid, d.join("daemon.pid"));
577        let p = daemon_paths_in(d, Some("work/../evil"));
578        assert_eq!(p.sock, d.join("daemon-work____evil.sock"));
579    }
580}