Skip to main content

locode_host/
shell.rs

1//! Shell execution: capture stdout/stderr/exit, hard timeout, output cap, group-kill.
2
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::time::{Duration, Instant};
6
7use tokio::io::{AsyncRead, AsyncReadExt};
8use tokio::process::{Child, Command};
9use tokio_util::sync::CancellationToken;
10
11use crate::Host;
12
13/// A command to run through the host's shell.
14#[derive(Debug, Clone)]
15pub struct ExecRequest {
16    /// The command line, run as `<shell> -lc <command>` (or `-c` when not a login shell).
17    pub command: String,
18    /// Working directory (the caller jail-resolves this).
19    pub cwd: PathBuf,
20    /// Per-call timeout; `None` uses the host default, then clamped to the host max.
21    pub timeout: Option<Duration>,
22    /// Extra environment variables, layered on top of the inherited environment.
23    pub env: Vec<(String, String)>,
24    /// Harness-specific shell shape (Task 26 Slice 0b); `None` = host default.
25    pub shell: Option<ShellSpec>,
26    /// Opt-in combined front/back capture + spill; `None` = per-stream tails.
27    pub front_back: Option<FrontBackSpec>,
28}
29
30/// A harness's shell invocation shape (plan-finalization Q5, 2026-07-21): the
31/// *choice* rides the request; execution stays behind the host door. Grok's
32/// shape is `{ detect_program: true, login_arg: false, login_path_probe: true }`.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct ShellSpec {
35    /// Use the user's `$SHELL` when its basename is `bash` or `zsh` (grok's
36    /// detection); otherwise fall back to the host's configured program.
37    pub detect_program: bool,
38    /// `true` = `-lc` (login), `false` = `-c` (grok always uses `-c`).
39    pub login_arg: bool,
40    /// Inject the login shell's `PATH` (probed once per host, cached) so
41    /// non-login `-c` children still see user PATH entries (grok's probe).
42    pub login_path_probe: bool,
43}
44
45/// Opt-in combined-stream retention (Task 26 Slice 0b; grok's bash capture
46/// shape): keep the first and last halves of a char budget, stream the full
47/// output to a host-owned spill file. The pack renders separators/headers —
48/// the host returns structure only.
49#[derive(Debug, Clone, Copy)]
50pub struct FrontBackSpec {
51    /// Total retained characters across front + back (grok: `20_000`).
52    pub char_budget: usize,
53    /// Also stream the full combined output to a temp spill file
54    /// (retention-capped at [`SPILL_RETAIN_MAX`]).
55    pub spill: bool,
56}
57
58/// Hard cap on bytes written to a spill file (grok retains ≤ 64 MiB).
59pub const SPILL_RETAIN_MAX: u64 = 64 * 1024 * 1024;
60
61/// The combined front/back capture (present when the request asked for it).
62#[derive(Debug, Clone)]
63pub struct FrontBackCapture {
64    /// The first ~half of the char budget (everything, when not truncated).
65    pub front: String,
66    /// The last ~half of the char budget; `None` when nothing was cut.
67    pub back: Option<String>,
68    /// True total combined output size in bytes (before any retention).
69    pub total_bytes: u64,
70    /// The spill file holding the full output (≤ [`SPILL_RETAIN_MAX`]), when
71    /// requested and creatable.
72    pub spill_path: Option<PathBuf>,
73}
74
75/// The captured result of a command (completed, timed out, or cancelled).
76#[derive(Debug, Clone)]
77pub struct ExecOutput {
78    /// Captured stdout (tail-retained to the byte cap, lossy UTF-8).
79    pub stdout: String,
80    /// Captured stderr (tail-retained to the byte cap, lossy UTF-8).
81    pub stderr: String,
82    /// stdout then stderr, the convenience most tools render.
83    pub combined: String,
84    /// The exit code, or `None` if the command was killed by a signal.
85    pub exit_code: Option<i32>,
86    /// Whether the hard timeout fired.
87    pub timed_out: bool,
88    /// Whether cooperative cancellation fired.
89    pub cancelled: bool,
90    /// Whether either stream hit the byte cap (older bytes dropped).
91    pub truncated: bool,
92    /// Wall-clock duration.
93    pub duration: Duration,
94    /// Combined front/back capture (only when the request set `front_back`).
95    pub front_back: Option<FrontBackCapture>,
96}
97
98/// A failure to *spawn or capture* — not a failed command (that is a successful capture
99/// with a non-zero `exit_code`).
100#[derive(Debug, thiserror::Error)]
101pub enum ExecError {
102    /// The shell process could not be spawned.
103    #[error("failed to spawn shell: {0}")]
104    Spawn(String),
105}
106
107impl Host {
108    /// Run `req` through the shell, capturing output under the host's limits and honoring
109    /// cooperative `cancel`.
110    ///
111    /// A command that fails, times out, or is cancelled is a **successful capture** with
112    /// the corresponding fields set — the pack decides how to present it.
113    ///
114    /// # Errors
115    /// [`ExecError::Spawn`] only if the shell process itself cannot be started.
116    pub async fn exec(
117        &self,
118        req: ExecRequest,
119        cancel: &CancellationToken,
120    ) -> Result<ExecOutput, ExecError> {
121        let timeout = req
122            .timeout
123            .unwrap_or(self.limits.default_timeout)
124            .min(self.limits.max_timeout);
125        let program = self.shell_program_for(req.shell);
126        let path_env = match req.shell {
127            Some(spec) if spec.login_path_probe => self.probed_login_path(&program).await,
128            _ => None,
129        };
130        let cmd = self.build_shell_command(&req, &program, path_env.as_deref());
131        self.run_captured(cmd, timeout, cancel, req.front_back)
132            .await
133    }
134
135    /// The shell program for a request: `$SHELL` when detection is asked for
136    /// and its basename is `bash`/`zsh` (grok's rule), else the host default.
137    fn shell_program_for(&self, spec: Option<ShellSpec>) -> String {
138        if spec.is_some_and(|s| s.detect_program)
139            && let Ok(user_shell) = std::env::var("SHELL")
140        {
141            let base = std::path::Path::new(&user_shell)
142                .file_name()
143                .map(|n| n.to_string_lossy().into_owned());
144            if matches!(base.as_deref(), Some("bash" | "zsh")) {
145                return user_shell;
146            }
147        }
148        self.shell_program.clone()
149    }
150
151    /// The login shell's `PATH`, probed once per host and cached (grok's
152    /// login-PATH probe): `<program> -lc 'printf %s "$PATH"'` with a short
153    /// timeout; `None` (no injection) when the probe fails.
154    async fn probed_login_path(&self, program: &str) -> Option<String> {
155        self.login_path
156            .get_or_init(|| async {
157                let probe = Command::new(program)
158                    .arg("-lc")
159                    .arg("printf %s \"$PATH\"")
160                    .stdin(Stdio::null())
161                    .output();
162                match tokio::time::timeout(Duration::from_secs(5), probe).await {
163                    Ok(Ok(out)) if out.status.success() => {
164                        let path = String::from_utf8_lossy(&out.stdout).into_owned();
165                        (!path.trim().is_empty()).then_some(path)
166                    }
167                    _ => None,
168                }
169            })
170            .await
171            .clone()
172    }
173
174    /// Run a resolved program with explicit argv (**no shell** — safe for untrusted args
175    /// like a regex or glob), capturing output under the same limits/kill/cancel machinery
176    /// as [`Host::exec`]. Used by rg-backed tools (Task 11).
177    ///
178    /// # Errors
179    /// [`ExecError::Spawn`] if the program cannot be started (e.g. `rg` not on PATH).
180    pub async fn run_capture(
181        &self,
182        program: &Path,
183        args: &[String],
184        cwd: &Path,
185        timeout: Option<Duration>,
186        cancel: &CancellationToken,
187    ) -> Result<ExecOutput, ExecError> {
188        let timeout = timeout
189            .unwrap_or(self.limits.default_timeout)
190            .min(self.limits.max_timeout);
191        let mut cmd = Command::new(program);
192        cmd.args(args);
193        cmd.current_dir(cwd);
194        cmd.stdin(Stdio::null())
195            .stdout(Stdio::piped())
196            .stderr(Stdio::piped());
197        #[cfg(unix)]
198        cmd.process_group(0);
199        self.run_captured(cmd, timeout, cancel, None).await
200    }
201
202    /// The shared spawn → bounded-capture → timeout/cancel-kill → assemble body.
203    async fn run_captured(
204        &self,
205        mut cmd: Command,
206        timeout: Duration,
207        cancel: &CancellationToken,
208        front_back: Option<FrontBackSpec>,
209    ) -> Result<ExecOutput, ExecError> {
210        let started = Instant::now();
211        let cap = self.limits.max_output_bytes;
212
213        let mut child = cmd.spawn().map_err(|e| ExecError::Spawn(e.to_string()))?;
214        let pid = child.id();
215
216        let stdout = child.stdout.take();
217        let stderr = child.stderr.take();
218        let (out_task, err_task, combined_task) = if let Some(spec) = front_back {
219            // Combined interleaved capture: both streams forward chunks in
220            // arrival order to one collector (front/back retention + spill);
221            // per-stream capped buffers are maintained alongside for the
222            // existing `stdout`/`stderr` fields.
223            let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
224            let tx_err = tx.clone();
225            let out = tokio::spawn(read_capped_forwarding(stdout, cap, tx));
226            let err = tokio::spawn(read_capped_forwarding(stderr, cap, tx_err));
227            let collector = tokio::spawn(collect_front_back(rx, spec));
228            (out, err, Some(collector))
229        } else {
230            (
231                tokio::spawn(read_capped(stdout, cap)),
232                tokio::spawn(read_capped(stderr, cap)),
233                None,
234            )
235        };
236
237        let mut exit_code = None;
238        let mut timed_out = false;
239        let mut cancelled = false;
240        tokio::select! {
241            status = child.wait() => {
242                exit_code = status.ok().and_then(|s| s.code());
243            }
244            () = tokio::time::sleep(timeout) => {
245                timed_out = true;
246                terminate(&mut child, pid, self.limits.kill_grace).await;
247            }
248            () = cancel.cancelled() => {
249                cancelled = true;
250                terminate(&mut child, pid, self.limits.kill_grace).await;
251            }
252        }
253
254        let (stdout, out_trunc) = out_task.await.unwrap_or_else(|_| (Vec::new(), false));
255        let (stderr, err_trunc) = err_task.await.unwrap_or_else(|_| (Vec::new(), false));
256        let front_back_capture = match combined_task {
257            Some(task) => task.await.ok(),
258            None => None,
259        };
260        let stdout = String::from_utf8_lossy(&stdout).into_owned();
261        let stderr = String::from_utf8_lossy(&stderr).into_owned();
262        let combined = combine(&stdout, &stderr);
263
264        Ok(ExecOutput {
265            stdout,
266            stderr,
267            combined,
268            exit_code,
269            timed_out,
270            cancelled,
271            truncated: out_trunc || err_trunc,
272            duration: started.elapsed(),
273            front_back: front_back_capture,
274        })
275    }
276
277    fn build_shell_command(
278        &self,
279        req: &ExecRequest,
280        program: &str,
281        path_env: Option<&str>,
282    ) -> Command {
283        let mut cmd = Command::new(program);
284        let login = req.shell.map_or(self.login_shell, |s| s.login_arg);
285        cmd.arg(if login { "-lc" } else { "-c" });
286        if let Some(path) = path_env {
287            cmd.env("PATH", path);
288        }
289        cmd.arg(&req.command);
290        cmd.current_dir(&req.cwd);
291        cmd.stdin(Stdio::null())
292            .stdout(Stdio::piped())
293            .stderr(Stdio::piped());
294        for (key, value) in &req.env {
295            cmd.env(key, value);
296        }
297        // New process group so we can kill the whole tree, not just the shell. This is a
298        // safe API — the fork-time `setpgid` lives inside std/tokio, not our crate.
299        #[cfg(unix)]
300        cmd.process_group(0);
301        cmd
302    }
303}
304
305/// Read a child stream, retaining at most `cap` bytes (drop oldest — the tail holds the
306/// error/exit summary). Bounds peak memory to O(cap) rather than O(total output).
307async fn read_capped<R: AsyncRead + Unpin>(reader: Option<R>, cap: usize) -> (Vec<u8>, bool) {
308    let Some(mut reader) = reader else {
309        return (Vec::new(), false);
310    };
311    let mut buf = Vec::new();
312    let mut chunk = [0u8; 8192];
313    let mut truncated = false;
314    loop {
315        match reader.read(&mut chunk).await {
316            Ok(0) | Err(_) => break,
317            Ok(n) => {
318                buf.extend_from_slice(&chunk[..n]);
319                if buf.len() > cap {
320                    let excess = buf.len() - cap;
321                    buf.drain(..excess);
322                    truncated = true;
323                }
324            }
325        }
326    }
327    (buf, truncated)
328}
329
330/// Like [`read_capped`], additionally forwarding every chunk (arrival order)
331/// to the combined collector.
332async fn read_capped_forwarding<R: AsyncRead + Unpin>(
333    reader: Option<R>,
334    cap: usize,
335    tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
336) -> (Vec<u8>, bool) {
337    let Some(mut reader) = reader else {
338        return (Vec::new(), false);
339    };
340    let mut buf = Vec::new();
341    let mut chunk = [0u8; 8192];
342    let mut truncated = false;
343    loop {
344        match reader.read(&mut chunk).await {
345            Ok(0) | Err(_) => break,
346            Ok(n) => {
347                let _ = tx.send(chunk[..n].to_vec());
348                buf.extend_from_slice(&chunk[..n]);
349                if buf.len() > cap {
350                    let excess = buf.len() - cap;
351                    buf.drain(..excess);
352                    truncated = true;
353                }
354            }
355        }
356    }
357    (buf, truncated)
358}
359
360/// The combined collector: front half + back half of the char budget in
361/// memory, full stream to the spill file (≤ [`SPILL_RETAIN_MAX`]), true byte
362/// total. Text conversion is lossy UTF-8 with char-boundary-safe cuts.
363async fn collect_front_back(
364    mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
365    spec: FrontBackSpec,
366) -> FrontBackCapture {
367    use std::collections::VecDeque;
368    use tokio::io::AsyncWriteExt;
369
370    let front_budget = spec.char_budget / 2;
371    let back_budget = spec.char_budget - front_budget;
372    let mut front: Vec<u8> = Vec::new();
373    let mut back: VecDeque<u8> = VecDeque::new();
374    let mut total: u64 = 0;
375    let mut spill = None;
376    let mut spill_path = None;
377    if spec.spill {
378        let dir = std::env::temp_dir().join("locode").join("exec");
379        if tokio::fs::create_dir_all(&dir).await.is_ok() {
380            static SPILL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
381            let seq = SPILL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
382            let path = dir.join(format!("cmd-{}-{}.log", std::process::id(), seq));
383            if let Ok(file) = tokio::fs::File::create(&path).await {
384                spill = Some(file);
385                spill_path = Some(path);
386            }
387        }
388    }
389    while let Some(chunk) = rx.recv().await {
390        if let Some(file) = spill.as_mut()
391            && total < SPILL_RETAIN_MAX
392        {
393            let room = usize::try_from(SPILL_RETAIN_MAX - total).unwrap_or(usize::MAX);
394            let write = &chunk[..chunk.len().min(room)];
395            let _ = file.write_all(write).await;
396        }
397        total += chunk.len() as u64;
398        if front.len() < front_budget {
399            let room = front_budget - front.len();
400            front.extend_from_slice(&chunk[..chunk.len().min(room)]);
401            if chunk.len() > room {
402                back.extend(&chunk[room..]);
403            }
404        } else {
405            back.extend(&chunk);
406        }
407        while back.len() > back_budget {
408            back.pop_front();
409        }
410    }
411    if let Some(mut file) = spill {
412        let _ = file.flush().await;
413    }
414    let truncated = total > (front.len() + back.len()) as u64;
415    let front_str = String::from_utf8_lossy(&front).into_owned();
416    let back_bytes: Vec<u8> = back.into_iter().collect();
417    let back_str = String::from_utf8_lossy(&back_bytes).into_owned();
418    if truncated {
419        FrontBackCapture {
420            front: front_str,
421            back: Some(back_str),
422            total_bytes: total,
423            spill_path,
424        }
425    } else {
426        FrontBackCapture {
427            front: format!("{front_str}{back_str}"),
428            back: None,
429            total_bytes: total,
430            spill_path,
431        }
432    }
433}
434
435/// Kill a running child: its whole process group on Unix (SIGTERM → grace → SIGKILL),
436/// else the direct child. Best-effort; always reaps.
437async fn terminate(child: &mut Child, pid: Option<u32>, grace: Duration) {
438    if group_kill(child, pid, grace).await {
439        return;
440    }
441    let _ = child.start_kill();
442    let _ = child.wait().await;
443}
444
445#[cfg(unix)]
446async fn group_kill(child: &mut Child, pid: Option<u32>, grace: Duration) -> bool {
447    use nix::sys::signal::{Signal, killpg};
448    use nix::unistd::Pid;
449
450    let Some(pid) = pid else { return false };
451    let Ok(raw) = i32::try_from(pid) else {
452        return false;
453    };
454    // Guard against a degenerate pgid (0/1) that would broadcast the signal. A child
455    // spawned with `process_group(0)` leads its own group with pid > 1.
456    if raw <= 1 {
457        return false;
458    }
459    let leader = Pid::from_raw(raw);
460    let _ = killpg(leader, Signal::SIGTERM);
461    if tokio::time::timeout(grace, child.wait()).await.is_err() {
462        let _ = killpg(leader, Signal::SIGKILL);
463        let _ = child.wait().await;
464    }
465    true
466}
467
468#[cfg(not(unix))]
469async fn group_kill(_child: &mut Child, _pid: Option<u32>, _grace: Duration) -> bool {
470    false
471}
472
473/// stdout then stderr, separated by a newline when both are present.
474fn combine(stdout: &str, stderr: &str) -> String {
475    match (stdout.is_empty(), stderr.is_empty()) {
476        (false, false) => format!("{stdout}\n{stderr}"),
477        (false, true) => stdout.to_string(),
478        (true, false) => stderr.to_string(),
479        (true, true) => String::new(),
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::{ExecLimits, Host, HostConfig, PathPolicy, test_host};
487    use std::time::Duration;
488    use tempfile::tempdir;
489
490    fn req(command: &str, cwd: PathBuf) -> ExecRequest {
491        ExecRequest {
492            command: command.to_string(),
493            cwd,
494            timeout: None,
495            env: Vec::new(),
496            shell: None,
497            front_back: None,
498        }
499    }
500
501    // Tests run the shell as a NON-login shell (`-c`) so login-profile output can't
502    // pollute the captured streams.
503    fn shell_host(root: &std::path::Path) -> Host {
504        test_host(root, PathPolicy::Jailed, false)
505    }
506
507    #[tokio::test]
508    async fn captures_stdout_and_exit() {
509        let dir = tempdir().unwrap();
510        let host = shell_host(dir.path());
511        let out = host
512            .exec(
513                req("echo hello", dir.path().into()),
514                &CancellationToken::new(),
515            )
516            .await
517            .unwrap();
518        assert!(out.stdout.contains("hello"));
519        assert_eq!(out.exit_code, Some(0));
520        assert!(!out.timed_out && !out.cancelled);
521    }
522
523    #[tokio::test]
524    async fn captures_stderr_and_nonzero_exit() {
525        let dir = tempdir().unwrap();
526        let host = shell_host(dir.path());
527        let out = host
528            .exec(
529                req(">&2 echo boom; exit 3", dir.path().into()),
530                &CancellationToken::new(),
531            )
532            .await
533            .unwrap();
534        assert!(out.stderr.contains("boom"));
535        assert_eq!(out.exit_code, Some(3));
536    }
537
538    #[tokio::test]
539    async fn timeout_kills_sleeper() {
540        let dir = tempdir().unwrap();
541        let mut config = HostConfig::new(dir.path());
542        config.login_shell = false;
543        config.exec = ExecLimits {
544            default_timeout: Duration::from_millis(200),
545            ..ExecLimits::default()
546        };
547        let host = Host::new(config).unwrap();
548        let started = Instant::now();
549        let out = host
550            .exec(
551                req("sleep 30", dir.path().into()),
552                &CancellationToken::new(),
553            )
554            .await
555            .unwrap();
556        assert!(out.timed_out);
557        assert!(out.exit_code.is_none());
558        assert!(
559            started.elapsed() < Duration::from_secs(5),
560            "should not wait for the sleep"
561        );
562    }
563
564    #[tokio::test]
565    async fn cancellation_kills_running_command() {
566        let dir = tempdir().unwrap();
567        let host = shell_host(dir.path());
568        let cancel = CancellationToken::new();
569        let child_cancel = cancel.clone();
570        tokio::spawn(async move {
571            tokio::time::sleep(Duration::from_millis(50)).await;
572            child_cancel.cancel();
573        });
574        let started = Instant::now();
575        let out = host
576            .exec(req("sleep 30", dir.path().into()), &cancel)
577            .await
578            .unwrap();
579        assert!(out.cancelled);
580        assert!(started.elapsed() < Duration::from_secs(5));
581    }
582
583    #[tokio::test]
584    async fn output_over_cap_is_truncated() {
585        let dir = tempdir().unwrap();
586        let mut config = HostConfig::new(dir.path());
587        config.login_shell = false;
588        config.exec = ExecLimits {
589            max_output_bytes: 1000,
590            ..ExecLimits::default()
591        };
592        let host = Host::new(config).unwrap();
593        // Print ~200 KB.
594        let out = host
595            .exec(
596                req(
597                    "for i in $(seq 1 20000); do echo 0123456789; done",
598                    dir.path().into(),
599                ),
600                &CancellationToken::new(),
601            )
602            .await
603            .unwrap();
604        assert!(out.truncated);
605        assert!(
606            out.stdout.len() <= 1000 + 16,
607            "kept ~cap bytes, got {}",
608            out.stdout.len()
609        );
610    }
611
612    #[tokio::test]
613    async fn front_back_retains_head_and_tail_and_spills() {
614        let dir = tempdir().unwrap();
615        let host = shell_host(dir.path());
616        let mut request = req(
617            "for i in $(seq 1 2000); do echo line-$i; done",
618            dir.path().into(),
619        );
620        request.front_back = Some(FrontBackSpec {
621            char_budget: 400,
622            spill: true,
623        });
624        let out = host.exec(request, &CancellationToken::new()).await.unwrap();
625        let capture = out.front_back.expect("capture requested");
626        assert!(
627            capture.front.starts_with("line-1\n"),
628            "front holds the head"
629        );
630        let back = capture.back.expect("truncated -> back present");
631        assert!(
632            back.ends_with("line-2000\n"),
633            "back holds the tail: {back:?}"
634        );
635        assert!(capture.front.len() <= 200 && back.len() <= 200);
636        assert!(capture.total_bytes > 400, "true total, not retained size");
637        let spill = capture.spill_path.expect("spill requested");
638        let spilled = std::fs::read_to_string(&spill).unwrap();
639        assert!(spilled.starts_with("line-1\n") && spilled.ends_with("line-2000\n"));
640        assert_eq!(spilled.len() as u64, capture.total_bytes);
641        let _ = std::fs::remove_file(spill);
642    }
643
644    #[tokio::test]
645    async fn front_back_small_output_is_untruncated() {
646        let dir = tempdir().unwrap();
647        let host = shell_host(dir.path());
648        let mut request = req("echo tiny", dir.path().into());
649        request.front_back = Some(FrontBackSpec {
650            char_budget: 400,
651            spill: false,
652        });
653        let out = host.exec(request, &CancellationToken::new()).await.unwrap();
654        let capture = out.front_back.expect("capture requested");
655        assert_eq!(capture.front, "tiny\n");
656        assert!(capture.back.is_none(), "nothing cut");
657        assert_eq!(capture.total_bytes, 5);
658        assert!(capture.spill_path.is_none());
659    }
660
661    #[tokio::test]
662    async fn shell_spec_c_arg_and_path_probe() {
663        let dir = tempdir().unwrap();
664        let host = shell_host(dir.path());
665        let mut request = req("echo $PATH", dir.path().into());
666        request.shell = Some(ShellSpec {
667            detect_program: false,
668            login_arg: false,
669            login_path_probe: true,
670        });
671        let out = host.exec(request, &CancellationToken::new()).await.unwrap();
672        assert_eq!(out.exit_code, Some(0));
673        // The probed login PATH is non-empty and injected.
674        assert!(!out.stdout.trim().is_empty());
675        // Probe is cached: second call reuses the OnceCell (observable as
676        // identical PATH output and no error).
677        let mut request2 = req("echo $PATH", dir.path().into());
678        request2.shell = Some(ShellSpec {
679            detect_program: false,
680            login_arg: false,
681            login_path_probe: true,
682        });
683        let out2 = host
684            .exec(request2, &CancellationToken::new())
685            .await
686            .unwrap();
687        assert_eq!(out.stdout, out2.stdout);
688    }
689
690    #[tokio::test]
691    async fn null_stdin_does_not_hang() {
692        let dir = tempdir().unwrap();
693        let host = shell_host(dir.path());
694        // `cat` with no args reads stdin; null stdin → immediate EOF.
695        let out = host
696            .exec(req("cat", dir.path().into()), &CancellationToken::new())
697            .await
698            .unwrap();
699        assert_eq!(out.exit_code, Some(0));
700    }
701}