Skip to main content

kaish_kernel/
dispatch.rs

1//! Command dispatch — the single execution path for all commands.
2//!
3//! The `CommandDispatcher` trait defines how a single command is resolved and
4//! executed. The Kernel implements this trait with the full dispatch chain:
5//! user tools → builtins → .kai scripts → external commands → backend tools.
6//!
7//! `PipelineRunner` calls `dispatcher.dispatch()` for each command in a
8//! pipeline, handling I/O routing (stdin piping, redirects) around each call.
9//!
10//! ```text
11//! Stmt::Command ──┐
12//!                  ├──▶ execute_pipeline() ──▶ PipelineRunner::run(dispatcher, commands, ctx)
13//! Stmt::Pipeline ──┘                                  │
14//!                                               for each command:
15//!                                                 dispatcher.dispatch(cmd, ctx)
16//!                                                     │
17//!                                               ┌─────┼──────────────┐
18//!                                               │     │              │
19//!                                          user_tools builtins  .kai scripts
20//!                                                                external cmds
21//!                                                                backend tools
22//! ```
23
24use std::sync::Arc;
25
26use anyhow::Result;
27use async_trait::async_trait;
28
29use crate::ast::{Command, Expr, Stmt, Value};
30use crate::interpreter::ExecResult;
31use crate::tools::ExecContext;
32#[cfg(test)]
33use crate::tools::{
34    external_commands_unavailable_error, ExternalCommandOutcome, ExternalCommandsUnavailable,
35};
36
37// The following imports are only used by the test-only `BackendDispatcher`.
38#[cfg(test)]
39use crate::ast::Arg;
40#[cfg(test)]
41use crate::backend::BackendError;
42#[cfg(test)]
43use crate::interpreter::apply_output_format;
44#[cfg(test)]
45use crate::scheduler::build_tool_args;
46#[cfg(test)]
47use crate::tools::{GlobalFlags, ToolRegistry};
48#[cfg(all(test, feature = "subprocess"))]
49use crate::tools::{resolve_in_path, virtual_cwd_error};
50
51/// Arm `PR_SET_PDEATHSIG(SIGKILL)` in a freshly forked child, so the OS kills
52/// it the moment `parent_pid` dies — for any reason, including `kill -9`, a
53/// segfault, or an OOM kill, none of which let the parent run a single
54/// instruction of cleanup. This is the one orphan guard that does not depend
55/// on `setpgid` + a pidfd kill, `kill_on_drop`, or any other code of ours
56/// getting to run.
57///
58/// **Call only between fork and exec.** `prctl` and `getppid` are both
59/// async-signal-safe per POSIX, which is what makes that legal.
60///
61/// The `getppid` check closes `PR_SET_PDEATHSIG`'s documented race: if the
62/// parent dies in the window between `fork` and the `prctl` above, the signal
63/// is armed against a parent that is already gone and will never be delivered
64/// — the exact orphan the flag exists to prevent, in the exact window it is
65/// hardest to notice. Comparing against the pid the parent captured *before*
66/// forking detects it, and failing the `pre_exec` fails the spawn loudly
67/// rather than exec'ing a process nothing will ever reap.
68///
69/// Linux only. macOS has no equivalent that works without a live watcher
70/// process, so this is compiled out there rather than faked with something
71/// weaker — see `KernelConfig::kill_children_on_parent_death`.
72#[cfg(all(unix, feature = "subprocess"))]
73pub(crate) fn arm_parent_death_signal(parent_pid: u32) -> std::io::Result<()> {
74    #[cfg(target_os = "linux")]
75    {
76        nix::sys::prctl::set_pdeathsig(nix::sys::signal::Signal::SIGKILL)
77            .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
78
79        if nix::unistd::getppid().as_raw() as u32 != parent_pid {
80            return Err(std::io::Error::other(
81                "parent died before the parent-death signal was armed",
82            ));
83        }
84    }
85    #[cfg(not(target_os = "linux"))]
86    let _ = parent_pid;
87    Ok(())
88}
89
90/// Position of a command within a pipeline.
91///
92/// Used by external command execution to decide stdio inheritance:
93/// - `Only` or `Last` in interactive mode → inherit terminal
94/// - `First` or `Middle` → always capture
95///
96/// Not `#[non_exhaustive]`, deliberately: kaish pipelines are a strictly
97/// linear chain of stages, so these four variants exhaust every position a
98/// stage can occupy. A fifth would mean pipelines stopped being linear — a
99/// grammar change, not a variant this enum grows on its own.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum PipelinePosition {
102    /// Single command, no pipe.
103    #[default]
104    Only,
105    /// First command in a pipeline (no stdin from pipe).
106    First,
107    /// Middle of a pipeline (piped stdin, piped stdout).
108    Middle,
109    /// Last command in a pipeline (piped stdin, final output).
110    Last,
111}
112
113/// Trait for dispatching a single command through the full resolution chain.
114///
115/// Implementations handle argument parsing, tool lookup, and execution.
116/// The pipeline runner handles I/O routing (stdin, redirects, piping).
117#[async_trait]
118pub trait CommandDispatcher: Send + Sync {
119    /// Dispatch a single command for execution.
120    ///
121    /// The `ctx` provides stdin (from pipe or redirect), scope, and backend.
122    /// Implementations should handle schema-aware argument parsing and
123    /// output format extraction internally.
124    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult>;
125
126    /// Dispatch a compound statement (`if`, `for`, `while`, `case`) that sits
127    /// in a pipeline stage.
128    ///
129    /// The statement runs to completion and its whole output comes back in the
130    /// `ExecResult`; `PipelineRunner` then writes those bytes to the pipe. So
131    /// `ctx.pipe_stdout` must stay with the runner — hand it to the statement
132    /// and the first nested command inside it would take the writer and the
133    /// rest of the loop would write nowhere.
134    ///
135    /// The default rejects the form. Only a dispatcher that can execute a
136    /// whole statement (the `Kernel`) overrides it; a dispatcher that resolves
137    /// one command at a time has nothing to run a loop body with, and saying
138    /// so beats returning empty output at exit 0.
139    async fn dispatch_stmt(&self, _stmt: &Stmt, _ctx: &mut ExecContext) -> Result<ExecResult> {
140        anyhow::bail!("this dispatcher cannot run a compound statement in a pipeline stage")
141    }
142
143    /// Evaluate an expression through the full async chain.
144    ///
145    /// Unlike the runner's sync `eval_simple_expr`, this can run command
146    /// substitution (`$(...)`) because it has access to pipeline execution.
147    /// Used for redirect targets and heredoc bodies so `cat < $(cmd)`,
148    /// `echo x > $(cmd)`, and `$(...)` inside heredoc bodies work. The `ctx`
149    /// carries scope/cwd/backend for dispatchers that evaluate against it;
150    /// stateful dispatchers (Kernel) snapshot their own session state and
151    /// only let command output escape (side effects like `cd` do not).
152    async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value>;
153
154    /// Fork the dispatcher for concurrent execution (detached).
155    ///
156    /// Returns a subsidiary dispatcher with independent mutable state, safe
157    /// to run concurrently with the parent and other forks without data
158    /// races on shared scope/cwd/aliases. Used by background `&` jobs,
159    /// where the fork must survive parent cancellation.
160    ///
161    /// For stateful dispatchers (e.g. Kernel) this snapshots per-session
162    /// state into a fresh instance. Stateless dispatchers may clone.
163    async fn fork(&self) -> Arc<dyn CommandDispatcher>;
164
165    /// Fork the dispatcher for concurrent execution (attached to parent cancel).
166    ///
167    /// Like [`Self::fork`] but the fork's cancellation token is a *child* of
168    /// the parent's. Cancelling the parent (timeout, Ctrl-C, embedder
169    /// `Kernel::cancel`) cascades into the fork, which then kills its own
170    /// external children via the usual SIGTERM/SIGKILL discipline.
171    ///
172    /// Used for foreground concurrency: scatter workers, concurrent pipeline
173    /// stages, command substitution. Default implementation delegates to
174    /// [`Self::fork`] for stateless dispatchers that don't track cancellation.
175    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
176        self.fork().await
177    }
178}
179
180/// Minimal stateless dispatcher used by pipeline/runner unit tests.
181///
182/// Production code uses `Kernel` (via `Kernel::fork` for concurrent contexts).
183/// This test-only dispatcher routes directly through `backend.call_tool()` so
184/// the pipeline runner can be exercised without spinning up a full Kernel.
185///
186/// Limitations (intentional — these are test-only constraints):
187/// - No user-defined tools
188/// - No .kai script resolution
189/// - No async argument evaluation (command substitution in args won't work)
190#[cfg(test)]
191pub(crate) struct BackendDispatcher {
192    tools: Arc<ToolRegistry>,
193}
194
195#[cfg(test)]
196impl BackendDispatcher {
197    /// Create a new backend dispatcher with the given tool registry.
198    pub(crate) fn new(tools: Arc<ToolRegistry>) -> Self {
199        Self { tools }
200    }
201
202    /// Try to execute an external command (PATH lookup + process spawn).
203    ///
204    /// Used as fallback when no builtin/backend tool matches. `Unavailable`
205    /// if kaish will not attempt this at all (kept in sync with
206    /// kernel.rs::try_execute_external — see `ExternalCommandOutcome`).
207    /// Always captures stdout/stderr (never inherits terminal — pipeline
208    /// stages don't need interactive I/O).
209    #[cfg(not(feature = "subprocess"))]
210    async fn try_external(
211        &self,
212        _name: &str,
213        _args: &[Arg],
214        _ctx: &mut ExecContext,
215    ) -> ExternalCommandOutcome {
216        ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::NotCompiled)
217    }
218
219    /// Try to execute an external command (PATH lookup + process spawn).
220    #[cfg(feature = "subprocess")]
221    async fn try_external(
222        &self,
223        name: &str,
224        args: &[Arg],
225        ctx: &mut ExecContext,
226    ) -> ExternalCommandOutcome {
227        if !ctx.allow_external_commands {
228            return ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::ConfiguredOff);
229        }
230        match self.try_external_on_path(name, args, ctx).await {
231            Some(result) => ExternalCommandOutcome::Ran(Box::new(result)),
232            None => ExternalCommandOutcome::NotFound,
233        }
234    }
235
236    /// The actual PATH lookup + spawn, once the caller has confirmed
237    /// external commands are allowed at all — kept in sync with
238    /// kernel.rs::try_execute_external_on_path.
239    #[cfg(feature = "subprocess")]
240    async fn try_external_on_path(
241        &self,
242        name: &str,
243        args: &[Arg],
244        ctx: &mut ExecContext,
245    ) -> Option<ExecResult> {
246        // Real filesystem location of the shell's cwd, if any. A `None` real
247        // path means the cwd is virtual (a CoW overlay, an in-memory VFS
248        // mount, …) — there's nowhere for a child OS process to run. Don't
249        // bail out here: a bare command name that isn't in PATH at all is a
250        // genuine "not found" regardless of cwd. Once the command actually
251        // resolves, `real_cwd` is checked again below and the honest reason
252        // is given then — kept in sync with kernel.rs::try_execute_external
253        // (issue #181).
254        let real_cwd = ctx.backend.resolve_real_path(&ctx.cwd);
255
256        // Resolve command: absolute/relative path or PATH lookup
257        let executable = if name.contains('/') {
258            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
259            let resolved = if std::path::Path::new(name).is_absolute() {
260                std::path::PathBuf::from(name)
261            } else {
262                match &real_cwd {
263                    Some(real_cwd) => real_cwd.join(name),
264                    // Can't resolve a relative path without a real cwd to
265                    // join against, so we can't even tell whether it would
266                    // exist — name the actual blocker.
267                    None => return Some(virtual_cwd_error(name, &ctx.cwd)),
268                }
269            };
270            // Kept in sync with kernel.rs::try_execute_external (issue
271            // #229): `exists()` alone isn't enough — a directory or a
272            // non-executable file both "exist" but must fail with the
273            // clean, documented exit-126 class instead of falling through
274            // to `Command::spawn()` and leaking whatever raw OS error comes
275            // back (e.g. "Permission denied (os error 13)" under exit 127).
276            if !resolved.exists() {
277                return Some(ExecResult::failure(127, format!("{}: No such file or directory", name)));
278            }
279            if !resolved.is_file() {
280                return Some(ExecResult::failure(126, format!("{}: Is a directory", name)));
281            }
282            #[cfg(unix)]
283            {
284                use std::os::unix::fs::PermissionsExt;
285                let mode = std::fs::metadata(&resolved)
286                    .map(|m| m.permissions().mode())
287                    .unwrap_or(0);
288                if mode & 0o111 == 0 {
289                    return Some(ExecResult::failure(126, format!("{}: Permission denied", name)));
290                }
291            }
292            resolved.to_string_lossy().into_owned()
293        } else {
294            // PATH from scope only — never OS env (keeps this test-only spawn
295            // site in sync with kernel.rs::try_execute_external).
296            let path_var = ctx.scope.get("PATH")
297                .map(crate::interpreter::value_to_string)
298                .unwrap_or_default();
299            resolve_in_path(name, &path_var)?
300        };
301
302        // The executable resolved — found in PATH, or a path that exists —
303        // but there's still nowhere to run it without a real cwd.
304        let real_cwd = match real_cwd {
305            Some(p) => p,
306            None => return Some(virtual_cwd_error(name, &ctx.cwd)),
307        };
308
309        // Build flat argv from args. A for-loop (not filter_map) so the
310        // Decision D collection-argv guard can short-circuit the whole spawn
311        // — kept in sync with the production build in kernel.rs::build_args_flat.
312        let mut argv: Vec<String> = Vec::new();
313        for arg in args {
314            match arg {
315                Arg::Positional(expr) => match expr {
316                    Expr::Literal(Value::String(s)) => argv.push(s.clone()),
317                    Expr::Literal(Value::Int(i)) => argv.push(i.to_string()),
318                    Expr::Literal(Value::Float(f)) => argv.push(f.to_string()),
319                    // Kept in sync with kernel.rs::build_args_flat, which
320                    // pushes `raw` for the same reason.
321                    Expr::NumericLiteral { raw, .. } => argv.push(raw.clone()),
322                    Expr::VarRef(path) => {
323                        if let Ok(v) = ctx.scope.resolve_path(path) {
324                            if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &v) {
325                                return Some(ExecResult::failure(1, msg));
326                            }
327                            // Text sink: binary goes loud (kept in sync with
328                            // kernel.rs::build_args_flat).
329                            match crate::interpreter::value_to_text_sink(&v) {
330                                Ok(s) => argv.push(s),
331                                Err(e) => return Some(ExecResult::failure(1, e.to_string())),
332                            }
333                        }
334                    }
335                    // Remaining literal types (Bool/Json/Null/Bytes) — kept in
336                    // sync with the production build_args_flat, which resolves
337                    // every positional through value_to_text_sink (binary loud).
338                    Expr::Literal(other) => match crate::interpreter::value_to_text_sink(other) {
339                        Ok(s) => argv.push(s),
340                        Err(e) => return Some(ExecResult::failure(1, e.to_string())),
341                    },
342                    _ => {}
343                },
344                Arg::ShortFlag(f) => argv.push(format!("-{f}")),
345                Arg::LongFlag(f) => argv.push(format!("--{f}")),
346                Arg::Named { key, value } => match value {
347                    Expr::Literal(Value::String(s)) => argv.push(format!("--{key}={s}")),
348                    _ => argv.push(format!("--{key}=")),
349                },
350                Arg::WordAssign { key, value } => match value {
351                    Expr::Literal(Value::String(s)) => argv.push(format!("{key}={s}")),
352                    _ => argv.push(format!("{key}=")),
353                },
354                Arg::DoubleDash => argv.push("--".to_string()),
355            }
356        }
357
358        // Check for streaming pipes
359        let has_pipe_stdin = ctx.pipe_stdin.is_some();
360        let has_buffered_stdin = ctx.stdin.is_some();
361
362        // Spawn process
363        use tokio::process::Command;
364        use tokio::io::{AsyncReadExt, AsyncWriteExt};
365
366        let mut cmd = Command::new(&executable);
367        cmd.args(&argv);
368        cmd.current_dir(&real_cwd);
369        cmd.kill_on_drop(true);
370
371        // Hermetic env: child sees only kaish's exported vars, not the kaish
372        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
373        // populate it via KernelConfig::initial_vars at construction.
374        cmd.env_clear();
375        let exported = ctx.scope.exported_vars();
376        // A structured value can't cross the process boundary; refuse rather than
377        // silently JSON-serialize it into the child's environment. Kept in sync
378        // with the production spawn site in kernel.rs::try_execute_external.
379        if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
380            return Some(ExecResult::failure(1, msg));
381        }
382        for (var_name, value) in exported {
383            // Binary can't cross the process boundary as an env var value
384            // either — loud, not the `[binary: N bytes]` placeholder (kept in
385            // sync with the production spawn site).
386            match crate::interpreter::value_to_text_sink_named(
387                &value,
388                "an exported environment variable value",
389            ) {
390                Ok(s) => {
391                    cmd.env(var_name, s);
392                }
393                Err(e) => return Some(ExecResult::failure(1, e.to_string())),
394            }
395        }
396
397        // Stdin: pipe_stdin or buffered bytes or inherit (interactive) or null
398        cmd.stdin(if has_pipe_stdin || has_buffered_stdin {
399            std::process::Stdio::piped()
400        } else if ctx.interactive && matches!(ctx.pipeline_position, PipelinePosition::First | PipelinePosition::Only) {
401            std::process::Stdio::inherit()
402        } else {
403            std::process::Stdio::null()
404        });
405        cmd.stdout(std::process::Stdio::piped());
406        cmd.stderr(std::process::Stdio::piped());
407
408        // On Unix, always put the child in its own process group so a
409        // cancel can `killpg` the whole tree (the child plus any
410        // grandchildren) — matching the production spawn site
411        // (kernel.rs::try_execute_external) exactly. Without this, `killpg`
412        // targets a group nobody is actually in (an ESRCH no-op), and a
413        // grandchild spawned by the child survives cancellation — the exact
414        // gap GH #133 item 4 closes. This dispatcher has no job-control
415        // terminal integration (no `terminal_state`), so unlike production
416        // there is no signal-handler restoration to gate here.
417        #[cfg(unix)]
418        {
419            let kill_on_parent_death = ctx.kill_children_on_parent_death;
420            let parent_pid = std::process::id();
421            // SAFETY: setpgid, prctl, and getppid are async-signal-safe per
422            // POSIX; safe to call between fork and exec.
423            #[allow(unsafe_code)]
424            unsafe {
425                cmd.pre_exec(move || {
426                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
427                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
428                    if kill_on_parent_death {
429                        arm_parent_death_signal(parent_pid)?;
430                    }
431                    Ok(())
432                });
433            }
434        }
435
436        let mut child = match cmd.spawn() {
437            Ok(c) => c,
438            Err(e) => return Some(ExecResult::failure(127, format!("{}: {}", name, e))),
439        };
440        // Open a pidfd (Linux) for race-free direct-child kill via wait_or_kill.
441        let kill_target = crate::pidfd::KillTarget::from_child(&child);
442
443        // Stream stdin: copy pipe_stdin → child stdin in chunks (bounded memory)
444        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = ctx.pipe_stdin.take() {
445            let prefix = ctx.stdin.take();
446            child.stdin.take().map(|mut child_stdin| {
447                tokio::spawn(async move {
448                    // A buffered prefix and a live pipe are one stream, not two
449                    // candidates — see the same reasoning in
450                    // `kernel.rs::try_execute_external`, which this twin mirrors.
451                    if let Some(data) = prefix
452                        && child_stdin.write_all(&data).await.is_err()
453                    {
454                        return; // child closed stdin; drop signals EOF
455                    }
456                    let mut buf = [0u8; 8192];
457                    loop {
458                        match pipe_in.read(&mut buf).await {
459                            Ok(0) => break, // EOF
460                            Ok(n) => {
461                                if child_stdin.write_all(&buf[..n]).await.is_err() {
462                                    break; // child closed stdin
463                                }
464                            }
465                            Err(_) => break,
466                        }
467                    }
468                    // Drop child_stdin signals EOF to child
469                })
470            })
471        } else if let Some(data) = ctx.stdin.take() {
472            // Buffered stdin bytes written from a DETACHED task, not inline:
473            // an inline write deadlocks once the stdin pipe fills before the
474            // output drain below has spawned (mirrors the kernel.rs fix; keeps
475            // the two spawn sites in sync). Drop signals EOF; a broken pipe
476            // (child closed stdin early) is fine.
477            child.stdin.take().map(|mut child_stdin| {
478                tokio::spawn(async move {
479                    let _ = child_stdin.write_all(&data).await;
480                })
481            })
482        } else {
483            None
484        };
485
486        // Capture stdout via the spill-aware collector, regardless of whether
487        // this is a pipeline stage (`ctx.pipe_stdout` set) or the last/only
488        // stage. This intentionally does NOT special-case `ctx.pipe_stdout`
489        // — production's `try_execute_external` never touches that field at
490        // all; a middle/first pipeline stage's forwarding to the next stage
491        // is entirely `PipelineRunner::run_pipeline`'s job (pipeline.rs),
492        // which reads `stage_ctx.pipe_stdout` (still `Some`, untouched here)
493        // after `dispatch()` returns and forwards `result.out` itself.
494        //
495        // Before this fix, this dispatcher special-cased `pipe_stdout` and
496        // streamed the child's stdout straight through in 8KB chunks — full
497        // fidelity, no cap. Production has no such fast path: every external
498        // stage's stdout is captured here first, then forwarded by the
499        // runner, so a >10MB intermediate stage silently loses its head in
500        // production (the runner's forward goes through the SAME capture,
501        // still true after this fix — see GH #133 item 2 for the capture
502        // primitive itself). Losing the pipe_stdout special case is what lets
503        // a test reproduce that production bug class at all (GH #133 item 3).
504        let Some(child_stdout) = child.stdout.take() else {
505            return Some(ExecResult::failure(1, "internal: stdout not available"));
506        };
507        let Some(mut child_stderr) = child.stderr.take() else {
508            return Some(ExecResult::failure(1, "internal: stderr not available"));
509        };
510
511        // Capture stdout into a fixed 10MB tail-evicting ring (`BoundedStream`
512        // + `drain_to_stream`) — the SAME capture primitive the production
513        // spawn site uses (kernel.rs::try_execute_external), not the
514        // limit-aware `spill_aware_collect` this used to call. Production
515        // does not spill-check an external command's own capture inline
516        // against `ctx.output_limit`; the pipeline-level post-hoc
517        // `spill_if_needed` (`Kernel::execute_pipeline`) is what applies that
518        // afterward, and `did_spill` is left `false` here for THAT reason — a
519        // caller wanting the limit-aware post-hoc behavior applies it
520        // separately, same as the real pipeline path (GH #133 item 2).
521        // Independently, `did_spill` CAN still end up `true` below: if the
522        // ring itself overflows (unconditionally, regardless of
523        // `ctx.output_limit`), that's the GH #191 loud-overflow signal, not
524        // the limit-aware spill this comment is about.
525        let stdout_stream = Arc::new(crate::scheduler::BoundedStream::new(
526            crate::scheduler::DEFAULT_STREAM_MAX_SIZE,
527        ));
528        let stdout_clone = stdout_stream.clone();
529        let stdout_task = tokio::spawn(async move {
530            crate::scheduler::drain_to_stream(child_stdout, stdout_clone).await;
531        });
532
533        // Stderr streaming is intentionally left as-is (live to
534        // `ctx.stderr` when present, else buffered) — production instead
535        // caps stderr into its own 10MB ring with no live streaming. That
536        // divergence is out of scope for this PR; see GH #133 follow-ups.
537        let stderr_stream_handle = ctx.stderr.clone();
538        let stderr_task = tokio::spawn(async move {
539            let mut buf = Vec::new();
540            let mut chunk = [0u8; 8192];
541            loop {
542                match child_stderr.read(&mut chunk).await {
543                    Ok(0) => break,
544                    Ok(n) => {
545                        if let Some(ref stream) = stderr_stream_handle {
546                            stream.write(&chunk[..n]);
547                        } else {
548                            buf.extend_from_slice(&chunk[..n]);
549                        }
550                    }
551                    Err(_) => break,
552                }
553            }
554            if stderr_stream_handle.is_some() {
555                String::new()
556            } else {
557                String::from_utf8_lossy(&buf).into_owned()
558            }
559        });
560
561        let cancel = ctx.cancel.clone();
562        // Mirror production's cancel-aware drain handling: spawn the
563        // drains concurrently with the wait (not after collection
564        // completes) so a cancel can actually interrupt a still-running,
565        // still-silent child instead of blocking until it produces EOF.
566        let cancelled_before_wait = cancel.is_cancelled();
567        let status = crate::kernel::wait_or_kill(
568            &mut child,
569            kill_target.as_ref(),
570            &cancel,
571            std::time::Duration::from_secs(2),
572        ).await;
573        if let Some(task) = stdin_task { task.abort(); }
574        let mut stderr = if cancelled_before_wait || cancel.is_cancelled() {
575            // The child's pipes are gone; late output is lost but
576            // predictable death beats partial capture (same tradeoff
577            // production makes).
578            stdout_task.abort();
579            stderr_task.abort();
580            String::new()
581        } else {
582            let _ = stdout_task.await;
583            stderr_task.await.unwrap_or_default()
584        };
585
586        // Signal-death mapping (128+signal, e.g. SIGKILL→137) must match
587        // the production spawn site exactly — kept in sync via the shared
588        // `exit_code_from_status` helper (GH #133 item 1). A `wait_or_kill`
589        // I/O error (not a signal death) falls back to 1, same as before.
590        let code = match status {
591            Ok(s) => crate::kernel::exit_code_from_status(&s),
592            Err(_) => 1,
593        };
594        let stdout = stdout_stream.read().await;
595        // stdout came back as raw bytes: text if valid UTF-8, else a Bytes
596        // result (so `curl url`, `curl url > file.bin`, etc. keep binary intact).
597        let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
598
599        // Mirror production's overflow signaling (GH #191) for the piece this
600        // twin actually shares with `kernel.rs::try_execute_external`: the
601        // stdout `BoundedStream` ring. Stderr here is captured differently
602        // from production (live-streamed to `ctx.stderr` when set, else an
603        // unbounded `Vec` — see the comment above `stderr_stream_handle`,
604        // GH #133 follow-up), so there is no stderr `BoundedStream` overflow
605        // to mirror; only the stdout side applies. `did_spill` stays `false`
606        // otherwise, matching `output_limit_is_not_applied_inline_matching_production`
607        // below — this is the fixed-ring overflow signal, not the
608        // limit-aware post-hoc spill Kernel::execute_pipeline applies.
609        if stdout_stream.has_overflowed().await {
610            let stats = stdout_stream.stats().await;
611            stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
612            result.did_spill = true;
613        }
614        result.err = stderr;
615        Some(result)
616    }
617}
618
619#[cfg(test)]
620#[async_trait]
621impl CommandDispatcher for BackendDispatcher {
622    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
623        // Handle built-in true/false/: (`:` is another spelling of `true`)
624        match cmd.name.as_str() {
625            "true" | ":" => return Ok(ExecResult::success("")),
626            "false" => return Ok(ExecResult::failure(1, "")),
627            _ => {}
628        }
629
630        // Build tool args through the reduced sync evaluator (no command
631        // substitution) — see `SyncEvalSource` in `scheduler::pipeline`.
632        // A bad/subscripted collection access is a genuine PathError here too —
633        // propagate it via `?` rather than swallowing, same as the production
634        // Kernel::dispatch_command's `execute_command(..).await?`.
635        let schema = self.tools.get(&cmd.name).map(|t| t.schema());
636        let tool_args = build_tool_args(&cmd.args, ctx, schema.as_ref())
637            .await
638            .map_err(|e| anyhow::anyhow!(e))?;
639
640        // Honor --json before the tool runs so a parse failure inside the
641        // builtin doesn't drop the format on the floor. See kernel.rs for the
642        // matching call in the production path.
643        let raw_argv = schema.as_ref().is_some_and(|s| s.raw_argv);
644        GlobalFlags::apply_from_args(&tool_args, raw_argv, ctx);
645
646        // Execute via backend
647        let backend = ctx.backend.clone();
648        let result = match backend.call_tool(&cmd.name, tool_args, ctx).await {
649            // Route through the same `From<ToolResult> for ExecResult` the
650            // production dispatch path uses (kernel.rs) rather than
651            // hand-rolling the field-by-field copy: the old inline version
652            // wrapped `data` unconditionally as `Value::Json`, which skipped
653            // `json_to_value_no_envelope`'s scalar-unwrap (`Value::Int`/
654            // `Value::String`/…) and silently dropped `did_spill`/
655            // `original_code` — a divergence this test-only dispatcher must
656            // not have from the real path (GH #93 item 4).
657            Ok(tool_result) => ExecResult::from(tool_result),
658            Err(BackendError::ToolNotFound(_)) => {
659                // Fall back to external command execution. The backend
660                // registry already had its chance above, so — unlike the
661                // production path in kernel.rs, which tries external first —
662                // `Unavailable` is a final answer here, not something that
663                // needs to survive a later backend lookup.
664                match self.try_external(&cmd.name, &cmd.args, ctx).await {
665                    ExternalCommandOutcome::Ran(result) => *result,
666                    ExternalCommandOutcome::NotFound => {
667                        ExecResult::failure(127, format!("command not found: {}", cmd.name))
668                    }
669                    ExternalCommandOutcome::Unavailable(reason) => {
670                        external_commands_unavailable_error(&cmd.name, reason)
671                    }
672                }
673            }
674            Err(e) => ExecResult::failure(127, e.to_string()),
675        };
676
677        // Migrated builtins parse --json via the GlobalFlags flatten and
678        // write ctx.output_format. The kernel just applies it.
679        let result = match ctx.output_format {
680            Some(format) => apply_output_format(result, format),
681            None => result,
682        };
683
684        Ok(result)
685    }
686
687    /// Sync-only evaluation (no command substitution) — matches this
688    /// test dispatcher's documented "no async argument evaluation" limit.
689    async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value> {
690        crate::scheduler::pipeline::eval_simple_expr(expr, ctx)
691            .map_err(|e| anyhow::anyhow!(e))?
692            .ok_or_else(|| anyhow::anyhow!("cannot evaluate expression in test dispatcher"))
693    }
694
695    /// BackendDispatcher is stateless, so a fork is just a clone.
696    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
697        Arc::new(Self { tools: Arc::clone(&self.tools) })
698    }
699}
700
701/// Tests that spawn real external processes through `try_external`, to catch
702/// behavioral drift from the production spawn site (`kernel.rs::try_execute_external`)
703/// — GH #133. Unlike the `BackendDispatcher` tests in `scheduler::pipeline`,
704/// which exercise builtins over a `MemoryFs` (virtual cwd, so `try_external`
705/// never spawns), these give the dispatcher a real tempdir cwd + PATH so the
706/// external fallback actually runs a child process.
707#[cfg(all(test, feature = "subprocess"))]
708mod external_process_tests {
709    // Test-fixture helpers (not `#[test]` bodies themselves), so the
710    // workspace's usual allow-in-tests clippy.toml carve-out doesn't cover
711    // them — see CLAUDE.md's "clap builtin gotchas" / test-code conventions.
712    #![allow(clippy::unwrap_used, clippy::expect_used)]
713    use super::*;
714    use crate::ast::{Arg, Command, Expr, Value};
715    use crate::tools::{ExecContext, ToolRegistry};
716    use crate::vfs::{LocalFs, VfsRouter};
717
718    /// A `BackendDispatcher` + `ExecContext` rooted at a real tempdir, with an
719    /// empty tool registry (every command name falls through to
720    /// `try_external`, exactly like a real external command with no matching
721    /// builtin/user tool) and PATH seeded from the test process's own OS env.
722    /// Reading OS env here is fixture code, not kaish's hermetic runtime — see
723    /// CLAUDE.md and `external_command_tests.rs::repl_kernel`.
724    fn real_cwd_dispatcher() -> (BackendDispatcher, ExecContext, tempfile::TempDir) {
725        let dir = tempfile::tempdir().expect("tempdir");
726        let mut vfs = VfsRouter::new();
727        vfs.mount("/", LocalFs::new(dir.path().to_path_buf()));
728        let tools = Arc::new(ToolRegistry::new());
729        let mut ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
730        // Exported (not just set): try_external's own PATH lookup reads
731        // ctx.scope directly, but the CHILD process only inherits exported
732        // vars (cmd.env_clear() + exported_vars()) — a script that shells
733        // out further (`sh -c "yes | head"`) needs PATH in ITS env too,
734        // not just kaish's resolver.
735        ctx.scope.set_exported(
736            "PATH",
737            Value::String(std::env::var("PATH").unwrap_or_default()),
738        );
739        let dispatcher = BackendDispatcher::new(tools);
740        (dispatcher, ctx, dir)
741    }
742
743    /// `sh -c <script>` as a `Command`, matching how the parser would build it
744    /// from `sh -c 'script'` (a short flag, then a positional literal).
745    fn sh_cmd(script: &str) -> Command {
746        Command {
747            name: "sh".to_string(),
748            args: vec![
749                Arg::ShortFlag("c".to_string()),
750                Arg::Positional(Expr::Literal(Value::String(script.to_string()))),
751            ],
752            redirects: vec![],
753        }
754    }
755
756    /// GH #133 item 1: production maps a signal-killed child to `128 + signal`
757    /// (SIGKILL -> 137); the twin used to hardcode `code().unwrap_or(1)` -> 1,
758    /// so a cancel/timeout test run through this dispatcher observed an exit
759    /// code production never actually produces. Fails at `code == 1` pre-fix.
760    #[tokio::test]
761    async fn signal_killed_child_maps_to_128_plus_signal() {
762        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
763        let cmd = sh_cmd("kill -KILL $$");
764        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
765        assert_eq!(
766            result.code, 137,
767            "SIGKILL should map to 128+9=137 (production's mapping), got {}",
768            result.code
769        );
770    }
771
772    /// GH #133 item 2: the twin used to call the limit-aware
773    /// `spill_aware_collect` in its non-pipe capture branch, applying
774    /// `ctx.output_limit` inline and setting `did_spill` itself. Production's
775    /// `try_execute_external` never spill-checks its own capture that way —
776    /// spill is a pipeline-level, post-hoc step (`Kernel::execute_pipeline`
777    /// calls `spill_if_needed` AFTER the dispatcher returns). So even with a
778    /// tiny `output_limit` configured, `try_external` itself must return the
779    /// full (up to the 10MB ring) captured output with `did_spill == false`.
780    /// Pre-fix, the twin truncated inline and set `did_spill = true` here.
781    #[tokio::test]
782    async fn output_limit_is_not_applied_inline_matching_production() {
783        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
784        // A tiny in-memory limit (no disk spill file — CLAUDE.md: no real
785        // system paths in tests) — if try_external still spill-checked
786        // inline (the bug), this would trigger truncation right here.
787        ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
788        ctx.output_limit.set_limit(Some(64));
789
790        let cmd = sh_cmd("yes x | head -c 1000");
791        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
792
793        assert_eq!(result.code, 0, "err: {}", result.err);
794        assert_eq!(
795            result.text_out().len(),
796            1000,
797            "try_external must return the full captured output — production \
798             defers spill to the post-hoc pipeline step, not its own capture; \
799             got {} bytes: {:?}",
800            result.text_out().len(),
801            result.text_out()
802        );
803        assert!(
804            !result.did_spill,
805            "try_external itself must not set did_spill — that's \
806             Kernel::execute_pipeline's post-hoc spill_if_needed's job, \
807             matching production"
808        );
809    }
810
811    /// GH #133 item 3: before this fix, `try_external` special-cased
812    /// `ctx.pipe_stdout` — taking it out of the context and hand-streaming
813    /// the child's stdout straight into it in 8KB chunks, bypassing the
814    /// capture logic a non-pipeline external goes through, and always
815    /// returning an empty `result.out` ("output was streamed to pipe").
816    /// Production's `try_execute_external` has no such special case: it never
817    /// reads or writes `ctx.pipe_stdout` at all — `PipelineRunner::run_pipeline`
818    /// (pipeline.rs) is solely responsible for reading a stage's captured
819    /// `result.out` back out and forwarding it to the next stage.
820    #[tokio::test]
821    async fn pipeline_stage_leaves_pipe_stdout_for_the_runner_to_forward() {
822        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
823
824        // Simulate what PipelineRunner::run_pipeline wires onto a first/middle
825        // stage's ctx before calling dispatch(): a pipe_stdout the runner
826        // expects to read back out afterward.
827        let (writer, reader) = crate::scheduler::pipe_stream_default();
828        ctx.pipe_stdout = Some(writer);
829
830        // Drain the reader concurrently — a full-fidelity writer (the old
831        // special case) would otherwise still work here for a small payload,
832        // but this also lets the pipe close out cleanly either way.
833        let drain = tokio::spawn(async move {
834            use tokio::io::AsyncReadExt;
835            let mut reader = reader;
836            let mut buf = Vec::new();
837            let _ = reader.read_to_end(&mut buf).await;
838            buf
839        });
840
841        let cmd = sh_cmd("echo hello");
842        // A generous but bounded timeout: a real hang here (e.g. an
843        // accidental deadlock reintroduced by a future edit) should fail
844        // loud and fast in CI, not stall the suite indefinitely.
845        let result = tokio::time::timeout(
846            std::time::Duration::from_secs(15),
847            dispatcher.dispatch(&cmd, &mut ctx),
848        )
849        .await
850        .expect("dispatch timed out")
851        .expect("dispatch");
852
853        assert!(
854            ctx.pipe_stdout.is_some(),
855            "try_external must leave ctx.pipe_stdout untouched — forwarding \
856             to the next stage is PipelineRunner's job, matching production, \
857             which never reads or writes this field at all"
858        );
859
860        // Drop the writer now (the runner would take it back out and, after
861        // forwarding, let it go) so the reader sees EOF and `drain` actually
862        // completes — nothing else in this test closes the pipe, since
863        // try_external no longer touches it at all post-fix.
864        drop(ctx.pipe_stdout.take());
865        let _ = drain.await;
866
867        assert!(
868            result.text_out().contains("hello"),
869            "try_external must capture and return stdout the same way for a \
870             pipeline stage as a non-pipeline call (not force it empty \
871             because a pipe was attached) — got: {:?}",
872            result.text_out()
873        );
874    }
875
876    /// GH #133 item 3, large-payload consequence: before this fix, a pipeline
877    /// stage's stdout went through the hand-rolled full-fidelity streamer,
878    /// which ignored any size cap entirely and forwarded byte-for-byte no
879    /// matter the size — an intermediate stage had NO cap at all, of any
880    /// kind. Post-fix, every stage (pipe or not) goes through the same
881    /// capture path a non-pipeline external uses.
882    ///
883    /// Updated for GH #133 item 2 (landed since this test was written): the
884    /// shared capture path now caps via an *unconditional* ~10MB
885    /// `BoundedStream` ring regardless of `ctx.output_limit` configuration —
886    /// production never spill-checks its own capture inline against
887    /// `ctx.output_limit`, deferring THAT to the pipeline-level, post-hoc
888    /// `spill_if_needed`. So `ctx.output_limit` is configured below only to
889    /// prove it's inert here (matching item 2's contract) — it plays no part
890    /// in why this payload gets capped.
891    ///
892    /// Updated again for GH #191: the fixed ring overflowing IS now loud on
893    /// its own terms, independent of `ctx.output_limit`. `did_spill` flips to
894    /// `true` (this dispatcher calling `dispatch()` directly, not through
895    /// `Kernel::execute_pipeline`, is exactly why `code` stays `0` here — the
896    /// exit-3 remap lives in that caller, not in `try_external` itself), and
897    /// stderr carries a truncation marker. Stdout still comes back as a
898    /// clean, marker-free tail — the marker is never prepended into stdout
899    /// (which may be binary), only into stderr. This test still pins the
900    /// piece item 3 alone is responsible for: a pipeline stage is no longer
901    /// special-cased into a no-cap-of-any-kind fast path.
902    #[tokio::test]
903    async fn oversized_pipeline_stage_output_is_no_longer_forwarded_losslessly() {
904        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
905
906        ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
907        ctx.output_limit.set_limit(Some(1024)); // tiny vs. the >10MB payload below
908
909        let (writer, reader) = crate::scheduler::pipe_stream_default();
910        ctx.pipe_stdout = Some(writer);
911
912        // Drain the pipe concurrently — a full-fidelity writer would
913        // otherwise block on the 64KB pipe capacity well before finishing an
914        // 11MB write, deadlocking the test.
915        let drain = tokio::spawn(async move {
916            use tokio::io::AsyncReadExt;
917            let mut reader = reader;
918            let mut buf = Vec::new();
919            let _ = reader.read_to_end(&mut buf).await;
920            buf
921        });
922
923        let cmd = sh_cmd("yes x | head -c 11000000");
924        // A generous but bounded timeout: a real hang here should fail loud
925        // and fast in CI, not stall the suite indefinitely.
926        let result = tokio::time::timeout(
927            std::time::Duration::from_secs(15),
928            dispatcher.dispatch(&cmd, &mut ctx),
929        )
930        .await
931        .expect("dispatch timed out")
932        .expect("dispatch");
933
934        // Drop the writer (try_external no longer touches it post-fix, so
935        // nothing else will) so the reader sees EOF and `drain` completes.
936        drop(ctx.pipe_stdout.take());
937        let _ = drain.await;
938
939        // The exit-3 remap lives in `Kernel::execute_pipeline` (`if
940        // result.did_spill { code = 3 }`), which this test never calls —
941        // it drives `dispatcher.dispatch()` directly. So `code` stays the
942        // child's own exit status (0) even though `did_spill` is now `true`.
943        assert_eq!(result.code, 0, "err: {}", result.err);
944        assert!(
945            result.text_out().len() < 11_000_000,
946            "an oversized (~11MB) pipeline stage's output must now be capped, \
947             not forwarded byte-for-byte losslessly — the pre-fix special \
948             case ignored any cap entirely; post-fix it goes through the same \
949             capped capture (the unconditional ~10MB ring) a non-pipeline \
950             external uses. got {} bytes",
951            result.text_out().len()
952        );
953        assert!(
954            !result.text_out().contains("truncated"),
955            "the loud-overflow marker (GH #191) must never contaminate stdout \
956             — it belongs in stderr only, since stdout may be binary: got {:?}",
957            &result.text_out()[..result.text_out().len().min(80)]
958        );
959        assert!(
960            result.did_spill,
961            "the fixed ~10MB ring overflowing must set did_spill (GH #191) so \
962             a real `Kernel::execute_pipeline` caller remaps to exit 3 — this \
963             is independent of ctx.output_limit's own spill_if_needed, which \
964             stays out of scope for try_external as before"
965        );
966        assert!(
967            result.err.contains("stdout truncated"),
968            "stderr must carry the loud overflow marker (GH #191): {}",
969            result.err
970        );
971    }
972
973    /// GH #133 item 4: production always puts the spawned child in its own
974    /// process group (`setpgid(0,0)` in `pre_exec`) so a cancel's `killpg`
975    /// reaches the whole tree — the direct child AND any grandchildren it
976    /// spawns. Pre-fix, this dispatcher never called `setpgid`, so `killpg`
977    /// targeted a process group nobody was actually in (an ESRCH no-op): a
978    /// grandchild survived cancellation even though the direct child died.
979    /// Any existing test asserting "grandchild cleanup" against this
980    /// dispatcher was passing trivially, verifying nothing real.
981    ///
982    /// # Why this test checks the structural fact, not an end-to-end kill
983    ///
984    /// The most faithful reproduction of the issue would background a
985    /// grandchild (`sleep N &`), cancel mid-flight, and assert the
986    /// grandchild dies too — pinning the exact "existing test passes
987    /// trivially" symptom. That reproduction turned out to be **blocked by a
988    /// separate, pre-existing ordering issue** in this dispatcher, not
989    /// introduced by this PR: `try_external`'s output collection used to run
990    /// to completion BEFORE `wait_or_kill` was even called, so cancellation
991    /// had no observable effect until the child's stdout closed on its own —
992    /// which, for a `sh -c '... & wait'` script producing no stdout, only
993    /// happened once the whole script finished naturally. GH #133 item 2 (PR
994    /// #152, already landed on main alongside this fix) restructured
995    /// collection to run *concurrently* with `wait_or_kill`, matching
996    /// production — an end-to-end grandchild-kill test is now meaningful and
997    /// fast, and remains a natural follow-up. Until then, this test pins the
998    /// concrete, fast, unconfounded consequence of *this* PR's diff: the
999    /// spawned child's own pgid equals its own pid, i.e. `setpgid(0, 0)` in
1000    /// `pre_exec` actually took effect. `ps -p $$` runs and exits almost
1001    /// immediately, producing no stdout for kaish to block draining — so the
1002    /// ordering issue above never enters into it either way.
1003    #[cfg(unix)]
1004    #[tokio::test]
1005    async fn spawned_child_becomes_its_own_process_group_leader() {
1006        let tmp = tempfile::tempdir().expect("tempdir");
1007        let out_file = tmp.path().join("pgid_info");
1008
1009        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
1010
1011        // `$$` is the running shell's own PID; `ps -o pid=,pgid= -p $$`
1012        // reports that shell's pid and process-group id. If setpgid(0,0)
1013        // took effect in pre_exec (before `ps` even execs), the two must be
1014        // equal. Redirected straight to a file — sh's own captured stdout
1015        // (what kaish pipes) stays empty, so collection returns immediately.
1016        let script = format!("ps -o pid=,pgid= -p $$ > {}", out_file.display());
1017        let cmd = sh_cmd(&script);
1018
1019        let result = tokio::time::timeout(
1020            std::time::Duration::from_secs(10),
1021            dispatcher.dispatch(&cmd, &mut ctx),
1022        )
1023        .await
1024        .expect("dispatch timed out")
1025        .expect("dispatch");
1026        assert_eq!(result.code, 0, "err: {}", result.err);
1027
1028        let contents = std::fs::read_to_string(&out_file).expect("read pgid info");
1029        let mut fields = contents.split_whitespace();
1030        let pid: i32 = fields.next().expect("pid field").parse().expect("pid parse");
1031        let pgid: i32 = fields.next().expect("pgid field").parse().expect("pgid parse");
1032
1033        assert_eq!(
1034            pid, pgid,
1035            "the spawned child's pgid must equal its own pid — setpgid(0,0) \
1036             in pre_exec should make it its own process-group leader (so a \
1037             later killpg reaches it and any of its own children), matching \
1038             production (kernel.rs::try_execute_external); got pid={pid} \
1039             pgid={pgid}"
1040        );
1041    }
1042
1043    /// GH #229: `try_external`'s path-with-slash branch checked only
1044    /// `resolved.exists()` before spawning, diverging from production
1045    /// (`kernel.rs::try_execute_external`), which additionally checks
1046    /// `is_file()` (exit 126 "Is a directory") and the Unix executable bit
1047    /// (exit 126 "Permission denied"). Spawning a directory through this
1048    /// test-only dispatcher used to fall through to `Command::spawn()`,
1049    /// which fails with a raw OS error (mapped to exit 127 here, "{name}:
1050    /// {e}") instead of the clean, documented exit-126 class production
1051    /// gives every `kernel.execute()` test.
1052    #[tokio::test]
1053    async fn path_with_slash_to_a_directory_is_126_not_a_leaked_os_error() {
1054        let (dispatcher, mut ctx, dir) = real_cwd_dispatcher();
1055        std::fs::create_dir(dir.path().join("adir")).expect("mkdir");
1056
1057        let cmd = Command { name: "./adir".to_string(), args: vec![], redirects: vec![] };
1058        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1059
1060        assert_eq!(
1061            result.code, 126,
1062            "spawning a directory must report the clean 'Is a directory' class \
1063             (matching kernel.rs::try_execute_external), not leak whatever raw \
1064             OS spawn error Command::spawn() happens to produce: {:?}",
1065            result
1066        );
1067        assert!(
1068            result.err.contains("Is a directory"),
1069            "err should name the reason: {}",
1070            result.err
1071        );
1072    }
1073
1074    /// GH #229 companion: a resolved-but-non-executable regular file must
1075    /// report exit 126 "Permission denied", matching production's Unix mode
1076    /// check. Pre-fix, this dispatcher had no mode check at all and fell
1077    /// through to `Command::spawn()`, leaking whatever raw OS error resulted
1078    /// instead of the clean exit-126 class. The mode check reads the file's
1079    /// own permission bits directly (not an effective-permission check via
1080    /// the OS), so this is deterministic even when the test runs as root.
1081    #[cfg(unix)]
1082    #[tokio::test]
1083    async fn path_with_slash_to_a_non_executable_file_is_126_not_a_leaked_os_error() {
1084        use std::os::unix::fs::PermissionsExt;
1085
1086        let (dispatcher, mut ctx, dir) = real_cwd_dispatcher();
1087        let file_path = dir.path().join("not_executable");
1088        std::fs::write(&file_path, b"#!/bin/sh\necho hi\n").expect("write file");
1089        let mut perms = std::fs::metadata(&file_path).expect("metadata").permissions();
1090        perms.set_mode(0o644); // no exec bits, regardless of effective uid
1091        std::fs::set_permissions(&file_path, perms).expect("chmod");
1092
1093        let cmd = Command { name: "./not_executable".to_string(), args: vec![], redirects: vec![] };
1094        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1095
1096        assert_eq!(
1097            result.code, 126,
1098            "a non-executable file must report the clean 'Permission denied' \
1099             class (matching kernel.rs::try_execute_external), not leak a raw \
1100             OS spawn error: {:?}",
1101            result
1102        );
1103        assert!(
1104            result.err.contains("Permission denied"),
1105            "err should name the reason: {}",
1106            result.err
1107        );
1108    }
1109}