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//! Command arguments are passed verbatim: where systemd-run supports it
20//! (systemd >= 254, from a cached `--version`), `--expand-environment=no`
21//! stops it from substituting `${NAME}` in tool scripts before exec.
22//!
23//! Used only when it is available (Linux, a reachable systemd user manager)
24//! and not turned off (`process_scopes = off` in config, or
25//! `SYNAPS_PROCESS_SCOPES=off`). Otherwise commands start exactly as before.
26
27use std::ffi::OsString;
28use std::path::PathBuf;
29#[cfg(target_os = "linux")]
30use std::sync::OnceLock;
31
32/// Turns `program args…` into `systemd-run --user --scope … -- program args…`.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct ScopeLauncher {
35    systemd_run: PathBuf,
36    /// Pass `--expand-environment=no`. Only set when the binary is known to
37    /// accept it (systemd >= 254); an unknown option would fail every launch.
38    no_expand: bool,
39}
40
41/// First systemd release whose systemd-run knows `--expand-environment=`.
42///
43/// systemd NEWS: v254 added the switch and, for `--scope` only, left
44/// expansion off by default (with a warning); v258 turned it on for scopes
45/// too, so systemd-run itself rewrites `${NAME}`/`$NAME` in the command
46/// arguments before exec. Releases before 254 never expanded scope commands
47/// (a scope has no ExecStart for the manager to expand), so omitting the flag
48/// there keeps arguments verbatim.
49const EXPAND_ENVIRONMENT_SINCE: u32 = 254;
50
51/// systemd-run options shared by real launches and the probe, so the probe
52/// also proves the exact option set is accepted.
53fn scope_options(description: &str, no_expand: bool) -> Vec<OsString> {
54    let mut argv: Vec<OsString> = vec![
55        "--user".into(),
56        "--scope".into(),
57        "--quiet".into(),
58        // Garbage-collect the unit even if the command fails, so failed
59        // commands do not accumulate as failed units.
60        "--collect".into(),
61    ];
62    if no_expand {
63        // Arguments are data: tool scripts contain literal `${...}` (shell
64        // parameter expansion, JS template literals) that systemd-run would
65        // otherwise substitute from its own environment.
66        argv.push("--expand-environment=no".into());
67    }
68    argv.push(format!("--description={description}").into());
69    argv
70}
71
72impl ScopeLauncher {
73    /// The program to spawn and its full argument list. `description` names
74    /// the scope in `systemctl --user list-units --type=scope`.
75    pub fn wrap(
76        &self,
77        description: &str,
78        program: impl Into<OsString>,
79        args: impl IntoIterator<Item = OsString>,
80    ) -> (OsString, Vec<OsString>) {
81        let mut argv = scope_options(description, self.no_expand);
82        argv.push("--".into());
83        argv.push(program.into());
84        argv.extend(args);
85        (self.systemd_run.clone().into_os_string(), argv)
86    }
87}
88
89/// Major version from `systemd-run --version` ("systemd 259 (259.5-1)").
90fn parse_systemd_version(output: &str) -> Option<u32> {
91    let rest = output.lines().next()?.trim().strip_prefix("systemd ")?;
92    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
93    digits.parse().ok()
94}
95
96/// Whether to pass `--expand-environment=no`; `None` if the version could not
97/// be determined, in which case scopes are not used at all: the flag may be
98/// required (>= 258) yet cannot be known to be accepted.
99fn no_expand_for_version(version: Option<u32>) -> Option<bool> {
100    version.map(|v| v >= EXPAND_ENVIRONMENT_SINCE)
101}
102
103/// `true` unless turned off. The environment variable wins over config so it
104/// can be flipped for one process without editing the config file.
105fn enabled_from(env_value: Option<&str>, config_value: bool) -> bool {
106    match env_value.map(|v| v.trim().to_ascii_lowercase()) {
107        Some(v) if matches!(v.as_str(), "0" | "off" | "false" | "no") => false,
108        Some(v) if matches!(v.as_str(), "1" | "on" | "true" | "yes" | "auto") => true,
109        _ => config_value,
110    }
111}
112
113/// Snapshot inherited environment before applying child-specific overrides.
114pub fn inherited_env() -> Vec<(OsString, OsString)> {
115    std::env::vars_os().collect()
116}
117
118pub fn set_env(env: &mut Vec<(OsString, OsString)>, key: &str, value: impl Into<OsString>) {
119    env.retain(|(k, _)| k != key);
120    env.push((key.into(), value.into()));
121}
122
123#[cfg(target_os = "linux")]
124fn find_systemd_run() -> Option<PathBuf> {
125    // Discovery alone is cached. Never cache manager availability or consult
126    // a tool-controlled PATH to choose the isolation launcher.
127    ["/usr/bin/systemd-run", "/bin/systemd-run"]
128        .into_iter()
129        .map(PathBuf::from)
130        .find(|p| p.is_file())
131}
132
133/// Start only a throwaway scope, with the exact child environment and cwd and
134/// the same options real launches use. Bounded to three seconds.
135#[cfg(target_os = "linux")]
136fn probe(
137    systemd_run: &std::path::Path,
138    no_expand: bool,
139    env: &[(OsString, OsString)],
140    cwd: Option<&std::path::Path>,
141) -> bool {
142    if !env
143        .iter()
144        .any(|(k, v)| (k == "XDG_RUNTIME_DIR" || k == "DBUS_SESSION_BUS_ADDRESS") && !v.is_empty())
145    {
146        return false;
147    }
148    let mut cmd = std::process::Command::new(systemd_run);
149    cmd.env_clear().envs(env.iter().cloned());
150    if let Some(cwd) = cwd {
151        cmd.current_dir(cwd);
152    }
153    cmd.args(scope_options("synaps scope probe", no_expand))
154        .args(["--", "/bin/true"])
155        .stdout(std::process::Stdio::null());
156    run_bounded(cmd).is_some_and(|(status, _)| status.success())
157}
158
159/// `systemd-run --version`, bounded like the probe. Touches no manager.
160#[cfg(target_os = "linux")]
161fn systemd_run_version(systemd_run: &std::path::Path) -> Option<u32> {
162    let mut cmd = std::process::Command::new(systemd_run);
163    cmd.env_clear()
164        .env("LC_ALL", "C")
165        .env("SYSTEMD_PAGER", "")
166        .arg("--version")
167        .stdout(std::process::Stdio::piped());
168    let (status, stdout) = run_bounded(cmd)?;
169    status.success().then_some(())?;
170    parse_systemd_version(&String::from_utf8_lossy(&stdout))
171}
172
173/// Run a short helper command in its own session with null stdin/stderr.
174/// Returns its status and (if piped) stdout, or `None` on spawn failure or
175/// after three seconds, when the whole group is killed and reaped off-path.
176#[cfg(target_os = "linux")]
177fn run_bounded(mut cmd: std::process::Command) -> Option<(std::process::ExitStatus, Vec<u8>)> {
178    use std::io::Read;
179    use std::os::unix::process::CommandExt;
180    use std::time::{Duration, Instant};
181    // Helper descendants must not survive a timeout. No real payload runs here.
182    unsafe {
183        cmd.pre_exec(|| {
184            if libc::setsid() < 0 {
185                return Err(std::io::Error::last_os_error());
186            }
187            Ok(())
188        });
189    }
190    let mut child = cmd
191        .stdin(std::process::Stdio::null())
192        .stderr(std::process::Stdio::null())
193        .spawn()
194        .ok()?;
195    let deadline = Instant::now() + Duration::from_secs(3);
196    loop {
197        match child.try_wait() {
198            Ok(Some(status)) => {
199                // Output is tiny (a version line), well under a pipe buffer.
200                let mut out = Vec::new();
201                if let Some(mut stdout) = child.stdout.take() {
202                    let _ = stdout.by_ref().take(64 * 1024).read_to_end(&mut out);
203                }
204                return Some((status, out));
205            }
206            Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)),
207            _ => {
208                // Signal before any reap; the unreaped leader pins this ID.
209                unsafe {
210                    libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
211                }
212                let _ = child.kill();
213                let cleanup_deadline = Instant::now() + Duration::from_millis(100);
214                while matches!(child.try_wait(), Ok(None)) && Instant::now() < cleanup_deadline {
215                    std::thread::sleep(Duration::from_millis(5));
216                }
217                // SIGKILL cannot bound kernel D-state. Reap eventually off the
218                // launch path rather than blocking the caller indefinitely.
219                std::thread::spawn(move || {
220                    let _ = child.wait();
221                });
222                return None;
223            }
224        }
225    }
226}
227
228/// Fresh, bounded availability check. Synchronous callers (PTY and detached
229/// daemon spawn) may wait up to three seconds. Async callers must offload it.
230/// Probe failure selects direct launch. After success, wrapper failure is an
231/// error, NEVER a reason to replay the command: availability can race, and the
232/// wrapped command may already have executed. This is not a launch handshake.
233pub fn launcher_for_effective_env(
234    env: &[(OsString, OsString)],
235    cwd: Option<&std::path::Path>,
236) -> Option<ScopeLauncher> {
237    // Test-support builds must not reach a developer's user manager merely
238    // because ordinary bash/PTY tests inherit a desktop environment. Synthetic
239    // launcher tests call launcher_with_binary directly; real smoke is opt-in.
240    #[cfg(any(test, feature = "testing"))]
241    if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() != Ok("1") {
242        return None;
243    }
244    // Operator policy belongs to the host, not the tool's rebuilt env. A
245    // child cannot enable scopes behind the host's explicit opt-out.
246    if !enabled_from(
247        std::env::var("SYNAPS_PROCESS_SCOPES").ok().as_deref(),
248        crate::config::load_config().process_scopes,
249    ) {
250        return None;
251    }
252    #[cfg(target_os = "linux")]
253    {
254        static BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
255        let binary = BINARY.get_or_init(find_systemd_run).as_ref()?;
256        // A property of the binary, not of manager availability: cache only a
257        // determined answer. Unknown means no scope rather than a guess.
258        static NO_EXPAND: OnceLock<bool> = OnceLock::new();
259        let no_expand = match NO_EXPAND.get() {
260            Some(v) => *v,
261            None => {
262                let v = no_expand_for_version(systemd_run_version(binary))?;
263                *NO_EXPAND.get_or_init(|| v)
264            }
265        };
266        launcher_with_binary(binary, no_expand, env, cwd)
267    }
268    #[cfg(not(target_os = "linux"))]
269    {
270        let _ = cwd;
271        None
272    }
273}
274
275#[cfg(target_os = "linux")]
276fn launcher_with_binary(
277    binary: &std::path::Path,
278    no_expand: bool,
279    env: &[(OsString, OsString)],
280    cwd: Option<&std::path::Path>,
281) -> Option<ScopeLauncher> {
282    probe(binary, no_expand, env, cwd).then(|| ScopeLauncher {
283        systemd_run: binary.to_owned(),
284        no_expand,
285    })
286}
287
288/// Compatibility convenience for inherited or fully rebuilt environments.
289pub fn launcher_for_env(env: Option<&[(String, String)]>) -> Option<ScopeLauncher> {
290    let env = env
291        .map(|e| e.iter().map(|(k, v)| (k.into(), v.into())).collect())
292        .unwrap_or_else(inherited_env);
293    launcher_for_effective_env(&env, None)
294}
295
296/// Resolve before wrapping: systemd-run must not substitute its own executable
297/// search rules for the child's PATH/cwd. Missing programs remain spawn errors.
298pub fn resolve_program(
299    program: OsString,
300    env: &[(OsString, OsString)],
301    cwd: Option<&std::path::Path>,
302) -> std::io::Result<OsString> {
303    #[cfg(unix)]
304    {
305        use std::os::unix::fs::PermissionsExt;
306        let base = cwd
307            .map(PathBuf::from)
308            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
309        let base = if base.is_absolute() {
310            base
311        } else {
312            std::env::current_dir().unwrap_or_default().join(base)
313        };
314        if std::path::Path::new(&program).components().count() > 1 {
315            return Ok(base.join(program).into_os_string());
316        }
317        let path = env
318            .iter()
319            .find(|(k, _)| k == "PATH")
320            .map(|(_, v)| v.clone())
321            .unwrap_or_else(|| "/bin:/usr/bin".into());
322        for dir in std::env::split_paths(&path) {
323            let candidate = base.join(dir).join(&program);
324            if candidate
325                .metadata()
326                .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
327            {
328                return Ok(candidate.into_os_string());
329            }
330        }
331    }
332    Err(std::io::Error::new(
333        std::io::ErrorKind::NotFound,
334        format!("program not found in child PATH: {program:?}"),
335    ))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn wrapped(no_expand: bool, script: &str) -> (OsString, Vec<String>) {
343        let l = ScopeLauncher {
344            systemd_run: PathBuf::from("/usr/bin/systemd-run"),
345            no_expand,
346        };
347        let (program, args) = l.wrap("synaps tool: bash", "bash", ["-c".into(), script.into()]);
348        let args = args
349            .iter()
350            .map(|a| a.to_string_lossy().into_owned())
351            .collect();
352        (program, args)
353    }
354
355    #[test]
356    fn wrap_disables_expansion_when_systemd_run_supports_it() {
357        let (program, args) = wrapped(true, "echo 'D:${x}' $HOME");
358        assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
359        assert_eq!(
360            args,
361            [
362                "--user",
363                "--scope",
364                "--quiet",
365                "--collect",
366                "--expand-environment=no",
367                "--description=synaps tool: bash",
368                "--",
369                "bash",
370                "-c",
371                "echo 'D:${x}' $HOME",
372            ]
373        );
374    }
375
376    #[test]
377    fn wrap_never_passes_the_flag_to_a_systemd_run_without_it() {
378        let (program, args) = wrapped(false, "echo hi");
379        assert_eq!(program, OsString::from("/usr/bin/systemd-run"));
380        assert_eq!(
381            args,
382            [
383                "--user",
384                "--scope",
385                "--quiet",
386                "--collect",
387                "--description=synaps tool: bash",
388                "--",
389                "bash",
390                "-c",
391                "echo hi",
392            ]
393        );
394        assert!(!args.iter().any(|a| a.starts_with("--expand-environment")));
395    }
396
397    #[test]
398    fn version_decides_the_flag_and_unknown_versions_decide_nothing() {
399        assert_eq!(
400            parse_systemd_version("systemd 259 (259.5-0ubuntu3.4)\n+PAM +AUDIT"),
401            Some(259)
402        );
403        assert_eq!(parse_systemd_version("systemd 254 (254.5-1)"), Some(254));
404        assert_eq!(parse_systemd_version("systemd 249\n"), Some(249));
405        assert_eq!(parse_systemd_version("systemd 219"), Some(219));
406        assert_eq!(parse_systemd_version(""), None);
407        assert_eq!(parse_systemd_version("something else 300"), None);
408        assert_eq!(parse_systemd_version("systemd x"), None);
409        assert_eq!(no_expand_for_version(Some(259)), Some(true));
410        assert_eq!(no_expand_for_version(Some(254)), Some(true));
411        assert_eq!(no_expand_for_version(Some(253)), Some(false));
412        assert_eq!(no_expand_for_version(Some(219)), Some(false));
413        assert_eq!(no_expand_for_version(None), None);
414    }
415
416    #[cfg(target_os = "linux")]
417    #[test]
418    fn version_query_and_probe_use_exactly_the_launch_options() {
419        let dir = tempfile::tempdir().unwrap();
420        // An old systemd-run: rejects the unknown option like the real one.
421        let binary = fake_script(
422            dir.path(),
423            "systemd-run",
424            r#"
425[ "$1" = --version ] && { echo 'systemd 249 (249.11-0ubuntu3)'; exit 0; }
426for a in "$@"; do case "$a" in --expand-environment=*) exit 1;; --) break;; esac; done
427exit 0
428"#,
429        );
430        assert_eq!(systemd_run_version(&binary), Some(249));
431        let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
432        assert!(launcher_with_binary(&binary, false, &env, None).is_some());
433        // Had support been misjudged, the probe fails: direct launch, never a
434        // wrapped launch with an option the binary rejects.
435        assert!(launcher_with_binary(&binary, true, &env, None).is_none());
436        fake_script(dir.path(), "systemd-run", "exit 1");
437        assert_eq!(systemd_run_version(&binary), None);
438        fake_script(dir.path(), "systemd-run", "echo 'systemd 258 (258)'");
439        assert_eq!(systemd_run_version(&binary), Some(258));
440    }
441
442    /// Real systemd-run and user manager; skipped where either is missing.
443    /// A literal `${x}` must reach the program unchanged even though `x` is
444    /// set in the child environment (systemd >= 258 expands it by default).
445    #[cfg(target_os = "linux")]
446    #[test]
447    fn real_scope_passes_dollar_braces_through_verbatim() {
448        if std::env::var("SYNAPS_PROCESS_SCOPES").is_ok_and(|v| !enabled_from(Some(&v), true)) {
449            eprintln!("skip: SYNAPS_PROCESS_SCOPES is off");
450            return;
451        }
452        let Some(binary) = find_systemd_run() else {
453            eprintln!("skip: no systemd-run");
454            return;
455        };
456        let Some(no_expand) = no_expand_for_version(systemd_run_version(&binary)) else {
457            eprintln!("skip: systemd-run --version unusable");
458            return;
459        };
460        let mut env = inherited_env();
461        set_env(&mut env, "x", "EXPANDED");
462        let Some(launcher) = launcher_with_binary(&binary, no_expand, &env, None) else {
463            eprintln!("skip: no reachable systemd user manager");
464            return;
465        };
466        let payload = ["D:${x}", "$x", "'${x:-dflt}'", "`${a}${b}`", "$$", "${}"];
467        let mut args: Vec<OsString> =
468            vec!["-c".into(), "printf '%s\\n' \"$@\"".into(), "sh".into()];
469        args.extend(payload.iter().map(OsString::from));
470        let (program, argv) = launcher.wrap("synaps test: no expand", "/bin/sh", args);
471        let out = std::process::Command::new(program)
472            .args(argv)
473            .env_clear()
474            .envs(env)
475            .stdin(std::process::Stdio::null())
476            .output()
477            .unwrap();
478        assert!(
479            out.status.success(),
480            "{}",
481            String::from_utf8_lossy(&out.stderr)
482        );
483        let stdout = String::from_utf8(out.stdout).unwrap();
484        assert_eq!(stdout.lines().collect::<Vec<_>>(), payload);
485        assert!(
486            !String::from_utf8_lossy(&out.stderr).contains("environment variable"),
487            "{}",
488            String::from_utf8_lossy(&out.stderr)
489        );
490    }
491
492    #[test]
493    fn ordinary_test_build_does_not_probe_host_manager() {
494        if std::env::var("SYNAPS_TEST_REAL_SCOPES").as_deref() == Ok("1") {
495            return;
496        }
497        let env = vec![(
498            "DBUS_SESSION_BUS_ADDRESS".into(),
499            "fake:must-not-probe".into(),
500        )];
501        assert!(launcher_for_effective_env(&env, None).is_none());
502    }
503
504    #[test]
505    fn env_variable_overrides_config_and_unknown_values_fall_back_to_config() {
506        assert!(enabled_from(None, true));
507        assert!(!enabled_from(None, false));
508        for off in ["0", "off", "OFF", " false ", "no"] {
509            assert!(!enabled_from(Some(off), true), "{off:?}");
510        }
511        for on in ["1", "on", "true", "yes", "auto"] {
512            assert!(enabled_from(Some(on), false), "{on:?}");
513        }
514        assert!(enabled_from(Some("maybe"), true));
515        assert!(!enabled_from(Some("maybe"), false));
516    }
517
518    // These tests execute only synthetic scripts in private tempdirs, never a
519    // manager or a host systemd-run. No production marker-file protocol.
520    #[cfg(target_os = "linux")]
521    fn fake_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
522        use std::os::unix::fs::PermissionsExt;
523        let path = dir.join(name);
524        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
525        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
526        path
527    }
528
529    #[cfg(target_os = "linux")]
530    #[test]
531    fn fresh_probe_uses_exact_env_cwd_and_observes_availability_changes() {
532        let dir = tempfile::tempdir().unwrap();
533        let binary = fake_script(
534            dir.path(),
535            "systemd-run",
536            r#"
537[ "$DBUS_SESSION_BUS_ADDRESS" = fake:good ] || exit 1
538[ "$TERM" = user-term ] || exit 2
539[ "$SYNAPS_DAEMON" = 1 ] || exit 3
540[ "$SYNAPS_DAEMON_READY_FD" = 987 ] || exit 4
541[ "${HOME+x}" != x ] || exit 5
542[ "$PWD" = "$EXPECTED_CWD" ] || exit 6
543while [ "$1" != -- ]; do shift; done
544shift
545[ "$1" = /bin/true ] || exit 7
546exit 0
547"#,
548        );
549        let mut env = vec![];
550        for (k, v) in [
551            ("DBUS_SESSION_BUS_ADDRESS", "fake:good"),
552            ("TERM", "user-term"),
553            ("SYNAPS_DAEMON", "1"),
554            ("SYNAPS_DAEMON_READY_FD", "987"),
555            ("PATH", "/nonexistent"),
556        ] {
557            set_env(&mut env, k, v);
558        }
559        set_env(&mut env, "EXPECTED_CWD", dir.path().as_os_str());
560        assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_some());
561        set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:stale");
562        assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_none());
563        set_env(&mut env, "DBUS_SESSION_BUS_ADDRESS", "fake:good");
564        assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_some());
565        // Same discovered path, changed availability: no cached positive.
566        fake_script(dir.path(), "systemd-run", "exit 1");
567        assert!(launcher_with_binary(&binary, true, &env, Some(dir.path())).is_none());
568        env.retain(|(k, _)| k != "DBUS_SESSION_BUS_ADDRESS");
569        assert!(!probe(&binary, true, &env, Some(dir.path())));
570    }
571
572    #[cfg(target_os = "linux")]
573    #[test]
574    fn post_probe_failure_never_replays_and_exec_preserves_pid_and_stdio() {
575        use std::io::Write;
576        use std::process::{Command, Stdio};
577        let dir = tempfile::tempdir().unwrap();
578        let command = fake_script(
579            dir.path(),
580            "command",
581            "printf 'command=%s\\n' \"$$\"; read line; printf '%s\\n' \"$line\"; exit 23",
582        );
583        let env = vec![("DBUS_SESSION_BUS_ADDRESS".into(), "fake:good".into())];
584        for started in [false, true] {
585            let binary = fake_script(
586                dir.path(),
587                "systemd-run",
588                &format!(
589                    r#"
590while [ "$1" != -- ]; do shift; done
591shift
592[ "$1" = /bin/true ] && exit 0
593printf 'wrapper=%s\n' "$$"
594{}
595"#,
596                    if started { "exec \"$@\"" } else { "exit 42" }
597                ),
598            );
599            let launcher = launcher_with_binary(&binary, true, &env, Some(dir.path())).unwrap();
600            let (program, args) = launcher.wrap("test", command.as_os_str(), []);
601            let mut child = Command::new(program)
602                .args(args)
603                .env_clear()
604                .envs(env.clone())
605                .current_dir(dir.path())
606                .stdin(Stdio::piped())
607                .stdout(Stdio::piped())
608                .spawn()
609                .unwrap();
610            let pid = child.id();
611            if started {
612                child.stdin.take().unwrap().write_all(b"input\n").unwrap();
613            }
614            let output = child.wait_with_output().unwrap();
615            let text = String::from_utf8(output.stdout).unwrap();
616            assert_eq!(
617                text,
618                if started {
619                    format!("wrapper={pid}\ncommand={pid}\ninput\n")
620                } else {
621                    format!("wrapper={pid}\n")
622                }
623            );
624            assert_eq!(output.status.code(), Some(if started { 23 } else { 42 }));
625        }
626    }
627
628    #[cfg(unix)]
629    #[test]
630    fn missing_child_path_program_is_an_error_not_launcher_path_fallback() {
631        let dir = tempfile::tempdir().unwrap();
632        let env = vec![("PATH".into(), dir.path().as_os_str().to_owned())];
633        assert!(resolve_program("bash".into(), &env, Some(dir.path())).is_err());
634    }
635
636    #[test]
637    fn host_policy_is_not_selected_from_the_child_environment() {
638        let source = include_str!("process_scope.rs");
639        let selection = source
640            .split("pub fn launcher_for_effective_env")
641            .nth(1)
642            .unwrap()
643            .split("/// Compatibility convenience")
644            .next()
645            .unwrap();
646        assert!(selection.contains("std::env::var(\"SYNAPS_PROCESS_SCOPES\")"));
647        assert!(!selection.contains("env.iter()"));
648        assert!(!enabled_from(Some("off"), true));
649    }
650
651    #[test]
652    fn production_callers_snapshot_overrides_and_do_not_retry_wrapped_launches() {
653        let pty = include_str!("tools/shell/pty.rs");
654        let spawn = pty
655            .split("pub(crate) fn prepare_scope")
656            .nth(1)
657            .unwrap()
658            .split("// 4.")
659            .next()
660            .unwrap();
661        assert!(
662            spawn.find("set_env(&mut effective_env, \"TERM\"").unwrap()
663                < spawn.find("for (k, v) in env").unwrap()
664        );
665        assert!(
666            spawn.find("for (k, v) in env").unwrap()
667                < spawn.find("launcher_for_effective_env").unwrap()
668        );
669        assert!(spawn.contains("cmd.env_clear();"));
670        assert_eq!(spawn.matches(".spawn_command(cmd)").count(), 1);
671        let daemon = include_str!("daemon/mod.rs");
672        let spawn = daemon.split("pub fn spawn_detached(").nth(1).unwrap();
673        assert!(
674            spawn.find("launcher_for_effective_env").unwrap() < spawn.find("libc::pipe(").unwrap()
675        );
676        assert!(
677            spawn.find("libc::pipe(").unwrap()
678                < spawn
679                    .find("set_env(&mut effective_env, READY_FD_ENV")
680                    .unwrap()
681        );
682        assert!(spawn.find("child.kill()").unwrap() < spawn.find("take(64 * 1024)").unwrap());
683        assert!(spawn.contains("cmd.env_clear().envs(effective_env)"));
684        let bash = include_str!("tools/bash.rs");
685        let spawn = bash
686            .split("pub(crate) async fn run_shell_command")
687            .nth(1)
688            .unwrap()
689            .split("#[cfg(test)]")
690            .next()
691            .unwrap();
692        assert_eq!(spawn.matches("cmd.spawn()").count(), 1);
693        assert!(spawn.contains("_ = cancel.cancelled()"));
694    }
695}