Skip to main content

agent_engine/events/
registry.rs

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