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, Value};
30use crate::interpreter::ExecResult;
31use crate::tools::ExecContext;
32
33// The following imports are only used by the test-only `BackendDispatcher`.
34#[cfg(test)]
35use crate::ast::Arg;
36#[cfg(test)]
37use crate::backend::BackendError;
38#[cfg(test)]
39use crate::interpreter::apply_output_format;
40#[cfg(test)]
41use crate::scheduler::build_tool_args;
42#[cfg(test)]
43use crate::tools::{GlobalFlags, ToolRegistry};
44#[cfg(all(test, feature = "subprocess"))]
45use crate::tools::resolve_in_path;
46
47/// Position of a command within a pipeline.
48///
49/// Used by external command execution to decide stdio inheritance:
50/// - `Only` or `Last` in interactive mode → inherit terminal
51/// - `First` or `Middle` → always capture
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum PipelinePosition {
54    /// Single command, no pipe.
55    #[default]
56    Only,
57    /// First command in a pipeline (no stdin from pipe).
58    First,
59    /// Middle of a pipeline (piped stdin, piped stdout).
60    Middle,
61    /// Last command in a pipeline (piped stdin, final output).
62    Last,
63}
64
65/// Trait for dispatching a single command through the full resolution chain.
66///
67/// Implementations handle argument parsing, tool lookup, and execution.
68/// The pipeline runner handles I/O routing (stdin, redirects, piping).
69#[async_trait]
70pub trait CommandDispatcher: Send + Sync {
71    /// Dispatch a single command for execution.
72    ///
73    /// The `ctx` provides stdin (from pipe or redirect), scope, and backend.
74    /// Implementations should handle schema-aware argument parsing and
75    /// output format extraction internally.
76    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult>;
77
78    /// Evaluate an expression through the full async chain.
79    ///
80    /// Unlike the runner's sync `eval_simple_expr`, this can run command
81    /// substitution (`$(...)`) because it has access to pipeline execution.
82    /// Used for redirect targets and heredoc bodies so `cat < $(cmd)`,
83    /// `echo x > $(cmd)`, and `$(...)` inside heredoc bodies work. The `ctx`
84    /// carries scope/cwd/backend for dispatchers that evaluate against it;
85    /// stateful dispatchers (Kernel) snapshot their own session state and
86    /// only let command output escape (side effects like `cd` do not).
87    async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value>;
88
89    /// Fork the dispatcher for concurrent execution (detached).
90    ///
91    /// Returns a subsidiary dispatcher with independent mutable state, safe
92    /// to run concurrently with the parent and other forks without data
93    /// races on shared scope/cwd/aliases. Used by background `&` jobs,
94    /// where the fork must survive parent cancellation.
95    ///
96    /// For stateful dispatchers (e.g. Kernel) this snapshots per-session
97    /// state into a fresh instance. Stateless dispatchers may clone.
98    async fn fork(&self) -> Arc<dyn CommandDispatcher>;
99
100    /// Fork the dispatcher for concurrent execution (attached to parent cancel).
101    ///
102    /// Like [`Self::fork`] but the fork's cancellation token is a *child* of
103    /// the parent's. Cancelling the parent (timeout, Ctrl-C, embedder
104    /// `Kernel::cancel`) cascades into the fork, which then kills its own
105    /// external children via the usual SIGTERM/SIGKILL discipline.
106    ///
107    /// Used for foreground concurrency: scatter workers, concurrent pipeline
108    /// stages, command substitution. Default implementation delegates to
109    /// [`Self::fork`] for stateless dispatchers that don't track cancellation.
110    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
111        self.fork().await
112    }
113}
114
115/// Minimal stateless dispatcher used by pipeline/runner unit tests.
116///
117/// Production code uses `Kernel` (via `Kernel::fork` for concurrent contexts).
118/// This test-only dispatcher routes directly through `backend.call_tool()` so
119/// the pipeline runner can be exercised without spinning up a full Kernel.
120///
121/// Limitations (intentional — these are test-only constraints):
122/// - No user-defined tools
123/// - No .kai script resolution
124/// - No async argument evaluation (command substitution in args won't work)
125#[cfg(test)]
126pub(crate) struct BackendDispatcher {
127    tools: Arc<ToolRegistry>,
128}
129
130#[cfg(test)]
131impl BackendDispatcher {
132    /// Create a new backend dispatcher with the given tool registry.
133    pub(crate) fn new(tools: Arc<ToolRegistry>) -> Self {
134        Self { tools }
135    }
136
137    /// Try to execute an external command (PATH lookup + process spawn).
138    ///
139    /// Used as fallback when no builtin/backend tool matches. Returns None if
140    /// the command is not found in PATH. Always captures stdout/stderr (never
141    /// inherits terminal — pipeline stages don't need interactive I/O).
142    #[cfg(not(feature = "subprocess"))]
143    async fn try_external(
144        &self,
145        _name: &str,
146        _args: &[Arg],
147        _ctx: &mut ExecContext,
148    ) -> Option<ExecResult> {
149        None
150    }
151
152    /// Try to execute an external command (PATH lookup + process spawn).
153    #[cfg(feature = "subprocess")]
154    async fn try_external(
155        &self,
156        name: &str,
157        args: &[Arg],
158        ctx: &mut ExecContext,
159    ) -> Option<ExecResult> {
160        if !ctx.allow_external_commands {
161            return None;
162        }
163
164        // Get real working directory (needed for relative path resolution and child cwd).
165        // If the CWD is virtual (no real path), skip external execution entirely.
166        let real_cwd = match ctx.backend.resolve_real_path(&ctx.cwd) {
167            Some(p) => p,
168            None => return None,
169        };
170
171        // Resolve command: absolute/relative path or PATH lookup
172        let executable = if name.contains('/') {
173            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
174            let resolved = if std::path::Path::new(name).is_absolute() {
175                std::path::PathBuf::from(name)
176            } else {
177                real_cwd.join(name)
178            };
179            if resolved.exists() {
180                resolved.to_string_lossy().into_owned()
181            } else {
182                return Some(ExecResult::failure(127, format!("{}: No such file or directory", name)));
183            }
184        } else {
185            // PATH from scope only — never OS env (keeps this test-only spawn
186            // site in sync with kernel.rs::try_execute_external).
187            let path_var = ctx.scope.get("PATH")
188                .map(crate::interpreter::value_to_string)
189                .unwrap_or_default();
190            resolve_in_path(name, &path_var)?
191        };
192
193        // Build flat argv from args. A for-loop (not filter_map) so the
194        // Decision D collection-argv guard can short-circuit the whole spawn
195        // — kept in sync with the production build in kernel.rs::build_args_flat.
196        let mut argv: Vec<String> = Vec::new();
197        for arg in args {
198            match arg {
199                Arg::Positional(expr) => match expr {
200                    Expr::Literal(Value::String(s)) => argv.push(s.clone()),
201                    Expr::Literal(Value::Int(i)) => argv.push(i.to_string()),
202                    Expr::Literal(Value::Float(f)) => argv.push(f.to_string()),
203                    Expr::VarRef(path) => {
204                        if let Ok(v) = ctx.scope.resolve_path(path) {
205                            if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &v) {
206                                return Some(ExecResult::failure(1, msg));
207                            }
208                            // Text sink: binary goes loud (kept in sync with
209                            // kernel.rs::build_args_flat).
210                            match crate::interpreter::value_to_text_sink(&v) {
211                                Ok(s) => argv.push(s),
212                                Err(e) => return Some(ExecResult::failure(1, e.to_string())),
213                            }
214                        }
215                    }
216                    // Remaining literal types (Bool/Json/Null/Bytes) — kept in
217                    // sync with the production build_args_flat, which resolves
218                    // every positional through value_to_text_sink (binary loud).
219                    Expr::Literal(other) => match crate::interpreter::value_to_text_sink(other) {
220                        Ok(s) => argv.push(s),
221                        Err(e) => return Some(ExecResult::failure(1, e.to_string())),
222                    },
223                    _ => {}
224                },
225                Arg::ShortFlag(f) => argv.push(format!("-{f}")),
226                Arg::LongFlag(f) => argv.push(format!("--{f}")),
227                Arg::Named { key, value } => match value {
228                    Expr::Literal(Value::String(s)) => argv.push(format!("--{key}={s}")),
229                    _ => argv.push(format!("--{key}=")),
230                },
231                Arg::WordAssign { key, value } => match value {
232                    Expr::Literal(Value::String(s)) => argv.push(format!("{key}={s}")),
233                    _ => argv.push(format!("{key}=")),
234                },
235                Arg::DoubleDash => argv.push("--".to_string()),
236            }
237        }
238
239        // Check for streaming pipes
240        let has_pipe_stdin = ctx.pipe_stdin.is_some();
241        let has_buffered_stdin = ctx.stdin.is_some();
242
243        // Spawn process
244        use tokio::process::Command;
245        use tokio::io::{AsyncReadExt, AsyncWriteExt};
246
247        let mut cmd = Command::new(&executable);
248        cmd.args(&argv);
249        cmd.current_dir(&real_cwd);
250        cmd.kill_on_drop(true);
251
252        // Hermetic env: child sees only kaish's exported vars, not the kaish
253        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
254        // populate it via KernelConfig::initial_vars at construction.
255        cmd.env_clear();
256        let exported = ctx.scope.exported_vars();
257        // A structured value can't cross the process boundary; refuse rather than
258        // silently JSON-serialize it into the child's environment. Kept in sync
259        // with the production spawn site in kernel.rs::try_execute_external.
260        if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
261            return Some(ExecResult::failure(1, msg));
262        }
263        for (var_name, value) in exported {
264            // Binary can't cross the process boundary as an env var value
265            // either — loud, not the `[binary: N bytes]` placeholder (kept in
266            // sync with the production spawn site).
267            match crate::interpreter::value_to_text_sink_named(
268                &value,
269                "an exported environment variable value",
270            ) {
271                Ok(s) => {
272                    cmd.env(var_name, s);
273                }
274                Err(e) => return Some(ExecResult::failure(1, e.to_string())),
275            }
276        }
277
278        // Stdin: pipe_stdin or buffered string or inherit (interactive) or null
279        cmd.stdin(if has_pipe_stdin || has_buffered_stdin {
280            std::process::Stdio::piped()
281        } else if ctx.interactive && matches!(ctx.pipeline_position, PipelinePosition::First | PipelinePosition::Only) {
282            std::process::Stdio::inherit()
283        } else {
284            std::process::Stdio::null()
285        });
286        cmd.stdout(std::process::Stdio::piped());
287        cmd.stderr(std::process::Stdio::piped());
288
289        // On Unix, always put the child in its own process group so a
290        // cancel can `killpg` the whole tree (the child plus any
291        // grandchildren) — matching the production spawn site
292        // (kernel.rs::try_execute_external) exactly. Without this, `killpg`
293        // targets a group nobody is actually in (an ESRCH no-op), and a
294        // grandchild spawned by the child survives cancellation — the exact
295        // gap GH #133 item 4 closes. This dispatcher has no job-control
296        // terminal integration (no `terminal_state`), so unlike production
297        // there is no signal-handler restoration to gate here.
298        #[cfg(unix)]
299        {
300            // SAFETY: setpgid is async-signal-safe per POSIX; safe to call
301            // between fork and exec.
302            #[allow(unsafe_code)]
303            unsafe {
304                cmd.pre_exec(|| {
305                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
306                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
307                    Ok(())
308                });
309            }
310        }
311
312        let mut child = match cmd.spawn() {
313            Ok(c) => c,
314            Err(e) => return Some(ExecResult::failure(127, format!("{}: {}", name, e))),
315        };
316        // Open a pidfd (Linux) for race-free direct-child kill via wait_or_kill.
317        let kill_target = crate::pidfd::KillTarget::from_child(&child);
318
319        // Stream stdin: copy pipe_stdin → child stdin in chunks (bounded memory)
320        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = ctx.pipe_stdin.take() {
321            child.stdin.take().map(|mut child_stdin| {
322                tokio::spawn(async move {
323                    let mut buf = [0u8; 8192];
324                    loop {
325                        match pipe_in.read(&mut buf).await {
326                            Ok(0) => break, // EOF
327                            Ok(n) => {
328                                if child_stdin.write_all(&buf[..n]).await.is_err() {
329                                    break; // child closed stdin
330                                }
331                            }
332                            Err(_) => break,
333                        }
334                    }
335                    // Drop child_stdin signals EOF to child
336                })
337            })
338        } else if let Some(data) = ctx.stdin.take() {
339            // Buffered string stdin written from a DETACHED task, not inline:
340            // an inline write deadlocks once the stdin pipe fills before the
341            // output drain below has spawned (mirrors the kernel.rs fix; keeps
342            // the two spawn sites in sync). Drop signals EOF; a broken pipe
343            // (child closed stdin early) is fine.
344            child.stdin.take().map(|mut child_stdin| {
345                tokio::spawn(async move {
346                    let _ = child_stdin.write_all(data.as_bytes()).await;
347                })
348            })
349        } else {
350            None
351        };
352
353        // Capture stdout via the spill-aware collector, regardless of whether
354        // this is a pipeline stage (`ctx.pipe_stdout` set) or the last/only
355        // stage. This intentionally does NOT special-case `ctx.pipe_stdout`
356        // — production's `try_execute_external` never touches that field at
357        // all; a middle/first pipeline stage's forwarding to the next stage
358        // is entirely `PipelineRunner::run_pipeline`'s job (pipeline.rs),
359        // which reads `stage_ctx.pipe_stdout` (still `Some`, untouched here)
360        // after `dispatch()` returns and forwards `result.out` itself.
361        //
362        // Before this fix, this dispatcher special-cased `pipe_stdout` and
363        // streamed the child's stdout straight through in 8KB chunks — full
364        // fidelity, no cap. Production has no such fast path: every external
365        // stage's stdout is captured here first, then forwarded by the
366        // runner, so a >10MB intermediate stage silently loses its head in
367        // production (the runner's forward goes through the SAME capture,
368        // still true after this fix — see GH #133 item 2 for the capture
369        // primitive itself). Losing the pipe_stdout special case is what lets
370        // a test reproduce that production bug class at all (GH #133 item 3).
371        let Some(child_stdout) = child.stdout.take() else {
372            return Some(ExecResult::failure(1, "internal: stdout not available"));
373        };
374        let Some(mut child_stderr) = child.stderr.take() else {
375            return Some(ExecResult::failure(1, "internal: stderr not available"));
376        };
377
378        // Capture stdout into a fixed 10MB tail-evicting ring (`BoundedStream`
379        // + `drain_to_stream`) — the SAME capture primitive the production
380        // spawn site uses (kernel.rs::try_execute_external), not the
381        // limit-aware `spill_aware_collect` this used to call. Production
382        // does not spill-check an external command's own capture inline; the
383        // pipeline-level post-hoc `spill_if_needed`
384        // (`Kernel::execute_pipeline`) is what applies `ctx.output_limit`
385        // afterward. `did_spill` is intentionally left `false`; a caller
386        // wanting the post-hoc behavior applies it separately, same as the
387        // real pipeline path (GH #133 item 2).
388        let stdout_stream = Arc::new(crate::scheduler::BoundedStream::new(
389            crate::scheduler::DEFAULT_STREAM_MAX_SIZE,
390        ));
391        let stdout_clone = stdout_stream.clone();
392        let stdout_task = tokio::spawn(async move {
393            crate::scheduler::drain_to_stream(child_stdout, stdout_clone).await;
394        });
395
396        // Stderr streaming is intentionally left as-is (live to
397        // `ctx.stderr` when present, else buffered) — production instead
398        // caps stderr into its own 10MB ring with no live streaming. That
399        // divergence is out of scope for this PR; see GH #133 follow-ups.
400        let stderr_stream_handle = ctx.stderr.clone();
401        let stderr_task = tokio::spawn(async move {
402            let mut buf = Vec::new();
403            let mut chunk = [0u8; 8192];
404            loop {
405                match child_stderr.read(&mut chunk).await {
406                    Ok(0) => break,
407                    Ok(n) => {
408                        if let Some(ref stream) = stderr_stream_handle {
409                            stream.write(&chunk[..n]);
410                        } else {
411                            buf.extend_from_slice(&chunk[..n]);
412                        }
413                    }
414                    Err(_) => break,
415                }
416            }
417            if stderr_stream_handle.is_some() {
418                String::new()
419            } else {
420                String::from_utf8_lossy(&buf).into_owned()
421            }
422        });
423
424        let cancel = ctx.cancel.clone();
425        // Mirror production's cancel-aware drain handling: spawn the
426        // drains concurrently with the wait (not after collection
427        // completes) so a cancel can actually interrupt a still-running,
428        // still-silent child instead of blocking until it produces EOF.
429        let cancelled_before_wait = cancel.is_cancelled();
430        let status = crate::kernel::wait_or_kill(
431            &mut child,
432            kill_target.as_ref(),
433            &cancel,
434            std::time::Duration::from_secs(2),
435        ).await;
436        if let Some(task) = stdin_task { task.abort(); }
437        let stderr = if cancelled_before_wait || cancel.is_cancelled() {
438            // The child's pipes are gone; late output is lost but
439            // predictable death beats partial capture (same tradeoff
440            // production makes).
441            stdout_task.abort();
442            stderr_task.abort();
443            String::new()
444        } else {
445            let _ = stdout_task.await;
446            stderr_task.await.unwrap_or_default()
447        };
448
449        // Signal-death mapping (128+signal, e.g. SIGKILL→137) must match
450        // the production spawn site exactly — kept in sync via the shared
451        // `exit_code_from_status` helper (GH #133 item 1). A `wait_or_kill`
452        // I/O error (not a signal death) falls back to 1, same as before.
453        let code = match status {
454            Ok(s) => crate::kernel::exit_code_from_status(&s),
455            Err(_) => 1,
456        };
457        let stdout = stdout_stream.read().await;
458        // stdout came back as raw bytes: text if valid UTF-8, else a Bytes
459        // result (so `curl url`, `curl url > file.bin`, etc. keep binary intact).
460        let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
461        result.err = stderr;
462        Some(result)
463    }
464}
465
466#[cfg(test)]
467#[async_trait]
468impl CommandDispatcher for BackendDispatcher {
469    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
470        // Handle built-in true/false
471        match cmd.name.as_str() {
472            "true" => return Ok(ExecResult::success("")),
473            "false" => return Ok(ExecResult::failure(1, "")),
474            _ => {}
475        }
476
477        // Build tool args with schema-aware parsing (sync — no command substitution).
478        // A bad/subscripted collection access is a genuine PathError here too —
479        // propagate it via `?` rather than swallowing, same as the production
480        // Kernel::dispatch_command's `execute_command(..).await?`.
481        let schema = self.tools.get(&cmd.name).map(|t| t.schema());
482        let tool_args = build_tool_args(&cmd.args, ctx, schema.as_ref())
483            .map_err(|e| anyhow::anyhow!(e))?;
484
485        // Honor --json before the tool runs so a parse failure inside the
486        // builtin doesn't drop the format on the floor. See kernel.rs for the
487        // matching call in the production path.
488        GlobalFlags::apply_from_args(&tool_args, ctx);
489
490        // Execute via backend
491        let backend = ctx.backend.clone();
492        let result = match backend.call_tool(&cmd.name, tool_args, ctx).await {
493            // Route through the same `From<ToolResult> for ExecResult` the
494            // production dispatch path uses (kernel.rs) rather than
495            // hand-rolling the field-by-field copy: the old inline version
496            // wrapped `data` unconditionally as `Value::Json`, which skipped
497            // `json_to_value_no_envelope`'s scalar-unwrap (`Value::Int`/
498            // `Value::String`/…) and silently dropped `did_spill`/
499            // `original_code` — a divergence this test-only dispatcher must
500            // not have from the real path (GH #93 item 4).
501            Ok(tool_result) => ExecResult::from(tool_result),
502            Err(BackendError::ToolNotFound(_)) => {
503                // Fall back to external command execution
504                match self.try_external(&cmd.name, &cmd.args, ctx).await {
505                    Some(result) => result,
506                    None => ExecResult::failure(127, format!("command not found: {}", cmd.name)),
507                }
508            }
509            Err(e) => ExecResult::failure(127, e.to_string()),
510        };
511
512        // Migrated builtins parse --json via the GlobalFlags flatten and
513        // write ctx.output_format. The kernel just applies it.
514        let result = match ctx.output_format {
515            Some(format) => apply_output_format(result, format),
516            None => result,
517        };
518
519        Ok(result)
520    }
521
522    /// Sync-only evaluation (no command substitution) — matches this
523    /// test dispatcher's documented "no async argument evaluation" limit.
524    async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value> {
525        crate::scheduler::pipeline::eval_simple_expr(expr, ctx)
526            .map_err(|e| anyhow::anyhow!(e))?
527            .ok_or_else(|| anyhow::anyhow!("cannot evaluate expression in test dispatcher"))
528    }
529
530    /// BackendDispatcher is stateless, so a fork is just a clone.
531    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
532        Arc::new(Self { tools: Arc::clone(&self.tools) })
533    }
534}
535
536/// Tests that spawn real external processes through `try_external`, to catch
537/// behavioral drift from the production spawn site (`kernel.rs::try_execute_external`)
538/// — GH #133. Unlike the `BackendDispatcher` tests in `scheduler::pipeline`,
539/// which exercise builtins over a `MemoryFs` (virtual cwd, so `try_external`
540/// never spawns), these give the dispatcher a real tempdir cwd + PATH so the
541/// external fallback actually runs a child process.
542#[cfg(all(test, feature = "subprocess"))]
543mod external_process_tests {
544    // Test-fixture helpers (not `#[test]` bodies themselves), so the
545    // workspace's usual allow-in-tests clippy.toml carve-out doesn't cover
546    // them — see CLAUDE.md's "clap builtin gotchas" / test-code conventions.
547    #![allow(clippy::unwrap_used, clippy::expect_used)]
548    use super::*;
549    use crate::ast::{Arg, Command, Expr, Value};
550    use crate::tools::{ExecContext, ToolRegistry};
551    use crate::vfs::{LocalFs, VfsRouter};
552
553    /// A `BackendDispatcher` + `ExecContext` rooted at a real tempdir, with an
554    /// empty tool registry (every command name falls through to
555    /// `try_external`, exactly like a real external command with no matching
556    /// builtin/user tool) and PATH seeded from the test process's own OS env.
557    /// Reading OS env here is fixture code, not kaish's hermetic runtime — see
558    /// CLAUDE.md and `external_command_tests.rs::repl_kernel`.
559    fn real_cwd_dispatcher() -> (BackendDispatcher, ExecContext, tempfile::TempDir) {
560        let dir = tempfile::tempdir().expect("tempdir");
561        let mut vfs = VfsRouter::new();
562        vfs.mount("/", LocalFs::new(dir.path().to_path_buf()));
563        let tools = Arc::new(ToolRegistry::new());
564        let mut ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
565        // Exported (not just set): try_external's own PATH lookup reads
566        // ctx.scope directly, but the CHILD process only inherits exported
567        // vars (cmd.env_clear() + exported_vars()) — a script that shells
568        // out further (`sh -c "yes | head"`) needs PATH in ITS env too,
569        // not just kaish's resolver.
570        ctx.scope.set_exported(
571            "PATH",
572            Value::String(std::env::var("PATH").unwrap_or_default()),
573        );
574        let dispatcher = BackendDispatcher::new(tools);
575        (dispatcher, ctx, dir)
576    }
577
578    /// `sh -c <script>` as a `Command`, matching how the parser would build it
579    /// from `sh -c 'script'` (a short flag, then a positional literal).
580    fn sh_cmd(script: &str) -> Command {
581        Command {
582            name: "sh".to_string(),
583            args: vec![
584                Arg::ShortFlag("c".to_string()),
585                Arg::Positional(Expr::Literal(Value::String(script.to_string()))),
586            ],
587            redirects: vec![],
588        }
589    }
590
591    /// GH #133 item 1: production maps a signal-killed child to `128 + signal`
592    /// (SIGKILL -> 137); the twin used to hardcode `code().unwrap_or(1)` -> 1,
593    /// so a cancel/timeout test run through this dispatcher observed an exit
594    /// code production never actually produces. Fails at `code == 1` pre-fix.
595    #[tokio::test]
596    async fn signal_killed_child_maps_to_128_plus_signal() {
597        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
598        let cmd = sh_cmd("kill -KILL $$");
599        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
600        assert_eq!(
601            result.code, 137,
602            "SIGKILL should map to 128+9=137 (production's mapping), got {}",
603            result.code
604        );
605    }
606
607    /// GH #133 item 2: the twin used to call the limit-aware
608    /// `spill_aware_collect` in its non-pipe capture branch, applying
609    /// `ctx.output_limit` inline and setting `did_spill` itself. Production's
610    /// `try_execute_external` never spill-checks its own capture that way —
611    /// spill is a pipeline-level, post-hoc step (`Kernel::execute_pipeline`
612    /// calls `spill_if_needed` AFTER the dispatcher returns). So even with a
613    /// tiny `output_limit` configured, `try_external` itself must return the
614    /// full (up to the 10MB ring) captured output with `did_spill == false`.
615    /// Pre-fix, the twin truncated inline and set `did_spill = true` here.
616    #[tokio::test]
617    async fn output_limit_is_not_applied_inline_matching_production() {
618        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
619        // A tiny in-memory limit (no disk spill file — CLAUDE.md: no real
620        // system paths in tests) — if try_external still spill-checked
621        // inline (the bug), this would trigger truncation right here.
622        ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
623        ctx.output_limit.set_limit(Some(64));
624
625        let cmd = sh_cmd("yes x | head -c 1000");
626        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
627
628        assert_eq!(result.code, 0, "err: {}", result.err);
629        assert_eq!(
630            result.text_out().len(),
631            1000,
632            "try_external must return the full captured output — production \
633             defers spill to the post-hoc pipeline step, not its own capture; \
634             got {} bytes: {:?}",
635            result.text_out().len(),
636            result.text_out()
637        );
638        assert!(
639            !result.did_spill,
640            "try_external itself must not set did_spill — that's \
641             Kernel::execute_pipeline's post-hoc spill_if_needed's job, \
642             matching production"
643        );
644    }
645
646    /// GH #133 item 3: before this fix, `try_external` special-cased
647    /// `ctx.pipe_stdout` — taking it out of the context and hand-streaming
648    /// the child's stdout straight into it in 8KB chunks, bypassing the
649    /// capture logic a non-pipeline external goes through, and always
650    /// returning an empty `result.out` ("output was streamed to pipe").
651    /// Production's `try_execute_external` has no such special case: it never
652    /// reads or writes `ctx.pipe_stdout` at all — `PipelineRunner::run_pipeline`
653    /// (pipeline.rs) is solely responsible for reading a stage's captured
654    /// `result.out` back out and forwarding it to the next stage.
655    #[tokio::test]
656    async fn pipeline_stage_leaves_pipe_stdout_for_the_runner_to_forward() {
657        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
658
659        // Simulate what PipelineRunner::run_pipeline wires onto a first/middle
660        // stage's ctx before calling dispatch(): a pipe_stdout the runner
661        // expects to read back out afterward.
662        let (writer, reader) = crate::scheduler::pipe_stream_default();
663        ctx.pipe_stdout = Some(writer);
664
665        // Drain the reader concurrently — a full-fidelity writer (the old
666        // special case) would otherwise still work here for a small payload,
667        // but this also lets the pipe close out cleanly either way.
668        let drain = tokio::spawn(async move {
669            use tokio::io::AsyncReadExt;
670            let mut reader = reader;
671            let mut buf = Vec::new();
672            let _ = reader.read_to_end(&mut buf).await;
673            buf
674        });
675
676        let cmd = sh_cmd("echo hello");
677        // A generous but bounded timeout: a real hang here (e.g. an
678        // accidental deadlock reintroduced by a future edit) should fail
679        // loud and fast in CI, not stall the suite indefinitely.
680        let result = tokio::time::timeout(
681            std::time::Duration::from_secs(15),
682            dispatcher.dispatch(&cmd, &mut ctx),
683        )
684        .await
685        .expect("dispatch timed out")
686        .expect("dispatch");
687
688        assert!(
689            ctx.pipe_stdout.is_some(),
690            "try_external must leave ctx.pipe_stdout untouched — forwarding \
691             to the next stage is PipelineRunner's job, matching production, \
692             which never reads or writes this field at all"
693        );
694
695        // Drop the writer now (the runner would take it back out and, after
696        // forwarding, let it go) so the reader sees EOF and `drain` actually
697        // completes — nothing else in this test closes the pipe, since
698        // try_external no longer touches it at all post-fix.
699        drop(ctx.pipe_stdout.take());
700        let _ = drain.await;
701
702        assert!(
703            result.text_out().contains("hello"),
704            "try_external must capture and return stdout the same way for a \
705             pipeline stage as a non-pipeline call (not force it empty \
706             because a pipe was attached) — got: {:?}",
707            result.text_out()
708        );
709    }
710
711    /// GH #133 item 3, large-payload consequence: before this fix, a pipeline
712    /// stage's stdout went through the hand-rolled full-fidelity streamer,
713    /// which ignored any size cap entirely and forwarded byte-for-byte no
714    /// matter the size — an intermediate stage had NO cap at all, of any
715    /// kind. Post-fix, every stage (pipe or not) goes through the same
716    /// capture path a non-pipeline external uses.
717    ///
718    /// Updated for GH #133 item 2 (landed since this test was written): the
719    /// shared capture path now caps via an *unconditional* ~10MB
720    /// `BoundedStream` ring regardless of `ctx.output_limit` configuration —
721    /// production never spill-checks its own capture inline, deferring that
722    /// to the pipeline-level, post-hoc `spill_if_needed`. So `ctx.output_limit`
723    /// is configured below only to prove it's inert here (matching item 2's
724    /// contract); the actual size trigger is the payload exceeding the fixed
725    /// ring, and `did_spill` correctly stays `false` — this dispatcher never
726    /// flags it, same as production. This test still pins the piece item 3
727    /// alone is responsible for: a pipeline stage is no longer special-cased
728    /// into a no-cap-of-any-kind fast path.
729    #[tokio::test]
730    async fn oversized_pipeline_stage_output_is_no_longer_forwarded_losslessly() {
731        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
732
733        ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
734        ctx.output_limit.set_limit(Some(1024)); // tiny vs. the >10MB payload below
735
736        let (writer, reader) = crate::scheduler::pipe_stream_default();
737        ctx.pipe_stdout = Some(writer);
738
739        // Drain the pipe concurrently — a full-fidelity writer would
740        // otherwise block on the 64KB pipe capacity well before finishing an
741        // 11MB write, deadlocking the test.
742        let drain = tokio::spawn(async move {
743            use tokio::io::AsyncReadExt;
744            let mut reader = reader;
745            let mut buf = Vec::new();
746            let _ = reader.read_to_end(&mut buf).await;
747            buf
748        });
749
750        let cmd = sh_cmd("yes x | head -c 11000000");
751        // A generous but bounded timeout: a real hang here should fail loud
752        // and fast in CI, not stall the suite indefinitely.
753        let result = tokio::time::timeout(
754            std::time::Duration::from_secs(15),
755            dispatcher.dispatch(&cmd, &mut ctx),
756        )
757        .await
758        .expect("dispatch timed out")
759        .expect("dispatch");
760
761        // Drop the writer (try_external no longer touches it post-fix, so
762        // nothing else will) so the reader sees EOF and `drain` completes.
763        drop(ctx.pipe_stdout.take());
764        let _ = drain.await;
765
766        assert_eq!(result.code, 0, "err: {}", result.err);
767        assert!(
768            result.text_out().len() < 11_000_000,
769            "an oversized (~11MB) pipeline stage's output must now be capped, \
770             not forwarded byte-for-byte losslessly — the pre-fix special \
771             case ignored any cap entirely; post-fix it goes through the same \
772             capped capture (the unconditional ~10MB ring) a non-pipeline \
773             external uses. got {} bytes",
774            result.text_out().len()
775        );
776        assert!(
777            !result.did_spill,
778            "try_external itself must not set did_spill — that's \
779             Kernel::execute_pipeline's post-hoc spill_if_needed's job, \
780             matching production, even for a pipeline stage's capture"
781        );
782    }
783
784    /// GH #133 item 4: production always puts the spawned child in its own
785    /// process group (`setpgid(0,0)` in `pre_exec`) so a cancel's `killpg`
786    /// reaches the whole tree — the direct child AND any grandchildren it
787    /// spawns. Pre-fix, this dispatcher never called `setpgid`, so `killpg`
788    /// targeted a process group nobody was actually in (an ESRCH no-op): a
789    /// grandchild survived cancellation even though the direct child died.
790    /// Any existing test asserting "grandchild cleanup" against this
791    /// dispatcher was passing trivially, verifying nothing real.
792    ///
793    /// # Why this test checks the structural fact, not an end-to-end kill
794    ///
795    /// The most faithful reproduction of the issue would background a
796    /// grandchild (`sleep N &`), cancel mid-flight, and assert the
797    /// grandchild dies too — pinning the exact "existing test passes
798    /// trivially" symptom. That reproduction turned out to be **blocked by a
799    /// separate, pre-existing ordering issue** in this dispatcher, not
800    /// introduced by this PR: `try_external`'s output collection used to run
801    /// to completion BEFORE `wait_or_kill` was even called, so cancellation
802    /// had no observable effect until the child's stdout closed on its own —
803    /// which, for a `sh -c '... & wait'` script producing no stdout, only
804    /// happened once the whole script finished naturally. GH #133 item 2 (PR
805    /// #152, already landed on main alongside this fix) restructured
806    /// collection to run *concurrently* with `wait_or_kill`, matching
807    /// production — an end-to-end grandchild-kill test is now meaningful and
808    /// fast, and remains a natural follow-up. Until then, this test pins the
809    /// concrete, fast, unconfounded consequence of *this* PR's diff: the
810    /// spawned child's own pgid equals its own pid, i.e. `setpgid(0, 0)` in
811    /// `pre_exec` actually took effect. `ps -p $$` runs and exits almost
812    /// immediately, producing no stdout for kaish to block draining — so the
813    /// ordering issue above never enters into it either way.
814    #[cfg(unix)]
815    #[tokio::test]
816    async fn spawned_child_becomes_its_own_process_group_leader() {
817        let tmp = tempfile::tempdir().expect("tempdir");
818        let out_file = tmp.path().join("pgid_info");
819
820        let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
821
822        // `$$` is the running shell's own PID; `ps -o pid=,pgid= -p $$`
823        // reports that shell's pid and process-group id. If setpgid(0,0)
824        // took effect in pre_exec (before `ps` even execs), the two must be
825        // equal. Redirected straight to a file — sh's own captured stdout
826        // (what kaish pipes) stays empty, so collection returns immediately.
827        let script = format!("ps -o pid=,pgid= -p $$ > {}", out_file.display());
828        let cmd = sh_cmd(&script);
829
830        let result = tokio::time::timeout(
831            std::time::Duration::from_secs(10),
832            dispatcher.dispatch(&cmd, &mut ctx),
833        )
834        .await
835        .expect("dispatch timed out")
836        .expect("dispatch");
837        assert_eq!(result.code, 0, "err: {}", result.err);
838
839        let contents = std::fs::read_to_string(&out_file).expect("read pgid info");
840        let mut fields = contents.split_whitespace();
841        let pid: i32 = fields.next().expect("pid field").parse().expect("pid parse");
842        let pgid: i32 = fields.next().expect("pgid field").parse().expect("pgid parse");
843
844        assert_eq!(
845            pid, pgid,
846            "the spawned child's pgid must equal its own pid — setpgid(0,0) \
847             in pre_exec should make it its own process-group leader (so a \
848             later killpg reaches it and any of its own children), matching \
849             production (kernel.rs::try_execute_external); got pid={pid} \
850             pgid={pgid}"
851        );
852    }
853}