Skip to main content

agent_engine/
process_scope.rs

1//! Run child processes in their own systemd scope (Linux).
2//!
3//! systemd-oomd, under memory pressure, kills a whole cgroup at once, choosing
4//! the one using the most swap. Without this, everything Synaps starts lives
5//! in the cgroup of whatever started Synaps — usually a terminal's scope. A
6//! single runaway tool command (S348: a 270-file test run from the bash tool)
7//! then makes that whole scope the biggest, and systemd-oomd kills it: the
8//! command, the daemon, every session it hosts, and the terminal.
9//!
10//! A scope gives each child a separate cgroup, not a memory cap. An ancestor
11//! cgroup OOM or global kernel OOM can still kill the daemon and other scopes.
12//! Windows isolation is not implemented.
13//!
14//! `systemd-run --user --scope` registers a transient scope for its own pid
15//! and then execs the command in place: same pid, process group, session,
16//! environment, inherited fds and stdio. Group cleanup covers descendants that
17//! remain in that group. Each launch first performs a bounded fresh probe.
18//!
19//! Used only when it is available (Linux, a reachable systemd user manager)
20//! and not turned off (`process_scopes = off` in config, or
21//! `SYNAPS_PROCESS_SCOPES=off`). Otherwise commands start exactly as before.
22
23use std::ffi::OsString;
24use std::path::PathBuf;
25#[cfg(target_os = "linux")]
26use std::sync::OnceLock;
27
28/// Turns `program args…` into `systemd-run --user --scope … -- program args…`.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct ScopeLauncher {
31    systemd_run: PathBuf,
32}
33
34impl ScopeLauncher {
35    /// The program to spawn and its full argument list. `description` names
36    /// the scope in `systemctl --user list-units --type=scope`.
37    pub fn wrap(
38        &self,
39        description: &str,
40        program: impl Into<OsString>,
41        args: impl IntoIterator<Item = OsString>,
42    ) -> (OsString, Vec<OsString>) {
43        let mut argv: Vec<OsString> = vec![
44            "--user".into(),
45            "--scope".into(),
46            "--quiet".into(),
47            // Garbage-collect the unit even if the command fails, so failed
48            // commands do not accumulate as failed units.
49            "--collect".into(),
50            format!("--description={description}").into(),
51            "--".into(),
52            program.into(),
53        ];
54        argv.extend(args);
55        (self.systemd_run.clone().into_os_string(), argv)
56    }
57}
58
59/// `true` unless turned off. The environment variable wins over config so it
60/// can be flipped for one process without editing the config file.
61fn enabled_from(env_value: Option<&str>, config_value: bool) -> bool {
62    match env_value.map(|v| v.trim().to_ascii_lowercase()) {
63        Some(v) if matches!(v.as_str(), "0" | "off" | "false" | "no") => false,
64        Some(v) if matches!(v.as_str(), "1" | "on" | "true" | "yes" | "auto") => true,
65        _ => config_value,
66    }
67}
68
69/// Snapshot inherited environment before applying child-specific overrides.
70pub fn inherited_env() -> Vec<(OsString, OsString)> {
71    std::env::vars_os().collect()
72}
73
74pub fn set_env(env: &mut Vec<(OsString, OsString)>, key: &str, value: impl Into<OsString>) {
75    env.retain(|(k, _)| k != key);
76    env.push((key.into(), value.into()));
77}
78
79#[cfg(target_os = "linux")]
80fn find_systemd_run() -> Option<PathBuf> {
81    // Discovery alone is cached. Never cache manager availability or consult
82    // a tool-controlled PATH to choose the isolation launcher.
83    ["/usr/bin/systemd-run", "/bin/systemd-run"]
84        .into_iter()
85        .map(PathBuf::from)
86        .find(|p| p.is_file())
87}
88
89/// Start only a throwaway scope, with the exact child environment and cwd.
90/// Bounded to three seconds; on timeout kill and reap the probe child.
91#[cfg(target_os = "linux")]
92fn probe(
93    systemd_run: &std::path::Path,
94    env: &[(OsString, OsString)],
95    cwd: Option<&std::path::Path>,
96) -> bool {
97    use std::time::{Duration, Instant};
98    if !env
99        .iter()
100        .any(|(k, v)| (k == "XDG_RUNTIME_DIR" || k == "DBUS_SESSION_BUS_ADDRESS") && !v.is_empty())
101    {
102        return false;
103    }
104    use std::os::unix::process::CommandExt;
105    let mut cmd = std::process::Command::new(systemd_run);
106    // Probe descendants must not survive a timeout. No real payload runs here.
107    unsafe {
108        cmd.pre_exec(|| {
109            if libc::setsid() < 0 {
110                return Err(std::io::Error::last_os_error());
111            }
112            Ok(())
113        });
114    }
115    cmd.env_clear().envs(env.iter().cloned());
116    if let Some(cwd) = cwd {
117        cmd.current_dir(cwd);
118    }
119    let Ok(mut child) = cmd
120        .args([
121            "--user",
122            "--scope",
123            "--quiet",
124            "--collect",
125            "--description=synaps scope probe",
126            "--",
127            "/bin/true",
128        ])
129        .stdin(std::process::Stdio::null())
130        .stdout(std::process::Stdio::null())
131        .stderr(std::process::Stdio::null())
132        .spawn()
133    else {
134        return false;
135    };
136    let deadline = Instant::now() + Duration::from_secs(3);
137    loop {
138        match child.try_wait() {
139            Ok(Some(status)) => return status.success(),
140            Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
141            _ => {
142                // Signal before any reap; the unreaped leader pins this ID.
143                unsafe {
144                    libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
145                }
146                let _ = child.kill();
147                let cleanup_deadline = Instant::now() + Duration::from_millis(100);
148                while matches!(child.try_wait(), Ok(None)) && Instant::now() < cleanup_deadline {
149                    std::thread::sleep(Duration::from_millis(5));
150                }
151                // SIGKILL cannot bound kernel D-state. Reap eventually off the
152                // launch path rather than blocking the caller indefinitely.
153                std::thread::spawn(move || {
154                    let _ = child.wait();
155                });
156                return false;
157            }
158        }
159    }
160}
161
162/// Fresh, bounded availability check. Synchronous callers (PTY and detached
163/// daemon spawn) may wait up to three seconds. Async callers must offload it.
164/// Probe failure selects direct launch. After success, wrapper failure is an
165/// error, NEVER a reason to replay the command: availability can race, and the
166/// wrapped command may already have executed. This is not a launch handshake.
167pub fn launcher_for_effective_env(
168    env: &[(OsString, OsString)],
169    cwd: Option<&std::path::Path>,
170) -> Option<ScopeLauncher> {
171    // Test-support builds must not reach a developer's user manager merely
172    // because ordinary bash/PTY tests inherit a desktop environment. Synthetic
173    // launcher tests call launcher_with_binary directly; real smoke is opt-in.
174    #[cfg(any(test, feature = "testing"))]
175    if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") {
176        return None;
177    }
178    // Operator policy belongs to the host, not the tool's rebuilt env. A
179    // child cannot enable scopes behind the host's explicit opt-out.
180    if !enabled_from(
181        std::env::var("SYNAPS_PROCESS_SCOPES").ok().as_deref(),
182        crate::config::load_config().process_scopes,
183    ) {
184        return None;
185    }
186    #[cfg(target_os = "linux")]
187    {
188        static BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
189        let binary = BINARY.get_or_init(find_systemd_run).as_ref()?;
190        launcher_with_binary(binary, env, cwd)
191    }
192    #[cfg(not(target_os = "linux"))]
193    {
194        let _ = cwd;
195        None
196    }
197}
198
199#[cfg(target_os = "linux")]
200fn launcher_with_binary(
201    binary: &std::path::Path,
202    env: &[(OsString, OsString)],
203    cwd: Option<&std::path::Path>,
204) -> Option<ScopeLauncher> {
205    probe(binary, env, cwd).then(|| ScopeLauncher {
206        systemd_run: binary.to_owned(),
207    })
208}
209
210/// Compatibility convenience for inherited or fully rebuilt environments.
211pub fn launcher_for_env(env: Option<&[(String, String)]>) -> Option<ScopeLauncher> {
212    let env = env
213        .map(|e| e.iter().map(|(k, v)| (k.into(), v.into())).collect())
214        .unwrap_or_else(inherited_env);
215    launcher_for_effective_env(&env, None)
216}
217
218/// Resolve before wrapping: systemd-run must not substitute its own executable
219/// search rules for the child's PATH/cwd. Missing programs remain spawn errors.
220pub fn resolve_program(
221    program: OsString,
222    env: &[(OsString, OsString)],
223    cwd: Option<&std::path::Path>,
224) -> std::io::Result<OsString> {
225    #[cfg(unix)]
226    {
227        use std::os::unix::fs::PermissionsExt;
228        let base = cwd
229            .map(PathBuf::from)
230            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
231        let base = if base.is_absolute() {
232            base
233        } else {
234            std::env::current_dir().unwrap_or_default().join(base)
235        };
236        if std::path::Path::new(&program).components().count() > 1 {
237            return Ok(base.join(program).into_os_string());
238        }
239        let path = env
240            .iter()
241            .find(|(k, _)| k == "PATH")
242            .map(|(_, v)| v.clone())
243            .unwrap_or_else(|| "/bin:/usr/bin".into());
244        for dir in std::env::split_paths(&path) {
245            let candidate = base.join(dir).join(&program);
246            if candidate
247                .metadata()
248                .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
249            {
250                return Ok(candidate.into_os_string());
251            }
252        }
253    }
254    Err(std::io::Error::new(
255        std::io::ErrorKind::NotFound,
256        format!("program not found in child PATH: {program:?}"),
257    ))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn wrap_puts_the_command_after_systemd_run_options() {
266        let l = ScopeLauncher {
267            systemd_run: PathBuf::from("/usr/bin/systemd-run"),
268        };
269        let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), "echo hi".into()]);
270        assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
271        let args: Vec<String> = args
272            .iter()
273            .map(|a| a.to_string_lossy().into_owned())
274            .collect();
275        assert_eq!(
276            args,
277            [
278                "--user",
279                "--scope",
280                "--quiet",
281                "--collect",
282                "--description=synaps tool: bash",
283                "--",
284                "bash",
285                "-c",
286                "echo hi",
287            ]
288        );
289    }
290
291    #[test]
292    fn ordinary_test_build_does_not_probe_host_manager() {
293        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() == Ok("1") {
294            return;
295        }
296        let env = vec![(
297            "DBUS_SESSION_BUS_ADDRESS".into(),
298            "fake:must-not-probe".into(),
299        )];
300        assert!(launcher_for_effective_env(&env, None).is_none());
301    }
302
303    #[test]
304    fn env_variable_overrides_config_and_unknown_values_fall_back_to_config() {
305        assert!(enabled_from(None, true));
306        assert!(!enabled_from(None, false));
307        for off in ["0", "off", "OFF", " false ", "no"] {
308            assert!(!enabled_from(Some(off), true), "{off:?}");
309        }
310        for on in ["1", "on", "true", "yes", "auto"] {
311            assert!(enabled_from(Some(on), false), "{on:?}");
312        }
313        assert!(enabled_from(Some("maybe"), true));
314        assert!(!enabled_from(Some("maybe"), false));
315    }
316
317    // These tests execute only synthetic scripts in private tempdirs, never a
318    // manager or a host systemd-run. No production marker-file protocol.
319    #[cfg(target_os = "linux")]
320    fn fake_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
321        use std::os::unix::fs::PermissionsExt;
322        let path = dir.join(name);
323        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
324        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
325        path
326    }
327
328    #[cfg(target_os = "linux")]
329    #[test]
330    fn fresh_probe_uses_exact_env_cwd_and_observes_availability_changes() {
331        let dir = tempfile::tempdir().unwrap();
332        let binary = fake_script(
333            dir.path(),
334            "systemd-run",
335            r#"
336[ "$DBUS_SESSION_BUS_ADDRESS" = fake:good ] || exit 1
337[ "$TERM" = user-term ] || exit 2
338[ "$SYNAPS_DAEMON" = 1 ] || exit 3
339[ "$SYNAPS_DAEMON_READY_FD" = 987 ] || exit 4
340[ "${HOME+x}" != x ] || exit 5
341[ "$PWD" = "$EXPECTED_CWD" ] || exit 6
342while [ "$1" != -- ]; do shift; done
343shift
344[ "$1" = /bin/true ] || exit 7
345exit 0
346"#,
347        );
348        let mut env = vec![];
349        for (k, v) in [
350            ("DBUS_SESSION_BUS_ADDRESS", "fake:good"),
351            ("TERM", "user-term"),
352            ("SYNAPS_DAEMON", "1"),
353            ("SYNAPS_DAEMON_READY_FD", "987"),
354            ("PATH", "/nonexistent"),
355        ] {
356            set_env(&mut env, k, v);
357        }
358        set_env(&mut env, "EXPECTED_CWD", dir.path().as_os_str());
359        assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_some());
360        set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:stale");
361        assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_none());
362        set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:good");
363        assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_some());
364        // Same discovered path, changed availability: no cached positive.
365        fake_script(dir.path(), "systemd-run", "exit 1");
366        assert!(launcher_with_binary(&binary, &env, Some(dir.path())).is_none());
367        env.retain(|(k, _)| k != "DBUS_SESSION_BUS_ADDRESS");
368        assert!(!probe(&binary, &env, Some(dir.path())));
369    }
370
371    #[cfg(target_os = "linux")]
372    #[test]
373    fn post_probe_failure_never_replays_and_exec_preserves_pid_and_stdio() {
374        use std::io::Write;
375        use std::process::{Command, Stdio};
376        let dir = tempfile::tempdir().unwrap();
377        let command = fake_script(
378            dir.path(),
379            "command",
380            "printf 'command=%s\\n' \"$$\"; read line; printf '%s\\n' \"$line\"; exit 23",
381        );
382        let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
383        for started in [false, true] {
384            let binary = fake_script(
385                dir.path(),
386                "systemd-run",
387                &format!(
388                    r#"
389while [ "$1" != -- ]; do shift; done
390shift
391[ "$1" = /bin/true ] && exit 0
392printf 'wrapper=%s\n' "$$"
393{}
394"#,
395                    if started { "exec \"$@\"" } else { "exit 42" }
396                ),
397            );
398            let launcher = launcher_with_binary(&binary, &env, Some(dir.path())).unwrap();
399            let (program, args) = launcher.wrap("test", command.as_os_str(), []);
400            let mut child = Command::new(program)
401                .args(args)
402                .env_clear()
403                .envs(env.clone())
404                .current_dir(dir.path())
405                .stdin(Stdio::piped())
406                .stdout(Stdio::piped())
407                .spawn()
408                .unwrap();
409            let pid = child.id();
410            if started {
411                child.stdin.take().unwrap().write_all(b"input\n").unwrap();
412            }
413            let output = child.wait_with_output().unwrap();
414            let text = String::from_utf8(output.stdout).unwrap();
415            assert_eq!(
416                text,
417                if started {
418                    format!("wrapper={pid}\ncommand={pid}\ninput\n")
419                } else {
420                    format!("wrapper={pid}\n")
421                }
422            );
423            assert_eq!(output.status.code(), Some(if started { 23 } else { 42 }));
424        }
425    }
426
427    #[cfg(unix)]
428    #[test]
429    fn missing_child_path_program_is_an_error_not_launcher_path_fallback() {
430        let dir = tempfile::tempdir().unwrap();
431        let env = vec![("PATH".into(), dir.path().as_os_str().to_owned())];
432        assert!(resolve_program("bash".into(), &env, Some(dir.path())).is_err());
433    }
434
435    #[test]
436    fn host_policy_is_not_selected_from_the_child_environment() {
437        let source = include_str!("process_scope.rs");
438        let selection = source
439            .split("pub fn launcher_for_effective_env")
440            .nth(1)
441            .unwrap()
442            .split("/// Compatibility convenience")
443            .next()
444            .unwrap();
445        assert!(selection.contains("std::env::var(\"SYNAPS_PROCESS_SCOPES\")"));
446        assert!(!selection.contains("env.iter()"));
447        assert!(!enabled_from(Some("off"), true));
448    }
449
450    #[test]
451    fn production_callers_snapshot_overrides_and_do_not_retry_wrapped_launches() {
452        let pty = include_str!("tools/shell/pty.rs");
453        let spawn = pty
454            .split("pub(crate) fn prepare_scope")
455            .nth(1)
456            .unwrap()
457            .split("// 4.")
458            .next()
459            .unwrap();
460        assert!(
461            spawn.find("set_env(&mut effective_env, \"TERM\"").unwrap()
462                < spawn.find("for (k, v) in env").unwrap()
463        );
464        assert!(
465            spawn.find("for (k, v) in env").unwrap()
466                < spawn.find("launcher_for_effective_env").unwrap()
467        );
468        assert!(spawn.contains("cmd.env_clear();"));
469        assert_eq!(spawn.matches(".spawn_command(cmd)").count(), 1);
470        let daemon = include_str!("daemon/mod.rs");
471        let spawn = daemon.split("pub fn spawn_detached(").nth(1).unwrap();
472        assert!(
473            spawn.find("launcher_for_effective_env").unwrap() < spawn.find("libc::pipe(").unwrap()
474        );
475        assert!(
476            spawn.find("libc::pipe(").unwrap()
477                < spawn
478                    .find("set_env(&mut effective_env, READY_FD_ENV")
479                    .unwrap()
480        );
481        assert!(spawn.find("child.kill()").unwrap() < spawn.find("take(64 * 1024)").unwrap());
482        assert!(spawn.contains("cmd.env_clear().envs(effective_env)"));
483        let bash = include_str!("tools/bash.rs");
484        let spawn = bash
485            .split("pub(crate) async fn run_shell_command")
486            .nth(1)
487            .unwrap()
488            .split("#[cfg(test)]")
489            .next()
490            .unwrap();
491        assert_eq!(spawn.matches("cmd.spawn()").count(), 1);
492        assert!(spawn.contains("_ = cancel.cancelled()"));
493    }
494}