Skip to main content

a3s_code_core/
shell_session.rs

1//! Session-scoped shell identity. Cwd and env do not cross sessions.
2//!
3//! Detach, poll, and kill stay permission-checked by the caller. Persisted
4//! transcripts never copy secret-shaped env values. A timeout or cancel kills
5//! the process group.
6
7use anyhow::{anyhow, Result};
8use std::collections::HashMap;
9#[cfg(unix)]
10use std::os::unix::process::CommandExt;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::sync::{Mutex, OnceLock};
14use std::time::Duration;
15
16const SECRET_MARKERS: &[&str] = &[
17    "SECRET",
18    "TOKEN",
19    "PASSWORD",
20    "API_KEY",
21    "AUTHORIZATION",
22    "PRIVATE_KEY",
23];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CommandAdmission {
27    Allow,
28    Deny,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AdmittedCommand {
33    pub cwd: PathBuf,
34    pub command: String,
35    pub transcript_env: Vec<(String, String)>,
36}
37
38#[derive(Debug)]
39struct Job {
40    child: Child,
41}
42
43struct Session {
44    cwd: PathBuf,
45    /// When set, `cd` cannot leave this directory. Unset sessions keep a free cwd.
46    root: Option<PathBuf>,
47    env: HashMap<String, String>,
48    jobs: HashMap<String, Job>,
49}
50
51fn sessions() -> &'static Mutex<HashMap<String, Session>> {
52    static SESSIONS: OnceLock<Mutex<HashMap<String, Session>>> = OnceLock::new();
53    SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
54}
55
56pub fn bind_session(session_id: &str, cwd: &Path) {
57    bind_session_with_root(session_id, cwd, None);
58}
59
60/// Bind a session whose `cd` cannot leave `root`. Isolation uses this so a
61/// conversation shell cannot walk back onto the source tree.
62pub fn bind_session_rooted(session_id: &str, cwd: &Path, root: &Path) {
63    bind_session_with_root(session_id, cwd, Some(root.to_path_buf()));
64}
65
66fn bind_session_with_root(session_id: &str, cwd: &Path, root: Option<PathBuf>) {
67    sessions().lock().expect("shell sessions").insert(
68        session_id.to_string(),
69        Session {
70            cwd: cwd.to_path_buf(),
71            root,
72            env: HashMap::new(),
73            jobs: HashMap::new(),
74        },
75    );
76}
77
78pub fn drop_session(session_id: &str) {
79    let jobs = sessions()
80        .lock()
81        .expect("shell sessions")
82        .remove(session_id)
83        .map(|mut session| session.jobs.drain().map(|(_, job)| job).collect::<Vec<_>>());
84    if let Some(jobs) = jobs {
85        for mut job in jobs {
86            let _ = kill_child(&mut job.child);
87        }
88    }
89}
90
91#[cfg(test)]
92fn job_count(session_id: &str) -> usize {
93    sessions()
94        .lock()
95        .expect("shell sessions")
96        .get(session_id)
97        .map(|session| session.jobs.len())
98        .unwrap_or(0)
99}
100
101pub fn cwd(session_id: &str) -> Option<PathBuf> {
102    sessions()
103        .lock()
104        .expect("shell sessions")
105        .get(session_id)
106        .map(|session| session.cwd.clone())
107}
108
109pub fn set_env(session_id: &str, key: &str, value: &str) -> Result<()> {
110    let mut guard = sessions().lock().expect("shell sessions");
111    let session = guard
112        .get_mut(session_id)
113        .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?;
114    session.env.insert(key.to_string(), value.to_string());
115    Ok(())
116}
117
118/// Model-visible env overlay. Secret-shaped keys are omitted, not redacted in place.
119pub fn transcript_env(session_id: &str) -> Vec<(String, String)> {
120    sessions()
121        .lock()
122        .expect("shell sessions")
123        .get(session_id)
124        .map(|session| {
125            session
126                .env
127                .iter()
128                .filter(|(key, _)| !is_secret_key(key))
129                .map(|(key, value)| (key.clone(), value.clone()))
130                .collect()
131        })
132        .unwrap_or_default()
133}
134
135/// Apply a command to the session. A denial does not change cwd or start a job.
136pub fn admit(
137    session_id: &str,
138    command: &str,
139    admission: CommandAdmission,
140) -> Result<AdmittedCommand> {
141    let mut guard = sessions().lock().expect("shell sessions");
142    let session = guard
143        .get_mut(session_id)
144        .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?;
145    if admission == CommandAdmission::Deny {
146        return Err(anyhow!("command denied inside shell session {session_id}"));
147    }
148    let trimmed = command.trim();
149    if let Some(path) = simple_cd(trimmed) {
150        let next = resolve_cd(&session.cwd, path);
151        if let Some(root) = session.root.as_deref() {
152            if !stays_under_root(&next, root) {
153                return Err(anyhow!(
154                    "cd cannot leave the isolation worktree for shell session {session_id}"
155                ));
156            }
157        }
158        session.cwd = next;
159    }
160    Ok(AdmittedCommand {
161        cwd: session.cwd.clone(),
162        command: trimmed.to_string(),
163        transcript_env: session
164            .env
165            .iter()
166            .filter(|(key, _)| !is_secret_key(key))
167            .map(|(key, value)| (key.clone(), value.clone()))
168            .collect(),
169    })
170}
171
172pub fn detach(session_id: &str, command: &str, admission: CommandAdmission) -> Result<String> {
173    let admitted = admit(session_id, command, admission)?;
174    if admitted.command.starts_with("cd ") {
175        return Err(anyhow!("cd is not a detachable job"));
176    }
177    let child = spawn(session_id, &admitted)?;
178    let id = format!("job-{}", child.id());
179    sessions()
180        .lock()
181        .expect("shell sessions")
182        .get_mut(session_id)
183        .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?
184        .jobs
185        .insert(id.clone(), Job { child });
186    Ok(id)
187}
188
189pub fn poll(session_id: &str, job_id: &str) -> Result<String> {
190    let mut guard = sessions().lock().expect("shell sessions");
191    let job = guard
192        .get_mut(session_id)
193        .and_then(|session| session.jobs.get_mut(job_id))
194        .ok_or_else(|| anyhow!("unknown job {job_id}"))?;
195    match job.child.try_wait()? {
196        Some(status) => Ok(format!("exited {status}")),
197        None => Ok("running".to_string()),
198    }
199}
200
201pub fn kill(session_id: &str, job_id: &str) -> Result<()> {
202    let mut job = sessions()
203        .lock()
204        .expect("shell sessions")
205        .get_mut(session_id)
206        .and_then(|session| session.jobs.remove(job_id))
207        .ok_or_else(|| anyhow!("unknown job {job_id}"))?;
208    kill_child(&mut job.child)
209}
210
211/// Kill every detached job in the session. Used when the run is cancelled
212/// so a timeout-independent cancel still reaps the process group.
213pub fn kill_session(session_id: &str) {
214    let jobs = sessions()
215        .lock()
216        .expect("shell sessions")
217        .get_mut(session_id)
218        .map(|session| session.jobs.drain().map(|(_, job)| job).collect::<Vec<_>>())
219        .unwrap_or_default();
220    for mut job in jobs {
221        let _ = kill_child(&mut job.child);
222    }
223}
224
225/// Run a command with a timeout and kill the process group if it expires.
226pub fn run_bounded(session_id: &str, command: &str, timeout: Duration) -> Result<String> {
227    let admitted = admit(session_id, command, CommandAdmission::Allow)?;
228    if admitted.command.starts_with("cd ") {
229        return Ok(format!("cwd {}", admitted.cwd.display()));
230    }
231    let mut child = spawn(session_id, &admitted)?;
232    let started = std::time::Instant::now();
233    loop {
234        if child.try_wait()?.is_some() {
235            return Ok("exited".to_string());
236        }
237        if started.elapsed() >= timeout {
238            kill_child(&mut child)?;
239            return Err(anyhow!("shell timeout killed the process group"));
240        }
241        std::thread::sleep(Duration::from_millis(20));
242    }
243}
244
245fn spawn(session_id: &str, admitted: &AdmittedCommand) -> Result<Child> {
246    let overlay = overlay_env(session_id);
247    #[cfg(unix)]
248    let mut command = {
249        let mut command = Command::new("sh");
250        command.arg("-c").arg(&admitted.command);
251        command
252    };
253    #[cfg(windows)]
254    let mut command = windows_detached_command(admitted, &overlay)?;
255    command
256        .current_dir(&admitted.cwd)
257        .stdin(Stdio::null())
258        .stdout(Stdio::piped())
259        .stderr(Stdio::piped());
260    for (key, value) in &overlay {
261        command.env(key, value);
262    }
263    #[cfg(unix)]
264    unsafe {
265        command.pre_exec(|| {
266            if libc::setpgid(0, 0) != 0 {
267                return Err(std::io::Error::last_os_error());
268            }
269            Ok(())
270        });
271    }
272    #[cfg_attr(not(windows), allow(unused_mut))]
273    let mut child = command.spawn().map_err(|error| {
274        anyhow!("failed to spawn detached shell for session {session_id}: {error}")
275    })?;
276    #[cfg(windows)]
277    {
278        use std::os::windows::io::AsRawHandle;
279        if let Err(error) = crate::tools::builtin::bash::bind_windows_process_tree(
280            child.as_raw_handle(),
281            child.id(),
282        ) {
283            let _ = child.kill();
284            let _ = child.wait();
285            return Err(anyhow!(
286                "failed to bind detached shell job for session {session_id}: {error}"
287            ));
288        }
289    }
290    Ok(child)
291}
292
293/// Detached jobs use the same host shell as foreground commands. Windows has
294/// no `sh` on PATH; PowerShell 7 runs the compat shim so `printf` and `sleep`
295/// still start a child the session can poll and kill.
296#[cfg(windows)]
297fn windows_detached_command(
298    admitted: &AdmittedCommand,
299    overlay: &HashMap<String, String>,
300) -> Result<Command> {
301    use std::os::windows::process::CommandExt;
302
303    let powershell =
304        crate::tools::builtin::bash::windows_host_powershell(&admitted.cwd).map_err(|error| {
305            anyhow!(
306                "failed to resolve PowerShell 7 for {}: {error}",
307                admitted.cwd.display()
308            )
309        })?;
310    let mut source = String::new();
311    for key in overlay.keys() {
312        if is_powershell_identifier(key) {
313            source.push_str(&format!("${key} = $env:{key}\n"));
314        }
315    }
316    source.push_str(&admitted.command);
317    let wrapped = crate::tools::builtin::bash::build_powershell_command(&source);
318    let encoded = crate::tools::builtin::bash::encode_powershell_command(&wrapped);
319    let mut command = Command::new(&powershell);
320    command.args([
321        "-NoLogo",
322        "-NoProfile",
323        "-NonInteractive",
324        "-ExecutionPolicy",
325        "Bypass",
326    ]);
327    let encoded_chars = format!("{powershell:?} -EncodedCommand {encoded}")
328        .encode_utf16()
329        .count();
330    if encoded_chars <= 30_000 {
331        command.arg("-EncodedCommand").arg(encoded);
332    } else {
333        let unique = std::time::SystemTime::now()
334            .duration_since(std::time::UNIX_EPOCH)
335            .map(|elapsed| elapsed.as_nanos())
336            .unwrap_or(0);
337        let path =
338            std::env::temp_dir().join(format!("a3s-detach-{}-{unique}.ps1", std::process::id()));
339        let literal = path.to_string_lossy().replace('\'', "''");
340        let body = format!(
341            "trap {{ exit 1 }}\nRegister-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {{ Remove-Item -LiteralPath '{literal}' -Force -ErrorAction SilentlyContinue }} | Out-Null\n{wrapped}\nif (-not $?) {{ exit 1 }}\n"
342        );
343        std::fs::write(&path, body.as_bytes())
344            .map_err(|error| anyhow!("failed to write detached shell script: {error}"))?;
345        command.arg("-File").arg(&path);
346    }
347    command.creation_flags(crate::tools::builtin::bash::CREATE_NO_WINDOW);
348    Ok(command)
349}
350
351#[cfg(windows)]
352fn is_powershell_identifier(key: &str) -> bool {
353    let mut chars = key.chars();
354    match chars.next() {
355        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
356        _ => return false,
357    }
358    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
359}
360
361fn kill_child(child: &mut Child) -> Result<()> {
362    // Unix kills the process group. On Windows the shell is assigned to a
363    // Job Object at spawn; ending this process makes that job terminate the
364    // remaining descendants.
365    #[cfg(unix)]
366    let pid = child.id();
367    #[cfg(unix)]
368    unsafe {
369        libc::kill(-(pid as i32), libc::SIGKILL);
370    }
371    #[cfg(not(unix))]
372    {
373        let _ = child.kill();
374    }
375    let _ = child.wait();
376    #[cfg(unix)]
377    unsafe {
378        let gone = libc::kill(pid as i32, 0) != 0;
379        if !gone {
380            return Err(anyhow!("child {pid} survived process-group kill"));
381        }
382    }
383    Ok(())
384}
385
386/// Env overlay for the named session only. A shared cwd is not an identity.
387fn overlay_env(session_id: &str) -> HashMap<String, String> {
388    sessions()
389        .lock()
390        .expect("shell sessions")
391        .get(session_id)
392        .map(|session| session.env.clone())
393        .unwrap_or_default()
394}
395
396fn simple_cd(command: &str) -> Option<&str> {
397    let rest = command.strip_prefix("cd ")?.trim();
398    if rest.is_empty()
399        || rest.contains("&&")
400        || rest.contains(';')
401        || rest.contains('|')
402        || rest.contains('>')
403    {
404        return None;
405    }
406    Some(rest)
407}
408
409fn resolve_cd(cwd: &Path, raw: &str) -> PathBuf {
410    let path = Path::new(raw);
411    if path.is_absolute() {
412        path.to_path_buf()
413    } else {
414        cwd.join(path)
415    }
416}
417
418fn stays_under_root(next: &Path, root: &Path) -> bool {
419    let lexical = normalize_lexical(next);
420    let root = normalize_lexical(root);
421    if !lexical.starts_with(&root) {
422        return false;
423    }
424    let Ok(canonical) = std::fs::canonicalize(&lexical) else {
425        return true;
426    };
427    let root = std::fs::canonicalize(&root).unwrap_or(root);
428    canonical.starts_with(root)
429}
430
431fn normalize_lexical(path: &Path) -> PathBuf {
432    let mut normalized = PathBuf::new();
433    for component in path.components() {
434        match component {
435            std::path::Component::ParentDir => {
436                normalized.pop();
437            }
438            std::path::Component::CurDir => {}
439            other => normalized.push(other.as_os_str()),
440        }
441    }
442    normalized
443}
444
445fn is_secret_key(key: &str) -> bool {
446    let upper = key.to_ascii_uppercase();
447    SECRET_MARKERS.iter().any(|marker| upper.contains(marker))
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn cd_is_visible_in_the_same_session_and_not_in_another() {
456        let root = tempfile::tempdir().unwrap();
457        let nested = root.path().join("nested");
458        std::fs::create_dir(&nested).unwrap();
459        bind_session("a", root.path());
460        bind_session("b", root.path());
461        admit("a", "cd nested", CommandAdmission::Allow).unwrap();
462        assert_eq!(cwd("a").unwrap(), nested);
463        assert_eq!(cwd("b").unwrap(), root.path());
464        let denied = admit("a", "cd ..", CommandAdmission::Deny);
465        assert!(denied.is_err());
466        assert_eq!(cwd("a").unwrap(), nested);
467        let denied_again = admit("a", "touch leaked.txt", CommandAdmission::Deny);
468        assert!(denied_again
469            .unwrap_err()
470            .to_string()
471            .contains("command denied inside shell session"));
472        assert_eq!(cwd("a").unwrap(), nested);
473        assert!(!nested.join("leaked.txt").exists());
474        drop_session("a");
475        drop_session("b");
476    }
477
478    #[test]
479    fn detached_job_outlives_the_return_and_can_be_polled_and_killed() {
480        let root = tempfile::tempdir().unwrap();
481        bind_session("jobs", root.path());
482        let id = detach("jobs", "sleep 30", CommandAdmission::Allow).unwrap();
483        assert_eq!(poll("jobs", &id).unwrap(), "running");
484        kill("jobs", &id).unwrap();
485        assert!(poll("jobs", &id).is_err());
486        drop_session("jobs");
487    }
488
489    #[test]
490    fn command_overlay_does_not_cross_sessions_that_share_a_cwd() {
491        let root = tempfile::tempdir().unwrap();
492        let owner = "overlay-owner";
493        let other = "overlay-other";
494        bind_session(owner, root.path());
495        bind_session(other, root.path());
496        set_env(owner, "A3S_SHELL_OVERLAY", "owner-only").unwrap();
497        set_env(other, "A3S_SHELL_OVERLAY", "other-only").unwrap();
498        assert_eq!(
499            overlay_env(other)
500                .get("A3S_SHELL_OVERLAY")
501                .map(String::as_str),
502            Some("other-only")
503        );
504        run_bounded(
505            other,
506            "printf '%s' \"$A3S_SHELL_OVERLAY\" > other-overlay.txt",
507            Duration::from_secs(5),
508        )
509        .unwrap();
510        let written = std::fs::read_to_string(root.path().join("other-overlay.txt")).unwrap();
511        assert_eq!(
512            written.trim(),
513            "other-only",
514            "the writer session must record its own overlay, not the other session"
515        );
516        assert!(
517            !written.contains("owner-only"),
518            "overlay leaked across sessions that share a cwd: {written:?}"
519        );
520        drop_session(owner);
521        drop_session(other);
522    }
523
524    #[test]
525    fn cancel_kills_detached_jobs_in_the_session() {
526        let root = tempfile::tempdir().unwrap();
527        bind_session("cancel", root.path());
528        let id = detach("cancel", "sleep 30", CommandAdmission::Allow).unwrap();
529        assert_eq!(poll("cancel", &id).unwrap(), "running");
530        kill_session("cancel");
531        assert!(poll("cancel", &id).is_err());
532        drop_session("cancel");
533    }
534
535    #[test]
536    fn timeout_leaves_no_child_process() {
537        let root = tempfile::tempdir().unwrap();
538        bind_session("timeout", root.path());
539        let error = run_bounded("timeout", "sleep 30", Duration::from_millis(80)).unwrap_err();
540        assert!(error.to_string().contains("killed the process group"));
541        drop_session("timeout");
542    }
543
544    #[cfg(windows)]
545    #[test]
546    fn killing_a_detached_job_stops_its_descendant_before_a_later_write() {
547        let root = tempfile::tempdir().unwrap();
548        let child_started = root.path().join("child-started");
549        let leaked = root.path().join("leaked");
550        let child_started_literal = child_started.to_string_lossy().replace('\'', "''");
551        let leaked_literal = leaked.to_string_lossy().replace('\'', "''");
552        let powershell = crate::tools::builtin::bash::windows_host_powershell(root.path())
553            .expect("PowerShell 7");
554        let powershell_literal = powershell.to_string_lossy().replace('\'', "''");
555        let command = format!(
556            "$child = Start-Process -FilePath '{powershell_literal}' -PassThru -WindowStyle Hidden \
557             -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Set-Content -LiteralPath ''{child_started_literal}'' -Value started; Start-Sleep -Seconds 1; Set-Content -LiteralPath ''{leaked_literal}'' -Value leaked'; \
558             Wait-Process -Id $child.Id"
559        );
560        let session = format!("detach-tree-{}", std::process::id());
561        bind_session(&session, root.path());
562        let id = detach(&session, &command, CommandAdmission::Allow).unwrap();
563        let deadline = std::time::Instant::now() + Duration::from_secs(8);
564        while !child_started.exists() {
565            assert!(
566                deadline > std::time::Instant::now(),
567                "descendant did not start before the detached shell was killed"
568            );
569            std::thread::sleep(Duration::from_millis(20));
570        }
571        kill(&session, &id).unwrap();
572        std::thread::sleep(Duration::from_millis(1_200));
573        drop_session(&session);
574        assert!(
575            !leaked.exists(),
576            "killing a detached job must stop a descendant before its later write"
577        );
578    }
579
580    #[cfg(windows)]
581    #[test]
582    fn timing_out_a_shell_stops_its_descendant_before_a_later_write() {
583        let root = tempfile::tempdir().unwrap();
584        let child_started = root.path().join("child-started");
585        let leaked = root.path().join("leaked");
586        let child_started_literal = child_started.to_string_lossy().replace('\'', "''");
587        let leaked_literal = leaked.to_string_lossy().replace('\'', "''");
588        let powershell = crate::tools::builtin::bash::windows_host_powershell(root.path())
589            .expect("PowerShell 7");
590        let powershell_literal = powershell.to_string_lossy().replace('\'', "''");
591        let command = format!(
592            "$child = Start-Process -FilePath '{powershell_literal}' -PassThru -WindowStyle Hidden \
593             -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Set-Content -LiteralPath ''{child_started_literal}'' -Value started; Start-Sleep -Seconds 30; Set-Content -LiteralPath ''{leaked_literal}'' -Value leaked'; \
594             Wait-Process -Id $child.Id"
595        );
596        let session = format!("timeout-tree-{}", std::process::id());
597        bind_session(&session, root.path());
598        let session_for_run = session.clone();
599        let runner = std::thread::spawn(move || {
600            run_bounded(&session_for_run, &command, Duration::from_secs(8))
601        });
602        let deadline = std::time::Instant::now() + Duration::from_secs(6);
603        while !child_started.exists() {
604            assert!(
605                deadline > std::time::Instant::now(),
606                "descendant did not start before the shell timeout"
607            );
608            std::thread::sleep(Duration::from_millis(20));
609        }
610        let error = runner.join().expect("timeout runner").unwrap_err();
611        assert!(
612            error.to_string().contains("killed the process group"),
613            "timeout did not report the process-group kill: {error}"
614        );
615        std::thread::sleep(Duration::from_millis(1_200));
616        drop_session(&session);
617        assert!(
618            !leaked.exists(),
619            "a shell timeout must stop a descendant before its later write"
620        );
621    }
622
623    #[test]
624    fn transcript_omits_secret_env() {
625        let root = tempfile::tempdir().unwrap();
626        bind_session("env", root.path());
627        set_env("env", "PATH", "/usr/bin").unwrap();
628        set_env("env", "OPENAI_API_KEY", "sk-secret").unwrap();
629        let visible = transcript_env("env");
630        assert!(visible.iter().any(|(key, _)| key == "PATH"));
631        assert!(visible.iter().all(|(key, _)| key != "OPENAI_API_KEY"));
632        drop_session("env");
633    }
634
635    /// S-WT-01: one rooted session, 100 commands, including escapes.
636    #[test]
637    #[ignore = "S-WT-01 rooted shell soak"]
638    fn soak_rooted_shell_keeps_cwd_inside_one_hundred_commands() {
639        let root = tempfile::tempdir().unwrap();
640        let nested = root.path().join("nested");
641        std::fs::create_dir(&nested).unwrap();
642        let outside =
643            std::env::temp_dir().join(format!("a3s-shell-escape-{}.txt", std::process::id()));
644        let _ = std::fs::remove_file(&outside);
645        let session = format!("soak-wt-{}", std::process::id());
646        bind_session_rooted(&session, root.path(), root.path());
647        let parent = root.path().parent().expect("parent");
648        let absolute_escape = format!("cd {}", parent.display());
649
650        for index in 0..100 {
651            let before = cwd(&session).expect("cwd");
652            let command = match index % 4 {
653                0 => "cd nested",
654                1 => "cd ..",
655                2 => absolute_escape.as_str(),
656                _ => "cd ..",
657            };
658            let result = admit(&session, command, CommandAdmission::Allow);
659            let after = cwd(&session).expect("cwd");
660            assert!(
661                stays_under_root(&after, root.path()),
662                "cycle {index} cwd left the root: {after:?}"
663            );
664            if result.is_err() {
665                assert_eq!(before, after, "denied cd moved cwd on cycle {index}");
666            }
667            assert_eq!(
668                job_count(&session),
669                0,
670                "cycle {index} leaked a detached job"
671            );
672            assert!(
673                !outside.exists(),
674                "cycle {index} created a file outside the root"
675            );
676        }
677
678        drop_session(&session);
679        let _ = std::fs::remove_file(&outside);
680    }
681}