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#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionRegistration {
9    pub session_id: String,
10    pub name: Option<String>,
11    pub socket_path: String,
12    pub pid: u32,
13    pub started_at: DateTime<Utc>,
14}
15
16/// Returns the session runtime directory, creating it mode 0700 if needed.
17///
18/// `SYNAPS_RUNTIME_DIR` deliberately controls only ephemeral Unix sockets and
19/// registration files. Persistent state and plugin discovery remain rooted at
20/// `SYNAPS_BASE_DIR`; this avoids the 108-byte `sun_path` limit when the base
21/// directory is an EFS session path.
22pub fn registry_dir() -> PathBuf {
23    let dir = std::env::var_os("SYNAPS_RUNTIME_DIR")
24        .map(PathBuf::from)
25        .unwrap_or_else(|| base_dir().join("run"));
26    if let Err(e) = std::fs::create_dir_all(&dir) {
27        tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
28    }
29    #[cfg(unix)]
30    {
31        use std::os::unix::fs::PermissionsExt;
32        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
33    }
34    dir
35}
36
37/// Sanitize a session ID for safe use in filenames and socket paths.
38/// Rejects path separators, `..`, and non-printable characters.
39/// Returns the sanitized string (replaces unsafe chars with `_`).
40pub fn sanitize_session_id(raw: &str) -> String {
41    // Only allow alphanumeric, hyphens, and underscores. Dots are not needed
42    // in session IDs (format is {name}-{timestamp}-{pid}) and allowing them
43    // complicates path traversal prevention (single-pass ".." replace is
44    // incomplete for "..." inputs).
45    raw.chars()
46        .map(|c| {
47            if c.is_alphanumeric() || c == '-' || c == '_' {
48                c
49            } else {
50                '_'
51            }
52        })
53        .collect::<String>()
54}
55
56/// Returns the Unix domain socket path for a session.
57/// Sockets live in the registry dir — `$SYNAPS_RUNTIME_DIR` when set (e.g.
58/// `/run/user/<uid>/synaps`), otherwise `$SYNAPS_BASE_DIR/run` (default
59/// `~/.synaps-cli/run/`) — which is user-owned and mode 0700, avoiding /tmp
60/// symlink squatting and TOCTOU races.
61pub fn socket_path_for_session(session_id: &str) -> String {
62    socket_path_in_dir(&registry_dir(), session_id)
63}
64
65fn socket_path_in_dir(dir: &std::path::Path, session_id: &str) -> String {
66    let safe_id = sanitize_session_id(session_id);
67    dir.join(format!("{}.sock", safe_id))
68        .to_string_lossy()
69        .into_owned()
70}
71
72/// Write `{session_id}.json` atomically (tmp + rename). Chmod 0600 on Unix.
73pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
74    register_session_in(reg, &registry_dir())
75}
76
77fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
78    let safe_id = sanitize_session_id(&reg.session_id);
79    let path = dir.join(format!("{}.json", safe_id));
80    let tmp = path.with_extension("tmp");
81
82    let json = serde_json::to_string(reg).map_err(|e| format!("serialize error: {}", e))?;
83
84    std::fs::write(&tmp, &json).map_err(|e| format!("write error: {}", e))?;
85
86    #[cfg(unix)]
87    {
88        use std::os::unix::fs::PermissionsExt;
89        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
90    }
91
92    std::fs::rename(&tmp, &path).map_err(|e| format!("rename error: {}", e))?;
93
94    Ok(())
95}
96
97/// Remove the registration file. Best-effort — never panics.
98/// Also removes the socket file at `socket_path` if it exists.
99pub fn unregister_session(session_id: &str) {
100    unregister_session_in(session_id, &registry_dir());
101}
102
103fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
104    let safe_id = sanitize_session_id(session_id);
105    let path = dir.join(format!("{}.json", safe_id));
106
107    // Load first so we can clean up the socket.
108    if let Ok(content) = std::fs::read_to_string(&path) {
109        if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
110            let sock = std::path::Path::new(&reg.socket_path);
111            // Only delete if socket_path is inside the registry dir — prevents
112            // a crafted JSON from causing arbitrary file deletion.
113            if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
114                let _ = std::fs::remove_file(sock);
115            }
116        }
117    }
118
119    let _ = std::fs::remove_file(&path);
120}
121
122/// Returns true if a process with `pid` is alive (Unix: `kill(pid, 0)`).
123fn pid_is_alive(pid: u32) -> bool {
124    #[cfg(unix)]
125    {
126        // SAFETY: kill with signal 0 never sends a signal; it only checks
127        // whether the process exists and we have permission to signal it.
128        let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
129        result == 0
130    }
131    #[cfg(not(unix))]
132    {
133        let _ = pid;
134        true
135    }
136}
137
138/// Read all registration files, prune stale ones (dead PID), return live set.
139pub fn list_active_sessions() -> Vec<SessionRegistration> {
140    list_active_sessions_in(&registry_dir())
141}
142
143fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
144    let Ok(entries) = std::fs::read_dir(dir) else {
145        return Vec::new();
146    };
147
148    let mut live = Vec::new();
149
150    for entry in entries.flatten() {
151        let path = entry.path();
152        if path.extension().is_some_and(|e| e == "json") {
153            let Ok(content) = std::fs::read_to_string(&path) else {
154                continue;
155            };
156            let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) else {
157                let _ = std::fs::remove_file(&path);
158                continue;
159            };
160
161            if pid_is_alive(reg.pid) {
162                live.push(reg);
163            } else {
164                let _ = std::fs::remove_file(std::path::Path::new(&reg.socket_path));
165                let _ = std::fs::remove_file(&path);
166            }
167        }
168    }
169
170    live
171}
172
173/// Resolve a query to a registration. Resolution order:
174/// 1. Exact session_id
175/// 2. Name match
176/// 3. Partial session_id prefix (unambiguous)
177pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
178    find_session_registration_in(query, &registry_dir())
179}
180
181fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
182    let sessions = list_active_sessions_in(dir);
183
184    // 1. Exact ID
185    if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
186        return Some(reg.clone());
187    }
188
189    // 2. Name match
190    if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
191        return Some(reg.clone());
192    }
193
194    // 3. Partial prefix — only if unambiguous
195    let matches: Vec<_> = sessions
196        .iter()
197        .filter(|r| r.session_id.starts_with(query))
198        .collect();
199
200    if matches.len() == 1 {
201        Some(matches[0].clone())
202    } else {
203        None
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use serial_test::serial;
211    use tempfile::TempDir;
212
213    fn tmp_registry() -> TempDir {
214        let dir = tempfile::tempdir().unwrap();
215        std::fs::create_dir_all(dir.path()).unwrap();
216        dir
217    }
218
219    fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
220        SessionRegistration {
221            session_id: id.to_string(),
222            name: name.map(|s| s.to_string()),
223            socket_path: socket_path_for_session(id),
224            pid,
225            started_at: Utc::now(),
226        }
227    }
228
229    fn dir_buf(tmp: &TempDir) -> PathBuf {
230        tmp.path().to_path_buf()
231    }
232
233    #[test]
234    fn register_creates_file() {
235        let tmp = tmp_registry();
236        let dir = dir_buf(&tmp);
237        let reg = make_reg("abc-1234", None, std::process::id());
238        register_session_in(&reg, &dir).unwrap();
239        assert!(dir.join("abc-1234.json").exists());
240    }
241
242    #[test]
243    fn list_returns_live_sessions() {
244        let tmp = tmp_registry();
245        let dir = dir_buf(&tmp);
246        let pid = std::process::id();
247        let reg = make_reg("live-0001", Some("my-agent"), pid);
248        register_session_in(&reg, &dir).unwrap();
249
250        let sessions = list_active_sessions_in(&dir);
251        assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
252    }
253
254    #[test]
255    fn find_by_exact_id() {
256        let tmp = tmp_registry();
257        let dir = dir_buf(&tmp);
258        let reg = make_reg("find-exact-01", None, std::process::id());
259        register_session_in(&reg, &dir).unwrap();
260
261        let found = find_session_registration_in("find-exact-01", &dir);
262        assert!(found.is_some());
263        assert_eq!(found.unwrap().session_id, "find-exact-01");
264    }
265
266    #[test]
267    fn find_by_name() {
268        let tmp = tmp_registry();
269        let dir = dir_buf(&tmp);
270        let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
271        register_session_in(&reg, &dir).unwrap();
272
273        let found = find_session_registration_in("prod-agent", &dir);
274        assert!(found.is_some());
275        assert_eq!(found.unwrap().session_id, "named-session-01");
276    }
277
278    #[test]
279    fn find_by_partial_prefix() {
280        let tmp = tmp_registry();
281        let dir = dir_buf(&tmp);
282        let reg = make_reg("prefix-abcdef-01", None, std::process::id());
283        register_session_in(&reg, &dir).unwrap();
284
285        let found = find_session_registration_in("prefix-abc", &dir);
286        assert!(found.is_some());
287        assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
288    }
289
290    #[test]
291    fn ambiguous_prefix_returns_none() {
292        let tmp = tmp_registry();
293        let dir = dir_buf(&tmp);
294        let pid = std::process::id();
295        register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
296        register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
297
298        let found = find_session_registration_in("dup-aaaa", &dir);
299        assert!(found.is_none(), "ambiguous prefix should return None");
300    }
301
302    #[test]
303    fn unregister_removes_file() {
304        let tmp = tmp_registry();
305        let dir = dir_buf(&tmp);
306        let reg = make_reg("unreg-0001", None, std::process::id());
307        register_session_in(&reg, &dir).unwrap();
308
309        let path = dir.join("unreg-0001.json");
310        assert!(path.exists());
311
312        unregister_session_in("unreg-0001", &dir);
313        assert!(!path.exists());
314    }
315
316    #[test]
317    fn unregister_is_idempotent() {
318        let tmp = tmp_registry();
319        let dir = dir_buf(&tmp);
320        // Should not panic even if the file was never registered
321        unregister_session_in("ghost-session-99", &dir);
322    }
323
324    #[test]
325    fn stale_pid_pruned() {
326        let tmp = tmp_registry();
327        let dir = dir_buf(&tmp);
328        // PID 999999 is effectively guaranteed to not exist
329        let reg = make_reg("stale-dead-pid", None, 999999);
330        register_session_in(&reg, &dir).unwrap();
331
332        let sessions = list_active_sessions_in(&dir);
333        assert!(
334            !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
335            "stale registration should have been pruned"
336        );
337
338        // File should also be gone
339        assert!(!dir.join("stale-dead-pid.json").exists());
340    }
341
342    #[test]
343    // Keyed to the same lock as every HOME/SYNAPS_BASE_DIR mutator: unkeyed
344    // `#[serial]` and `#[serial(synaps_base_dir)]` do NOT exclude each other,
345    // so this base-dir *reader* raced mutator tests that point
346    // SYNAPS_BASE_DIR at a tempdir under /tmp.
347    #[serial(synaps_base_dir)]
348    fn socket_path_format() {
349        let path = socket_path_for_session("20240101-120000-ab12");
350        // Sockets now live in the registry dir, not /tmp
351        assert!(
352            path.ends_with("/run/20240101-120000-ab12.sock"),
353            "got: {}",
354            path
355        );
356        assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
357    }
358
359    // Regression: an EFS-backed SYNAPS_BASE_DIR can exceed Linux's 108-byte
360    // sockaddr_un.sun_path limit. Runtime sockets must be independently rooted.
361    #[cfg(unix)]
362    #[test]
363    fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
364        use std::os::unix::net::UnixListener;
365        let long_session = "s".repeat(80);
366        // These model deterministic per-UID runtime roots created by guest-agent.
367        let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
368        let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
369        assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
370        assert_ne!(
371            path_a, path_b,
372            "per-user runtime roots must isolate same session ids"
373        );
374
375        // Bind under SHORT, /tmp-rooted dirs. The whole point is short socket
376        // paths; some platforms' tempdir() (e.g. macOS /var/folders/...) is long
377        // enough to blow the sun_path limit and flake this exact assertion, so we
378        // control the root length explicitly. Two sibling dirs model two per-uid
379        // runtime roots.
380        let pid = std::process::id();
381        let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
382        let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
383        std::fs::create_dir_all(&a).unwrap();
384        std::fs::create_dir_all(&b).unwrap();
385        let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
386        let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
387        let _ = std::fs::remove_dir_all(&a);
388        let _ = std::fs::remove_dir_all(&b);
389    }
390
391    #[cfg(unix)]
392    #[test]
393    fn registration_file_is_0600() {
394        use std::os::unix::fs::PermissionsExt;
395        let tmp = tmp_registry();
396        let dir = dir_buf(&tmp);
397        let reg = make_reg("perms-check-01", None, std::process::id());
398        register_session_in(&reg, &dir).unwrap();
399
400        let path = dir.join("perms-check-01.json");
401        let perms = std::fs::metadata(&path).unwrap().permissions();
402        assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
403    }
404}