Skip to main content

kaish_kernel/
kernel.rs

1//! The Kernel (核) — the heart of kaish.
2//!
3//! The Kernel owns and coordinates all core components:
4//! - Interpreter state (scope, $?)
5//! - Tool registry (builtins, user tools)
6//! - VFS router (mount points)
7//! - Job manager (background jobs)
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌────────────────────────────────────────────────────────────┐
13//! │                         Kernel (核)                         │
14//! │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
15//! │  │   Scope      │  │ ToolRegistry │  │  VfsRouter       │  │
16//! │  │  (variables) │  │  (builtins,  │  │  (mount points)  │  │
17//! │  │              │  │   user tools)│  │                  │  │
18//! │  └──────────────┘  └──────────────┘  └──────────────────┘  │
19//! │  ┌──────────────────────────────┐  ┌──────────────────┐    │
20//! │  │  JobManager (background)     │  │  ExecResult ($?) │    │
21//! │  └──────────────────────────────┘  └──────────────────┘    │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
29use std::time::Duration;
30
31use anyhow::{Context, Result};
32use tokio::sync::RwLock;
33
34/// Monotonic counter assigned to each Kernel at construction time, exposed
35/// via `$$` / `${$}`. Starts at 1; each new Kernel gets the next value.
36/// `Kernel::fork()` inherits the parent's value (matching bash's "subshell
37/// keeps parent's $$" semantics) because forks clone the parent's Scope
38/// rather than calling `set_pid` again.
39///
40/// Deliberately *not* the OS PID — kaish runs as a long-lived MCP server
41/// or embedded inside other binaries (kaijutsu), where the host PID is
42/// meaningless to the script. See
43/// `~/.claude/projects/-home-atobey-src-kaish/memory/lang_dollar_dollar_identifier.md`
44/// for the design rationale.
45static KERNEL_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47/// Maximum depth of dynamic statement-engine re-entry — command substitution
48/// (`$(…)`), shell-function calls, and `.kai` script sourcing — before a
49/// **loud** error is returned instead of letting the native call stack
50/// overflow (a `SIGSEGV`/abort with no diagnostic). Mirrors the intent of the
51/// alias re-entry cap (10) and the lexer's `MAX_PAREN_DEPTH` (256): a runaway
52/// or mutually recursive script hits a catchable ceiling, not a signal.
53///
54/// Each level stacks the dispatch chain between re-entries. After the GH #48
55/// allocation pass this measures ~50 KB (release) / ~57 KB (debug at the default
56/// `opt-level = 1` dev profile — see the root `Cargo.toml`) / ~193 KB (a fully
57/// unoptimized debug build) of native stack per level, down from ~80 / ~380 KB
58/// before; the `recursion_stack_cost_tests` probe reports the live figure.
59///
60/// This cap and [`RECOMMENDED_STACK_SIZE`] are a **matched pair**: the cap must
61/// trip *before* `cap × (worst-case per-level stack)` can exceed the floor, so a
62/// runaway is caught, not a `SIGSEGV`. The worst case is the ~193 KB unoptimized
63/// figure — the `opt-level = 1` dev profile above is local to this workspace and
64/// does **not** propagate to embedders, whose own debug builds of the kernel pay
65/// the full unoptimized cost. `48 × 193 KB ≈ 9.3 MB` under the 12 MiB floor
66/// keeps the same ~1.3× margin the pre-#48 pair had (`32 × 380 KB ≈ 12 MB` under
67/// 16 MiB); #48's smaller frames are what let the cap rise 32→48 and the floor
68/// drop 16→12 MiB together. **The guard only fires *before* the stack overflows
69/// on a thread that meets that floor** — this is why the REPL sizes its threads
70/// to it and embedders must too (see `docs/EMBEDDING.md`). Forks (background
71/// jobs, scatter workers, pipeline stages) run on fresh stacks and get a fresh
72/// counter, bounding each chain independently. GH #46 / #47 / #48.
73pub const MAX_RECURSION_DEPTH: usize = 48;
74
75/// Recommended native stack size (12 MiB) for any thread that drives kaish
76/// execution — the REPL sizes its `block_on` thread and tokio worker threads
77/// to this, and embedders that call `Kernel::execute` (directly or via a tokio
78/// runtime) should do the same (`runtime::Builder::thread_stack_size`, and a
79/// `std::thread` stack for a non-worker driver).
80///
81/// The kernel recurses on the native stack (command substitution, shell
82/// functions, `.kai` scripts). [`MAX_RECURSION_DEPTH`] converts a runaway into
83/// a loud error, but only *if the stack is at least this large* — on the
84/// default ~2 MB tokio worker stack the recursion overflows (SIGSEGV) before
85/// reaching the cap. This floor is the companion to that cap (see its docs for
86/// the `cap × per-level < floor` relationship): 12 MiB holds the depth-48 cap
87/// with margin even for an unoptimized embedder build (~193 KB/level), and #48
88/// shrank the per-level cost enough to drop it from 16 MiB. kaish can't set this
89/// itself (it doesn't own the runtime), so it exposes the floor for owners to
90/// apply. See GH #47 / #48.
91pub const RECOMMENDED_STACK_SIZE: usize = 12 * 1024 * 1024;
92
93use async_trait::async_trait;
94
95use crate::ast::{
96    spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
97    StringPart, TestExpr, ToolDef, Value,
98};
99pub use kaish_types::{CommandKind, ExecuteOptions};
100use crate::backend::{BackendError, KernelBackend};
101use kaish_glob::glob_match;
102use crate::dispatch::{CommandDispatcher, PipelinePosition};
103use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value_no_envelope, value_to_bool, value_to_string, value_to_text_sink, ControlFlow, ExecResult, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream_teed, BoundedStream, DEFAULT_STREAM_MAX_SIZE};
108use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
109#[cfg(feature = "subprocess")]
110use crate::tools::{resolve_in_path, virtual_cwd_error};
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127    /// LocalFs at "/" — native paths work directly.
128    ///
129    /// Full filesystem access. Use for human-operated REPL sessions where
130    /// native paths like `/home/user/project` should just work.
131    ///
132    /// Mounts:
133    /// - `/` → LocalFs("/")
134    /// - `/v` → MemoryFs (blob storage)
135    #[cfg(feature = "localfs")]
136    Passthrough,
137
138    /// Transparent sandbox — paths look native but access is restricted.
139    ///
140    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141    /// so `/home/user/src/project` just works. But paths outside the sandbox
142    /// root are not accessible.
143    ///
144    /// **Note:** This only restricts VFS (builtin) operations. External commands
145    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146    ///
147    /// Mounts:
148    /// - `/` → MemoryFs (catches paths outside sandbox)
149    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
150    /// - `/tmp` → LocalFs("/tmp")
151    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152    /// - `/v` → MemoryFs (blob storage)
153    #[cfg(feature = "localfs")]
154    Sandboxed {
155        /// Root path for local filesystem. Defaults to `$HOME`.
156        /// Can be restricted further, e.g., `~/src`.
157        root: Option<PathBuf>,
158    },
159
160    /// No local filesystem. Memory only.
161    ///
162    /// Complete isolation — no access to the host filesystem.
163    /// Useful for tests or pure sandboxed execution.
164    ///
165    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166    /// for this mode at kernel construction: with no host filesystem mounted,
167    /// large output must not write a host spill file (`paths::spill_dir()`
168    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169    ///
170    /// Mounts:
171    /// - `/` → MemoryFs
172    /// - `/tmp` → MemoryFs
173    /// - `/v` → MemoryFs
174    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175    NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180    fn default() -> Self {
181        #[cfg(feature = "localfs")]
182        { VfsMountMode::Sandboxed { root: None } }
183        #[cfg(not(feature = "localfs"))]
184        { VfsMountMode::NoLocal }
185    }
186}
187
188/// Configuration for kernel initialization.
189#[derive(Clone)]
190pub struct KernelConfig {
191    /// Name of this kernel (for identification).
192    pub name: String,
193
194    /// VFS mount mode — controls how local filesystem is exposed.
195    pub vfs_mode: VfsMountMode,
196
197    /// Initial working directory (VFS path).
198    pub cwd: PathBuf,
199
200    /// Whether to skip pre-execution validation.
201    ///
202    /// When false (default), scripts are validated before execution to catch
203    /// errors early. Set to true to skip validation for performance or to
204    /// allow dynamic/external commands.
205    pub skip_validation: bool,
206
207    /// When true, standalone external commands inherit stdio for real-time output.
208    ///
209    /// Set by script runner and REPL for human-visible output.
210    /// Not set by MCP server (output must be captured for structured responses).
211    pub interactive: bool,
212
213    /// Ignore file configuration for file-walking tools.
214    pub ignore_config: crate::ignore_config::IgnoreConfig,
215
216    /// Output size limit configuration for agent safety.
217    pub output_limit: crate::output_limit::OutputLimitConfig,
218
219    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
220    ///
221    /// When `true` (default), commands not found as builtins are resolved via PATH
222    /// and executed as child processes. When `false`, only kaish builtins and
223    /// backend-registered tools are available.
224    ///
225    /// **Security:** External commands bypass the VFS sandbox entirely — they see
226    /// the real filesystem, network, and environment. Set to `false` when running
227    /// untrusted input.
228    pub allow_external_commands: bool,
229
230
231    /// Enable trash-on-delete for rm (set -o trash).
232    ///
233    /// When enabled, small files are moved to freedesktop.org Trash instead of
234    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
235    /// or via `KAISH_TRASH=1`.
236    pub trash_enabled: bool,
237
238    /// Variables to populate the root scope with at construction, all marked
239    /// for export to child processes.
240    ///
241    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
242    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
243    /// from `std::env::vars()`. Embedders that want isolation pass nothing
244    /// (or only the keys they curate).
245    pub initial_vars: HashMap<String, Value>,
246
247    /// Default per-request timeout. When `Some`, every `execute_with_options`
248    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
249    /// When elapsed, the kernel cancels the request, kills any external
250    /// children with the configured grace, and returns exit code 124.
251    ///
252    /// `None` means no default timeout — only explicit per-call timeouts apply.
253    pub request_timeout: Option<Duration>,
254
255    /// Grace period between SIGTERM and SIGKILL when killing an external
256    /// child on cancellation or timeout.
257    ///
258    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
259    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
260    pub kill_grace: Duration,
261
262    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
263    ///
264    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
265    /// construction and handed to every `MemoryFs` the kernel builds in
266    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
267    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
268    /// `StorageFull` — an in-band error a model reads and adapts to; fail
269    /// loud over quietly eating RAM.
270    ///
271    /// **Why the agent preset is bounded by default:** an agent embedder
272    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
273    /// is per-call, not per-session. Embedders that know their workload needs
274    /// more opt out with `without_vfs_budget()` or raise the cap with
275    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
276    /// All other profiles default to `None` (unbounded).
277    ///
278    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
279    pub vfs_budget_bytes: Option<u64>,
280
281    /// Enable copy-on-write overlay mode (opt-in).
282    ///
283    /// When `true`, the primary local filesystem mount is wrapped in an
284    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
285    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
286    /// overlay transaction.
287    ///
288    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
289    /// **Sandboxed{root}:** the `{root}` mount becomes
290    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
291    /// mounts stay as real `LocalFs` (real writes escape the transaction —
292    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
293    /// **NoLocal:** incompatible — construction fails loudly (everything is
294    /// already virtual; an overlay adds no value and no lower layer to wrap).
295    /// **with_backend:** incompatible — the embedder controls the VFS; the
296    /// kernel cannot wrap it without bypassing the embedder's semantics.
297    ///
298    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
299    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
300    /// in the same call as the writes, or the transaction is discarded on drop.
301    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
302    pub overlay: bool,
303
304    /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
305    /// fresh one, so every kernel owns its own job table.
306    ///
307    /// Supply one to share a single job table across kernels. An embedder that
308    /// builds a kernel per request (kaijutsu builds one per tool call) has no
309    /// other way to keep a `cmd &` job reachable: ids, status, and output
310    /// streams all live on the manager, so a per-kernel manager takes them
311    /// down with the kernel that made it. One manager held by the embedder and
312    /// handed to every kernel keeps `&` usable across calls, and keeps job ids
313    /// unique because they are minted from the manager's own counter.
314    ///
315    /// **A shared manager carries shared settings.** `kill_grace` and
316    /// `persist_output_files` are stamped onto the manager at kernel
317    /// construction, so the last kernel built wins for both: a hermetic kernel
318    /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
319    /// off for every kernel on that manager, and each kernel's
320    /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
321    /// between kernels configured alike, or accept the last writer.
322    ///
323    /// Set through [`Self::with_job_manager`].
324    pub job_manager: Option<Arc<JobManager>>,
325
326    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
327    /// spawns, so the OS kills the child the instant this process dies —
328    /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
329    ///
330    /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
331    /// the same "protection on by default for the agent preset, opt in
332    /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
333    ///
334    /// **Why not unconditional.** kaish already puts every child in its own
335    /// process group and kills through a pidfd on cancel, and drops it with
336    /// `kill_on_drop`. All three need this process to still be running code,
337    /// so none of them survive a hard kill — that is the gap this closes. But
338    /// closing it costs something a human at a REPL may not want: an armed
339    /// child cannot outlive its shell, at all, and the child has no way to
340    /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
341    /// escape). A REPL user who backgrounds a long download and exits expects
342    /// it to keep going. An agent embedder expects the opposite — an
343    /// invisible orphaned `cargo build` is the failure — so the presets
344    /// differ rather than one behavior being forced on both.
345    ///
346    /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
347    /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
348    /// process). This flag is accepted and has no effect there, rather than
349    /// being faked with something weaker.
350    ///
351    /// Set through [`Self::with_kill_children_on_parent_death`].
352    pub kill_children_on_parent_death: bool,
353}
354
355/// Get the default sandbox root ($HOME).
356#[cfg(feature = "localfs")]
357fn default_sandbox_root() -> PathBuf {
358    std::env::var("HOME")
359        .map(PathBuf::from)
360        .unwrap_or_else(|_| PathBuf::from("/"))
361}
362
363impl Default for KernelConfig {
364    fn default() -> Self {
365        #[cfg(feature = "localfs")]
366        {
367            let home = default_sandbox_root();
368            Self {
369                name: "default".to_string(),
370                vfs_mode: VfsMountMode::Sandboxed { root: None },
371                cwd: home,
372                skip_validation: false,
373                interactive: false,
374                ignore_config: crate::ignore_config::IgnoreConfig::none(),
375                output_limit: crate::output_limit::OutputLimitConfig::none(),
376                allow_external_commands: cfg!(feature = "subprocess"),
377                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
378                initial_vars: HashMap::new(),
379                request_timeout: None,
380                kill_grace: Duration::from_secs(2),
381                vfs_budget_bytes: None,
382                overlay: false,
383                job_manager: None,
384                kill_children_on_parent_death: false,
385            }
386        }
387        #[cfg(not(feature = "localfs"))]
388        {
389            Self {
390                name: "default".to_string(),
391                vfs_mode: VfsMountMode::NoLocal,
392                cwd: PathBuf::from("/"),
393                skip_validation: false,
394                interactive: false,
395                ignore_config: crate::ignore_config::IgnoreConfig::none(),
396                output_limit: crate::output_limit::OutputLimitConfig::none(),
397                allow_external_commands: false,
398                trash_enabled: false,
399                initial_vars: HashMap::new(),
400                request_timeout: None,
401                kill_grace: Duration::from_secs(2),
402                vfs_budget_bytes: None,
403                overlay: false,
404                job_manager: None,
405                kill_children_on_parent_death: false,
406            }
407        }
408    }
409}
410
411impl KernelConfig {
412    /// Create a transient kernel config (sandboxed, for temporary use).
413    #[cfg(feature = "localfs")]
414    pub fn transient() -> Self {
415        let home = default_sandbox_root();
416        Self {
417            name: "transient".to_string(),
418            vfs_mode: VfsMountMode::Sandboxed { root: None },
419            cwd: home,
420            skip_validation: false,
421            interactive: false,
422            ignore_config: crate::ignore_config::IgnoreConfig::none(),
423            output_limit: crate::output_limit::OutputLimitConfig::none(),
424            allow_external_commands: cfg!(feature = "subprocess"),
425            trash_enabled: false,
426            initial_vars: HashMap::new(),
427            request_timeout: None,
428            kill_grace: Duration::from_secs(2),
429            vfs_budget_bytes: None,
430            overlay: false,
431            job_manager: None,
432            kill_children_on_parent_death: false,
433        }
434    }
435
436    /// Create a transient kernel config (isolated, no-default-features).
437    #[cfg(not(feature = "localfs"))]
438    pub fn transient() -> Self {
439        Self::isolated()
440    }
441
442    /// Create a kernel config with the given name (sandboxed by default).
443    #[cfg(feature = "localfs")]
444    pub fn named(name: &str) -> Self {
445        let home = default_sandbox_root();
446        Self {
447            name: name.to_string(),
448            vfs_mode: VfsMountMode::Sandboxed { root: None },
449            cwd: home,
450            skip_validation: false,
451            interactive: false,
452            ignore_config: crate::ignore_config::IgnoreConfig::none(),
453            output_limit: crate::output_limit::OutputLimitConfig::none(),
454            allow_external_commands: cfg!(feature = "subprocess"),
455            trash_enabled: false,
456            initial_vars: HashMap::new(),
457            request_timeout: None,
458            kill_grace: Duration::from_secs(2),
459            vfs_budget_bytes: None,
460            overlay: false,
461            job_manager: None,
462            kill_children_on_parent_death: false,
463        }
464    }
465
466    /// Create a kernel config with the given name (isolated, no-default-features).
467    #[cfg(not(feature = "localfs"))]
468    pub fn named(name: &str) -> Self {
469        Self {
470            name: name.to_string(),
471            ..Self::isolated()
472        }
473    }
474
475    /// Create a REPL config with passthrough filesystem access.
476    ///
477    /// Native paths like `/home/user/project` work directly.
478    /// The cwd is set to the actual current working directory.
479    #[cfg(feature = "localfs")]
480    pub fn repl() -> Self {
481        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
482        Self {
483            name: "repl".to_string(),
484            vfs_mode: VfsMountMode::Passthrough,
485            cwd,
486            skip_validation: false,
487            interactive: false,
488            // Ignore-aware by default (GH #134): .gitignore + default ignores
489            // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
490            ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
491            output_limit: crate::output_limit::OutputLimitConfig::none(),
492            allow_external_commands: cfg!(feature = "subprocess"),
493            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
494            initial_vars: HashMap::new(),
495            request_timeout: None,
496            kill_grace: Duration::from_secs(2),
497            vfs_budget_bytes: None,
498            overlay: false,
499            job_manager: None,
500            kill_children_on_parent_death: false,
501        }
502    }
503
504    /// Create a sandboxed-agent config with sandboxed filesystem access.
505    ///
506    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
507    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
508    /// memory and output. Local filesystem is accessible at its real path (e.g.,
509    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
510    /// accessible through builtins. External commands still access the real
511    /// filesystem — use `.with_allow_external_commands(false)` to block them.
512    ///
513    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
514    /// embedder typically creates a fresh kernel per call). Raise or remove with
515    /// `with_vfs_budget` / `without_vfs_budget`.
516    #[cfg(feature = "localfs")]
517    pub fn agent() -> Self {
518        let home = default_sandbox_root();
519        Self {
520            name: "agent".to_string(),
521            vfs_mode: VfsMountMode::Sandboxed { root: None },
522            cwd: home,
523            skip_validation: false,
524            interactive: false,
525            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
526            output_limit: crate::output_limit::OutputLimitConfig::agent(),
527            allow_external_commands: cfg!(feature = "subprocess"),
528            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
529            initial_vars: HashMap::new(),
530            request_timeout: None,
531            kill_grace: Duration::from_secs(2),
532            vfs_budget_bytes: Some(64 * 1024 * 1024),
533            overlay: false,
534            job_manager: None,
535            // An agent embedder must never leave an invisible `cargo build` running
536            // after its process is hard-killed; see the field doc for why this is
537            // not the default everywhere.
538            kill_children_on_parent_death: true,
539        }
540    }
541
542    /// Create a sandboxed-agent config with a custom sandbox root.
543    ///
544    /// Use this to restrict access to a subdirectory like `~/src`.
545    ///
546    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
547    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
548    #[cfg(feature = "localfs")]
549    pub fn agent_with_root(root: PathBuf) -> Self {
550        Self {
551            name: "agent".to_string(),
552            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
553            cwd: root,
554            skip_validation: false,
555            interactive: false,
556            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
557            output_limit: crate::output_limit::OutputLimitConfig::agent(),
558            allow_external_commands: cfg!(feature = "subprocess"),
559            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
560            initial_vars: HashMap::new(),
561            request_timeout: None,
562            kill_grace: Duration::from_secs(2),
563            vfs_budget_bytes: Some(64 * 1024 * 1024),
564            overlay: false,
565            job_manager: None,
566            // Same reasoning as `agent()`.
567            kill_children_on_parent_death: true,
568        }
569    }
570
571    /// Create a config with no local filesystem (memory only).
572    ///
573    /// Complete isolation: no local filesystem and external commands are disabled.
574    /// Useful for tests or pure sandboxed execution.
575    pub fn isolated() -> Self {
576        Self {
577            name: "isolated".to_string(),
578            vfs_mode: VfsMountMode::NoLocal,
579            cwd: PathBuf::from("/"),
580            skip_validation: false,
581            interactive: false,
582            ignore_config: crate::ignore_config::IgnoreConfig::none(),
583            output_limit: crate::output_limit::OutputLimitConfig::none(),
584            allow_external_commands: false,
585            trash_enabled: false,
586            initial_vars: HashMap::new(),
587            request_timeout: None,
588            kill_grace: Duration::from_secs(2),
589            vfs_budget_bytes: None,
590            overlay: false,
591            job_manager: None,
592            kill_children_on_parent_death: false,
593        }
594    }
595
596    /// Set the VFS mount mode.
597    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
598        self.vfs_mode = mode;
599        self
600    }
601
602    /// Set the initial working directory.
603    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
604        self.cwd = cwd;
605        self
606    }
607
608    /// Skip pre-execution validation.
609    pub fn with_skip_validation(mut self, skip: bool) -> Self {
610        self.skip_validation = skip;
611        self
612    }
613
614    /// Enable interactive mode (external commands inherit stdio).
615    pub fn with_interactive(mut self, interactive: bool) -> Self {
616        self.interactive = interactive;
617        self
618    }
619
620    /// Set the ignore file configuration.
621    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
622        self.ignore_config = config;
623        self
624    }
625
626    /// Set the output limit configuration.
627    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
628        self.output_limit = config;
629        self
630    }
631
632    /// Set whether external command execution is allowed.
633    ///
634    /// When `false`, commands not found as builtins produce "command not found"
635    /// instead of searching PATH. The `exec` and `spawn` builtins also return
636    /// errors. Use this to prevent VFS sandbox bypass via external binaries.
637    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
638        self.allow_external_commands = allow;
639        self
640    }
641
642    /// Enable or disable trash-on-delete at startup.
643    pub fn with_trash(mut self, enabled: bool) -> Self {
644        self.trash_enabled = enabled;
645        self
646    }
647
648    /// Add a single initial variable; marked exported when the kernel boots.
649    ///
650    /// Repeated calls add (last write wins on key collision).
651    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
652        self.initial_vars.insert(name.into(), value);
653        self
654    }
655
656    /// Replace the entire initial-vars map. All entries are marked exported.
657    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
658        self.initial_vars = vars;
659        self
660    }
661
662    /// Extend the initial-vars map with the given entries (last write wins).
663    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
664        self.initial_vars.extend(vars);
665        self
666    }
667
668    /// Set the default per-request timeout (kernel-wide).
669    ///
670    /// Each `execute_with_options` call without an explicit timeout uses
671    /// this. On elapsed, the kernel cancels and returns exit code 124.
672    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
673        self.request_timeout = Some(timeout);
674        self
675    }
676
677    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
678    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
679        self.kill_grace = grace;
680        self
681    }
682
683    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
684    /// kaish process cannot orphan them (Linux only — read
685    /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
686    /// gap).
687    pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
688        self.kill_children_on_parent_death = on;
689        self
690    }
691
692    /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
693    /// so background jobs outlive the kernel that started them. Read
694    /// [`Self::job_manager`] before sharing one manager between kernels that
695    /// are configured differently.
696    pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
697        self.job_manager = Some(jobs);
698        self
699    }
700
701    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
702    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
703    /// created at kernel construction and passed to every `MemoryFs` the
704    /// kernel builds (see `setup_vfs` and `with_backend`).
705    ///
706    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
707    /// in-band error a model reads and adapts to; fail loud over quietly eating
708    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
709    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
710        self.vfs_budget_bytes = Some(bytes);
711        self
712    }
713
714    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
715    ///
716    /// Use when the caller knows the workload and the default 64 MiB cap
717    /// (set by `KernelConfig::agent`) is too conservative.
718    pub fn without_vfs_budget(mut self) -> Self {
719        self.vfs_budget_bytes = None;
720        self
721    }
722
723    /// Enable or disable copy-on-write overlay mode.
724    ///
725    /// When `true`, the primary local filesystem mount is wrapped in an
726    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
727    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
728    /// and `with_backend` kernels (same — the embedder controls the VFS).
729    pub fn with_overlay(mut self, overlay: bool) -> Self {
730        self.overlay = overlay;
731        self
732    }
733
734}
735
736
737/// Handle to an active overlay session, kept on the kernel and shared to
738/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
739///
740/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
741/// `/home/user`); `commit_root` is the real filesystem path the overlay's
742/// lower is backed by (used as the target for `kaish-vfs commit`).
743#[cfg(all(feature = "localfs", feature = "overlay"))]
744#[derive(Clone)]
745pub struct OverlayHandle {
746    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
747    /// methods without holding a VfsRouter lock.
748    pub fs: Arc<OverlayFs>,
749    /// VFS path this overlay is mounted at (e.g. `/home/user`).
750    pub mount_path: PathBuf,
751    /// Real filesystem root to commit into. Same as the lower's root.
752    pub commit_root: PathBuf,
753}
754
755/// The Kernel (核) — executes kaish code.
756///
757/// This is the primary interface for running kaish commands. It owns all
758/// the runtime state: variables, tools, VFS, jobs, and persistence.
759pub struct Kernel {
760    /// Kernel name.
761    name: String,
762    /// Variable scope.
763    scope: RwLock<Scope>,
764    /// Tool registry.
765    tools: Arc<ToolRegistry>,
766    /// User-defined tools (from `tool name { body }` statements).
767    user_tools: RwLock<HashMap<String, ToolDef>>,
768    /// Virtual filesystem router.
769    vfs: Arc<VfsRouter>,
770    /// Background job manager.
771    jobs: Arc<JobManager>,
772    /// Pipeline runner.
773    runner: PipelineRunner,
774    /// Execution context (cwd, stdin, etc.).
775    exec_ctx: RwLock<ExecContext>,
776    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
777    /// retained past construction so `reset()` can re-seed them into the fresh
778    /// scope instead of silently dropping them.
779    initial_vars: HashMap<String, Value>,
780    /// Whether to skip pre-execution validation.
781    skip_validation: bool,
782    /// When true, standalone external commands inherit stdio for real-time output.
783    interactive: bool,
784    /// Whether external command execution is allowed.
785    allow_external_commands: bool,
786    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
787    ///
788    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
789    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
790    /// the same pool — a background job's writes reduce the same cap as
791    /// foreground writes, which is the correct behaviour.
792    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
793    /// Active overlay session handle, if this kernel was constructed with
794    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
795    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
796    /// holding a kernel write lock. Propagated to forks via `fork_inner`
797    /// and `child_for_pipeline` so `kaish-vfs` works inside background
798    /// jobs, scatter workers, and pipeline stages.
799    #[cfg(all(feature = "localfs", feature = "overlay"))]
800    overlay_handle: Option<Arc<OverlayHandle>>,
801    /// Default per-request timeout (None = no default).
802    request_timeout: Option<Duration>,
803    /// SIGTERM-to-SIGKILL grace period for child kills.
804    kill_grace: Duration,
805    /// Receiver for the kernel stderr stream.
806    ///
807    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
808    /// The kernel drains this after each statement in `execute_streaming`.
809    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
810    /// Cancellation token for interrupting execution (Ctrl-C).
811    ///
812    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
813    /// needs sync access. Each `execute()` call gets a fresh child token;
814    /// `cancel()` cancels the current token and replaces it.
815    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
816    /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
817    /// installed for the duration of an `execute_with_options` call and
818    /// cleared on exit. Consulted by `is_cancelled()` so every existing
819    /// cancellation checkpoint gains interrupt awareness without new wiring.
820    /// std Mutex for the same sync-access reason as `cancel_token`.
821    interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
822    /// Terminal state for job control (interactive mode only, Unix only).
823    #[cfg(all(unix, feature = "subprocess"))]
824    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
825    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
826    ///
827    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
828    /// through the full Kernel resolution chain.
829    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
830    /// Background job this kernel (a fork) is executing on behalf of, if any.
831    /// Set on the fork created by `execute_background` and inherited by all its
832    /// sub-forks (pipeline stages, scatter workers), so an external command
833    /// spawned anywhere under a background job can record its process group on
834    /// that job for `kill -<sig> %N`. `None` for foreground execution.
835    bg_job_id: Option<crate::scheduler::JobId>,
836    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
837    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
838    /// queue. Background jobs, scatter workers, and concurrent pipeline
839    /// stages do NOT take this lock — they run against a *forked* Kernel
840    /// (see [`Kernel::fork`]) so they never contend with the foreground.
841    execute_lock: tokio::sync::Mutex<()>,
842    /// Current dynamic statement-engine re-entry depth — incremented on entry
843    /// to command substitution, a shell-function call, or a `.kai` source, and
844    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
845    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
846    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
847    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
848    /// the recursion chain is single-threaded (top-level `execute` is
849    /// serialized by `execute_lock`; concurrency happens on forks).
850    recursion_depth: AtomicUsize,
851}
852
853/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
854/// (in `enter_recursion`) and decrements on drop, so a cancelled or
855/// error-unwound re-entry can never leave the counter inflated (which would
856/// spuriously trip later, unrelated recursions).
857struct RecursionGuard<'a> {
858    counter: &'a AtomicUsize,
859}
860
861impl Drop for RecursionGuard<'_> {
862    fn drop(&mut self) {
863        self.counter.fetch_sub(1, Ordering::Relaxed);
864    }
865}
866
867/// Internal result of [`Kernel::setup_vfs`].
868struct VfsSetupResult {
869    vfs: VfsRouter,
870    budget: Option<Arc<ByteBudget>>,
871    #[cfg(all(feature = "localfs", feature = "overlay"))]
872    overlay_handle: Option<Arc<OverlayHandle>>,
873}
874
875impl Kernel {
876    /// Create a new kernel with the given configuration.
877    pub fn new(config: KernelConfig) -> Result<Self> {
878        let mut setup = Self::setup_vfs(&config)?;
879        // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
880        // (see `KernelConfig::job_manager`); with none, this kernel owns its
881        // own job table exactly as before.
882        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
883        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
884        // kill builtin bounds its wait-for-death on the same number (GH #244).
885        jobs.set_kill_grace(config.kill_grace);
886
887        // Mount JobFs for job observability at /v/jobs
888        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
889
890        #[cfg(all(feature = "localfs", feature = "overlay"))]
891        let overlay_handle = setup.overlay_handle.take();
892
893        // Mode-based construction: the kernel owns its host mounts, so whether
894        // host side channels are allowed is decided by the VFS mode inside
895        // `assemble` (NoLocal forbids them).
896        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
897            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
898        })?;
899
900        #[cfg(all(feature = "localfs", feature = "overlay"))]
901        {
902            let mut kernel = kernel;
903            kernel.overlay_handle = overlay_handle;
904            // Also set it on the ExecContext so builtins can access it.
905            if let Some(ref handle) = kernel.overlay_handle {
906                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
907            }
908            return Ok(kernel);
909        }
910
911        #[allow(unreachable_code)]
912        Ok(kernel)
913    }
914
915    /// Set up VFS based on mount mode.
916    ///
917    /// Returns the router, the budget handle (if bounded), and an optional
918    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
919    /// every `MemoryFs` the kernel creates here holds a clone of the same
920    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
921    /// in-memory content across all kernel-owned memory mounts.
922    ///
923    /// # Errors
924    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
925    /// (overlay is meaningless when everything is already virtual — there is
926    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
927    /// this as an `anyhow::Error`.
928    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
929        let mut vfs = VfsRouter::new();
930
931        // One budget for all memory mounts this kernel owns — labeled so the
932        // error message tells the user exactly which knob to raise.
933        let budget: Option<Arc<ByteBudget>> = config
934            .vfs_budget_bytes
935            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
936
937        /// Helper: construct a `MemoryFs` wired to `budget` if present.
938        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
939            match budget {
940                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
941                None => MemoryFs::new(),
942            }
943        }
944
945        // Overlay handle — populated below if config.overlay is true.
946        #[cfg(all(feature = "localfs", feature = "overlay"))]
947        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
948
949        match &config.vfs_mode {
950            #[cfg(feature = "localfs")]
951            VfsMountMode::Passthrough => {
952                #[cfg(feature = "overlay")]
953                if config.overlay {
954                    // Wrap "/" in an OverlayFs so writes are virtual.
955                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
956                    let overlay_fs = Arc::new(match &budget {
957                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
958                        None => OverlayFs::over(lower),
959                    });
960                    let handle = Arc::new(OverlayHandle {
961                        fs: Arc::clone(&overlay_fs),
962                        mount_path: PathBuf::from("/"),
963                        commit_root: PathBuf::from("/"),
964                    });
965                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
966                    overlay_handle = Some(handle);
967                } else {
968                    // LocalFs at "/" — native paths work directly
969                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
970                }
971                #[cfg(not(feature = "overlay"))]
972                {
973                    if config.overlay {
974                        return Err(anyhow::anyhow!(
975                            "overlay=true requires the `overlay` feature, but this build \
976                             was compiled without it. Recompile with --features overlay \
977                             (or the default feature set) to enable overlay mode."
978                        ));
979                    }
980                    // LocalFs at "/" — native paths work directly
981                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
982                }
983                // Memory for blobs
984                vfs.mount("/v", mem(&budget));
985            }
986            #[cfg(feature = "localfs")]
987            VfsMountMode::Sandboxed { root } => {
988                // Memory at root for safety (catches paths outside sandbox).
989                // Note: /tmp and the XDG runtime dir are LocalFs — writes
990                // there escape the VFS budget and are NOT virtual. This is
991                // intentional: /tmp interop with other processes matters more
992                // than accounting for scratch files there.
993                vfs.mount("/", mem(&budget));
994                vfs.mount("/v", mem(&budget));
995
996                // Synthetic /dev: the host's real /dev isn't reachable here, so
997                // /dev/null and /dev/zero are software-backed (see DevFs).
998                vfs.mount("/dev", DevFs::new());
999
1000                // Real /tmp for interop with other processes
1001                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1002
1003                // Mount XDG runtime dir for spill files and socket access
1004                let runtime = crate::paths::xdg_runtime_dir();
1005                if runtime.exists() {
1006                    let runtime_str = runtime.to_string_lossy().to_string();
1007                    vfs.mount(&runtime_str, LocalFs::new(runtime));
1008                }
1009
1010                // Resolve the sandbox root (defaults to $HOME)
1011                let local_root = root.clone().unwrap_or_else(|| {
1012                    std::env::var("HOME")
1013                        .map(PathBuf::from)
1014                        .unwrap_or_else(|_| PathBuf::from("/"))
1015                });
1016
1017                let mount_point = local_root.to_string_lossy().to_string();
1018
1019                #[cfg(feature = "overlay")]
1020                if config.overlay {
1021                    // Wrap the sandbox root in an OverlayFs.
1022                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1023                    let overlay_fs = Arc::new(match &budget {
1024                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1025                        None => OverlayFs::over(lower),
1026                    });
1027                    let handle = Arc::new(OverlayHandle {
1028                        fs: Arc::clone(&overlay_fs),
1029                        mount_path: PathBuf::from(&mount_point),
1030                        commit_root: local_root,
1031                    });
1032                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1033                    overlay_handle = Some(handle);
1034                } else {
1035                    // Mount at the real path for transparent access
1036                    // e.g., /home/atobey → LocalFs("/home/atobey")
1037                    // so /home/atobey/src/kaish just works
1038                    vfs.mount(&mount_point, LocalFs::new(local_root));
1039                }
1040                #[cfg(not(feature = "overlay"))]
1041                {
1042                    if config.overlay {
1043                        return Err(anyhow::anyhow!(
1044                            "overlay=true requires the `overlay` feature, but this build \
1045                             was compiled without it. Recompile with --features overlay \
1046                             (or the default feature set) to enable overlay mode."
1047                        ));
1048                    }
1049                    // Mount at the real path for transparent access
1050                    vfs.mount(&mount_point, LocalFs::new(local_root));
1051                }
1052            }
1053            VfsMountMode::NoLocal => {
1054                if config.overlay {
1055                    return Err(anyhow::anyhow!(
1056                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
1057                         everything is already virtual, there is no real lower layer \
1058                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1059                         or Sandboxed VFS mode."
1060                    ));
1061                }
1062                // Pure memory mode — no local filesystem
1063                vfs.mount("/", mem(&budget));
1064                vfs.mount("/tmp", mem(&budget));
1065                vfs.mount("/v", mem(&budget));
1066                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1067                vfs.mount("/dev", DevFs::new());
1068            }
1069        }
1070
1071        Ok(VfsSetupResult {
1072            vfs,
1073            budget,
1074            #[cfg(all(feature = "localfs", feature = "overlay"))]
1075            overlay_handle,
1076        })
1077    }
1078
1079    /// Create a transient kernel (no persistence).
1080    pub fn transient() -> Result<Self> {
1081        Self::new(KernelConfig::transient())
1082    }
1083
1084    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1085    ///
1086    /// This is the constructor for embedding kaish in other systems that provide
1087    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1088    ///
1089    /// A `VirtualOverlayBackend` routes paths automatically:
1090    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1091    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1092    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1093    ///   read-only
1094    /// - Everything else → Your custom backend
1095    ///
1096    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1097    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1098    ///
1099    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1100    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1101    /// `skip_validation`, and `interactive`.
1102    ///
1103    /// # Example
1104    ///
1105    /// ```ignore
1106    /// // Simple: default /v/* mounts only
1107    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1108    ///
1109    /// // With custom mounts
1110    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1111    ///     vfs.mount_arc("/v/docs", docs_fs);
1112    ///     vfs.mount_arc("/v/g", git_fs);
1113    /// }, |_| {})?;
1114    ///
1115    /// // With custom tools
1116    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1117    ///     tools.register(MyCustomTool::new());
1118    /// })?;
1119    /// ```
1120    pub fn with_backend(
1121        backend: Arc<dyn KernelBackend>,
1122        config: KernelConfig,
1123        configure_vfs: impl FnOnce(&mut VfsRouter),
1124        configure_tools: impl FnOnce(&mut ToolRegistry),
1125    ) -> Result<Self> {
1126        use crate::backend::VirtualOverlayBackend;
1127
1128        // overlay=true is incompatible with with_backend: the embedder controls
1129        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1130        // semantics. Fail loudly rather than silently ignoring the flag.
1131        if config.overlay {
1132            return Err(anyhow::anyhow!(
1133                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1134                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1135                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1136            ));
1137        }
1138
1139        let mut vfs = VfsRouter::new();
1140        // See `Kernel::new` — the embedder's manager wins here too.
1141        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1142        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1143        // kill builtin bounds its wait-for-death on the same number (GH #244).
1144        jobs.set_kill_grace(config.kill_grace);
1145
1146        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1147        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1148        // kernel-owned memory mount here — embedders own the rest of the VFS.
1149        let vfs_budget: Option<Arc<ByteBudget>> = config
1150            .vfs_budget_bytes
1151            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1152
1153        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1154        let blobs_fs = match &vfs_budget {
1155            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1156            None => MemoryFs::new(),
1157        };
1158        vfs.mount("/v/blobs", blobs_fs);
1159
1160        // /dev/null and friends are software-backed (see DevFs) and must not
1161        // depend on the embedder's backend — a read-only embedder backend
1162        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1163        // to /dev/null as a filesystem error instead of discarding them.
1164        vfs.mount("/dev", DevFs::new());
1165
1166        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1167        configure_vfs(&mut vfs);
1168
1169        // A custom-backend kernel owns no host mounts — the embedder supplies
1170        // the entire VFS — so any kernel write to a host filesystem via
1171        // `std::fs` (output spill, job output files) bypasses that VFS and its
1172        // read-only guarantees. Forbid host side channels unconditionally.
1173        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1174            let overlay: Arc<dyn KernelBackend> =
1175                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1176            ExecContext::with_backend(overlay)
1177        })
1178    }
1179
1180    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1181    ///
1182    /// The `make_ctx` closure receives the VFS and tools so backends that need
1183    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1184    /// that already have their own storage can ignore these parameters.
1185    fn assemble(
1186        config: KernelConfig,
1187        mut vfs: VfsRouter,
1188        jobs: Arc<JobManager>,
1189        no_host_filesystem: bool,
1190        vfs_budget: Option<Arc<ByteBudget>>,
1191        configure_tools: impl FnOnce(&mut ToolRegistry),
1192        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1193    ) -> Result<Self> {
1194        // A kernel with no host filesystem of its own must never write to one
1195        // through a side channel. Two paths bypass the VFS by going straight to
1196        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1197        // background-job output files (`Job::write_output_file` → host temp).
1198        // Both would punch through the isolation, so force them off:
1199        // in-memory truncation for spill, no host file for job output.
1200        //
1201        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1202        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1203        // VFS, so the kernel controls no host mounts and any host write is a
1204        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1205        // when there is no kernel-owned host filesystem to spill to.
1206        let no_host_side_channel =
1207            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1208
1209        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1210
1211        if no_host_side_channel {
1212            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1213            jobs.set_persist_output_files(false);
1214        }
1215
1216        let mut tools = ToolRegistry::new();
1217        register_builtins(&mut tools);
1218        configure_tools(&mut tools);
1219        let tools = Arc::new(tools);
1220
1221        // Mount BuiltinFs so `ls /v/bin` lists builtins
1222        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1223
1224        let vfs = Arc::new(vfs);
1225
1226        let runner = PipelineRunner::new(tools.clone());
1227
1228        let (stderr_writer, stderr_receiver) = stderr_stream();
1229
1230        let mut exec_ctx = make_ctx(&vfs, &tools);
1231        exec_ctx.set_cwd(cwd);
1232        exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1233        exec_ctx.set_job_manager(jobs.clone());
1234        exec_ctx.set_tool_schemas(tools.schemas());
1235        exec_ctx.set_tools(tools.clone());
1236        #[cfg(feature = "os-integration")]
1237        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1238        exec_ctx.stderr = Some(stderr_writer);
1239        exec_ctx.ignore_config = ignore_config;
1240        exec_ctx.output_limit = output_limit;
1241        exec_ctx.allow_external_commands = allow_external_commands;
1242        exec_ctx.vfs_budget = vfs_budget.clone();
1243
1244        Ok(Self {
1245            name,
1246            scope: RwLock::new({
1247                let mut scope = Scope::new();
1248                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1249                // HOME is NOT read from the host env here — the kernel is
1250                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1251                // below (from `std::env::vars()`); a hermetic embedder leaves
1252                // `initial_vars` empty and gets no HOME (tilde stays literal).
1253                // Apply caller-supplied initial variables, all marked exported.
1254                // Frontends (REPL, MCP) populate this from std::env::vars()
1255                // for shell-like UX; embedders that want hermetic behavior
1256                // simply leave it empty.
1257                for (name, value) in initial_vars.clone() {
1258                    scope.set_exported(name, value);
1259                }
1260                scope.set_trash_enabled(trash_enabled);
1261                scope
1262            }),
1263            initial_vars,
1264            tools,
1265            user_tools: RwLock::new(HashMap::new()),
1266            vfs,
1267            jobs,
1268            runner,
1269            exec_ctx: RwLock::new(exec_ctx),
1270            skip_validation,
1271            interactive,
1272            allow_external_commands,
1273            vfs_budget,
1274            request_timeout,
1275            kill_grace,
1276            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1277            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1278            interrupt: std::sync::Mutex::new(None),
1279            #[cfg(all(unix, feature = "subprocess"))]
1280            terminal_state: None,
1281            self_weak: std::sync::OnceLock::new(),
1282            execute_lock: tokio::sync::Mutex::new(()),
1283            recursion_depth: AtomicUsize::new(0),
1284            bg_job_id: None,
1285            // Overlay handle is set by Kernel::new after assemble returns;
1286            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1287            // with_backend always has None (overlay=true is rejected above).
1288            #[cfg(all(feature = "localfs", feature = "overlay"))]
1289            overlay_handle: None,
1290        })
1291    }
1292
1293    /// Plan every statement of `source` without executing anything —
1294    /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1295    /// embedder holding a kernel can pair the plans with `get_var` lookups
1296    /// against this kernel's live state.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns the parse errors when `source` does not parse.
1301    pub fn plan_program(
1302        &self,
1303        source: &str,
1304    ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1305        crate::ast::plan::plan_program(source)
1306    }
1307
1308    /// Expand one heredoc body against a scope the caller supplies —
1309    /// [`expand_fragment`](crate::fragment::expand_fragment) as a method.
1310    ///
1311    /// The scope is the caller's, not this kernel's: pair it with `get_var`
1312    /// when the session's values are the ones to judge against, and supply
1313    /// different values when they are not. Nothing executes, and a `$(…)` in
1314    /// the body comes back as a [`Hole`](kaish_types::plan::Hole) rather than
1315    /// running here.
1316    ///
1317    /// # Errors
1318    ///
1319    /// Returns a [`FragmentError`](crate::fragment::FragmentError) when the
1320    /// source does not parse, the address names no heredoc, or the body reads
1321    /// something the supplied scope does not carry.
1322    pub fn expand_fragment(
1323        &self,
1324        source: &str,
1325        addr: kaish_types::plan::FragmentAddr,
1326        scope: &[(String, Value)],
1327    ) -> Result<kaish_types::plan::Expansion, crate::fragment::FragmentError> {
1328        crate::fragment::expand_fragment(source, addr, scope)
1329    }
1330
1331    /// Get the kernel name.
1332    pub fn name(&self) -> &str {
1333        &self.name
1334    }
1335
1336    /// Wrap this Kernel in an Arc and initialize its self-reference.
1337    ///
1338    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1339    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1340    /// commands through the full resolution chain (user tools → builtins →
1341    /// .kai scripts → external commands).
1342    pub fn into_arc(self) -> Arc<Self> {
1343        let arc = Arc::new(self);
1344        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1345        arc
1346    }
1347
1348    /// Fork a subsidiary kernel for concurrent execution.
1349    ///
1350    /// The fork is a fully-functional `Kernel` that:
1351    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1352    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1353    ///   the fork do NOT propagate back to the parent — matching bash
1354    ///   subshell / background-job semantics.
1355    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1356    ///   registry, the VFS router, and the job manager. A job registered by
1357    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1358    ///   sees the same VFS mounts.
1359    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1360    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1361    ///   `false` and `terminal_state` is `None`.
1362    ///
1363    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1364    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1365    /// routes through the fork itself, not the parent — which is essential
1366    /// for concurrency safety.
1367    ///
1368    /// Use this for **detached** background concurrency where the fork should
1369    /// survive parent cancellation: the `&` background-job operator and any
1370    /// other "fire and forget" worker. The fork gets a fresh, independent
1371    /// cancellation token.
1372    ///
1373    /// For foreground concurrency (scatter workers, concurrent pipeline
1374    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1375    /// into the fork's external children, use [`Self::fork_attached`].
1376    pub async fn fork(&self) -> Arc<Self> {
1377        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1378            .await
1379    }
1380
1381    /// Fork attached to the parent's cancellation.
1382    ///
1383    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1384    /// the parent's. When the parent cancels (request timeout, embedder
1385    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1386    /// turn kills any external children spawned in the fork via the
1387    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1388    pub async fn fork_attached(&self) -> Arc<Self> {
1389        let child_token = {
1390            #[allow(clippy::expect_used)]
1391            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1392            parent.child_token()
1393        };
1394        self.fork_inner(child_token, self.bg_job_id).await
1395    }
1396
1397    /// Fork for a background job, stamping the job id so external commands
1398    /// spawned anywhere beneath it record their process groups on that job
1399    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1400    /// `JobManager::cancel`.
1401    pub async fn fork_for_background(
1402        &self,
1403        cancel: tokio_util::sync::CancellationToken,
1404        job_id: crate::scheduler::JobId,
1405    ) -> Arc<Self> {
1406        self.fork_inner(cancel, Some(job_id)).await
1407    }
1408
1409    /// Shared fork implementation. Caller decides the cancellation token and
1410    /// which background job (if any) this fork runs on behalf of.
1411    async fn fork_inner(
1412        &self,
1413        cancel: tokio_util::sync::CancellationToken,
1414        bg_job_id: Option<crate::scheduler::JobId>,
1415    ) -> Arc<Self> {
1416        let scope_snapshot = self.scope.read().await.clone();
1417        let user_tools_snapshot = self.user_tools.read().await.clone();
1418
1419        // Snapshot exec_ctx by cloning the cloneable fields, then override
1420        // the ones that should not carry over (stderr channel, dispatcher,
1421        // interactive flag, terminal state, cancel — set from `cancel` arg).
1422        let mut fork_ctx = {
1423            let parent_ctx = self.exec_ctx.read().await;
1424            parent_ctx.child_for_pipeline()
1425        };
1426        let (stderr_writer, stderr_receiver) = stderr_stream();
1427        fork_ctx.stderr = Some(stderr_writer);
1428        // Clear dispatcher; dispatch_command will repopulate it to point at
1429        // the fork on the first dispatch call.
1430        fork_ctx.dispatcher = None;
1431        fork_ctx.interactive = false;
1432        fork_ctx.cancel = cancel.clone();
1433        #[cfg(all(unix, feature = "subprocess"))]
1434        {
1435            fork_ctx.terminal_state = None;
1436        }
1437
1438        let fork = Self {
1439            name: format!("{}:fork", self.name),
1440            scope: RwLock::new(scope_snapshot),
1441            initial_vars: self.initial_vars.clone(),
1442            tools: Arc::clone(&self.tools),
1443            user_tools: RwLock::new(user_tools_snapshot),
1444            vfs: Arc::clone(&self.vfs),
1445            jobs: Arc::clone(&self.jobs),
1446            runner: self.runner.clone(),
1447            exec_ctx: RwLock::new(fork_ctx),
1448            skip_validation: self.skip_validation,
1449            // Forks are never the TTY owner — they run in the background.
1450            interactive: false,
1451            allow_external_commands: self.allow_external_commands,
1452            // Arc-clone the budget so the fork draws from the same pool as the
1453            // parent — background jobs and scatter workers count against the same
1454            // cap as foreground writes.
1455            vfs_budget: self.vfs_budget.clone(),
1456            request_timeout: self.request_timeout,
1457            kill_grace: self.kill_grace,
1458            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1459            cancel_token: std::sync::Mutex::new(cancel),
1460            interrupt: std::sync::Mutex::new(None),
1461            #[cfg(all(unix, feature = "subprocess"))]
1462            terminal_state: None,
1463            self_weak: std::sync::OnceLock::new(),
1464            execute_lock: tokio::sync::Mutex::new(()),
1465            // A fork runs on a fresh stack (spawned task) — its recursion
1466            // budget is independent of the parent's current depth (GH #46).
1467            recursion_depth: AtomicUsize::new(0),
1468            // A fork surfaces its own holds; the parent's slot stays put.
1469            bg_job_id,
1470            // Arc-clone the overlay handle so forks (background jobs, scatter
1471            // workers, pipeline stages) can reach the same overlay transaction
1472            // via `kaish-vfs status/diff/commit/reset`.
1473            #[cfg(all(feature = "localfs", feature = "overlay"))]
1474            overlay_handle: self.overlay_handle.clone(),
1475        };
1476
1477        fork.into_arc()
1478    }
1479
1480    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1481    ///
1482    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1483    /// have been dropped (the `Weak` can no longer upgrade).
1484    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1485        self.self_weak
1486            .get()
1487            .and_then(|weak| weak.upgrade())
1488            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1489    }
1490
1491    /// Initialize terminal state for interactive job control.
1492    ///
1493    /// Call this after kernel creation when running as an interactive REPL
1494    /// and stdin is a TTY. Sets up process groups and signal handling.
1495    #[cfg(all(unix, feature = "subprocess"))]
1496    pub fn init_terminal(&mut self) {
1497        if !self.interactive {
1498            return;
1499        }
1500        match crate::terminal::TerminalState::init() {
1501            Ok(state) => {
1502                let state = Arc::new(state);
1503                self.terminal_state = Some(state.clone());
1504                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1505                self.exec_ctx.get_mut().terminal_state = Some(state);
1506                tracing::debug!("terminal job control initialized");
1507            }
1508            Err(e) => {
1509                tracing::warn!("failed to initialize terminal job control: {}", e);
1510            }
1511        }
1512    }
1513
1514    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1515    ///
1516    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1517    /// built with the `os-integration` feature. Embedders and tests can swap
1518    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1519    /// it — with trash enabled but no backend present, `rm` fails loud
1520    /// rather than falling through to permanent delete.
1521    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1522        self.exec_ctx.get_mut().trash_backend = backend;
1523    }
1524
1525    /// Cancel the current execution.
1526    ///
1527    /// This cancels the current cancellation token, causing any execution
1528    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1529    /// A fresh token is installed for the next `execute()` call.
1530    pub fn cancel(&self) {
1531        #[allow(clippy::expect_used)]
1532        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1533        token.cancel();
1534    }
1535
1536    /// Check if the current execution has been cancelled.
1537    ///
1538    /// Also the polling point for `ExecuteOptions::interrupt`: when the
1539    /// embedder's check reports true, the internal token fires here, so every
1540    /// call site of this method is an interrupt checkpoint for free.
1541    pub fn is_cancelled(&self) -> bool {
1542        let interrupted = {
1543            #[allow(clippy::expect_used)]
1544            let check = self.interrupt.lock().expect("interrupt poisoned");
1545            check.as_ref().is_some_and(|f| f())
1546        };
1547        if interrupted {
1548            self.cancel();
1549        }
1550        #[allow(clippy::expect_used)]
1551        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1552        token.is_cancelled()
1553    }
1554
1555    /// Reset the cancellation token (called at the start of each execute).
1556    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1557        #[allow(clippy::expect_used)]
1558        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1559        if token.is_cancelled() {
1560            *token = tokio_util::sync::CancellationToken::new();
1561        }
1562        token.clone()
1563    }
1564
1565    /// Acquire the per-Kernel execute lock, warning on contention.
1566    ///
1567    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1568    /// the lock is already held, emit a warning so the silent serialization
1569    /// is observable in logs — if you need real parallelism, fork the kernel.
1570    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1571        match self.execute_lock.try_lock() {
1572            Ok(guard) => guard,
1573            Err(_) => {
1574                tracing::warn!(
1575                    target: "kaish::kernel::concurrency",
1576                    kernel = %self.name,
1577                    "execute() contended — serializing concurrent caller; \
1578                     use Kernel::fork() for parallelism instead of sharing"
1579                );
1580                self.execute_lock.lock().await
1581            }
1582        }
1583    }
1584
1585    /// Execute kaish source code with default options.
1586    ///
1587    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1588    /// Returns the result of the last statement executed.
1589    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1590        self.run_inner(input, ExecuteOptions::default(), None, None).await
1591    }
1592
1593    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1594    /// are **already tokenized**.
1595    ///
1596    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1597    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1598    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1599    /// into a string just to have the lexer split it apart again — a round-trip
1600    /// that is lossy for typed values, since `to_argv()` stringifies
1601    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1602    ///
1603    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1604    /// command substitution, no word splitting — the "single-quoted word"
1605    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1606    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1607    /// does still apply, for consistency with the string door: a leading `~` is
1608    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1609    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1610    /// non-string `Value`
1611    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1612    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1613    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1614    /// sees a stringified value; the typed-passthrough win fully lands only for
1615    /// builtins that read `args.positional` directly — the documented pattern.)
1616    ///
1617    /// This is a *peer*, not a subset: a command string can carry pipelines,
1618    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1619    /// doors converge **late** (at the shared dispatch chain) rather than one
1620    /// wrapping the other. From argv classification onward `execute_argv` reuses
1621    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1622    /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1623    /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1624    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1625    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1626    ///
1627    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1628    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1629    /// external is interrupted at the deadline with exit code 124, the same as the
1630    /// string door). There is no per-call options surface yet — a future
1631    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1632    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1633    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1634        let _guard = self.acquire_execute_lock().await;
1635        self.execute_argv_locked(name, argv).await
1636    }
1637
1638    /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1639    async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1640        // Fresh cancel surface for this call: `execute_pipeline` reads
1641        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1642        // replaced first. The returned clone is the token the watchdog cancels on
1643        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1644        // cascading SIGTERM/SIGKILL to any external child.
1645        let cancel = self.reset_cancel();
1646
1647        // Honor the kernel-configured request timeout for parity with `execute`.
1648        let timeout = self.request_timeout;
1649        if timeout == Some(Duration::ZERO) {
1650            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1651        }
1652
1653        let command = crate::ast::Command {
1654            name: name.to_string(),
1655            args: argv_to_args(argv),
1656            redirects: Vec::new(),
1657        };
1658
1659        let pipeline = crate::ast::Pipeline {
1660            stages: vec![crate::ast::PipelineStage::Command(command)],
1661            background: false,
1662        };
1663        let work = async {
1664            let result = self.execute_pipeline(&pipeline).await?;
1665            // A gate raised while evaluating inside the dispatched tool — a
1666            // user tool body's `$(…)` — surfaces as this call's own held
1667            // result, and must not strand in the slot for the next serialized
1668            // call to mis-take.
1669            Ok(result)
1670        };
1671        let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1672        self.update_last_result(&result).await;
1673        Ok(result)
1674    }
1675
1676    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1677    /// string door ([`Self::execute_with_options`]) and the argv door
1678    /// ([`Self::execute_argv`]).
1679    ///
1680    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1681    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1682    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1683    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1684    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1685    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1686    /// stale handle would silently suspend nothing). Callers must short-circuit a
1687    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1688    async fn run_under_watchdog<F>(
1689        &self,
1690        timeout: Option<Duration>,
1691        cancel: &tokio_util::sync::CancellationToken,
1692        work: F,
1693    ) -> Result<ExecResult>
1694    where
1695        F: std::future::Future<Output = Result<ExecResult>>,
1696    {
1697        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1698        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1699        {
1700            let mut ec = self.exec_ctx.write().await;
1701            ec.watchdog = watchdog.clone();
1702        }
1703
1704        let result = if let Some(d) = timeout {
1705            #[allow(clippy::expect_used)]
1706            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1707            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1708            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1709            let r = work.await;
1710            timer.abort();
1711            match r {
1712                Ok(mut res) => {
1713                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1714                        res.code = 124;
1715                        if res.err.is_empty() {
1716                            res.err =
1717                                ExecResult::terminate_diagnostic(format!("timeout: timed out after {:?}", d));
1718                        }
1719                    }
1720                    Ok(res)
1721                }
1722                Err(e) => Err(e),
1723            }
1724        } else {
1725            work.await
1726        };
1727
1728        // The timer task is gone (fired or aborted); drop the stale handle.
1729        {
1730            let mut ec = self.exec_ctx.write().await;
1731            ec.watchdog = None;
1732        }
1733        result
1734    }
1735
1736    /// Execute with per-call options. The primary entry point for embedders
1737    /// that don't need per-statement output streaming.
1738    ///
1739    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1740    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1741    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1742    ///
1743    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1744    /// against the kernel's internal token. Either firing cancels and kills
1745    /// external children. The embedder's token is read-only — kernel
1746    /// timeouts do NOT propagate into it. Distinguish via the returned
1747    /// `code`: 124 = timeout, 130 = cancellation.
1748    ///
1749    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1750    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1751    ///
1752    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1753    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1754    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1755    pub async fn execute_with_options(
1756        &self,
1757        input: &str,
1758        opts: ExecuteOptions,
1759    ) -> Result<ExecResult> {
1760        self.run_inner(input, opts, None, None).await
1761    }
1762
1763    /// Same as [`Self::execute_with_options`] but with a per-statement output
1764    /// callback. The callback fires after each top-level statement so the
1765    /// embedder (REPL, MCP streaming) can flush output incrementally.
1766    pub async fn execute_with_options_streaming(
1767        &self,
1768        input: &str,
1769        opts: ExecuteOptions,
1770        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1771    ) -> Result<ExecResult> {
1772        self.run_inner(input, opts, None, Some(on_output)).await
1773    }
1774
1775    /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1776    ///
1777    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1778    /// forces the input to be drained before execution: the reader seeds the
1779    /// first top-level command's `pipe_stdin`, and a command that does not read
1780    /// stdin (`echo`) returns without touching it. This is the seam a
1781    /// non-interactive frontend uses to forward an *open* process stdin without
1782    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1783    ///
1784    /// Embedders that already hold a complete buffer (text or binary) should
1785    /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1786    pub async fn execute_with_pipe_stdin(
1787        &self,
1788        input: &str,
1789        opts: ExecuteOptions,
1790        pipe_stdin: crate::scheduler::PipeReader,
1791    ) -> Result<ExecResult> {
1792        self.run_inner(input, opts, Some(pipe_stdin), None).await
1793    }
1794
1795    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1796    /// `-c`/script frontend uses this to print output incrementally while
1797    /// feeding a lazy process-stdin pipe.
1798    pub async fn execute_with_pipe_stdin_streaming(
1799        &self,
1800        input: &str,
1801        opts: ExecuteOptions,
1802        pipe_stdin: crate::scheduler::PipeReader,
1803        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1804    ) -> Result<ExecResult> {
1805        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1806    }
1807
1808    /// Execute kaish source code with a transient overlay of exported variables.
1809    ///
1810    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1811    /// should use that method directly:
1812    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1813    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1814    pub async fn execute_with_vars(
1815        &self,
1816        input: &str,
1817        vars: HashMap<String, Value>,
1818    ) -> Result<ExecResult> {
1819        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1820    }
1821
1822    /// Execute kaish source code with a per-statement callback.
1823    ///
1824    /// Deprecated thin wrapper. New code should use
1825    /// [`Self::execute_with_options_streaming`].
1826    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1827    pub async fn execute_streaming(
1828        &self,
1829        input: &str,
1830        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1831    ) -> Result<ExecResult> {
1832        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1833    }
1834
1835    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1836    ///
1837    /// The `#[instrument]` execution span resolves its parent from the *current*
1838    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1839    /// captured when the span is first entered — not when the future is
1840    /// constructed. So a thread-local `attach()` scoped to construction is too
1841    /// early to be seen (the integration test confirms this). `with_context`
1842    /// re-attaches the embedder's context on *every* poll of the inner future,
1843    /// so the context is current at first-enter and survives runtime thread
1844    /// hops. With no embedder trace context, the future runs unwrapped.
1845    async fn run_inner(
1846        &self,
1847        input: &str,
1848        opts: ExecuteOptions,
1849        pipe_stdin: Option<crate::scheduler::PipeReader>,
1850        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1851    ) -> Result<ExecResult> {
1852        use opentelemetry::context::FutureExt;
1853
1854        // Capture the embedder's baggage before `opts` is consumed so it can be
1855        // echoed back onto the result on egress (see `merge_egress_baggage`).
1856        let embedder_baggage = opts.baggage.clone();
1857
1858        let result = match crate::telemetry::extract_parent(&opts) {
1859            Some(parent) => self
1860                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1861                .with_context(parent)
1862                .await,
1863            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1864        };
1865
1866        result.map(|mut r| {
1867            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1868            r
1869        })
1870    }
1871
1872    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1873    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1874    /// cwd override, and timeout race.
1875    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1876    async fn execute_with_options_inner(
1877        &self,
1878        input: &str,
1879        opts: ExecuteOptions,
1880        pipe_stdin: Option<crate::scheduler::PipeReader>,
1881        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1882    ) -> Result<ExecResult> {
1883        let _guard = self.acquire_execute_lock().await;
1884
1885        // Always reset to a fresh internal token; this is the kernel's own
1886        // cancel surface for embedders calling `Kernel::cancel()`. The
1887        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1888        // is NOT written into `self.cancel_token`, because doing so would
1889        // (a) leak the embedder's token past this call's lifetime,
1890        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1891        // (c) extend the token's lifetime via the kernel's strong clone.
1892        let internal = self.reset_cancel();
1893
1894        // Install the per-call polled interrupt for `is_cancelled()` to
1895        // consult. The guard clears it on every exit path — a stale check
1896        // must not outlive its call and fire into a later one.
1897        struct ClearInterrupt<'a>(&'a Kernel);
1898        impl Drop for ClearInterrupt<'_> {
1899            fn drop(&mut self) {
1900                if let Ok(mut slot) = self.0.interrupt.lock() {
1901                    *slot = None;
1902                }
1903            }
1904        }
1905        {
1906            #[allow(clippy::expect_used)]
1907            let mut slot = self.interrupt.lock().expect("interrupt poisoned");
1908            *slot = opts.interrupt.clone();
1909        }
1910        let _interrupt_guard = ClearInterrupt(self);
1911
1912        // Race the embedder token against the kernel's internal token via a
1913        // tracked watcher task. We hold the JoinHandle so we can abort the
1914        // task at function exit — otherwise it would wait forever for either
1915        // token to fire and leak per call.
1916        let (effective_cancel, watcher_handle): (
1917            tokio_util::sync::CancellationToken,
1918            Option<tokio::task::JoinHandle<()>>,
1919        ) = if let Some(ext) = opts.cancel_token {
1920            let combined = tokio_util::sync::CancellationToken::new();
1921            let combined_writer = combined.clone();
1922            let i = internal.clone();
1923            let handle = tokio::spawn(async move {
1924                tokio::select! {
1925                    _ = i.cancelled() => combined_writer.cancel(),
1926                    _ = ext.cancelled() => combined_writer.cancel(),
1927                }
1928            });
1929            (combined, Some(handle))
1930        } else {
1931            (internal, None)
1932        };
1933
1934        // Effective timeout: per-call wins over kernel-config default.
1935        let timeout = opts.timeout.or(self.request_timeout);
1936
1937        // ZERO timeout: return 124 immediately without spawning anything.
1938        if timeout == Some(Duration::ZERO) {
1939            if let Some(h) = watcher_handle {
1940                h.abort();
1941            }
1942            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1943        }
1944
1945        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1946        // an RAII guard so a panic inside `execute_streaming_inner` still
1947        // pops the frame and unexports the temporarily-exported names.
1948        struct VarsFrameGuard<'a> {
1949            kernel: &'a Kernel,
1950            newly_exported: Vec<String>,
1951        }
1952        impl Drop for VarsFrameGuard<'_> {
1953            fn drop(&mut self) {
1954                // Best-effort cleanup using try_write. The execute_lock held
1955                // throughout execute_with_options means there is no concurrent
1956                // foreground caller; forks have their own scope and won't
1957                // block this. blocking_write would deadlock the runtime when
1958                // called from a tokio worker thread, so we explicitly do NOT
1959                // fall back to it — if try_write fails (which we've never
1960                // seen in practice), log loudly and accept the leak rather
1961                // than deadlock the entire kernel.
1962                let Ok(mut scope) = self.kernel.scope.try_write() else {
1963                    tracing::error!(
1964                        "vars frame guard: scope lock unexpectedly busy; \
1965                         skipping pop_frame to avoid runtime deadlock — \
1966                         transient vars may leak"
1967                    );
1968                    return;
1969                };
1970                scope.pop_frame();
1971                for name in self.newly_exported.drain(..) {
1972                    scope.unexport(&name);
1973                }
1974            }
1975        }
1976
1977        // Per-call cwd override: save current cwd, set the new one, restore
1978        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1979        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1980        struct CwdGuard<'a> {
1981            kernel: &'a Kernel,
1982            saved: PathBuf,
1983        }
1984        impl Drop for CwdGuard<'_> {
1985            fn drop(&mut self) {
1986                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1987                    tracing::error!(
1988                        "cwd guard: exec_ctx lock unexpectedly busy; \
1989                         skipping cwd restore — kernel cwd may be wrong for next call"
1990                    );
1991                    return;
1992                };
1993                ec.cwd = std::mem::take(&mut self.saved);
1994            }
1995        }
1996        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1997            let mut ec = self.exec_ctx.write().await;
1998            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1999            drop(ec);
2000            Some(CwdGuard { kernel: self, saved })
2001        } else {
2002            None
2003        };
2004
2005        // Per-call stdin: seed the persistent exec_ctx so the first top-level
2006        // command that reads stdin consumes it (it's `take()`n at dispatch).
2007        // Restore the prior value on Drop — normally `None`, so this also drops
2008        // any residual seed an stdin-less program never consumed, keeping it
2009        // from bleeding into the next call. Same RAII pattern as CwdGuard.
2010        struct StdinGuard<'a> {
2011            kernel: &'a Kernel,
2012            saved: Option<Vec<u8>>,
2013        }
2014        impl Drop for StdinGuard<'_> {
2015            fn drop(&mut self) {
2016                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2017                    tracing::error!(
2018                        "stdin guard: exec_ctx lock unexpectedly busy; \
2019                         skipping stdin restore — stale stdin may leak to next call"
2020                    );
2021                    return;
2022                };
2023                ec.stdin = self.saved.take();
2024            }
2025        }
2026        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2027            let mut ec = self.exec_ctx.write().await;
2028            let saved = ec.stdin.replace(stdin);
2029            drop(ec);
2030            Some(StdinGuard { kernel: self, saved })
2031        } else {
2032            None
2033        };
2034
2035        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2036        // persistent exec_ctx so the first stdin-reading command drains it (it's
2037        // `take()`n at pipeline build). The RAII guard restores the prior value
2038        // on Drop (normally `None`), so an unread reader doesn't bleed into the
2039        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2040        struct PipeStdinGuard<'a> {
2041            kernel: &'a Kernel,
2042            saved: Option<crate::scheduler::PipeReader>,
2043        }
2044        impl Drop for PipeStdinGuard<'_> {
2045            fn drop(&mut self) {
2046                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2047                    tracing::error!(
2048                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2049                         skipping restore — stale pipe stdin may leak to next call"
2050                    );
2051                    return;
2052                };
2053                ec.pipe_stdin = self.saved.take();
2054            }
2055        }
2056        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2057            let mut ec = self.exec_ctx.write().await;
2058            let saved = ec.pipe_stdin.replace(reader);
2059            drop(ec);
2060            Some(PipeStdinGuard { kernel: self, saved })
2061        } else {
2062            None
2063        };
2064
2065        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2066            let mut scope = self.scope.write().await;
2067            scope.push_frame();
2068            let mut newly = Vec::with_capacity(opts.vars.len());
2069            for (name, value) in opts.vars {
2070                if !scope.is_exported(&name) {
2071                    newly.push(name.clone());
2072                }
2073                scope.set_exported(name, value);
2074            }
2075            drop(scope);
2076            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2077        } else {
2078            None
2079        };
2080
2081        // Sync the effective cancel into self.exec_ctx so try_execute_external
2082        // (which reads via self.cancel_token) sees cancellation. We also need
2083        // builtins to see it via ctx.cancel — handled in execute_command.
2084        // For simplicity here we mirror effective_cancel into self.cancel_token
2085        // for the duration of this call, then restore the internal token at
2086        // the end (so a later Kernel::cancel still hits our internal surface).
2087        {
2088            #[allow(clippy::expect_used)]
2089            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2090            *cur = effective_cancel.clone();
2091        }
2092
2093        // Run the script under the movable-deadline watchdog (shared with the
2094        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2095        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2096        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2097        // already handled by the early return above.
2098        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2099        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2100            Some(cb) => cb,
2101            None => &mut *noop_cb,
2102        };
2103
2104        let result = self
2105            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2106            .await;
2107
2108        // Restore self.cancel_token to a fresh, uncancelled token so the
2109        // embedder's view of `Kernel::cancel()` stays predictable on the
2110        // next call (it cancels the kernel's own token, not whatever was
2111        // left over from this call's combined token).
2112        {
2113            #[allow(clippy::expect_used)]
2114            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2115            *cur = tokio_util::sync::CancellationToken::new();
2116        }
2117
2118        // Tear down the embedder-token race watcher (if any). Leaving it
2119        // alive would idle forever waiting for tokens that may never fire.
2120        if let Some(h) = watcher_handle {
2121            h.abort();
2122        }
2123
2124        // VarsFrameGuard drops here on the success path and on early-return
2125        // paths above (error path included). Panic safety preserved.
2126        result
2127    }
2128
2129    /// The actual body of `execute_streaming`, run while holding the execute lock.
2130    ///
2131    /// Split out so internal kernel paths that are already under the lock can
2132    /// call this without deadlocking on re-entry. External callers must go
2133    /// through [`Self::execute_streaming`] so they acquire the lock.
2134    async fn execute_streaming_inner(
2135        &self,
2136        input: &str,
2137        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2138    ) -> Result<ExecResult> {
2139        let program = parse(input).map_err(|errors| {
2140            let msg = errors
2141                .iter()
2142                .map(|e| e.format(input))
2143                .collect::<Vec<_>>()
2144                .join("\n");
2145            anyhow::anyhow!("parse error:\n{}", msg)
2146        })?;
2147
2148        // AST display mode: show AST instead of executing
2149        {
2150            let scope = self.scope.read().await;
2151            if scope.show_ast() {
2152                let output = format!("{:#?}\n", program);
2153                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2154            }
2155        }
2156
2157        // Pre-execution validation. Most warnings stay trace-only (every
2158        // external command fires an `UndefinedCommand` warning), but a warning
2159        // whose code opts into agent surfacing is collected here and prepended
2160        // to the result's stderr at each return point below.
2161        let mut surfaced_warnings = String::new();
2162        if !self.skip_validation {
2163            // Catalog first: neither guard should ride the other's await, and
2164            // `validate()` is synchronous, so neither rides one after this.
2165            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
2166            let user_tools = self.user_tools.read().await;
2167            let validator = Validator::new(&self.tools, &user_tools, &catalog);
2168            let issues = validator.validate(&program);
2169
2170            // Collect errors (warnings are logged but don't prevent execution)
2171            let errors: Vec<_> = issues
2172                .iter()
2173                .filter(|i| i.severity == Severity::Error)
2174                .collect();
2175
2176            if !errors.is_empty() {
2177                let error_msg = errors
2178                    .iter()
2179                    .map(|e| e.format(input))
2180                    .collect::<Vec<_>>()
2181                    .join("\n");
2182                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2183            }
2184
2185            // Log warnings via tracing (trace level to avoid noise); surface the
2186            // opted-in ones to the agent so the guidance is actually seen.
2187            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2188                tracing::trace!("validation: {}", warning.format(input));
2189                if warning.code.surfaces_to_agent() {
2190                    surfaced_warnings.push_str(&warning.format(input));
2191                    surfaced_warnings.push('\n');
2192                }
2193            }
2194        }
2195
2196        // Surface opted-in validation warnings to the streaming frontend once,
2197        // before any command output. The streaming consumer (`-c`, REPL) prints
2198        // per `on_output` and ignores the returned aggregate err; non-streaming
2199        // callers (`kernel.execute`) use a noop callback and read the aggregate
2200        // `result.err` (prepended at each return below). The two paths are
2201        // disjoint, so this prints the advisory exactly once on each.
2202        if !surfaced_warnings.is_empty() {
2203            let mut advisory = ExecResult::success("");
2204            advisory.err = surfaced_warnings.clone();
2205            on_output(&advisory);
2206        }
2207
2208        let mut result = ExecResult::success("");
2209
2210        // Reset cancellation token for this execution.
2211        let cancel = self.reset_cancel();
2212
2213        for stmt in program.statements.into_iter() {
2214            if matches!(stmt, Stmt::Empty) {
2215                continue;
2216            }
2217
2218            // Cancellation checkpoint
2219            if cancel.is_cancelled() {
2220                result.code = 130;
2221                return Ok(result);
2222            }
2223
2224            // The statement tap and gate (spec §C.6) — one of exactly two
2225            // sites. It runs before `execute_stmt_flow`, so a held statement
2226            // has run *nothing*: no substitution, no redirect opened, no
2227            let flow_result = self.execute_stmt_flow(&stmt).await;
2228            let flow = flow_result?;
2229
2230            // Drain any stderr written by pipeline stages during this statement.
2231            // This captures stderr from intermediate pipeline stages that would
2232            // otherwise be lost (only the last stage's result is returned).
2233            let drained_stderr = {
2234                let mut receiver = self.stderr_receiver.lock().await;
2235                receiver.drain_lossy()
2236            };
2237
2238            match flow {
2239                ControlFlow::Normal(mut r) => {
2240                    if !drained_stderr.is_empty() {
2241                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2242                            r.err.push('\n');
2243                        }
2244                        // Prepend pipeline stderr before the last stage's stderr
2245                        let combined = format!("{}{}", drained_stderr, r.err);
2246                        r.err = combined;
2247                    }
2248                    on_output(&r);
2249                    // Carry the last statement's structured output for MCP TOON encoding.
2250                    // Must be done here (not in accumulate_result) because accumulate_result
2251                    // is also used in loops where per-iteration output would be wrong.
2252                    let last_output = r.output().cloned();
2253                    accumulate_result(&mut result, &r);
2254                    result.set_output(last_output);
2255                }
2256                ControlFlow::Exit { code, result: carried } => {
2257                    if !drained_stderr.is_empty() {
2258                        result.err.push_str(&drained_stderr);
2259                    }
2260                    // Output produced before the exit — e.g. by the loop the
2261                    // `exit` ran inside — arrives on the signal. Emit it like
2262                    // any other statement's, then let `code` decide the status.
2263                    on_output(&carried);
2264                    accumulate_result(&mut result, &carried);
2265                    result.code = code;
2266                    if !surfaced_warnings.is_empty() {
2267                        result.err = format!("{surfaced_warnings}{}", result.err);
2268                    }
2269                    return Ok(result);
2270                }
2271                ControlFlow::Return { mut value } => {
2272                    if !drained_stderr.is_empty() {
2273                        value.err = format!("{}{}", drained_stderr, value.err);
2274                    }
2275                    on_output(&value);
2276                    // A top-level `return` stops the script, like `exit` —
2277                    // it must not discard prior statements' accumulated
2278                    // output nor let execution continue past it.
2279                    accumulate_result(&mut result, &value);
2280                    if !surfaced_warnings.is_empty() {
2281                        result.err = format!("{surfaced_warnings}{}", result.err);
2282                    }
2283                    return Ok(result);
2284                }
2285                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2286                    if !drained_stderr.is_empty() {
2287                        r.err = format!("{}{}", drained_stderr, r.err);
2288                    }
2289                    on_output(&r);
2290                    accumulate_result(&mut result, &r);
2291                }
2292            }
2293        }
2294
2295        if !surfaced_warnings.is_empty() {
2296            result.err = format!("{surfaced_warnings}{}", result.err);
2297        }
2298        Ok(result)
2299    }
2300
2301    /// Execute a single statement, returning control flow information.
2302    fn execute_stmt_flow<'a>(
2303        &'a self,
2304        stmt: &'a Stmt,
2305    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2306        // No per-statement span here: `execute_stmt_flow` is the largest future
2307        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2308        // the span's state through every `.await` at every level, costing native
2309        // stack per level (GH #48). Coarser spans on the outer execute entries
2310        // remain. See item 3 of the #48 burndown.
2311        Box::pin(async move {
2312        match stmt {
2313            Stmt::Assignment(assign) => {
2314                // An assignment with no command name takes the exit status of
2315                // the last command substitution in its value, or 0 if there
2316                // was none (bash's rule, re-probed). Clear the note first so
2317                // a substitution from an earlier statement cannot leak in —
2318                // `false; x=5` must be 0, not stale.
2319                {
2320                    let mut scope = self.scope.write().await;
2321                    scope.clear_cmdsubst_code();
2322                }
2323                // Use async evaluator to support command substitution
2324                let value = self.eval_expr_async(&assign.value).await
2325                    .context("failed to evaluate assignment")?;
2326                let mut scope = self.scope.write().await;
2327                if assign.path.segments.len() == 1 {
2328                    // Plain `NAME=value` — no subscript, so `local` applies.
2329                    if assign.local {
2330                        // local: set in innermost (current function) frame
2331                        scope.set(assign.name(), value.clone());
2332                    } else {
2333                        // non-local: update existing or create in root frame
2334                        scope.set_global(assign.name(), value.clone());
2335                    }
2336                } else {
2337                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2338                    // mutates the existing root wherever it lives, so `local`
2339                    // has nothing to declare. See docs/LANGUAGE.md,
2340                    // "Assignment — bracket-path lvalues".
2341                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2342                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2343                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2344                        ),
2345                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2346                    })?;
2347                }
2348                drop(scope);
2349
2350                // Assignments don't produce output (like sh), but they are a
2351                // command: they write `$?` and honor `set -e` (bash: `set -e;
2352                // x=$(false)` exits). The code is the last substitution's, or
2353                // 0 — this is what lets `x="$(cmd)" || x="FALLBACK"` fire.
2354                let subst_code = {
2355                    let mut scope = self.scope.write().await;
2356                    scope.take_cmdsubst_code()
2357                };
2358                let result = match subst_code {
2359                    None | Some(0) => ExecResult::success(""),
2360                    Some(code) => ExecResult::failure(code, ""),
2361                };
2362                self.update_last_result(&result).await;
2363                if !result.ok() {
2364                    let scope = self.scope.read().await;
2365                    if scope.error_exit_enabled() {
2366                        // `-e` aborts the statement list, but the reason the
2367                        // command died must survive with it — carry `result`
2368                        // (its `out`/`err`/`data`) into the Exit signal instead
2369                        // of `ControlFlow::exit_code`'s empty placeholder.
2370                        let code = result.code;
2371                        return Ok(ControlFlow::Exit { code, result });
2372                    }
2373                }
2374                Ok(ControlFlow::ok(result))
2375            }
2376            Stmt::Command(cmd) => {
2377                // Route single commands through execute_pipeline for a unified path.
2378                // This ensures all commands go through the dispatcher chain.
2379                let pipeline = crate::ast::Pipeline {
2380                    stages: vec![crate::ast::PipelineStage::Command(cmd.clone())],
2381                    background: false,
2382                };
2383                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2384                self.update_last_result(&result).await;
2385
2386                // Check for error exit mode (set -e)
2387                if !result.ok() {
2388                    let scope = self.scope.read().await;
2389                    if scope.error_exit_enabled() {
2390                        // `-e` aborts the statement list, but the reason the
2391                        // command died must survive with it — carry `result`
2392                        // (its `out`/`err`/`data`) into the Exit signal instead
2393                        // of `ControlFlow::exit_code`'s empty placeholder.
2394                        let code = result.code;
2395                        return Ok(ControlFlow::Exit { code, result });
2396                    }
2397                }
2398
2399                Ok(ControlFlow::ok(result))
2400            }
2401            Stmt::Pipeline(pipeline) => {
2402                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2403                self.update_last_result(&result).await;
2404
2405                // Check for error exit mode (set -e)
2406                if !result.ok() {
2407                    let scope = self.scope.read().await;
2408                    if scope.error_exit_enabled() {
2409                        // `-e` aborts the statement list, but the reason the
2410                        // command died must survive with it — carry `result`
2411                        // (its `out`/`err`/`data`) into the Exit signal instead
2412                        // of `ControlFlow::exit_code`'s empty placeholder.
2413                        let code = result.code;
2414                        return Ok(ControlFlow::Exit { code, result });
2415                    }
2416                }
2417
2418                Ok(ControlFlow::ok(result))
2419            }
2420            Stmt::If(if_stmt) => {
2421                // Use async evaluator to support command substitution in conditions
2422                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2423
2424                let branch = if is_truthy(&cond_value) {
2425                    &if_stmt.then_branch
2426                } else {
2427                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2428                };
2429
2430                let mut result = ExecResult::success("");
2431                for stmt in branch {
2432                    let flow = self.execute_stmt_flow(stmt).await?;
2433                    match flow {
2434                        ControlFlow::Normal(r) => {
2435                            accumulate_result(&mut result, &r);
2436                            self.drain_stderr_into(&mut result).await;
2437                        }
2438                        mut other => {
2439                            self.drain_stderr_into(&mut result).await;
2440                            fold_block_output_into_flow(std::mem::take(&mut result), &mut other);
2441                            return Ok(other);
2442                        }
2443                    }
2444                }
2445                // A compound statement is a command: it writes `$?` whether or
2446                // not a body statement ran. Without this, `if false; then …; fi`
2447                // leaves the PREVIOUS statement's status visible to `$?` — a
2448                // failure that did not happen. Idempotent when a body did run:
2449                // the body's own arm already wrote the same code.
2450                self.update_last_result(&result).await;
2451                Ok(ControlFlow::ok(result))
2452            }
2453            Stmt::For(for_loop) => {
2454                // Evaluate all items and collect values for iteration
2455                // Use async evaluator to support command substitution like $(seq 1 5)
2456                let mut items: Vec<Value> = Vec::new();
2457                for item_expr in &for_loop.items {
2458                    // Glob expansion in for-loop items: `for f in *.txt`
2459                    if let Expr::GlobPattern(pattern) = item_expr {
2460                        let glob_enabled = {
2461                            let scope = self.scope.read().await;
2462                            scope.glob_enabled()
2463                        };
2464                        if glob_enabled {
2465                            let (paths, cwd) = {
2466                                let ctx = self.exec_ctx.read().await;
2467                                let paths = ctx.expand_glob(pattern).await
2468                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2469                                let cwd = ctx.resolve_path(".");
2470                                (paths, cwd)
2471                            };
2472                            if paths.is_empty() {
2473                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2474                            }
2475                            for path in paths {
2476                                let display = if !pattern.starts_with('/') {
2477                                    path.strip_prefix(&cwd)
2478                                        .unwrap_or(&path)
2479                                        .to_string_lossy().into_owned()
2480                                } else {
2481                                    path.to_string_lossy().into_owned()
2482                                };
2483                                items.push(Value::String(display));
2484                            }
2485                            continue;
2486                        }
2487                    }
2488                    // Track whether this item came from $(cmd); that's the
2489                    // only position where multi-line stdout auto-splits per
2490                    // line. Arrays still spread element-by-element; bare
2491                    // $VAR is rejected upstream by validator E012. See
2492                    // docs/LANGUAGE.md.
2493                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2494                    let item = self.eval_expr_async(item_expr).await?;
2495                    match item {
2496                        // JSON arrays iterate over elements (preferred path
2497                        // when builtins emit .data — seq, jq, cut, find, …)
2498                        Value::Json(serde_json::Value::Array(arr)) => {
2499                            for elem in arr {
2500                                // Envelope-free: an element that happens to be
2501                                // envelope-shaped (e.g. from `fromjson`) is
2502                                // external data, not an internal bytes round-trip,
2503                                // so it must NOT be re-decoded to Value::Bytes.
2504                                items.push(json_to_value_no_envelope(elem));
2505                            }
2506                        }
2507                        // Strings from $(cmd): empty → 0 iterations,
2508                        // multi-line → split per line (trimming trailing
2509                        // newlines and per-line trailing \r), single-line
2510                        // → one iteration. Whitespace within a line is
2511                        // NOT split — the "$VAR with spaces just works"
2512                        // promise is preserved because this only fires
2513                        // in CommandSubst position.
2514                        Value::String(s) if from_command_subst => {
2515                            let trimmed = s.trim_end_matches(['\n', '\r']);
2516                            if trimmed.is_empty() {
2517                                continue;
2518                            }
2519                            if trimmed.contains('\n') {
2520                                for line in trimmed.split('\n') {
2521                                    let line = line.trim_end_matches('\r');
2522                                    items.push(Value::String(line.to_string()));
2523                                }
2524                            } else {
2525                                items.push(Value::String(trimmed.to_string()));
2526                            }
2527                        }
2528                        // Binary isn't iterable — fail loud rather than loop
2529                        // once over an opaque byte blob.
2530                        Value::Bytes(_) => {
2531                            anyhow::bail!(
2532                                "for: cannot iterate over binary data — decode it \
2533                                 (base64/xxd) first"
2534                            );
2535                        }
2536                        // Strings not from $(cmd) stay as one value.
2537                        other => items.push(other),
2538                    }
2539                }
2540
2541                let mut result = ExecResult::success("");
2542                {
2543                    let mut scope = self.scope.write().await;
2544                    scope.push_frame();
2545                }
2546
2547                'outer: for item in items {
2548                    // Cancellation checkpoint per iteration
2549                    if self.is_cancelled() {
2550                        {
2551                            let mut scope = self.scope.write().await;
2552                            scope.pop_frame();
2553                        }
2554                        result.code = 130;
2555                        self.update_last_result(&result).await;
2556                        return Ok(ControlFlow::ok(result));
2557                    }
2558                    {
2559                        let mut scope = self.scope.write().await;
2560                        scope.set(&for_loop.variable, item);
2561                    }
2562                    for stmt in &for_loop.body {
2563                        let mut flow = match self.execute_stmt_flow(stmt).await {
2564                            Ok(f) => f,
2565                            Err(e) => {
2566                                let mut scope = self.scope.write().await;
2567                                scope.pop_frame();
2568                                return Err(e);
2569                            }
2570                        };
2571                        self.drain_stderr_into(&mut result).await;
2572                        match &mut flow {
2573                            ControlFlow::Normal(r) => {
2574                                accumulate_result(&mut result, r);
2575                                if !r.ok() {
2576                                    let scope = self.scope.read().await;
2577                                    if scope.error_exit_enabled() {
2578                                        drop(scope);
2579                                        let mut scope = self.scope.write().await;
2580                                        scope.pop_frame();
2581                                        // `result` already carries `r`'s out/err
2582                                        // via accumulate_result above — hand it to
2583                                        // the Exit signal so `-e` still aborts the
2584                                        // loop but the reason survives.
2585                                        let code = r.code;
2586                                        return Ok(ControlFlow::Exit {
2587                                            code,
2588                                            result: std::mem::take(&mut result),
2589                                        });
2590                                    }
2591                                }
2592                            }
2593                            ControlFlow::Break { .. } => {
2594                                if flow.decrement_level() {
2595                                    accumulate_flow_output(&mut result, &flow);
2596                                    break 'outer;
2597                                }
2598                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2599                                let mut scope = self.scope.write().await;
2600                                scope.pop_frame();
2601                                return Ok(flow);
2602                            }
2603                            ControlFlow::Continue { .. } => {
2604                                if flow.decrement_level() {
2605                                    accumulate_flow_output(&mut result, &flow);
2606                                    continue 'outer;
2607                                }
2608                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2609                                let mut scope = self.scope.write().await;
2610                                scope.pop_frame();
2611                                return Ok(flow);
2612                            }
2613                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2614                                fold_block_output_into_flow(
2615                                    std::mem::take(&mut result),
2616                                    &mut flow,
2617                                );
2618                                let mut scope = self.scope.write().await;
2619                                scope.pop_frame();
2620                                return Ok(flow);
2621                            }
2622                        }
2623                    }
2624                }
2625
2626                {
2627                    let mut scope = self.scope.write().await;
2628                    scope.pop_frame();
2629                }
2630                // Zero iterations still writes `$?` — see the `Stmt::If` arm.
2631                // `for x in $(grep …)` with no matches must not leave grep's 1
2632                // standing as the loop's status.
2633                self.update_last_result(&result).await;
2634                Ok(ControlFlow::ok(result))
2635            }
2636            Stmt::While(while_loop) => {
2637                let mut result = ExecResult::success("");
2638
2639                'outer: loop {
2640                    // Evaluate condition - use async to support command substitution
2641                    // Cancellation checkpoint per iteration
2642                    if self.is_cancelled() {
2643                        result.code = 130;
2644                        self.update_last_result(&result).await;
2645                        return Ok(ControlFlow::ok(result));
2646                    }
2647
2648                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2649
2650                    if !is_truthy(&cond_value) {
2651                        break;
2652                    }
2653
2654                    // Execute body
2655                    for stmt in &while_loop.body {
2656                        let mut flow = self.execute_stmt_flow(stmt).await?;
2657                        self.drain_stderr_into(&mut result).await;
2658                        match &mut flow {
2659                            ControlFlow::Normal(r) => {
2660                                accumulate_result(&mut result, r);
2661                                if !r.ok() {
2662                                    let scope = self.scope.read().await;
2663                                    if scope.error_exit_enabled() {
2664                                        // `result` already carries `r`'s out/err
2665                                        // via accumulate_result above — hand it to
2666                                        // the Exit signal so `-e` still aborts the
2667                                        // loop but the reason survives.
2668                                        let code = r.code;
2669                                        return Ok(ControlFlow::Exit {
2670                                            code,
2671                                            result: std::mem::take(&mut result),
2672                                        });
2673                                    }
2674                                }
2675                            }
2676                            ControlFlow::Break { .. } => {
2677                                if flow.decrement_level() {
2678                                    accumulate_flow_output(&mut result, &flow);
2679                                    break 'outer;
2680                                }
2681                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2682                                return Ok(flow);
2683                            }
2684                            ControlFlow::Continue { .. } => {
2685                                if flow.decrement_level() {
2686                                    accumulate_flow_output(&mut result, &flow);
2687                                    continue 'outer;
2688                                }
2689                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2690                                return Ok(flow);
2691                            }
2692                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2693                                fold_block_output_into_flow(
2694                                    std::mem::take(&mut result),
2695                                    &mut flow,
2696                                );
2697                                return Ok(flow);
2698                            }
2699                        }
2700                    }
2701                }
2702
2703                // A condition that is false on the first evaluation runs no
2704                // body — see the `Stmt::If` arm.
2705                self.update_last_result(&result).await;
2706                Ok(ControlFlow::ok(result))
2707            }
2708            Stmt::Case(case_stmt) => {
2709                // Evaluate the expression to match against. Text sink: a
2710                // `case $bin in ...)` pattern match on binary goes loud
2711                // rather than glob-matching against the `[binary: N bytes]`
2712                // placeholder (Decision E — same class as `==`/`in`).
2713                let match_value = {
2714                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2715                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2716                };
2717
2718                // Try each branch until we find a match
2719                for branch in &case_stmt.branches {
2720                    let matched = branch.patterns.iter().any(|pattern| {
2721                        glob_match(pattern, &match_value)
2722                    });
2723
2724                    if matched {
2725                        // Execute the branch body
2726                        let mut result = ExecResult::success("");
2727                        for stmt in &branch.body {
2728                            let flow = self.execute_stmt_flow(stmt).await?;
2729                            match flow {
2730                                ControlFlow::Normal(r) => {
2731                                    accumulate_result(&mut result, &r);
2732                                    self.drain_stderr_into(&mut result).await;
2733                                }
2734                                mut other => {
2735                                    self.drain_stderr_into(&mut result).await;
2736                                    fold_block_output_into_flow(
2737                                        std::mem::take(&mut result),
2738                                        &mut other,
2739                                    );
2740                                    return Ok(other);
2741                                }
2742                            }
2743                        }
2744                        self.update_last_result(&result).await;
2745                        return Ok(ControlFlow::ok(result));
2746                    }
2747                }
2748
2749                // No match - return success with empty output (like sh), and
2750                // write it to `$?` — see the `Stmt::If` arm.
2751                let result = ExecResult::success("");
2752                self.update_last_result(&result).await;
2753                Ok(ControlFlow::ok(result))
2754            }
2755            Stmt::Break(levels) => {
2756                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2757            }
2758            Stmt::Continue(levels) => {
2759                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2760            }
2761            Stmt::Return(expr) => {
2762                // return [N] - N becomes the exit code, NOT stdout
2763                // Shell semantics: return sets exit code, doesn't produce output
2764                let result = if let Some(e) = expr {
2765                    let val = self.eval_expr_async(e).await?;
2766                    let code = crate::interpreter::value_to_exit_code(&val)
2767                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2768                    ExecResult::from_parts(code, String::new(), String::new(), None)
2769                } else {
2770                    ExecResult::success("")
2771                };
2772                Ok(ControlFlow::return_value(result))
2773            }
2774            Stmt::Exit(expr) => {
2775                let code = if let Some(e) = expr {
2776                    let val = self.eval_expr_async(e).await?;
2777                    crate::interpreter::value_to_exit_code(&val)
2778                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2779                } else {
2780                    0
2781                };
2782                Ok(ControlFlow::exit_code(code))
2783            }
2784            Stmt::ToolDef(tool_def) => {
2785                let mut user_tools = self.user_tools.write().await;
2786                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2787                Ok(ControlFlow::ok(ExecResult::success("")))
2788            }
2789            Stmt::AndChain { left, right } => {
2790                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2791                // Suppress errexit for the left side — && handles failure itself.
2792                {
2793                    let mut scope = self.scope.write().await;
2794                    scope.suppress_errexit();
2795                }
2796                let left_flow = match self.execute_stmt_flow(left).await {
2797                    Ok(f) => f,
2798                    Err(e) => {
2799                        let mut scope = self.scope.write().await;
2800                        scope.unsuppress_errexit();
2801                        return Err(e);
2802                    }
2803                };
2804                {
2805                    let mut scope = self.scope.write().await;
2806                    scope.unsuppress_errexit();
2807                }
2808                match left_flow {
2809                    ControlFlow::Normal(mut left_result) => {
2810                        self.drain_stderr_into(&mut left_result).await;
2811                        self.update_last_result(&left_result).await;
2812                        // Pending is not failure (spec §I.5) — see the
2813                        // `OrChain` twin. The stash check matters here for a
2814                        // hold swallowed into an apparent success below.
2815                        if left_result.ok() {
2816                            let right_flow = self.execute_stmt_flow(right).await?;
2817                            match right_flow {
2818                                ControlFlow::Normal(mut right_result) => {
2819                                    self.drain_stderr_into(&mut right_result).await;
2820                                    self.update_last_result(&right_result).await;
2821                                    let mut combined = left_result;
2822                                    accumulate_result(&mut combined, &right_result);
2823                                    Ok(ControlFlow::ok(combined))
2824                                }
2825                                mut other => {
2826                                    // The left side already ran and printed;
2827                                    // a signal out of the right side must not
2828                                    // unprint it.
2829                                    fold_block_output_into_flow(left_result, &mut other);
2830                                    Ok(other)
2831                                }
2832                            }
2833                        } else {
2834                            Ok(ControlFlow::ok(left_result))
2835                        }
2836                    }
2837                    _ => Ok(left_flow),
2838                }
2839            }
2840            Stmt::OrChain { left, right } => {
2841                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2842                // Suppress errexit for the left side — || handles failure itself.
2843                {
2844                    let mut scope = self.scope.write().await;
2845                    scope.suppress_errexit();
2846                }
2847                let left_flow = match self.execute_stmt_flow(left).await {
2848                    Ok(f) => f,
2849                    Err(e) => {
2850                        let mut scope = self.scope.write().await;
2851                        scope.unsuppress_errexit();
2852                        return Err(e);
2853                    }
2854                };
2855                {
2856                    let mut scope = self.scope.write().await;
2857                    scope.unsuppress_errexit();
2858                }
2859                match left_flow {
2860                    ControlFlow::Normal(mut left_result) => {
2861                        self.drain_stderr_into(&mut left_result).await;
2862                        self.update_last_result(&left_result).await;
2863                        // Pending is not failure (spec §I.5): a fallback
2864                        // written for failure must not run on a decision
2865                        // nobody has made yet — and running it would also
2866                        // overwrite the request in the accumulated result.
2867                        // The stash check covers a hold whose typed error a
2868                        // layer below already stringified out of the result.
2869                        // On a stash-based hold the returned `left_result` is
2870                        // that stringified failure, not the held result — the
2871                        // statement boundary discards it and surfaces the
2872                        // slot's result instead. Do not "fix" this by taking
2873                        // the slot here: only statement boundaries take it.
2874                        if !left_result.ok() {
2875                            let right_flow = self.execute_stmt_flow(right).await?;
2876                            match right_flow {
2877                                ControlFlow::Normal(mut right_result) => {
2878                                    self.drain_stderr_into(&mut right_result).await;
2879                                    self.update_last_result(&right_result).await;
2880                                    let mut combined = left_result;
2881                                    accumulate_result(&mut combined, &right_result);
2882                                    Ok(ControlFlow::ok(combined))
2883                                }
2884                                mut other => {
2885                                    // The left side already ran and printed;
2886                                    // a signal out of the right side must not
2887                                    // unprint it.
2888                                    fold_block_output_into_flow(left_result, &mut other);
2889                                    Ok(other)
2890                                }
2891                            }
2892                        } else {
2893                            Ok(ControlFlow::ok(left_result))
2894                        }
2895                    }
2896                    _ => Ok(left_flow), // Propagate non-normal flow
2897                }
2898            }
2899            Stmt::Test(test_expr) => {
2900                let is_true = self.eval_test_async(test_expr).await?;
2901                let result = if is_true {
2902                    ExecResult::success("")
2903                } else {
2904                    ExecResult::failure(1, "")
2905                };
2906                // A bare test writes `$?` and honors `set -e` like any command
2907                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2908                // safe: the chain arms suppress errexit around their left side,
2909                // and `if`/`while` conditions evaluate as expressions, never
2910                // through this statement arm.
2911                self.update_last_result(&result).await;
2912                if !result.ok() {
2913                    let scope = self.scope.read().await;
2914                    if scope.error_exit_enabled() {
2915                        // `-e` aborts the statement list, but the reason the
2916                        // command died must survive with it — carry `result`
2917                        // (its `out`/`err`/`data`) into the Exit signal instead
2918                        // of `ControlFlow::exit_code`'s empty placeholder.
2919                        let code = result.code;
2920                        return Ok(ControlFlow::Exit { code, result });
2921                    }
2922                }
2923                Ok(ControlFlow::ok(result))
2924            }
2925            Stmt::EnvScoped { assignments, body } => {
2926                // Inline env prefix (`NAME=value ... command`): apply the
2927                // assignments as EXPORTED vars in a fresh frame so the command
2928                // — and its subprocess environment — sees them, then unwind so
2929                // they do NOT persist (bash-style command-scoped env). Values
2930                // evaluate left-to-right with earlier ones already in scope, so
2931                // `A=1 B=$A cmd` works.
2932                {
2933                    let mut scope = self.scope.write().await;
2934                    scope.push_frame();
2935                }
2936                let mut prior_export: Vec<(String, bool)> =
2937                    Vec::with_capacity(assignments.len());
2938                let mut setup_err: Option<anyhow::Error> = None;
2939                for assign in assignments {
2940                    match self.eval_expr_async(&assign.value).await {
2941                        Ok(value) => {
2942                            let mut scope = self.scope.write().await;
2943                            prior_export
2944                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
2945                            scope.set_exported(assign.name(), value);
2946                        }
2947                        Err(e) => {
2948                            setup_err = Some(e);
2949                            break;
2950                        }
2951                    }
2952                }
2953
2954                let flow = if setup_err.is_none() {
2955                    self.execute_stmt_flow(body).await
2956                } else {
2957                    Ok(ControlFlow::ok(ExecResult::success("")))
2958                };
2959
2960                // Unwind the env frame and restore export marks unconditionally
2961                // (names that were not exported before must not stay exported).
2962                {
2963                    let mut scope = self.scope.write().await;
2964                    scope.pop_frame();
2965                    for (name, was_exported) in &prior_export {
2966                        if !*was_exported {
2967                            scope.unexport(name);
2968                        }
2969                    }
2970                }
2971
2972                match setup_err {
2973                    Some(e) => Err(e),
2974                    None => flow,
2975                }
2976            }
2977            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2978        }
2979        })
2980    }
2981
2982    /// Build a boxed per-command `ExecContext` snapshot from the persistent
2983    /// kernel state (`ec`/`scope`, both already locked by the caller).
2984    ///
2985    /// Sync on purpose: the ~30 field clones live in this transient frame rather
2986    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2987    /// — not the 960-byte struct — rides the dispatch await at every recursion
2988    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2989    /// per-site differences (the pipeline runner uses the kernel's own cancel
2990    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2991    /// they're parameters; every other field is snapshotted identically.
2992    fn snapshot_exec_ctx(
2993        &self,
2994        ec: &ExecContext,
2995        scope: &Scope,
2996        pipeline_position: PipelinePosition,
2997        cancel: tokio_util::sync::CancellationToken,
2998    ) -> Box<ExecContext> {
2999        Box::new(ExecContext {
3000            backend: ec.backend.clone(),
3001            scope: scope.clone(),
3002            cwd: ec.cwd.clone(),
3003            prev_cwd: ec.prev_cwd.clone(),
3004            stdin: ec.stdin.clone(),
3005            stdin_data: ec.stdin_data.clone(),
3006            stdin_data_rx: None,
3007            pipe_stdin: None,
3008            pipe_stdout: None,
3009            stderr: ec.stderr.clone(),
3010            tool_schemas: ec.tool_schemas.clone(),
3011            tools: ec.tools.clone(),
3012            job_manager: ec.job_manager.clone(),
3013            pipeline_position,
3014            interactive: self.interactive,
3015            // The kernel-wide setting; a snapshot inherits it like `interactive`.
3016            kill_children_on_parent_death: ec.kill_children_on_parent_death,
3017            aliases: ec.aliases.clone(),
3018            ignore_config: ec.ignore_config.clone(),
3019            output_limit: ec.output_limit.clone(),
3020            allow_external_commands: self.allow_external_commands,
3021            trash_backend: ec.trash_backend.clone(),
3022            #[cfg(all(unix, feature = "subprocess"))]
3023            terminal_state: ec.terminal_state.clone(),
3024            dispatcher: self.dispatcher(),
3025            cancel,
3026            output_format: None,
3027            vfs_budget: self.vfs_budget.clone(),
3028            watchdog: ec.watchdog.clone(),
3029            #[cfg(all(feature = "localfs", feature = "overlay"))]
3030            overlay_handle: self.overlay_handle.clone(),
3031            // Correlate this command's requests with the background job it
3032            // runs for, if any — the ONE place `job_id` is stamped.
3033            // A replay correlation belongs to exactly one dispatch. Moved
3034            // (not cloned) out of the parent context at the dispatch seam —
3035            // see the stdin hand-off below, which takes it under the same
3036            // write lock — so the gate this snapshot reaches is the only one
3037            // that can adopt it.
3038            // A forked or backgrounded execution keeps its parenthood: a
3039            // gate reached from inside a gated statement is nested under it
3040            // (spec §A.7).
3041        })
3042    }
3043
3044    /// Execute a pipeline.
3045    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3046        if pipeline.stages.is_empty() {
3047            return Ok(ExecResult::success(""));
3048        }
3049
3050        // Handle background execution (`&` operator)
3051        if pipeline.background {
3052            return self.execute_background(pipeline).await;
3053        }
3054
3055        // All commands go through the runner with the Kernel as dispatcher.
3056        // This is the single execution path — no fast path for single commands.
3057        //
3058        // IMPORTANT: We snapshot exec_ctx into a local context and release the
3059        // lock before running. This prevents deadlocks when dispatch_command
3060        // is called from within the pipeline and recursively triggers another
3061        // pipeline (e.g., via user-defined tools).
3062        let (mut ctx, has_pipe_stdin) = {
3063            let ec = self.exec_ctx.read().await;
3064            let scope = self.scope.read().await;
3065            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
3066            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
3067            // the consume-once block below, so note its presence here.
3068            let has_pipe_stdin = ec.pipe_stdin.is_some();
3069            // The pipeline runner drives stage 0 with the first stage's stdin
3070            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
3071            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
3072            // and uses the kernel's own cancel token so a `cancel()` reaches the
3073            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
3074            let cancel = {
3075                #[allow(clippy::expect_used)]
3076                let token = self.cancel_token.lock().expect("cancel_token poisoned");
3077                token.clone()
3078            };
3079            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
3080        }; // locks released
3081
3082        // Consume-once: move/clear the seeded stdin sources from the persistent
3083        // exec_ctx now that this pipeline's ctx owns them, so a later statement
3084        // in the same call (`cat ; cat`) does not re-receive them — matching
3085        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
3086        // (the ctx above was built with `pipe_stdin: None`).
3087        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
3088            let mut ec = self.exec_ctx.write().await;
3089            ctx.pipe_stdin = ec.pipe_stdin.take();
3090            ec.stdin = None;
3091            ec.stdin_data = None;
3092        }
3093
3094        // Park the enclosing command's write end and sideband receiver here for
3095        // the duration. `ec` is one shared slot and the snapshot above zeroes
3096        // both, so a nested dispatch — `$(…)` in a command's own arguments, a
3097        // function body, a `source`d file — overwrites whatever is left in it.
3098        // `echo $(echo sub) | cat` printed nothing at exit 0;
3099        // `seq 1 3 | jq -c $(echo .)` fell back to reading the pipe as text.
3100        //
3101        // Here rather than at each re-entering caller: this is the one path
3102        // they all take. The shared slot is the actual defect — threading a
3103        // ctx through the interpreter would retire this whole dance.
3104        {
3105            let mut ec = self.exec_ctx.write().await;
3106            ctx.pipe_stdout = ec.pipe_stdout.take();
3107            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3108        }
3109
3110        let mut result = self.runner.run(&pipeline.stages, &mut ctx, self).await;
3111
3112        // Post-hoc spill check + exit-3 remap (catches builtins and fast
3113        // external commands; also catches a ring overflow that already
3114        // flipped `did_spill` even when the limit itself is disabled, GH
3115        // #191). This is the shared contract every execution surface must
3116        // apply — see `apply_spill_contract`'s doc comment (GH #212).
3117        crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
3118
3119        // Sync changes back from context
3120        {
3121            let mut ec = self.exec_ctx.write().await;
3122            ec.cwd = ctx.cwd.clone();
3123            ec.prev_cwd = ctx.prev_cwd.clone();
3124            ec.aliases = ctx.aliases.clone();
3125            ec.ignore_config = ctx.ignore_config.clone();
3126            ec.output_limit = ctx.output_limit.clone();
3127            // Unconsumed stdin goes back to the session, or it dies here with
3128            // `ctx`. A partial read (`read` takes one line) leaves the rest
3129            // split across two places: the bytes it over-read sit in `stdin`,
3130            // and the pipe still holds everything past them. Dropping the
3131            // reader discards that tail with no error — `read x; wc -c` over
3132            // 100 KiB counted 8187 bytes and said nothing.
3133            //
3134            // A multi-stage pipeline reaches here with the remainder already
3135            // returned by `run_pipeline`'s join, so this carries the
3136            // single-command and the pipeline case alike.
3137            ec.stdin = ctx.stdin.take();
3138            ec.pipe_stdin = ctx.pipe_stdin.take();
3139            // The parked handles go home. Stages get writers the runner owns,
3140            // so what is here is what was carried in.
3141            ec.pipe_stdout = ctx.pipe_stdout.take();
3142            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3143        }
3144        {
3145            let mut scope = self.scope.write().await;
3146            *scope = ctx.scope.clone();
3147        }
3148
3149        Ok(result)
3150    }
3151
3152    /// Execute a pipeline in the background.
3153    ///
3154    /// The command is spawned as a tokio task and registered with the
3155    /// JobManager. The job is observable via `/v/jobs/{id}/status`,
3156    /// `/v/jobs/{id}/command`, and — while it is
3157    /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
3158    ///
3159    /// GH #240 removed those two nodes because they filled once, at
3160    /// completion, while the docs promised a live stream. They are back on
3161    /// the terms the docs always claimed: `try_execute_external` tees each
3162    /// 8 KiB chunk into the job's stream as the child emits it. See
3163    /// `Job::stdout_stream` for exactly which bytes reach them.
3164    ///
3165    /// Returns immediately with a job ID like "[1]".
3166    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.stages.len()))]
3167    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3168        use tokio::sync::oneshot;
3169
3170        // Format the command for display in /v/jobs/{id}/command
3171        let command_str = self.format_pipeline(pipeline);
3172
3173        // Create channel for result notification
3174        let (tx, rx) = oneshot::channel();
3175
3176        // Register with JobManager to get job ID and create VFS entries
3177        let job_id = self.jobs.register(command_str.clone(), rx).await;
3178
3179        // Fork the kernel for this background job. The fork snapshots the
3180        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3181        // while sharing the job manager, VFS, and tool registry. The fork's
3182        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3183        // is available here — something BackendDispatcher couldn't provide.
3184        //
3185        // The fork gets its own cancellation token (recorded on the job so
3186        // `kill %N` can stop the job — including a pure-builtin job with no OS
3187        // process group) and is stamped with the job id so any external
3188        // command it spawns records its process group for `kill -<sig> %N`.
3189        let cancel = tokio_util::sync::CancellationToken::new();
3190        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3191        let jobs = self.jobs.clone();
3192        let fork = self.fork_for_background(cancel, job_id).await;
3193        let runner = self.runner.clone();
3194        let stages = pipeline.stages.clone();
3195
3196        // Snapshot the fork's exec_ctx for the spawned task. We have to do
3197        // this before tokio::spawn because the fork's exec_ctx is behind a
3198        // tokio RwLock and we want the spawned task to own its ctx.
3199        let mut bg_ctx = {
3200            let ec = fork.exec_ctx.read().await;
3201            ec.child_for_pipeline()
3202        };
3203        bg_ctx.scope = fork.scope.read().await.clone();
3204        // The fork's dispatcher points at the fork itself; set it here so
3205        // builtins inside the background task (e.g. timeout) re-dispatch
3206        // through the fork, not the parent.
3207        bg_ctx.dispatcher = fork.dispatcher();
3208
3209        // Spawn the background task. Propagate the embedder's trace context
3210        // across the spawn boundary so the job's spans stay in the same trace.
3211        tokio::spawn(crate::telemetry::bind_current_context(async move {
3212            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3213            // gives us that (Kernel implements CommandDispatcher).
3214            let mut result = runner.run(&stages, &mut bg_ctx, fork.as_ref()).await;
3215
3216            // A background task is its own statement boundary. Pipeline stages
3217            // and command substitutions flush stderr to the fork's stderr
3218            // channel exactly as they would in the foreground, but the
3219            // statement-boundary drains live in `Kernel::execute`, which this
3220            // task never runs. Drain here, or the job's stderr never reaches
3221            // its result: a substitution's failure reason is lost and
3222            // `/v/jobs/{id}/stderr` stays empty.
3223            fork.drain_stderr_into(&mut result).await;
3224
3225            // Apply the same spill/exit-3 contract the foreground path gets
3226            // (`execute_pipeline`'s `apply_spill_contract` call) — without
3227            // this, a background job whose output overflows the capture ring
3228            // or trips the output limit reports the child's ORIGINAL exit
3229            // code to JobManager, so `[N] done:0`/`Job::status()` silently
3230            // read success even though the output was capped (GH #212).
3231            crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3232
3233            // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3234            // drain tasks already fed live is left alone (re-writing the
3235            // aggregate would duplicate every byte), an untouched one takes
3236            // the captured result, and both close. Before `tx.send`, so a
3237            // reader that observes a terminal `status` also observes a
3238            // finished stream — never a `done:0` job whose output is still
3239            // arriving.
3240            jobs.finalize_streams(job_id, &result).await;
3241
3242            // Send result to JobManager (ignore error if receiver dropped)
3243            let _ = tx.send(result);
3244        }));
3245
3246        // The announcement is a shell message, not command output: bash writes
3247        // it to stderr, and stdout stays clean so `$(cmd &)` captures no shell
3248        // metadata. Terminated like every kaish diagnostic (#363).
3249        let mut announcement = ExecResult::success("");
3250        announcement.err = ExecResult::terminate_diagnostic(format!("[{job_id}]"));
3251        Ok(announcement)
3252    }
3253
3254    /// Format a pipeline as a command string for display.
3255    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3256        pipeline
3257            .stages
3258            .iter()
3259            .map(|stage| {
3260                let cmd = match stage {
3261                    crate::ast::PipelineStage::Command(cmd) => cmd,
3262                    // A compound stage renders through the plan renderer,
3263                    // which already knows every statement form.
3264                    crate::ast::PipelineStage::Compound(stmt) => {
3265                        return crate::ast::plan::render_stmt(stmt)
3266                    }
3267                };
3268                let mut parts = vec![cmd.name.clone()];
3269                for arg in &cmd.args {
3270                    match arg {
3271                        Arg::Positional(expr) => {
3272                            parts.push(self.format_expr(expr));
3273                        }
3274                        Arg::Named { key, value } => {
3275                            parts.push(format!("--{}={}", key, self.format_expr(value)));
3276                        }
3277                        Arg::WordAssign { key, value } => {
3278                            parts.push(format!("{}={}", key, self.format_expr(value)));
3279                        }
3280                        Arg::ShortFlag(name) => {
3281                            parts.push(format!("-{}", name));
3282                        }
3283                        Arg::LongFlag(name) => {
3284                            parts.push(format!("--{}", name));
3285                        }
3286                        Arg::DoubleDash => {
3287                            parts.push("--".to_string());
3288                        }
3289                    }
3290                }
3291                parts.join(" ")
3292            })
3293            .collect::<Vec<_>>()
3294            .join(" | ")
3295    }
3296
3297    /// Format an expression as a string for display.
3298    fn format_expr(&self, expr: &Expr) -> String {
3299        match expr {
3300            Expr::Literal(Value::String(s)) => {
3301                if s.contains(' ') || s.contains('"') {
3302                    format!("'{}'", s.replace('\'', "\\'"))
3303                } else {
3304                    s.clone()
3305                }
3306            }
3307            Expr::Literal(Value::Int(i)) => i.to_string(),
3308            Expr::Literal(Value::Float(f)) => f.to_string(),
3309            Expr::Literal(Value::Bool(b)) => b.to_string(),
3310            Expr::Literal(Value::Null) => "null".to_string(),
3311            Expr::VarRef(path) => {
3312                let mut name = String::new();
3313                for (i, seg) in path.segments.iter().enumerate() {
3314                    match seg {
3315                        crate::ast::VarSegment::Field(f) => {
3316                            if i > 0 {
3317                                name.push('.');
3318                            }
3319                            name.push_str(f);
3320                        }
3321                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3322                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3323                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3324                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3325                            "[{}:{}]",
3326                            a.map(|n| n.to_string()).unwrap_or_default(),
3327                            b.map(|n| n.to_string()).unwrap_or_default()
3328                        )),
3329                    }
3330                }
3331                format!("${{{}}}", name)
3332            }
3333            Expr::Interpolated(_) => "\"...\"".to_string(),
3334            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3335            _ => "...".to_string(),
3336        }
3337    }
3338
3339    /// Execute a single command.
3340    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3341        self.execute_command_depth(name, args, 0).await
3342    }
3343
3344    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3345        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3346        // most-recursed function on the ring, so wrapping its future in
3347        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3348        // every level (GH #48, item 3). A `trace!` event records the command name
3349        // without living in the future.
3350        tracing::trace!(command = %name, alias_depth, "dispatch");
3351        // Special built-ins. `SpecialForm::from_name` is the single source of
3352        // truth (shared with `classify_command` via `is_runtime_special_form`),
3353        // and this match on the enum is *exhaustive* — adding a special-form is a
3354        // compile error until both the name mapping and the behavior here are
3355        // updated. A name that is not a special-form falls through to alias /
3356        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3357        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3358            return match form {
3359                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3360                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3361                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3362            };
3363        }
3364
3365        // Alias expansion (with recursion limit)
3366        if alias_depth < 10 {
3367            let alias_value = {
3368                let ctx = self.exec_ctx.read().await;
3369                ctx.aliases.get(name).cloned()
3370            };
3371            if let Some(alias_val) = alias_value {
3372                // Split alias value into command + args
3373                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3374                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3375                    let mut new_args: Vec<Arg> = alias_args
3376                        .iter()
3377                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3378                        .collect();
3379                    new_args.extend_from_slice(args);
3380                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3381                }
3382            }
3383        }
3384
3385        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3386        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3387            return match self.tools.get(builtin_name) {
3388                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3389                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3390            };
3391        }
3392
3393        // Check user-defined tools first
3394        {
3395            let user_tools = self.user_tools.read().await;
3396            if let Some(tool_def) = user_tools.get(name) {
3397                let tool_def = tool_def.clone();
3398                drop(user_tools);
3399                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3400            }
3401        }
3402
3403        // Look up builtin tool
3404        let tool = match self.tools.get(name) {
3405            Some(t) => t,
3406            None => {
3407                // Try executing as .kai script from PATH
3408                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3409                    return Ok(result);
3410                }
3411                // Try executing as external command from PATH — boxed because its
3412                // future is the heaviest branch here (holds a `tokio::process::Command`,
3413                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3414                // it inline fattens every `execute_command_depth` frame on the recursion
3415                // ring even when the command is a builtin.
3416                if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3417                    return Ok(result);
3418                }
3419
3420                // Try backend-registered tools (embedder engines, etc.)
3421                // Look up tool schema for positional→named mapping.
3422                // Clone backend and drop read lock before awaiting (may involve network I/O).
3423                // Backend tools expect named JSON params, so enable positional mapping.
3424                let backend = self.exec_ctx.read().await.backend.clone();
3425                let tool_schema = backend
3426                    .get_tool(name)
3427                    .await
3428                    .unwrap_or_else(|e| {
3429                        // Schema lookup failing just means positionals won't
3430                        // get name-mapped below — `call_tool` is still
3431                        // attempted. Trace it so the degradation is visible
3432                        // rather than silently swallowed.
3433                        tracing::debug!("backend get_tool error for {name}: {e}");
3434                        None
3435                    })
3436                    .map(|t| {
3437                    let mut s = t.schema;
3438                    // Flat backend/MCP tools expect named JSON params, so map
3439                    // bare positionals onto named params. Subcommand-aware tools
3440                    // route positionals through the subcommand path and declare
3441                    // map_positionals per leaf (kj keeps it false so it re-parses
3442                    // the argv with its own clap) — don't blanket-override them.
3443                    if s.subcommands.is_empty() {
3444                        s.map_positionals = true;
3445                    }
3446                    s
3447                });
3448                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3449                let mut ctx = self.exec_ctx.write().await;
3450                {
3451                    let scope = self.scope.read().await;
3452                    ctx.scope = scope.clone();
3453                }
3454                let backend = ctx.backend.clone();
3455                match backend.call_tool(name, tool_args, &mut *ctx).await {
3456                    Ok(tool_result) => {
3457                        let mut scope = self.scope.write().await;
3458                        *scope = ctx.scope.clone();
3459                        // Preserve every field (data/content_type/baggage,
3460                        // not just stdout text) — this is the embedder seam:
3461                        // `x=$(embedder_tool)` and structured iteration over
3462                        // its result depend on `.data` surviving the crossing
3463                        // back into the kernel.
3464                        return Ok(ExecResult::from(tool_result));
3465                    }
3466                    Err(BackendError::ToolNotFound(_)) => {
3467                        // The backend confirms no such tool exists — fall
3468                        // through to "command not found" below.
3469                    }
3470                    Err(e) => {
3471                        // The tool was found (dispatch reached real
3472                        // execution) but running it failed — a genuine
3473                        // execution error, not "command not found". Surface
3474                        // it loudly instead of masking it as exit-127.
3475                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3476                    }
3477                }
3478
3479                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3480            }
3481        };
3482
3483        // Build arguments (async to support command substitution, schema-aware
3484        // for flag values), then decide `--help` and `owns_output` — all three
3485        // read the tool's schema and nothing after this block does, so the whole
3486        // schema borrow is scoped here and cannot ride the `tool.execute` await
3487        // below (GH #48, item 7).
3488        let (tool_args, wants_help, owns_output) = {
3489            // Prefer the kernel's schema catalog over `tool.schema()`: for a
3490            // clap-derived builtin, `schema()` rebuilds the entire clap
3491            // `Command` and reflects it into a fresh `ToolSchema` — ~34
3492            // allocations per command, 18% of all allocations in the GH #48
3493            // many-small-commands profile — to produce exactly what the catalog
3494            // already holds. The catalog is seeded from this same registry in
3495            // `Kernel::assemble` and is name-sorted, so this is a binary search
3496            // with no allocation at all. `owned` covers a tool the catalog
3497            // doesn't list (registered after assembly, or whose schema name
3498            // differs from its dispatch name): the fallback calls the same
3499            // `schema()` and is equivalent, just not free.
3500            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3501            let owned;
3502            let schema: &crate::tools::ToolSchema =
3503                match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3504                    Ok(i) => &catalog[i],
3505                    Err(_) => {
3506                        owned = tool.schema();
3507                        &owned
3508                    }
3509                };
3510
3511            let tool_args = self.build_args_async(args, Some(schema)).await?;
3512
3513            // --help / -h: show the generic whole-tool help, unless either the tool's
3514            // root schema claims that flag OR the tool owns its output. Owned-output
3515            // tools re-parse their own argv and route their own `--help` — including
3516            // leaf/subcommand help — through their internal (clap) parser, so the root
3517            // schema can't express "this leaf claims help" and intercepting here would
3518            // render top-level help and return before `execute()` ever sees the
3519            // request (#51). Pass it through and let the tool render its own help.
3520            let schema_claims = |flag: &str| -> bool {
3521                let bare = flag.trim_start_matches('-');
3522                schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3523            };
3524            let wants_help = !schema.owns_output
3525                && ((tool_args.flags.contains("help") && !schema_claims("help"))
3526                    || (tool_args.flags.contains("h") && !schema_claims("-h")));
3527
3528            (tool_args, wants_help, schema.owns_output)
3529        };
3530
3531        if wants_help {
3532            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3533            let ctx = self.exec_ctx.read().await;
3534            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3535            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3536        }
3537
3538        // Snapshot exec_ctx into a local context and release the write lock
3539        // before calling tool.execute. Holding the write across tool execution
3540        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3541        // (timeout, scatter) — the inner dispatch_command needs its own
3542        // exec_ctx.write() and would block forever.
3543        let mut ctx = {
3544            let ec = self.exec_ctx.write().await;
3545            let scope = self.scope.read().await;
3546            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3547            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3548            // child token — e.g. timeout's — reaches the spawned external via
3549            // wait_or_kill; it falls back to the kernel's own token on a
3550            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3551            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3552        }; // both locks released — tool.execute can re-dispatch safely
3553
3554        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3555        // semantics): take() so a later dispatch doesn't see stale stdin.
3556        // Done after the snapshot above so we hold the write briefly.
3557        {
3558            let mut ec = self.exec_ctx.write().await;
3559            ctx.stdin = ec.stdin.take();
3560            ctx.stdin_data = ec.stdin_data.take();
3561            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3562            ctx.pipe_stdin = ec.pipe_stdin.take();
3563            ctx.pipe_stdout = ec.pipe_stdout.take();
3564            // Same take-don't-clone discipline as stdin, and for the same
3565            // reason: these belong to exactly one dispatch, and a copy left
3566            // behind would let the next command adopt it.
3567        }
3568
3569        // Honor --json before the builtin runs so its setting survives a clap
3570        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3571        // --json on the floor when `try_parse_from` returns Err early).
3572        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3573        GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3574
3575        let result = tool.execute(tool_args, &mut *ctx).await;
3576
3577        // Sync mutations back. Tools may have changed scope (set/cd),
3578        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3579        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3580        // hands them back to the pipeline runner — the runner uses
3581        // stage_ctx.pipe_stdout to write the result to the next stage when
3582        // the tool itself didn't take and write to it.
3583        {
3584            let mut scope = self.scope.write().await;
3585            *scope = ctx.scope.clone();
3586        }
3587        {
3588            let mut ec = self.exec_ctx.write().await;
3589            ec.cwd = ctx.cwd;
3590            ec.prev_cwd = ctx.prev_cwd;
3591            ec.aliases = ctx.aliases;
3592            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3593            // mutate the runtime output limit; without this sync the change is
3594            // dropped here and never reaches dispatch_command's read-back, so
3595            // it would not survive past the current statement.
3596            ec.output_limit = ctx.output_limit.clone();
3597            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3598            // was missing from this sync, so every runtime ignore mutation
3599            // silently died at the end of its own statement — including the
3600            // documented `kaish-ignore add .gitignore` rc-file recipe.
3601            ec.ignore_config = ctx.ignore_config.clone();
3602            ec.pipe_stdin = ctx.pipe_stdin.take();
3603            ec.pipe_stdout = ctx.pipe_stdout.take();
3604            // What a partial read left behind goes back too: `read` takes one
3605            // line and keeps the rest, and that remainder belongs to the next
3606            // reader. Without this it dies with the tool's context and
3607            // `read x; read y` loses the second line.
3608            ec.stdin = ctx.stdin.take();
3609            // The sideband is stdin in typed form and returns by the same
3610            // rule; taken in above, an unconsumed value would die here.
3611            ec.stdin_data = ctx.stdin_data.take();
3612            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3613        }
3614
3615        // Builtins parse --json via the GlobalFlags flatten in their clap
3616        // struct and write ctx.output_format. The kernel applies it — unless the
3617        // tool owns its own output (renders --json itself), in which case we
3618        // leave its bytes untouched.
3619        let result = finalize_output(result, ctx.output_format, owns_output);
3620
3621        Ok(result)
3622    }
3623
3624    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3625    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3626    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3627    /// unexpanded rather than leaking the host home directory.
3628    async fn scope_home(&self) -> Option<String> {
3629        match self.scope.read().await.get("HOME") {
3630            Some(Value::String(s)) => Some(s.clone()),
3631            _ => None,
3632        }
3633    }
3634
3635    /// Build tool arguments from AST args.
3636    ///
3637    /// Uses async evaluation to support command substitution in arguments.
3638    /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3639    /// now only supplies the evaluator — `self` implements `ArgValueSource`
3640    /// against the kernel's own session state (full recursion through the
3641    /// async pipeline, real glob expansion, tilde expansion). Before this,
3642    /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3643    /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3644    /// scatter/gather's own option parsing and the `#[cfg(test)]`
3645    /// `BackendDispatcher`) that could — and did — drift from this method,
3646    /// the same drift-class GH #133 fixed for the external-command spawn
3647    /// sites. Now both paths call the one `bind_tool_args` core, differing
3648    /// only in which `ArgValueSource` they hand it.
3649    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3650        bind_tool_args(args, schema, self).await
3651    }
3652
3653    /// Build arguments as flat string list for external commands.
3654    ///
3655    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3656    /// this preserves the original flag format as strings for external commands:
3657    /// - `-l` stays as `-l`
3658    /// - `--verbose` stays as `--verbose`
3659    /// - `key=value` stays as `key=value`
3660    ///
3661    /// This is what external commands expect in their argv.
3662    #[cfg(feature = "subprocess")]
3663    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3664        let mut argv = Vec::new();
3665        let home = self.scope_home().await;
3666        for arg in args {
3667            match arg {
3668                Arg::Positional(expr) => {
3669                    // Glob expansion for external commands
3670                    if let Expr::GlobPattern(pattern) = expr {
3671                        let glob_enabled = {
3672                            let scope = self.scope.read().await;
3673                            scope.glob_enabled()
3674                        };
3675                        if glob_enabled {
3676                            let (paths, cwd) = {
3677                                let ctx = self.exec_ctx.read().await;
3678                                let paths = ctx.expand_glob(pattern).await
3679                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3680                                let cwd = ctx.resolve_path(".");
3681                                (paths, cwd)
3682                            };
3683                            if paths.is_empty() {
3684                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3685                            }
3686                            for path in paths {
3687                                let display = if !pattern.starts_with('/') {
3688                                    path.strip_prefix(&cwd)
3689                                        .unwrap_or(&path)
3690                                        .to_string_lossy().into_owned()
3691                                } else {
3692                                    path.to_string_lossy().into_owned()
3693                                };
3694                                argv.push(display);
3695                            }
3696                            continue;
3697                        }
3698                    }
3699                    let value = self.eval_expr_async(expr).await?;
3700                    // Decision D: a bare collection can't cross the external
3701                    // process boundary as an argv element — refuse rather than
3702                    // silently JSON-serializing it. A quoted `"$x"` already
3703                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3704                    // so only a live, un-interpolated `$x` trips this.
3705                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3706                        return Err(anyhow::anyhow!(msg));
3707                    }
3708                    let value = apply_tilde_expansion(value, home.as_deref());
3709                    // External-command argv is a text sink: a bare `$BIN` binary
3710                    // word goes loud, never the `[binary: N bytes]` placeholder.
3711                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3712                }
3713                Arg::Named { key, value } => {
3714                    let val = self.eval_expr_async(value).await?;
3715                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3716                        return Err(anyhow::anyhow!(msg));
3717                    }
3718                    let val = apply_tilde_expansion(val, home.as_deref());
3719                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3720                    argv.push(format!("--{key}={val_str}"));
3721                }
3722                Arg::WordAssign { key, value } => {
3723                    let val = self.eval_expr_async(value).await?;
3724                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3725                        return Err(anyhow::anyhow!(msg));
3726                    }
3727                    let val = apply_tilde_expansion(val, home.as_deref());
3728                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3729                    argv.push(format!("{key}={val_str}"));
3730                }
3731                Arg::ShortFlag(name) => {
3732                    // Preserve original format: -l, -la (combined flags)
3733                    argv.push(format!("-{}", name));
3734                }
3735                Arg::LongFlag(name) => {
3736                    // Preserve original format: --verbose
3737                    argv.push(format!("--{}", name));
3738                }
3739                Arg::DoubleDash => {
3740                    // Preserve the -- marker
3741                    argv.push("--".to_string());
3742                }
3743            }
3744        }
3745        Ok(argv)
3746    }
3747
3748    /// Async expression evaluator that supports command substitution.
3749    ///
3750    /// This is used for contexts where expressions may contain `$(...)` command
3751    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3752    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3753        Box::pin(async move {
3754        match expr {
3755            Expr::Literal(value) => Ok(value.clone()),
3756            Expr::VarRef(path) => {
3757                let scope = self.scope.read().await;
3758                match scope.resolve_path(path) {
3759                    Ok(v) => Ok(v),
3760                    Err(PathError::UndefinedRoot(_)) => {
3761                        Err(anyhow::anyhow!("undefined variable"))
3762                    }
3763                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3764                        Err(anyhow::anyhow!(msg))
3765                    }
3766                }
3767            }
3768            Expr::Interpolated(parts) => {
3769                let mut result = String::new();
3770                for part in parts {
3771                    result.push_str(&self.eval_string_part_async(part).await?);
3772                }
3773                Ok(Value::String(result))
3774            }
3775            Expr::HereDocBody { parts, strip_tabs } => {
3776                // Assemble part-by-part so `<<-` tab stripping applies to the
3777                // literal source, not to tabs from a `$var` value (bash strips
3778                // source-line tabs before parameter expansion).
3779                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3780                for sp in parts {
3781                    match &sp.part {
3782                        StringPart::Literal(s) => asm.push_literal(s),
3783                        other => {
3784                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
3785                        }
3786                    }
3787                }
3788                Ok(Value::String(asm.into_string()))
3789            }
3790            Expr::BinaryOp { left, op, right } => match op {
3791                BinaryOp::And => {
3792                    let left_val = self.eval_expr_async(left).await?;
3793                    if !is_truthy(&left_val) {
3794                        return Ok(left_val);
3795                    }
3796                    self.eval_expr_async(right).await
3797                }
3798                BinaryOp::Or => {
3799                    let left_val = self.eval_expr_async(left).await?;
3800                    if is_truthy(&left_val) {
3801                        return Ok(left_val);
3802                    }
3803                    self.eval_expr_async(right).await
3804                }
3805            },
3806            Expr::CommandSubst(stmts) => {
3807                // Snapshot scope, cwd, and session config before running —
3808                // only output escapes, not side effects like `cd`, variable
3809                // assignments, or config mutations (`kaish-ignore`,
3810                // `kaish-output-limit`, `alias`/`unalias`) — matching how
3811                // every other execution context (background forks, scatter
3812                // workers) already isolates mutations (GH #139).
3813                // Boxed: this ~470 B scope snapshot is held across the nested
3814                // `$(…)` recursion await below, so inlining it grows every
3815                // command-substitution level's future (GH #48, item 4).
3816                let saved_scope = Box::new(self.scope.read().await.clone());
3817                let saved_ec = {
3818                    let ec = self.exec_ctx.read().await;
3819                    (
3820                        ec.cwd.clone(),
3821                        ec.prev_cwd.clone(),
3822                        ec.aliases.clone(),
3823                        ec.ignore_config.clone(),
3824                        ec.output_limit.clone(),
3825                    )
3826                };
3827
3828                // Capture result without `?` — restore state unconditionally
3829                let run_result = self.execute_block_capturing(stmts).await;
3830
3831                // Restore scope and cwd regardless of success/failure
3832                {
3833                    let mut scope = self.scope.write().await;
3834                    *scope = *saved_scope;
3835                    if let Ok(ref r) = run_result {
3836                        scope.set_last_result(r.clone());
3837                        scope.note_cmdsubst_code(r.code);
3838                    }
3839                }
3840                {
3841                    let mut ec = self.exec_ctx.write().await;
3842                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3843                    ec.cwd = cwd;
3844                    ec.prev_cwd = prev_cwd;
3845                    ec.aliases = aliases;
3846                    ec.ignore_config = ignore_config;
3847                    ec.output_limit = output_limit;
3848                }
3849
3850                // A substitution's stderr belongs to the enclosing statement,
3851                // never to its value. Emit it before the value is built.
3852                if let Ok(ref r) = run_result {
3853                    self.emit_cmdsubst_stderr(&r.err).await;
3854                }
3855
3856                // Now propagate the error
3857                let result = run_result?;
3858
3859                // A held body stops the enclosing statement before its
3860                // missing output is used (spec §I.5) — the request rides up
3861                // as a typed error the statement loop converts back into a
3862                // held result, and is stashed for the boundary in case an
3863                // intermediate catch stringifies the error.
3864
3865                // A binary result is preserved as bytes — never lossy-decoded to
3866                // a string. No trailing-newline trim (every byte is significant).
3867                if let Some(bytes) = result.out_bytes() {
3868                    Ok(Value::Bytes(bytes.to_vec()))
3869                // Prefer structured data (enables `for i in $(cmd)` iteration)
3870                } else if let Some(data) = &result.data {
3871                    Ok(data.clone())
3872                } else if let Some(output) = result.output() {
3873                    // Flat non-text node lists (glob, ls, tree) → iterable array
3874                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3875                        let items: Vec<serde_json::Value> = output.root.iter()
3876                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
3877                            .collect();
3878                        Ok(Value::Json(serde_json::Value::Array(items)))
3879                    } else {
3880                        // Strip trailing newlines only (POSIX command-subst),
3881                        // not all trailing whitespace — spaces/tabs are
3882                        // significant. Use the exact same trim as the quoted
3883                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3884                        // `trim_end_matches('\n')`) so bare and quoted command
3885                        // substitution agree.
3886                        Ok(Value::String(
3887                            result.text_out().trim_end_matches('\n').to_string(),
3888                        ))
3889                    }
3890                } else {
3891                    // Otherwise return stdout as single string (NO implicit splitting)
3892                    Ok(Value::String(
3893                        result.text_out().trim_end_matches('\n').to_string(),
3894                    ))
3895                }
3896            }
3897            Expr::Test(test_expr) => {
3898                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3899            }
3900            Expr::Positional(n) => {
3901                let scope = self.scope.read().await;
3902                match scope.get_positional(*n) {
3903                    Some(s) => Ok(Value::String(s.to_string())),
3904                    None => Ok(Value::String(String::new())),
3905                }
3906            }
3907            Expr::AllArgs => {
3908                let scope = self.scope.read().await;
3909                Ok(Value::String(scope.all_args().join(" ")))
3910            }
3911            Expr::ArgCount => {
3912                let scope = self.scope.read().await;
3913                Ok(Value::Int(scope.arg_count() as i64))
3914            }
3915            Expr::VarLength(path) => {
3916                let scope = self.scope.read().await;
3917                crate::interpreter::resolve_length(&scope, path)
3918                    .map(Value::Int)
3919                    .map_err(|msg| anyhow::anyhow!(msg))
3920            }
3921            Expr::VarWithDefault { path, default } => {
3922                // Resolve inside a scoped guard so the lock is released before the
3923                // recursive default evaluation.
3924                let resolved = {
3925                    let scope = self.scope.read().await;
3926                    crate::interpreter::resolve_default(&scope, path)
3927                        .map_err(|msg| anyhow::anyhow!(msg))?
3928                };
3929                match resolved {
3930                    Some(value) => Ok(value),
3931                    None => self.eval_string_parts_async(default).await.map(Value::String),
3932                }
3933            }
3934            Expr::Arithmetic(expr_str) => {
3935                let scope = self.scope.read().await;
3936                crate::arithmetic::eval_arithmetic(expr_str, &scope)
3937                    .map(Value::Int)
3938                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3939            }
3940            Expr::Command(cmd) => {
3941                // Execute command and return boolean based on exit code
3942                let result = self.execute_command(&cmd.name, &cmd.args).await?;
3943                Ok(Value::Bool(result.code == 0))
3944            }
3945            Expr::LastExitCode => {
3946                let scope = self.scope.read().await;
3947                Ok(Value::Int(scope.last_result().code))
3948            }
3949            Expr::CurrentPid => {
3950                let scope = self.scope.read().await;
3951                Ok(Value::Int(scope.pid() as i64))
3952            }
3953            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3954            Expr::ListLiteral(elems) => {
3955                // Spread must itself be a list — a scalar/record spread is a
3956                // loud error, never silently coerced or dropped (mirrors the
3957                // sync `Evaluator::eval_list_literal`; wording shared via
3958                // `spread_non_list_message` so the two paths can't diverge).
3959                let mut out = Vec::with_capacity(elems.len());
3960                for elem in elems {
3961                    match elem {
3962                        ListElem::Item(e) => {
3963                            let value = self.eval_expr_async(e).await?;
3964                            out.push(crate::interpreter::value_to_json(&value));
3965                        }
3966                        ListElem::Spread(e) => {
3967                            let value = self.eval_expr_async(e).await?;
3968                            match value {
3969                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3970                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3971                            }
3972                        }
3973                    }
3974                }
3975                Ok(Value::Json(serde_json::Value::Array(out)))
3976            }
3977            Expr::RecordLiteral(entries) => {
3978                // Insertion order preserved (workspace serde_json has
3979                // `preserve_order`); a duplicate key keeps the last value
3980                // written, matching plain map-insert semantics.
3981                let mut map = serde_json::Map::new();
3982                for entry in entries {
3983                    let key = match &entry.key {
3984                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3985                        // `{"$k": v}` resolves like any double-quoted string
3986                        // (used to silently create a literal "$k" key).
3987                        RecordKey::Interpolated(parts) => {
3988                            self.eval_string_parts_async(parts).await?
3989                        }
3990                    };
3991                    let value = self.eval_expr_async(&entry.value).await?;
3992                    map.insert(key, crate::interpreter::value_to_json(&value));
3993                }
3994                Ok(Value::Json(serde_json::Value::Object(map)))
3995            }
3996        }
3997        })
3998    }
3999
4000    /// Async helper to evaluate multiple StringParts into a single string.
4001    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4002        Box::pin(async move {
4003            let mut result = String::new();
4004            for part in parts {
4005                result.push_str(&self.eval_string_part_async(part).await?);
4006            }
4007            Ok(result)
4008        })
4009    }
4010
4011    /// Async helper to evaluate a StringPart.
4012    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4013    /// through the VFS backend instead of using raw `std::path`.
4014    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4015        Box::pin(async move {
4016            match test_expr {
4017                TestExpr::FileTest { op, path } => {
4018                    let path_value = self.eval_expr_async(path).await?;
4019                    // Expand `~` against the session HOME before stat'ing, the
4020                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4021                    // stats the literal `~/x` and is always false.
4022                    let home = self.scope_home().await;
4023                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
4024                    // A binary `[[ -f $bin ]]` operand goes loud rather than
4025                    // silently stat'ing a file literally named
4026                    // `[binary: N bytes]` (the same path-positional guard
4027                    // builtins like `stat`/`cp` use).
4028                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4029                        .map_err(|e| anyhow::anyhow!("{e}"))?;
4030                    // Resolve against the *session* cwd, not the process cwd, so a
4031                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
4032                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
4033                    // relative path against the process cwd otherwise.
4034                    let (resolved, backend) = {
4035                        let ctx = self.exec_ctx.read().await;
4036                        (ctx.resolve_path(&path_str), ctx.backend.clone())
4037                    };
4038                    let entry = backend.stat(&resolved).await.ok();
4039                    Ok(match op {
4040                        FileTestOp::Exists => entry.is_some(),
4041                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4042                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4043                        FileTestOp::Readable => entry.is_some(),
4044                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4045                            e.permissions.is_none_or(|p| p & 0o222 != 0)
4046                        }),
4047                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4048                            e.permissions.is_some_and(|p| p & 0o111 != 0)
4049                        }),
4050                    })
4051                }
4052                TestExpr::StringTest { op, value } => match op {
4053                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4054                        let val = self.eval_expr_async(value).await?;
4055                        // Decision E: a collection operand is a loud Shape error
4056                        // here too — must not diverge from the sync path in
4057                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
4058                        let symbol = match op {
4059                            crate::ast::StringTestOp::IsEmpty => "-z",
4060                            crate::ast::StringTestOp::IsNonEmpty => "-n",
4061                            crate::ast::StringTestOp::IsList
4062                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4063                        };
4064                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4065                            anyhow::bail!(msg);
4066                        }
4067                        let s = value_to_string(&val);
4068                        Ok(match op {
4069                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4070                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4071                            crate::ast::StringTestOp::IsList
4072                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4073                        })
4074                    }
4075                    // Shape guard: propagates eval errors like -z/-n (a bare
4076                    // `$unset` is an undefined-variable error, not a silent
4077                    // false). A defined-but-wrong-shaped value is false. Must
4078                    // not diverge from the sync path in interpreter/eval.rs.
4079                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4080                        let val = self.eval_expr_async(value).await?;
4081                        Ok(op.matches_shape(&val))
4082                    }
4083                },
4084                TestExpr::Comparison { left, op, right } => {
4085                    // Evaluate operands async (handles $(cmd)), then compare sync
4086                    let left_val = self.eval_expr_async(left).await?;
4087                    let right_val = self.eval_expr_async(right).await?;
4088                    let resolved = TestExpr::Comparison {
4089                        left: Box::new(Expr::Literal(left_val)),
4090                        op: *op,
4091                        right: Box::new(Expr::Literal(right_val)),
4092                    };
4093                    let expr = Expr::Test(Box::new(resolved));
4094                    let mut scope = self.scope.write().await;
4095                    let value = eval_expr(&expr, &mut scope)
4096                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4097                    Ok(value_to_bool(&value))
4098                }
4099                TestExpr::And { left, right } => {
4100                    if !self.eval_test_async(left).await? {
4101                        Ok(false)
4102                    } else {
4103                        self.eval_test_async(right).await
4104                    }
4105                }
4106                TestExpr::Or { left, right } => {
4107                    if self.eval_test_async(left).await? {
4108                        Ok(true)
4109                    } else {
4110                        self.eval_test_async(right).await
4111                    }
4112                }
4113                TestExpr::Not { expr } => {
4114                    Ok(!self.eval_test_async(expr).await?)
4115                }
4116                TestExpr::In { left, right } => {
4117                    let left_val = self.eval_expr_async(left).await?;
4118                    let right_val = self.eval_expr_async(right).await?;
4119                    let resolved = TestExpr::In {
4120                        left: Box::new(Expr::Literal(left_val)),
4121                        right: Box::new(Expr::Literal(right_val)),
4122                    };
4123                    let expr = Expr::Test(Box::new(resolved));
4124                    let mut scope = self.scope.write().await;
4125                    let value = eval_expr(&expr, &mut scope)
4126                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4127                    Ok(value_to_bool(&value))
4128                }
4129                TestExpr::NotIn { left, right } => {
4130                    let left_val = self.eval_expr_async(left).await?;
4131                    let right_val = self.eval_expr_async(right).await?;
4132                    let resolved = TestExpr::NotIn {
4133                        left: Box::new(Expr::Literal(left_val)),
4134                        right: Box::new(Expr::Literal(right_val)),
4135                    };
4136                    let expr = Expr::Test(Box::new(resolved));
4137                    let mut scope = self.scope.write().await;
4138                    let value = eval_expr(&expr, &mut scope)
4139                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4140                    Ok(value_to_bool(&value))
4141                }
4142            }
4143        })
4144    }
4145
4146    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4147        Box::pin(async move {
4148            match part {
4149                StringPart::Literal(s) => Ok(s.clone()),
4150                StringPart::Var(path) => {
4151                    let scope = self.scope.read().await;
4152                    match scope.resolve_path(path) {
4153                        // Text sink: binary goes loud, never the placeholder —
4154                        // a `b=$(cat blob)` capture holds real bytes; splicing
4155                        // `[binary: N bytes]` into "$b" would be silent loss.
4156                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4157                        // Unset vars expand to empty; loud path errors surface.
4158                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4159                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4160                            Err(anyhow::anyhow!(msg))
4161                        }
4162                    }
4163                }
4164                StringPart::VarWithDefault { path, default } => {
4165                    let resolved = {
4166                        let scope = self.scope.read().await;
4167                        crate::interpreter::resolve_default(&scope, path)
4168                            .map_err(|msg| anyhow::anyhow!(msg))?
4169                    };
4170                    match resolved {
4171                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4172                        None => self.eval_string_parts_async(default).await,
4173                    }
4174                }
4175            StringPart::VarLength(path) => {
4176                let scope = self.scope.read().await;
4177                crate::interpreter::resolve_length(&scope, path)
4178                    .map(|n| n.to_string())
4179                    .map_err(|msg| anyhow::anyhow!(msg))
4180            }
4181            StringPart::Positional(n) => {
4182                let scope = self.scope.read().await;
4183                match scope.get_positional(*n) {
4184                    Some(s) => Ok(s.to_string()),
4185                    None => Ok(String::new()),
4186                }
4187            }
4188            StringPart::AllArgs => {
4189                let scope = self.scope.read().await;
4190                Ok(scope.all_args().join(" "))
4191            }
4192            StringPart::ArgCount => {
4193                let scope = self.scope.read().await;
4194                Ok(scope.arg_count().to_string())
4195            }
4196            StringPart::Arithmetic(expr) => {
4197                // Loud on purpose (GH #183): this used to be `Err(_) =>
4198                // Ok(String::new())`, silently splicing in "" for e.g.
4199                // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
4200                // at exit 0 instead of failing. Matches the bare (non-string)
4201                // `Expr::Arithmetic` arm above, which already propagates.
4202                let scope = self.scope.read().await;
4203                crate::arithmetic::eval_arithmetic(expr, &scope)
4204                    .map(|value| value.to_string())
4205                    .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
4206            }
4207            StringPart::CommandSubst(stmts) => {
4208                // Snapshot scope, cwd, and session config — command
4209                // substitution in strings must not leak side effects (e.g.,
4210                // `"dir: $(cd /; pwd)"` must not change cwd, and
4211                // `"$(kaish-ignore clear)"` must not change the session's
4212                // ignore config) — matching how every other execution
4213                // context (background forks, scatter workers) already
4214                // isolates mutations (GH #139).
4215                // Boxed: this ~470 B scope snapshot is held across the nested
4216                // `$(…)` recursion await below, so inlining it grows every
4217                // command-substitution level's future (GH #48, item 4).
4218                let saved_scope = Box::new(self.scope.read().await.clone());
4219                let saved_ec = {
4220                    let ec = self.exec_ctx.read().await;
4221                    (
4222                        ec.cwd.clone(),
4223                        ec.prev_cwd.clone(),
4224                        ec.aliases.clone(),
4225                        ec.ignore_config.clone(),
4226                        ec.output_limit.clone(),
4227                    )
4228                };
4229
4230                // Capture result without `?` — restore state unconditionally
4231                let run_result = self.execute_block_capturing(stmts).await;
4232
4233                // Restore scope and cwd regardless of success/failure
4234                {
4235                    let mut scope = self.scope.write().await;
4236                    *scope = *saved_scope;
4237                    if let Ok(ref r) = run_result {
4238                        scope.set_last_result(r.clone());
4239                        scope.note_cmdsubst_code(r.code);
4240                    }
4241                }
4242                {
4243                    let mut ec = self.exec_ctx.write().await;
4244                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4245                    ec.cwd = cwd;
4246                    ec.prev_cwd = prev_cwd;
4247                    ec.aliases = aliases;
4248                    ec.ignore_config = ignore_config;
4249                    ec.output_limit = output_limit;
4250                }
4251
4252                // A substitution's stderr belongs to the enclosing statement,
4253                // never to its value. Emit it before the value is built.
4254                if let Ok(ref r) = run_result {
4255                    self.emit_cmdsubst_stderr(&r.err).await;
4256                }
4257
4258                // Now propagate the error
4259                let result = run_result?;
4260
4261                // A held body stops the enclosing statement before its
4262                // missing output is spliced in (spec §I.5) — same conversion
4263                // and stash as the bare `$(…)` arm.
4264
4265                // Embedding binary into a string is a text context: fail loud
4266                // rather than splice in U+FFFD garbage.
4267                match result.try_text_out() {
4268                    // Text wins when present — unchanged behavior.
4269                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4270                    // `.out` is empty: a builtin/tool that set only structured
4271                    // `.data` must not silently evaporate to "" (SILENT DATA
4272                    // LOSS). Render it the same way a bare `"$x"`
4273                    // collection-valued variable renders — compact JSON for
4274                    // lists/records, plain form for scalars — by reusing
4275                    // `value_to_string` (the exact `StringPart::Var` helper
4276                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4277                    // identically. No trailing-newline trim here: that's a
4278                    // text-path artifact, not applicable to a freshly
4279                    // rendered JSON/scalar string.
4280                    Ok(_) => match &result.data {
4281                        Some(data) => Ok(value_to_string(data)),
4282                        None => Ok(String::new()),
4283                    },
4284                    Err(e) => anyhow::bail!(
4285                        "command substitution in a string produced binary data ({e}) — \
4286                         pipe through base64/xxd"
4287                    ),
4288                }
4289            }
4290            StringPart::LastExitCode => {
4291                let scope = self.scope.read().await;
4292                Ok(scope.last_result().code.to_string())
4293            }
4294            StringPart::CurrentPid => {
4295                let scope = self.scope.read().await;
4296                Ok(scope.pid().to_string())
4297            }
4298        }
4299        })
4300    }
4301
4302    /// Update the last result in scope.
4303    async fn update_last_result(&self, result: &ExecResult) {
4304        let mut scope = self.scope.write().await;
4305        scope.set_last_result(result.clone());
4306    }
4307
4308    /// Drain accumulated pipeline stderr into a result.
4309    ///
4310    /// Called after each sub-statement inside control structures (`if`, `for`,
4311    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4312    /// than batching until the entire structure finishes.
4313    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4314        let drained = {
4315            let mut receiver = self.stderr_receiver.lock().await;
4316            receiver.drain_lossy()
4317        };
4318        if !drained.is_empty() {
4319            if !result.err.is_empty() && !result.err.ends_with('\n') {
4320                result.err.push('\n');
4321            }
4322            result.err.push_str(&drained);
4323        }
4324    }
4325
4326    /// Execute a user-defined function with local variable scoping.
4327    ///
4328    /// Functions push a new scope frame for local variables. Variables declared
4329    /// with `local` are scoped to the function; other assignments modify outer
4330    /// scopes (or create in root if new).
4331    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4332        let _depth = self.enter_recursion("a shell function")?;
4333
4334        // 1. Build function args from AST args (async to support command substitution)
4335        let tool_args = self.build_args_async(args, None).await?;
4336
4337        // 2. Push a new scope frame for local variables
4338        {
4339            let mut scope = self.scope.write().await;
4340            scope.push_frame();
4341        }
4342
4343        // 3. Save current positional parameters and set new ones for this function
4344        let saved_positional = {
4345            let mut scope = self.scope.write().await;
4346            let saved = scope.save_positional();
4347
4348            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4349            let positional_args: Vec<String> = tool_args.positional
4350                .iter()
4351                .map(value_to_string)
4352                .collect();
4353            scope.set_positional(&def.name, positional_args);
4354
4355            saved
4356        };
4357
4358        // 3. Execute body statements with control flow handling
4359        // Accumulate output across statements (like sh)
4360        // Accumulate stdout as raw bytes so a binary-producing statement in a
4361        // function body survives instead of being lossy-decoded here.
4362        let mut accumulated_out: Vec<u8> = Vec::new();
4363        let mut accumulated_err = String::new();
4364        let mut last_code = 0i64;
4365        let mut last_data: Option<Value> = None;
4366
4367        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4368            match r.out_bytes() {
4369                Some(b) => buf.extend_from_slice(b),
4370                None => buf.extend_from_slice(r.text_out().as_bytes()),
4371            }
4372        }
4373
4374        // Track execution error for propagation after cleanup
4375        let mut exec_error: Option<anyhow::Error> = None;
4376        let mut exit_code: Option<i64> = None;
4377
4378        for stmt in &def.body {
4379            match self.execute_stmt_flow(stmt).await {
4380                Ok(flow) => {
4381                    // Drain pipeline stderr after each sub-statement.
4382                    let drained = {
4383                        let mut receiver = self.stderr_receiver.lock().await;
4384                        receiver.drain_lossy()
4385                    };
4386                    if !drained.is_empty() {
4387                        accumulated_err.push_str(&drained);
4388                    }
4389
4390                    match flow {
4391                        ControlFlow::Normal(r) => {
4392                            push_out(&mut accumulated_out, &r);
4393                            accumulated_err.push_str(&r.err);
4394                            last_code = r.code;
4395                            last_data = r.data;
4396                        }
4397                        ControlFlow::Return { value } => {
4398                            push_out(&mut accumulated_out, &value);
4399                            accumulated_err.push_str(&value.err);
4400                            last_code = value.code;
4401                            last_data = value.data;
4402                            break;
4403                        }
4404                        ControlFlow::Exit { code, result: r } => {
4405                            push_out(&mut accumulated_out, &r);
4406                            accumulated_err.push_str(&r.err);
4407                            exit_code = Some(code);
4408                            break;
4409                        }
4410                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4411                            push_out(&mut accumulated_out, &r);
4412                            accumulated_err.push_str(&r.err);
4413                            last_code = r.code;
4414                            last_data = r.data;
4415                        }
4416                    }
4417                }
4418                Err(e) => {
4419                    exec_error = Some(e);
4420                    break;
4421                }
4422            }
4423        }
4424
4425        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4426        {
4427            let mut scope = self.scope.write().await;
4428            scope.pop_frame();
4429            scope.set_positional(saved_positional.0, saved_positional.1);
4430        }
4431
4432        // 5. Propagate error or exit after cleanup
4433        if let Some(e) = exec_error {
4434            return Err(e);
4435        }
4436        let code = exit_code.unwrap_or(last_code);
4437        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4438        result.err = accumulated_err;
4439        result.data = last_data;
4440        Ok(result)
4441    }
4442
4443    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4444        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4445        let guard = RecursionGuard { counter: &self.recursion_depth };
4446        if depth > MAX_RECURSION_DEPTH {
4447            return Err(anyhow::anyhow!(
4448                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4449                 a runaway or mutually recursive script (deeply nested $(…), or \
4450                 functions/scripts that call each other without a base case) was \
4451                 stopped before it could overflow the stack"
4452            ));
4453        }
4454        Ok(guard)
4455    }
4456
4457    /// Hand a finished command substitution's stderr to the kernel's stderr
4458    /// stream.
4459    ///
4460    /// bash gives `$(…)` the shell's own fd 2, so a substitution's stderr goes
4461    /// straight to the terminal and is never captured alongside its stdout.
4462    /// kaish runs the block captured, so the equivalent is to write the block's
4463    /// stderr to the same channel pipeline stages use: the enclosing
4464    /// statement's drain folds it into that statement's `err`, ahead of the
4465    /// statement's own output. `x=$(cat /nope)` kept the exit code and lost the
4466    /// reason until this existed.
4467    ///
4468    /// Nesting composes without a stack. Each level drains at its own statement
4469    /// boundary, so an inner substitution's stderr is already inside the outer
4470    /// block's result by the time this runs for the outer one — which is why it
4471    /// is written exactly once, here, rather than also accumulated by callers.
4472    async fn emit_cmdsubst_stderr(&self, err: &str) {
4473        if err.is_empty() {
4474            return;
4475        }
4476        // Terminate the chunk. Builtins are inconsistent about a trailing
4477        // newline (`cat`'s failure message has none), and two substitutions in
4478        // one statement would otherwise concatenate into a single unreadable
4479        // line: `x="$(cat /a)$(cat /b)"` produced both messages run together.
4480        // The statement drain already normalizes this boundary the same way
4481        // when it joins drained stderr to a statement's own.
4482        let terminated;
4483        let err = if err.ends_with('\n') {
4484            err
4485        } else {
4486            terminated = format!("{err}\n");
4487            &terminated
4488        };
4489        match self.exec_ctx.read().await.stderr.as_ref() {
4490            Some(stream) => stream.write_str(err),
4491            // The kernel seeds this stream in both `new` and `fork`, so it is
4492            // always present on the kernel's own context; the `Option` exists
4493            // for tool contexts built elsewhere. If it is ever absent there is
4494            // no channel to carry the bytes and no drain to collect them, which
4495            // is the same condition under which every pipeline stage's stderr
4496            // is dropped — so record it rather than failing an interactive
4497            // shell over it.
4498            None => tracing::warn!("command substitution stderr dropped: no stderr stream"),
4499        }
4500    }
4501
4502    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4503        let _depth = self.enter_recursion("command substitution")?;
4504        // Accumulate stdout as raw bytes so a binary-producing statement
4505        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4506        // caller can preserve it. The final result is text iff valid UTF-8.
4507        let mut accumulated_out: Vec<u8> = Vec::new();
4508        let mut accumulated_err = String::new();
4509        let mut last_code = 0i64;
4510        let mut last_data: Option<Value> = None;
4511
4512        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4513        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4514            match r.out_bytes() {
4515                Some(b) => buf.extend_from_slice(b),
4516                None => buf.extend_from_slice(r.text_out().as_bytes()),
4517            }
4518        }
4519
4520        for stmt in stmts {
4521            let flow = self.execute_stmt_flow(stmt).await?;
4522
4523            // Drain pipeline stderr after each sub-statement (incremental, like
4524            // the control-structure and function-body executors).
4525            let drained = {
4526                let mut receiver = self.stderr_receiver.lock().await;
4527                receiver.drain_lossy()
4528            };
4529            if !drained.is_empty() {
4530                accumulated_err.push_str(&drained);
4531            }
4532
4533            match flow {
4534                ControlFlow::Normal(r)
4535                | ControlFlow::Break { result: r, .. }
4536                | ControlFlow::Continue { result: r, .. } => {
4537                    push_out(&mut accumulated_out, &r);
4538                    accumulated_err.push_str(&r.err);
4539                    last_code = r.code;
4540                    last_data = r.data;
4541                }
4542                ControlFlow::Return { value } => {
4543                    push_out(&mut accumulated_out, &value);
4544                    accumulated_err.push_str(&value.err);
4545                    last_code = value.code;
4546                    last_data = value.data;
4547                    break;
4548                }
4549                ControlFlow::Exit { code, result: r } => {
4550                    push_out(&mut accumulated_out, &r);
4551                    accumulated_err.push_str(&r.err);
4552                    last_code = code;
4553                    break;
4554                }
4555            }
4556        }
4557
4558        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4559        result.err = accumulated_err;
4560        result.data = last_data;
4561        Ok(result)
4562    }
4563
4564    /// Execute the `source` / `.` command to include and run a script.
4565    ///
4566    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4567    /// allowing the sourced script to set variables and modify shell state.
4568    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4569        // `source`/`.` is the fourth dynamic re-entry point: it runs the
4570        // sourced file's statements inline via `execute_stmt_flow`, so a file
4571        // that sources itself recurses unbounded just like a runaway function
4572        // (GH #46). It's intercepted as a special form *before* the other
4573        // guarded paths, so it needs its own guard.
4574        let _depth = self.enter_recursion("source")?;
4575
4576        // Get the file path from the first positional argument
4577        let tool_args = self.build_args_async(args, None).await?;
4578        let path = match tool_args.positional.first() {
4579            Some(Value::String(s)) => s.clone(),
4580            Some(v) => value_to_string(v),
4581            None => {
4582                return Ok(ExecResult::failure(1, "source: missing filename"));
4583            }
4584        };
4585
4586        // Resolve path relative to cwd
4587        let full_path = {
4588            let ctx = self.exec_ctx.read().await;
4589            if path.starts_with('/') {
4590                std::path::PathBuf::from(&path)
4591            } else {
4592                ctx.cwd.join(&path)
4593            }
4594        };
4595
4596        // Read file content via backend
4597        let content = {
4598            let ctx = self.exec_ctx.read().await;
4599            match ctx.backend.read(&full_path, None).await {
4600                Ok(bytes) => {
4601                    String::from_utf8(bytes).map_err(|e| {
4602                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4603                    })?
4604                }
4605                Err(e) => {
4606                    return Ok(ExecResult::failure(
4607                        1,
4608                        format!("source: {}: {}", path, e),
4609                    ));
4610                }
4611            }
4612        };
4613
4614        // Parse the content
4615        let program = match crate::parser::parse(&content) {
4616            Ok(p) => p,
4617            Err(errors) => {
4618                let msg = errors
4619                    .iter()
4620                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4621                    .collect::<Vec<_>>()
4622                    .join("\n");
4623                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4624            }
4625        };
4626
4627        // Execute each statement in the CURRENT scope (not isolated), accumulating
4628        // stdout/stderr across statements like `execute_user_tool` — a sourced
4629        // script's earlier statements must not be silently dropped in favor of
4630        // just the last one.
4631        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4632            match r.out_bytes() {
4633                Some(b) => buf.extend_from_slice(b),
4634                None => buf.extend_from_slice(r.text_out().as_bytes()),
4635            }
4636        }
4637
4638        let mut accumulated_out: Vec<u8> = Vec::new();
4639        let mut accumulated_err = String::new();
4640        let mut last_code = 0i64;
4641        let mut last_data: Option<Value> = None;
4642
4643        for stmt in program.statements {
4644            if matches!(stmt, crate::ast::Stmt::Empty) {
4645                continue;
4646            }
4647
4648            match self.execute_stmt_flow(&stmt).await {
4649                Ok(flow) => {
4650                    let drained = {
4651                        let mut receiver = self.stderr_receiver.lock().await;
4652                        receiver.drain_lossy()
4653                    };
4654                    if !drained.is_empty() {
4655                        accumulated_err.push_str(&drained);
4656                    }
4657                    match flow {
4658                        ControlFlow::Normal(r) => {
4659                            push_out(&mut accumulated_out, &r);
4660                            accumulated_err.push_str(&r.err);
4661                            last_code = r.code;
4662                            last_data = r.data.clone();
4663                            self.update_last_result(&r).await;
4664                        }
4665                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4666                            return Err(anyhow::anyhow!(
4667                                "source: {}: unexpected break/continue outside loop",
4668                                path
4669                            ));
4670                        }
4671                        ControlFlow::Return { value } => {
4672                            push_out(&mut accumulated_out, &value);
4673                            accumulated_err.push_str(&value.err);
4674                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4675                                .with_code(value.code);
4676                            result.err = accumulated_err;
4677                            result.data = value.data;
4678                            return Ok(result);
4679                        }
4680                        ControlFlow::Exit { code, result: r } => {
4681                            push_out(&mut accumulated_out, &r);
4682                            accumulated_err.push_str(&r.err);
4683                            let mut result =
4684                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4685                            result.err = accumulated_err;
4686                            result.data = last_data;
4687                            return Ok(result);
4688                        }
4689                    }
4690                }
4691                Err(e) => {
4692                    return Err(e.context(format!("source: {}", path)));
4693                }
4694            }
4695        }
4696
4697        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4698        result.err = accumulated_err;
4699        result.data = last_data;
4700        Ok(result)
4701    }
4702
4703    /// Try to execute a script from PATH directories.
4704    ///
4705    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4706    /// (like user-defined tools). Returns None if no script is found.
4707    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4708        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4709        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4710        // A non-script command pays only a transient, balanced increment during
4711        // the probe before falling through to the external path.
4712        let _depth = self.enter_recursion("a .kai script")?;
4713
4714        // Get PATH from scope (default to "/bin")
4715        let path_value = {
4716            let scope = self.scope.read().await;
4717            scope
4718                .get("PATH")
4719                .map(value_to_string)
4720                .unwrap_or_else(|| "/bin".to_string())
4721        };
4722
4723        // Search PATH directories for script
4724        for dir in path_value.split(':') {
4725            if dir.is_empty() {
4726                continue;
4727            }
4728
4729            // Build script path: {dir}/{name}.kai
4730            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4731
4732            // Check if script exists
4733            let exists = {
4734                let ctx = self.exec_ctx.read().await;
4735                ctx.backend.exists(&script_path).await
4736            };
4737
4738            if !exists {
4739                continue;
4740            }
4741
4742            // Read script content
4743            let content = {
4744                let ctx = self.exec_ctx.read().await;
4745                match ctx.backend.read(&script_path, None).await {
4746                    Ok(bytes) => match String::from_utf8(bytes) {
4747                        Ok(s) => s,
4748                        Err(e) => {
4749                            return Ok(Some(ExecResult::failure(
4750                                1,
4751                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4752                            )));
4753                        }
4754                    },
4755                    Err(e) => {
4756                        return Ok(Some(ExecResult::failure(
4757                            1,
4758                            format!("{}: {}", script_path.display(), e),
4759                        )));
4760                    }
4761                }
4762            };
4763
4764            // Parse the script
4765            let program = match crate::parser::parse(&content) {
4766                Ok(p) => p,
4767                Err(errors) => {
4768                    let msg = errors
4769                        .iter()
4770                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4771                        .collect::<Vec<_>>()
4772                        .join("\n");
4773                    return Ok(Some(ExecResult::failure(1, msg)));
4774                }
4775            };
4776
4777            // Build tool_args from args (async for command substitution support)
4778            let tool_args = self.build_args_async(args, None).await?;
4779
4780            // Create isolated scope (like user tools). The trash rail is NOT
4781            // session state a script may shed: a `.kai` script starting from
4782            // a blank scope would otherwise overwrite and delete without the
4783            // recovery net `set -o trash` promised. Carry it.
4784            let mut isolated_scope = Scope::new();
4785            {
4786                let scope = self.scope.read().await;
4787                isolated_scope.set_pid(scope.pid());
4788                isolated_scope.set_trash_enabled(scope.trash_enabled());
4789                isolated_scope.set_trash_max_size(scope.trash_max_size());
4790            }
4791
4792            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4793            let positional_args: Vec<String> = tool_args.positional
4794                .iter()
4795                .map(value_to_string)
4796                .collect();
4797            isolated_scope.set_positional(name, positional_args);
4798
4799            // Save current scope and swap with isolated scope
4800            let original_scope = {
4801                let mut scope = self.scope.write().await;
4802                std::mem::replace(&mut *scope, isolated_scope)
4803            };
4804
4805            // Execute script statements — accumulate stdout/stderr across
4806            // statements like `execute_user_tool`, rather than keeping only the
4807            // last one's result.
4808            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4809                match r.out_bytes() {
4810                    Some(b) => buf.extend_from_slice(b),
4811                    None => buf.extend_from_slice(r.text_out().as_bytes()),
4812                }
4813            }
4814
4815            let mut accumulated_out: Vec<u8> = Vec::new();
4816            let mut accumulated_err = String::new();
4817            let mut last_code = 0i64;
4818            let mut last_data: Option<Value> = None;
4819            let mut exec_error: Option<anyhow::Error> = None;
4820            let mut exit_code: Option<i64> = None;
4821
4822            for stmt in program.statements {
4823                if matches!(stmt, crate::ast::Stmt::Empty) {
4824                    continue;
4825                }
4826
4827                match self.execute_stmt_flow(&stmt).await {
4828                    Ok(flow) => {
4829                        let drained = {
4830                            let mut receiver = self.stderr_receiver.lock().await;
4831                            receiver.drain_lossy()
4832                        };
4833                        if !drained.is_empty() {
4834                            accumulated_err.push_str(&drained);
4835                        }
4836                        match flow {
4837                            ControlFlow::Normal(r) => {
4838                                push_out(&mut accumulated_out, &r);
4839                                accumulated_err.push_str(&r.err);
4840                                last_code = r.code;
4841                                last_data = r.data;
4842                            }
4843                            ControlFlow::Return { value } => {
4844                                push_out(&mut accumulated_out, &value);
4845                                accumulated_err.push_str(&value.err);
4846                                last_code = value.code;
4847                                last_data = value.data;
4848                                break;
4849                            }
4850                            ControlFlow::Exit { code, result: r } => {
4851                                push_out(&mut accumulated_out, &r);
4852                                accumulated_err.push_str(&r.err);
4853                                exit_code = Some(code);
4854                                break;
4855                            }
4856                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4857                                push_out(&mut accumulated_out, &r);
4858                                accumulated_err.push_str(&r.err);
4859                                last_code = r.code;
4860                                last_data = r.data;
4861                            }
4862                        }
4863                    }
4864                    Err(e) => {
4865                        exec_error = Some(e);
4866                        break;
4867                    }
4868                }
4869            }
4870
4871            // Restore original scope unconditionally
4872            {
4873                let mut scope = self.scope.write().await;
4874                *scope = original_scope;
4875            }
4876
4877            // Propagate error or exit after cleanup
4878            if let Some(e) = exec_error {
4879                return Err(e.context(format!("script: {}", script_path.display())));
4880            }
4881            let code = exit_code.unwrap_or(last_code);
4882            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4883            result.err = accumulated_err;
4884            result.data = last_data;
4885            return Ok(Some(result));
4886        }
4887
4888        // No script found
4889        Ok(None)
4890    }
4891
4892    /// Try to execute an external command from PATH.
4893    ///
4894    /// This is the fallback when no builtin or user-defined tool matches.
4895    /// External commands receive a clean argv (flags preserved in their original format).
4896    ///
4897    /// # Requirements
4898    /// - Command must be found in PATH
4899    /// - Current working directory must be on a real filesystem (not virtual like /v)
4900    ///
4901    /// # Returns
4902    /// - `Ok(Some(result))` if command was found and executed
4903    /// - `Ok(None)` if command was not found in PATH
4904    /// - `Err` on execution errors
4905    #[cfg(not(feature = "subprocess"))]
4906    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4907        Ok(None)
4908    }
4909
4910    /// Try to execute an external command from PATH.
4911    #[cfg(feature = "subprocess")]
4912    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4913    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4914        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4915        // populates from the inbound ctx.cancel on every dispatch. This is
4916        // what makes the `timeout` builtin's swapped child token reach the
4917        // wait_or_kill discipline below — reading `self.cancel_token` would
4918        // give the kernel-wide token and miss the timeout's child cascade.
4919        let cancel = {
4920            let ec = self.exec_ctx.read().await;
4921            ec.cancel.clone()
4922        };
4923        let kill_grace = self.kill_grace;
4924        if !self.allow_external_commands {
4925            return Ok(None);
4926        }
4927
4928        // Get the shell's cwd and its real filesystem location, if any. A
4929        // `None` real path means the cwd is virtual (a CoW overlay, an
4930        // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4931        // process to run. Don't bail out here: a bare command name that isn't
4932        // in PATH at all is a genuine "not found" regardless of cwd, and the
4933        // virtual-cwd error would blame the wrong thing for that case. Once
4934        // the command actually resolves, `real_cwd` is checked again below
4935        // and the honest reason is given then (issue #181).
4936        let (cwd, real_cwd) = {
4937            let ctx = self.exec_ctx.read().await;
4938            (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4939        };
4940
4941        let executable = if name.contains('/') {
4942            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4943            let resolved = if std::path::Path::new(name).is_absolute() {
4944                std::path::PathBuf::from(name)
4945            } else {
4946                match &real_cwd {
4947                    Some(real_cwd) => real_cwd.join(name),
4948                    // A relative path can't be resolved without a real cwd to
4949                    // join against, so we can't even tell whether it would
4950                    // exist — name the actual blocker instead of a
4951                    // misleading "No such file or directory".
4952                    None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4953                }
4954            };
4955            if !resolved.exists() {
4956                return Ok(Some(ExecResult::failure(
4957                    127,
4958                    format!("{}: No such file or directory", name),
4959                )));
4960            }
4961            if !resolved.is_file() {
4962                return Ok(Some(ExecResult::failure(
4963                    126,
4964                    format!("{}: Is a directory", name),
4965                )));
4966            }
4967            #[cfg(unix)]
4968            {
4969                use std::os::unix::fs::PermissionsExt;
4970                let mode = std::fs::metadata(&resolved)
4971                    .map(|m| m.permissions().mode())
4972                    .unwrap_or(0);
4973                if mode & 0o111 == 0 {
4974                    return Ok(Some(ExecResult::failure(
4975                        126,
4976                        format!("{}: Permission denied", name),
4977                    )));
4978                }
4979            }
4980            resolved.to_string_lossy().into_owned()
4981        } else {
4982            // Get PATH from scope only. The kernel never reads OS env: a
4983            // frontend that wants host PATH seeds it via initial_vars (the REPL
4984            // does, with os_env_vars()). No PATH in scope → nothing resolves.
4985            let path_var = {
4986                let scope = self.scope.read().await;
4987                scope.get("PATH").map(value_to_string).unwrap_or_default()
4988            };
4989
4990            // Resolve command in PATH
4991            match resolve_in_path(name, &path_var) {
4992                Some(path) => path,
4993                None => return Ok(None), // Not found - let caller handle error
4994            }
4995        };
4996
4997        // The executable resolved — found in PATH, or a path that exists and
4998        // is executable — but there's still nowhere to run it without a real
4999        // cwd to spawn the child process in.
5000        let real_cwd = match real_cwd {
5001            Some(p) => p,
5002            None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5003        };
5004
5005        tracing::debug!(executable = %executable, "resolved external command");
5006
5007        // Build flat argv (preserves flag format)
5008        let argv = self.build_args_flat(args).await?;
5009
5010        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5011        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5012        // byte vector. Take both out under the lock but do NOT drain here — a
5013        // pipe read can block on its producer (a still-running upstream stage),
5014        // so draining before spawn would serialize the pipeline (deadlocking
5015        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5016        // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
5017        // are mutually exclusive in practice; prefer the pipe.
5018        let (pipe_stdin, stdin_bytes) = {
5019            let mut ctx = self.exec_ctx.write().await;
5020            (ctx.pipe_stdin.take(), ctx.take_stdin())
5021        };
5022        let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
5023
5024        // Build and spawn the command
5025        use tokio::process::Command;
5026
5027        let mut cmd = Command::new(&executable);
5028        cmd.args(&argv);
5029        cmd.current_dir(&real_cwd);
5030
5031        // Hermetic env: child sees only kaish's exported vars, not the kaish
5032        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
5033        // populate it via KernelConfig::initial_vars at construction.
5034        cmd.env_clear();
5035        {
5036            let scope = self.scope.read().await;
5037            let exported = scope.exported_vars();
5038            // A structured value can't cross the process boundary; refuse rather
5039            // than silently JSON-serialize it into the child's environment.
5040            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
5041                return Err(anyhow::anyhow!(msg));
5042            }
5043            for (var_name, value) in exported {
5044                // Binary can't cross the process boundary as an env var value
5045                // either — loud, not the `[binary: N bytes]` placeholder
5046                // silently exported in its place (kept in sync with
5047                // dispatch.rs::try_external and env.rs::execute_with_env).
5048                let value_str = crate::interpreter::value_to_text_sink_named(
5049                    &value,
5050                    "an exported environment variable value",
5051                )
5052                .map_err(|e| anyhow::anyhow!("{e}"))?;
5053                cmd.env(var_name, value_str);
5054            }
5055        }
5056
5057        // Handle stdin
5058        cmd.stdin(if has_stdin {
5059            std::process::Stdio::piped()
5060        } else if self.interactive {
5061            std::process::Stdio::inherit()
5062        } else {
5063            std::process::Stdio::null()
5064        });
5065
5066        // In interactive mode, standalone or last-in-pipeline commands inherit
5067        // the terminal's stdout/stderr so output streams in real-time.
5068        // First/middle commands must capture stdout for the pipe — same as bash.
5069        let pipeline_position = {
5070            let ctx = self.exec_ctx.read().await;
5071            ctx.pipeline_position
5072        };
5073        let inherit_output = self.interactive
5074            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5075
5076        if inherit_output {
5077            cmd.stdout(std::process::Stdio::inherit());
5078            cmd.stderr(std::process::Stdio::inherit());
5079        } else {
5080            cmd.stdout(std::process::Stdio::piped());
5081            cmd.stderr(std::process::Stdio::piped());
5082        }
5083
5084        // On Unix, always put the child in its own process group so cancellation
5085        // can `killpg` the whole tree (the child plus any grandchildren).
5086        // Restoring default tty-related signal handlers stays gated on
5087        // job-control mode — those only matter when the child has a controlling
5088        // terminal.
5089        #[cfg(unix)]
5090        {
5091            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5092            // Read before the fork: the child compares `getppid()` against it to
5093            // catch a parent that died inside the fork/prctl window.
5094            let kill_on_parent_death = {
5095                let ec = self.exec_ctx.read().await;
5096                ec.kill_children_on_parent_death
5097            };
5098            let parent_pid = std::process::id();
5099            // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
5100            // async-signal-safe per POSIX; safe to call between fork and exec.
5101            #[allow(unsafe_code)]
5102            unsafe {
5103                cmd.pre_exec(move || {
5104                    // Own process group — for kill scope.
5105                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5106                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5107                    if kill_on_parent_death {
5108                        crate::dispatch::arm_parent_death_signal(parent_pid)?;
5109                    }
5110                    if restore_jc_signals {
5111                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5112                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
5113                        sa.sa_sigaction = SIG_DFL;
5114                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5115                            return Err(std::io::Error::last_os_error());
5116                        }
5117                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5118                            return Err(std::io::Error::last_os_error());
5119                        }
5120                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5121                            return Err(std::io::Error::last_os_error());
5122                        }
5123                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5124                            return Err(std::io::Error::last_os_error());
5125                        }
5126                    }
5127                    Ok(())
5128                });
5129            }
5130        }
5131
5132        // Backstop for kill on drop in case our explicit kill path is bypassed
5133        // (panic, early return, etc) on the **capture** wait path. We do NOT
5134        // set this on the JC inherit path: that uses sync `waitpid` outside
5135        // tokio's view of the child, so on drop tokio would try to kill an
5136        // already-reaped (possibly-reused) PID. The JC path has its own
5137        // cancel handling via the side-task watcher.
5138        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5139        if !in_jc_inherit_path {
5140            cmd.kill_on_drop(true);
5141        }
5142
5143        // Spawn the process. Capture a `KillTarget` immediately so cancel/
5144        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5145        // to this process's generation, immune to PID reuse if the OS reaps
5146        // the child before our kill syscalls fire.
5147        let mut child = match cmd.spawn() {
5148            Ok(child) => child,
5149            Err(e) => {
5150                return Ok(Some(ExecResult::failure(
5151                    127,
5152                    format!("{}: {}", name, e),
5153                )));
5154            }
5155        };
5156        let kill_target = crate::pidfd::KillTarget::from_child(&child);
5157
5158        // If this external runs on behalf of a background job, record its
5159        // process group on the job so `kill -<sig> %N` can signal the real
5160        // process directly (STOP/CONT/USR1/…, not just terminate). The child
5161        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5162        if let Some(job_id) = self.bg_job_id
5163            && let Some(pid) = child.id()
5164        {
5165            self.jobs.add_pgid(job_id, pid).await;
5166        }
5167
5168        // Same seam, for output: a background job's streams outlive this one
5169        // command, so the drain tasks below tee into them and the job closes
5170        // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
5171        // `cargo build &` is still building (GH #240 removed the node rather
5172        // than wire this tee; the tee is the half that was missing).
5173        let job_streams = match self.bg_job_id {
5174            Some(job_id) => self.jobs.streams(job_id).await,
5175            None => None,
5176        };
5177
5178        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5179        // detached task (bounded memory, no pre-drain) so an upstream stage and
5180        // this child run concurrently — and a child that never reads stdin (or
5181        // is killed) just breaks the copy, which stops. A buffered byte vector
5182        // is written verbatim (no text detour), so binary stdin survives.
5183        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5184            child.stdin.take().map(|mut child_stdin| {
5185                // A buffered prefix and a live pipe are one stream, not two
5186                // candidates. After `read x`, the bytes `read` over-read sit in
5187                // the buffer and the rest is still in the pipe; picking the pipe
5188                // and dropping the buffer would silently skip the front of the
5189                // child's input.
5190                let prefix = stdin_bytes;
5191                tokio::spawn(async move {
5192                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5193                    if let Some(data) = prefix
5194                        && child_stdin.write_all(&data).await.is_err()
5195                    {
5196                        return; // child closed stdin; dropping it signals EOF
5197                    }
5198                    let mut buf = [0u8; 8192];
5199                    loop {
5200                        match pipe_in.read(&mut buf).await {
5201                            Ok(0) => break, // EOF
5202                            Ok(n) => {
5203                                if child_stdin.write_all(&buf[..n]).await.is_err() {
5204                                    break; // child closed stdin
5205                                }
5206                            }
5207                            Err(_) => break,
5208                        }
5209                    }
5210                    // Dropping child_stdin signals EOF to the child.
5211                })
5212            })
5213        } else if let Some(data) = stdin_bytes {
5214            // Write the buffered bytes from a detached task too — NOT inline.
5215            // An inline write blocks once the stdin pipe fills, and the output
5216            // drain hasn't spawned yet, so a child that emits a lot before
5217            // consuming all its input (every pipe buffer full) deadlocks. A
5218            // write error here is normal, not a failure: a child that closes
5219            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5220            // signals EOF.
5221            child.stdin.take().map(|mut child_stdin| {
5222                tokio::spawn(async move {
5223                    use tokio::io::AsyncWriteExt;
5224                    let _ = child_stdin.write_all(&data).await;
5225                })
5226            })
5227        } else {
5228            None
5229        };
5230
5231        // Abort the stdin-copy task on EVERY exit path (the capture path, both
5232        // interactive `inherit_output` returns, and any early error return).
5233        // Once the child is reaped the copy has nothing left to deliver; if it
5234        // were left parked on `pipe_in.read()` it would leak and hold the
5235        // upstream pipe reader open. A drop guard is the single place that
5236        // covers all returns — explicit per-return aborts were error-prone (an
5237        // earlier version missed the two inherit_output returns).
5238        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5239        impl Drop for AbortStdinCopyOnDrop {
5240            fn drop(&mut self) {
5241                if let Some(t) = self.0.take() {
5242                    t.abort();
5243                }
5244            }
5245        }
5246        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5247
5248        if inherit_output {
5249            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5250            #[cfg(unix)]
5251            if let Some(ref term) = self.terminal_state {
5252                let child_id = child.id().unwrap_or(0);
5253                let pid = nix::unistd::Pid::from_raw(child_id as i32);
5254                let pgid = pid; // child is its own pgid leader
5255
5256                // Give the terminal to the child's process group
5257                if let Err(e) = term.give_terminal_to(pgid) {
5258                    tracing::warn!("failed to give terminal to child: {}", e);
5259                }
5260
5261                let term_clone = term.clone();
5262                let cmd_name = name.to_string();
5263                let cmd_display = format!("{} {}", name, argv.join(" "));
5264                let jobs = self.jobs.clone();
5265
5266                // Side task that watches for cancellation while the blocking
5267                // waitpid runs. On cancel, it SIGTERMs the process group, waits
5268                // the grace period, then SIGKILLs. The blocking waitpid returns
5269                // when the child dies. AbortOnDrop guard cancels the watcher
5270                // on the success path so it doesn't keep running after wait
5271                // returns naturally.
5272                //
5273                // `wait_complete` shrinks the PID-reuse race: the watcher
5274                // checks it before each kill syscall and bails out if
5275                // wait_for_foreground has already reaped the child. This
5276                // doesn't fully eliminate the race (atomic load + kill is
5277                // not atomic with the OS reap+reuse), but narrows the window
5278                // to nanoseconds — enough to be ignorable in practice.
5279                let wait_complete = std::sync::Arc::new(
5280                    std::sync::atomic::AtomicBool::new(false)
5281                );
5282                let cancel_watcher = {
5283                    let cancel = cancel.clone();
5284                    let wc = wait_complete.clone();
5285                    // Ownership transfer: the JC path's sync wait inside
5286                    // block_in_place owns the child's reaping, so the
5287                    // cancel_watcher drives the kill side via KillTarget
5288                    // (pidfd-bound on Linux). When kill_target is None
5289                    // (older kernel + open failure, or non-Linux), falls
5290                    // through to the older PID-based path the closure
5291                    // captures from `pid`.
5292                    let target = kill_target.as_ref().map(|t| {
5293                        // Re-borrow the components we need into Owned-ish form
5294                        // so the spawned task is 'static. We can't move
5295                        // KillTarget directly because try_execute_external
5296                        // still uses it after the spawn — but on the JC path
5297                        // there is no further use after the watcher spawn,
5298                        // so a clone-of-pid + owned None pidfd is safe.
5299                        // Simpler: signal via the existing target by cloning
5300                        // a fresh pidfd; the original keeps its handle.
5301                        // Pidfd is just an OwnedFd — not Clone — so do it
5302                        // by re-opening from the pid. Fall back if reopen
5303                        // fails (race already reaped → best-effort kill).
5304                        crate::pidfd::KillTarget::from_pid(t.pid())
5305                    });
5306                    tokio::spawn(async move {
5307                        cancel.cancelled().await;
5308                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5309                        use nix::sys::signal::Signal;
5310                        if let Some(t) = &target {
5311                            t.signal(Signal::SIGTERM);
5312                            t.signal_pg(Signal::SIGTERM);
5313                        } else {
5314                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5315                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5316                        }
5317                        if kill_grace > Duration::ZERO {
5318                            tokio::time::sleep(kill_grace).await;
5319                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5320                        }
5321                        if let Some(t) = &target {
5322                            t.signal(Signal::SIGKILL);
5323                            t.signal_pg(Signal::SIGKILL);
5324                        } else {
5325                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5326                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5327                        }
5328                    })
5329                };
5330                struct AbortOnDrop(tokio::task::JoinHandle<()>);
5331                impl Drop for AbortOnDrop {
5332                    fn drop(&mut self) {
5333                        self.0.abort();
5334                    }
5335                }
5336                let _watcher_guard = AbortOnDrop(cancel_watcher);
5337
5338                let wait_complete_setter = wait_complete.clone();
5339                let code = tokio::task::block_in_place(move || {
5340                    let result = term_clone.wait_for_foreground(pid);
5341                    // Mark wait done before the watcher might fire.
5342                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5343
5344                    // Always reclaim the terminal
5345                    if let Err(e) = term_clone.reclaim_terminal() {
5346                        tracing::warn!("failed to reclaim terminal: {}", e);
5347                    }
5348
5349                    match result {
5350                        crate::terminal::WaitResult::Exited(code) => code as i64,
5351                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5352                        crate::terminal::WaitResult::Stopped(_sig) => {
5353                            // Register as a stopped job
5354                            let rt = tokio::runtime::Handle::current();
5355                            let job_id = rt.block_on(jobs.register_stopped(
5356                                cmd_display,
5357                                child_id,
5358                                child_id, // pgid = pid for group leader
5359                            ));
5360                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5361                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5362                        }
5363                    }
5364                });
5365
5366                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5367            }
5368
5369            // Non-job-control path with inherited stdio.
5370            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5371                Ok(s) => s,
5372                Err(e) => {
5373                    return Ok(Some(ExecResult::failure(
5374                        1,
5375                        format!("{}: failed to wait: {}", name, e),
5376                    )));
5377                }
5378            };
5379
5380            let code = exit_code_from_status(&status);
5381
5382            // stdout/stderr already went to the terminal
5383            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5384        } else {
5385            // Capture output via bounded streams
5386            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5387            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5388
5389            let stdout_pipe = child.stdout.take();
5390            let stderr_pipe = child.stderr.take();
5391
5392            let stdout_clone = stdout_stream.clone();
5393            let stderr_clone = stderr_stream.clone();
5394
5395            // Only the stage whose stdout *is* the job's stdout tees: in
5396            // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5397            // the pipeline's intermediate data into the node alongside its
5398            // real output. stderr has no such routing — every stage's stderr
5399            // is the job's stderr — so it tees from any position.
5400            let stdout_tee = job_streams.as_ref().and_then(|s| {
5401                matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5402                    .then(|| s.stdout.clone())
5403            });
5404            let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5405
5406            let stdout_task = stdout_pipe.map(|pipe| {
5407                tokio::spawn(async move {
5408                    drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5409                })
5410            });
5411
5412            let stderr_task = stderr_pipe.map(|pipe| {
5413                tokio::spawn(async move {
5414                    drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5415                })
5416            });
5417
5418            let cancelled_before_wait = cancel.is_cancelled();
5419            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5420                Ok(s) => s,
5421                Err(e) => {
5422                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5423                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5424                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5425                    return Ok(Some(ExecResult::failure(
5426                        1,
5427                        format!("{}: failed to wait: {}", name, e),
5428                    )));
5429                }
5430            };
5431
5432            // On cancel, abort the drain tasks (the child's pipes are gone;
5433            // late output is lost but predictable death beats partial capture).
5434            // On normal exit, await drains so we don't lose buffered output.
5435            if cancelled_before_wait || cancel.is_cancelled() {
5436                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5437                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5438            } else {
5439                if let Some(task) = stdout_task {
5440                    // Ignore join error — the drain task logs its own errors
5441                    let _ = task.await;
5442                }
5443                if let Some(task) = stderr_task {
5444                    let _ = task.await;
5445                }
5446            }
5447
5448            let code = exit_code_from_status(&status);
5449
5450            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5451            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5452            // intact. stderr stays text. See docs/binary-data.md.
5453            let stdout = stdout_stream.read().await;
5454            let mut stderr = stderr_stream.read_string().await;
5455            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5456
5457            // Both streams are fixed-size rings regardless of `ctx.output_limit`
5458            // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5459            // when enabled). With the limit disabled — the repl/embedded/test
5460            // default — an overflow here used to be silent: `write` evicted the
5461            // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5462            // counter, so a >10MB stdout reported clean success with its head
5463            // quietly gone (GH #191). Surface it loudly instead.
5464            if stderr_stream.has_overflowed().await {
5465                let stats = stderr_stream.stats().await;
5466                stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5467            }
5468            if stdout_stream.has_overflowed().await {
5469                // The marker goes in stderr, never prepended into `result`'s
5470                // stdout payload: stdout may be binary
5471                // (`success_text_or_bytes` yields a `Bytes` result for
5472                // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5473                // string-formatting a marker into it would lossily reinterpret
5474                // bytes as text, introducing a SECOND, different kind of
5475                // corruption on top of the eviction itself.
5476                //
5477                // Only stdout overflow flips `did_spill` — exit-code integrity
5478                // tracks stdout, matching the enabled-limit path's contract
5479                // (stderr overflow alone doesn't remap the exit code).
5480                let stats = stdout_stream.stats().await;
5481                stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5482                result.did_spill = true;
5483            }
5484            result.err = stderr;
5485            Ok(Some(result))
5486        }
5487    }
5488
5489    // --- Variable Access ---
5490
5491    /// Get a variable value.
5492    pub async fn get_var(&self, name: &str) -> Option<Value> {
5493        let scope = self.scope.read().await;
5494        scope.get(name).cloned()
5495    }
5496
5497    /// Check if error-exit mode is enabled (for testing).
5498    #[cfg(test)]
5499    pub async fn error_exit_enabled(&self) -> bool {
5500        let scope = self.scope.read().await;
5501        scope.error_exit_enabled()
5502    }
5503
5504    /// Set a variable value.
5505    pub async fn set_var(&self, name: &str, value: Value) {
5506        let mut scope = self.scope.write().await;
5507        scope.set(name.to_string(), value);
5508    }
5509
5510    /// Set positional parameters ($0 script name and $1-$9 args).
5511    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5512        let mut scope = self.scope.write().await;
5513        scope.set_positional(script_name, args);
5514    }
5515
5516    /// List all variables.
5517    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5518        let scope = self.scope.read().await;
5519        scope.all()
5520    }
5521
5522    /// List exported variables (name, value), sorted by name. These are the
5523    /// vars a child process would see (see `dispatch`'s hermetic env build).
5524    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5525        let scope = self.scope.read().await;
5526        scope.exported_vars()
5527    }
5528
5529    // --- CWD ---
5530
5531    /// Get current working directory.
5532    pub async fn cwd(&self) -> PathBuf {
5533        self.exec_ctx.read().await.cwd.clone()
5534    }
5535
5536    /// Set current working directory.
5537    pub async fn set_cwd(&self, path: PathBuf) {
5538        let mut ctx = self.exec_ctx.write().await;
5539        ctx.set_cwd(path);
5540    }
5541
5542    /// Set the working directory only if `path` resolves to a directory in the
5543    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5544    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5545    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5546    /// disappeared. Returns whether the cwd was changed.
5547    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5548        // Clone the backend Arc out before the stat so we never hold the
5549        // exec_ctx lock across the await.
5550        let backend = self.exec_ctx.read().await.backend.clone();
5551        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5552        if is_dir {
5553            self.exec_ctx.write().await.set_cwd(path);
5554        }
5555        is_dir
5556    }
5557
5558    // --- Last Result ---
5559
5560    /// Get the last result ($?).
5561    pub async fn last_result(&self) -> ExecResult {
5562        let scope = self.scope.read().await;
5563        scope.last_result().clone()
5564    }
5565
5566    // --- Tools ---
5567
5568    /// Check if a user-defined function exists.
5569    pub async fn has_function(&self, name: &str) -> bool {
5570        self.user_tools.read().await.contains_key(name)
5571    }
5572
5573    /// Get available tool schemas.
5574    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5575        self.tools.schemas()
5576    }
5577
5578    /// Classify how the kernel will resolve a command name.
5579    ///
5580    /// This is the supported, single source of truth for command resolution that
5581    /// embedders should call instead of re-deriving the rules. Walk a parsed
5582    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5583    /// this per command name to bucket each into builtin / user-function /
5584    /// special-form / dynamic / external — for example a consent gate that blocks
5585    /// a script until external commands are approved.
5586    ///
5587    /// The classification mirrors the interpreter's real resolution order
5588    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5589    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5590    /// re-checking special-forms each step, exactly as execution does), then user
5591    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5592    /// name that is a variable or command-substitution expansion (`$cmd`,
5593    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5594    /// be resolved statically.
5595    ///
5596    /// Aliases are resolved against the kernel's current alias table, so an
5597    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5598    /// thing it would actually run. The safe direction of any residual imprecision
5599    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5600    /// `.kai`/backend-tool resolution are reported `External` even though some of
5601    /// those resolve in-process, so a consent gate over-gates rather than letting
5602    /// a `PATH` escape slip through.
5603    pub async fn classify_command(&self, name: &str) -> CommandKind {
5604        // Resolve the command head the way `execute_command_depth` does: a
5605        // special-form short-circuits before any alias lookup, otherwise expand
5606        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5607        // can't be resolved at all.
5608        let mut name = name.to_string();
5609        let mut alias_depth = 0u8;
5610        loop {
5611            if !crate::validator::is_static_command_name(&name) {
5612                return CommandKind::Dynamic;
5613            }
5614            if crate::validator::is_runtime_special_form(&name) {
5615                return CommandKind::Special;
5616            }
5617            if alias_depth >= 10 {
5618                break;
5619            }
5620            let alias_value = {
5621                let ctx = self.exec_ctx.read().await;
5622                ctx.aliases.get(&name).cloned()
5623            };
5624            // Expand to the alias's head command. An empty alias value (no head)
5625            // is ignored by execution, so resolution continues with this name.
5626            match alias_value
5627                .as_deref()
5628                .and_then(|v| v.split_whitespace().next())
5629            {
5630                Some(head) => {
5631                    name = head.to_string();
5632                    alias_depth += 1;
5633                }
5634                None => break,
5635            }
5636        }
5637
5638        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5639        let is_builtin = self.tools.contains(&name);
5640        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5641    }
5642
5643    // --- Jobs ---
5644
5645    /// Get job manager.
5646    pub fn jobs(&self) -> Arc<JobManager> {
5647        self.jobs.clone()
5648    }
5649
5650    // --- VFS ---
5651
5652    /// Get VFS router.
5653    pub fn vfs(&self) -> Arc<VfsRouter> {
5654        self.vfs.clone()
5655    }
5656
5657    // --- State ---
5658
5659    /// Reset kernel to initial state.
5660    ///
5661    /// Clears in-memory variables and resets cwd to root. History is not
5662    /// cleared (it persists across resets). The kernel's `$$` identity, the
5663    /// trash-on-delete configuration, and any frontend-seeded `initial_vars`
5664    /// (HOME/PATH/etc, from `KernelConfig`) are re-applied to the fresh
5665    /// scope rather than silently reverting to defaults — an embedder that
5666    /// opted into trash must not find it quietly disabled after a `reset()`
5667    /// between requests.
5668    ///
5669    /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
5670    /// reset, not a session boundary for `&`. A job started before `reset()`
5671    /// keeps running, stays in `jobs`, and the job ID counter keeps counting
5672    /// up. An embedder treating `reset()` as "new session" (a fresh MCP
5673    /// conversation reusing one kernel, say) inherits every job the previous
5674    /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
5675    /// that inheritance is not wanted.
5676    pub async fn reset(&self) -> Result<()> {
5677        {
5678            let mut scope = self.scope.write().await;
5679            let pid = scope.pid();
5680            let trash_enabled = scope.trash_enabled();
5681            let mut fresh = Scope::new();
5682            fresh.set_pid(pid);
5683            for (name, value) in self.initial_vars.clone() {
5684                fresh.set_exported(name, value);
5685            }
5686            // The pin travels with the policy it pins — a `reset()` between
5687            // requests that dropped it would hand the next request an
5688            // unpinned session (spec §F.3 item 3).
5689            fresh.set_trash_enabled(trash_enabled);
5690            *scope = fresh;
5691        }
5692        {
5693            let mut ctx = self.exec_ctx.write().await;
5694            ctx.cwd = PathBuf::from("/");
5695        }
5696        Ok(())
5697    }
5698
5699    /// Trip the cancellation token of every tracked background job (`&`) —
5700    /// whether or not `shutdown` follows.
5701    ///
5702    /// This is the same lever `kill %N` uses: a *running* job's in-process
5703    /// future exits at its next checkpoint, and any external children it
5704    /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
5705    /// status `Killed` once it unwinds. For an already-finished job the
5706    /// token trip is a no-op — its future has already resolved and the job
5707    /// keeps reporting its terminal status. This only
5708    /// *starts* cancellation, it does not wait (pair with
5709    /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
5710    /// unwind, bounded as [`Self::shutdown`] does).
5711    ///
5712    /// A job registered by an embedder via [`JobManager::register`] with no
5713    /// cancel token attached has no lever to cancel — silently skipped here,
5714    /// same as `kill %N`'s own "no cancellation token" case.
5715    ///
5716    /// Returns how many jobs a token was actually tripped for.
5717    pub async fn cancel_all_jobs(&self) -> usize {
5718        let ids = self.jobs.list_ids().await;
5719        let mut cancelled = 0;
5720        for id in ids {
5721            if self.jobs.mark_killed_and_cancel(id, false).await {
5722                cancelled += 1;
5723            }
5724        }
5725        cancelled
5726    }
5727
5728    /// Shut down the kernel.
5729    ///
5730    /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
5731    /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
5732    /// gives a single target (GH #244) — for it to actually unwind. The
5733    /// waits are sequential, so the worst case is additive: N jobs that all
5734    /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
5735    /// that unwind promptly (the normal case) cost only their own unwind
5736    /// time. Before this fix `shutdown` called `wait_all()` with no timeout
5737    /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
5738    ///
5739    /// A job that has not unwound by its deadline is abandoned: logged via
5740    /// `tracing::warn!` and left running detached until the tokio runtime
5741    /// itself goes away. There is no further lever once `shutdown()` has
5742    /// returned — this method does not hang, but it also does not guarantee
5743    /// every job actually stopped.
5744    ///
5745    /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
5746    /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
5747    /// `Arc::try_unwrap`, since the work here only touches the shared
5748    /// `Arc<JobManager>`, never kernel state that would need exclusive
5749    /// ownership.
5750    pub async fn shutdown(&self) -> Result<()> {
5751        let ids = self.jobs.list_ids().await;
5752        self.cancel_all_jobs().await;
5753
5754        let bound = self.jobs.kill_grace() + Duration::from_secs(3);
5755        for id in ids {
5756            if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
5757                tracing::warn!(
5758                    job_id = %id,
5759                    bound_secs = bound.as_secs_f64(),
5760                    "kernel shutdown: job did not exit within the grace period after \
5761                     cancellation — abandoning it"
5762                );
5763            }
5764        }
5765        Ok(())
5766    }
5767
5768    /// Run a compound statement that occupies a pipeline stage.
5769    ///
5770    /// Same ctx↔exec_ctx sync as `dispatch_command`, with one deliberate
5771    /// difference: the stage's pipe writer stays behind with the runner. The
5772    /// statement buffers — its whole output comes back in the `ExecResult` and
5773    /// the runner writes it to the pipe once. Handing the writer down instead
5774    /// would give it to whichever nested command grabbed the slot first, and
5775    /// every later iteration would write nowhere.
5776    ///
5777    /// Streaming a stage would mean threading a writer through nested
5778    /// statement execution, which is the shared-slot machinery GH #369 is
5779    /// about. Revisit once the interpreter takes a ctx parameter.
5780    async fn dispatch_statement(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
5781        if let Some(d) = self.dispatcher() {
5782            ctx.dispatcher = Some(d);
5783        }
5784
5785        // 1. Sync ctx → self internals
5786        {
5787            let mut scope = self.scope.write().await;
5788            *scope = ctx.scope.clone();
5789        }
5790        {
5791            let mut ec = self.exec_ctx.write().await;
5792            ec.cwd = ctx.cwd.clone();
5793            ec.prev_cwd = ctx.prev_cwd.clone();
5794            ec.stdin = ctx.stdin.take();
5795            ec.stdin_data = ctx.stdin_data.take();
5796            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5797            ec.pipe_stdin = ctx.pipe_stdin.take();
5798            // The writer is NOT handed over — see this function's doc comment.
5799            // Clearing the slot keeps a writer left by an earlier dispatch from
5800            // catching the first command inside the loop body.
5801            ec.pipe_stdout = None;
5802            if let Some(stderr) = ctx.stderr.clone() {
5803                ec.stderr = Some(stderr);
5804            }
5805            ec.aliases = ctx.aliases.clone();
5806            ec.ignore_config = ctx.ignore_config.clone();
5807            ec.output_limit = ctx.output_limit.clone();
5808            ec.pipeline_position = ctx.pipeline_position;
5809            ec.cancel = ctx.cancel.clone();
5810            ec.watchdog = ctx.watchdog.clone();
5811        }
5812
5813        // 2. Run the statement. A stage is its own execution unit, so a
5814        // `break`, `continue`, `return`, or `exit` that reaches the top of the
5815        // statement stops here rather than escaping into the enclosing script —
5816        // the same boundary bash draws by running each stage in a subshell.
5817        // Whatever output the statement produced before the signal still comes
5818        // back and still reaches the pipe.
5819        let result = match self.execute_stmt_flow(stmt).await? {
5820            ControlFlow::Normal(result)
5821            | ControlFlow::Break { result, .. }
5822            | ControlFlow::Continue { result, .. }
5823            | ControlFlow::Return { value: result } => result,
5824            ControlFlow::Exit { code, mut result } => {
5825                result.code = code;
5826                result
5827            }
5828        };
5829
5830        // 3. Sync self → ctx
5831        {
5832            let scope = self.scope.read().await;
5833            ctx.scope = scope.clone();
5834        }
5835        {
5836            let mut ec = self.exec_ctx.write().await;
5837            ctx.cwd = ec.cwd.clone();
5838            ctx.prev_cwd = ec.prev_cwd.clone();
5839            ctx.aliases = ec.aliases.clone();
5840            ctx.ignore_config = ec.ignore_config.clone();
5841            ctx.output_limit = ec.output_limit.clone();
5842            ctx.pipe_stdin = ec.pipe_stdin.take();
5843            ctx.stdin = ec.stdin.take();
5844            ctx.stdin_data = ec.stdin_data.take();
5845            ctx.stdin_data_rx = ec.stdin_data_rx.take();
5846        }
5847
5848        Ok(result)
5849    }
5850
5851    /// Dispatch a single command using the full resolution chain.
5852    ///
5853    /// This is the core of `CommandDispatcher` — it syncs state between the
5854    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5855    /// then delegates to `execute_command` for the actual dispatch.
5856    ///
5857    /// State flow:
5858    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5859    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5860    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5861    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5862        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5863        // its inner command via ctx.dispatcher) routes through THIS kernel,
5864        // not a stale parent. Critical for forks: the fork's builtins must
5865        // use the fork's dispatcher, not the parent's.
5866        if let Some(d) = self.dispatcher() {
5867            ctx.dispatcher = Some(d);
5868        }
5869
5870        // 1. Sync ctx → self internals
5871        {
5872            let mut scope = self.scope.write().await;
5873            *scope = ctx.scope.clone();
5874        }
5875        {
5876            let mut ec = self.exec_ctx.write().await;
5877            ec.cwd = ctx.cwd.clone();
5878            ec.prev_cwd = ctx.prev_cwd.clone();
5879            ec.stdin = ctx.stdin.take();
5880            ec.stdin_data = ctx.stdin_data.take();
5881            // The structured-data sideband receiver (set by the concurrent
5882            // pipeline runner on the stage ctx) must reach the tool's snapshot
5883            // too — same reason as the pipe endpoints below. Without this a
5884            // pipeline consumer never sees the producer's `.data`.
5885            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5886            // Streaming pipe endpoints and kernel stderr must flow to the
5887            // tool via self.exec_ctx — execute_command reads that, not the
5888            // passed-in ctx. Without moving these, concurrent pipeline
5889            // stages dispatched via a fork get pipe_stdin = None and
5890            // silently read nothing.
5891            ec.pipe_stdin = ctx.pipe_stdin.take();
5892            ec.pipe_stdout = ctx.pipe_stdout.take();
5893            if let Some(stderr) = ctx.stderr.clone() {
5894                ec.stderr = Some(stderr);
5895            }
5896            ec.aliases = ctx.aliases.clone();
5897            ec.ignore_config = ctx.ignore_config.clone();
5898            ec.output_limit = ctx.output_limit.clone();
5899            ec.pipeline_position = ctx.pipeline_position;
5900            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5901            // swap ctx.cancel to a derived child token before re-dispatching;
5902            // execute_command's snapshot reads ec.cancel (kept aligned by
5903            // this sync), so try_execute_external sees the right token.
5904            ec.cancel = ctx.cancel.clone();
5905            // Same alignment for the watchdog: a fork dispatching through its
5906            // own kernel must hand the shared script clock to the snapshot so
5907            // patient holds in forked stages suspend the right timer.
5908            ec.watchdog = ctx.watchdog.clone();
5909        }
5910
5911        // 2. Execute via the full dispatch chain
5912        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5913
5914        // 3. Sync self → ctx
5915        {
5916            let scope = self.scope.read().await;
5917            ctx.scope = scope.clone();
5918        }
5919        {
5920            let mut ec = self.exec_ctx.write().await;
5921            ctx.cwd = ec.cwd.clone();
5922            ctx.prev_cwd = ec.prev_cwd.clone();
5923            ctx.aliases = ec.aliases.clone();
5924            ctx.ignore_config = ec.ignore_config.clone();
5925            ctx.output_limit = ec.output_limit.clone();
5926            // Return any pipe endpoints that the tool didn't consume.
5927            // `take()` here keeps the fork's exec_ctx in a clean state for
5928            // the next dispatch — these are per-command and shouldn't leak
5929            // between calls.
5930            ctx.pipe_stdin = ec.pipe_stdin.take();
5931            ctx.pipe_stdout = ec.pipe_stdout.take();
5932            // Unconsumed buffered stdin comes back the same way, and for a
5933            // sharper reason than symmetry: a partial read (`read` takes one
5934            // line) leaves its remainder in `ec`, and the caller's own
5935            // end-of-statement sync writes `ctx.stdin` back over `ec.stdin`.
5936            // Without this the caller writes its stale `None` over the
5937            // remainder and the rest of the stream is gone.
5938            ctx.stdin = ec.stdin.take();
5939            // The sideband rides home with stdin, same rule.
5940            ctx.stdin_data = ec.stdin_data.take();
5941            ctx.stdin_data_rx = ec.stdin_data_rx.take();
5942            // Same take-don't-clone discipline as stdin, and for the same
5943            // reason: these belong to exactly one dispatch, and a copy left
5944            // behind would let the next command adopt it.
5945        }
5946
5947        Ok(result)
5948    }
5949}
5950
5951/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5952/// shared arg-binding core behind both `Kernel::build_args_async`
5953/// (production: full recursion through the async pipeline, command
5954/// substitution, real glob expansion) and the reduced sync evaluator behind
5955/// scatter/gather's own option parsing and the `#[cfg(test)]`
5956/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5957/// `SyncEvalSource`). GH #188 closes the drift class between those two
5958/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5959/// is now the ONLY implementation; only expression evaluation, which is
5960/// capability-bound (recursing into command substitution needs a live async
5961/// pipeline the reduced context doesn't have), still has two providers.
5962#[async_trait]
5963pub(crate) trait ArgValueSource: Send + Sync {
5964    /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5965    /// this evaluator" — the reduced sync evaluator's bash-compatible
5966    /// "coalesce" convention for an unset bare variable, or an expression
5967    /// form it doesn't support (a binary op) — and the caller drops the
5968    /// argument the same way an unset bare variable always has. The real
5969    /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5970    /// evaluate.
5971    async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5972
5973    /// Expand a bare glob-pattern positional to display strings, or `None`
5974    /// if this evaluator doesn't expand globs here (disabled, or the reduced
5975    /// sync context, which never has — matching its documented "no
5976    /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5977    /// back to `eval` (which hands back the pattern text as a literal
5978    /// string) when this returns `None`. An enabled expansion that matches
5979    /// nothing is a genuine error, not `Ok(None)`.
5980    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5981
5982    /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5983    /// — the reduced sync evaluator's existing behavior (it never expanded
5984    /// `~`).
5985    async fn home(&self) -> Option<String>;
5986}
5987
5988#[async_trait]
5989impl ArgValueSource for Kernel {
5990    async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5991        Ok(Some(self.eval_expr_async(expr).await?))
5992    }
5993
5994    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5995        let glob_enabled = self.scope.read().await.glob_enabled();
5996        if !glob_enabled {
5997            return Ok(None);
5998        }
5999        let (paths, cwd) = {
6000            let ctx = self.exec_ctx.read().await;
6001            let paths = ctx
6002                .expand_glob(pattern)
6003                .await
6004                .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
6005            let cwd = ctx.resolve_path(".");
6006            (paths, cwd)
6007        };
6008        if paths.is_empty() {
6009            anyhow::bail!("no matches: {}", pattern);
6010        }
6011        let display = paths
6012            .into_iter()
6013            .map(|path| {
6014                if !pattern.starts_with('/') {
6015                    path.strip_prefix(&cwd)
6016                        .unwrap_or(&path)
6017                        .to_string_lossy()
6018                        .into_owned()
6019                } else {
6020                    path.to_string_lossy().into_owned()
6021                }
6022            })
6023            .collect();
6024        Ok(Some(display))
6025    }
6026
6027    async fn home(&self) -> Option<String> {
6028        self.scope_home().await
6029    }
6030}
6031
6032/// Pull `consumes` positional args after a non-bool flag and stash them on
6033/// `tool_args.named` under the canonical param name. Shared core behind
6034/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
6035/// function's doc comment for the unification story (GH #188).
6036///
6037/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
6038///   single scalar value (last write wins on the rare duplicate).
6039/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
6040///   inside `named[canonical] = Value::Json(Array(...))`, preserving
6041///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
6042///   a repeated single-value flag must keep every value, not silently drop
6043///   all but the last (a "no silent corruption" violation).
6044/// - `consumes > 1` accumulates each occurrence as an inner
6045///   `serde_json::Value::Array` inside `named[canonical] =
6046///   Value::Json(Array(...))`, preserving invocation order. This is the
6047///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
6048///
6049/// Errors loudly if the flag is missing required positionals — matches
6050/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
6051/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
6052/// can't represent — Kernel's evaluator never returns this) falls back to a
6053/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
6054/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
6055/// error rather than a silently-partial array.
6056#[allow(clippy::too_many_arguments)]
6057async fn consume_flag_positionals(
6058    source: &dyn ArgValueSource,
6059    home: Option<&str>,
6060    args: &[Arg],
6061    flag_name: &str,
6062    canonical: &str,
6063    consumes: usize,
6064    repeatable: bool,
6065    positional_indices: &[usize],
6066    consumed: &mut std::collections::HashSet<usize>,
6067    current_idx: usize,
6068    tool_args: &mut ToolArgs,
6069) -> Result<()> {
6070    let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
6071    for _ in 0..consumes.max(1) {
6072        // A `key=value` (WordAssign) token is consumable only by a
6073        // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
6074        // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
6075        // filter` would reassemble `x=1` into the first slot and steal the
6076        // filter into the second. Multi-value flags take plain positionals.
6077        let allow_word_assign = consumes <= 1;
6078        let next_pos = positional_indices
6079            .iter()
6080            .find(|idx| {
6081                **idx > current_idx
6082                    && !consumed.contains(idx)
6083                    && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
6084            })
6085            .copied();
6086        match next_pos {
6087            Some(pos_idx) => match &args[pos_idx] {
6088                Arg::Positional(expr) => match source.eval(expr).await? {
6089                    Some(value) => {
6090                        let value = apply_tilde_expansion(value, home);
6091                        collected.push(value);
6092                        consumed.insert(pos_idx);
6093                    }
6094                    None if collected.is_empty() => {
6095                        tool_args.flags.insert(flag_name.to_string());
6096                        return Ok(());
6097                    }
6098                    None => anyhow::bail!(
6099                        "--{flag_name}: could not evaluate argument {} in this context",
6100                        collected.len() + 1
6101                    ),
6102                },
6103                // `-v a=1`: reassemble the `key=value` token as the flag's
6104                // scalar value (see `positional_indices` construction).
6105                Arg::WordAssign { key, value } => match source.eval(value).await? {
6106                    Some(val) => {
6107                        let val = apply_tilde_expansion(val, home);
6108                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
6109                        // reassemble the `[binary: N bytes]` placeholder into the
6110                        // flag's value — same text-sink boundary as the primary
6111                        // sinks fixed in #93 item 1.
6112                        let val_str = crate::interpreter::value_to_text_sink_named(
6113                            &val,
6114                            "a key=value argument",
6115                        )
6116                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6117                        collected.push(Value::String(format!("{key}={val_str}")));
6118                        consumed.insert(pos_idx);
6119                    }
6120                    None if collected.is_empty() => {
6121                        tool_args.flags.insert(flag_name.to_string());
6122                        return Ok(());
6123                    }
6124                    None => anyhow::bail!(
6125                        "--{flag_name}: could not evaluate argument {} in this context",
6126                        collected.len() + 1
6127                    ),
6128                },
6129                _ => {}
6130            },
6131            None => {
6132                if consumes <= 1 && collected.is_empty() {
6133                    // Back-compat: a flag with no follow-up positional
6134                    // becomes a bare flag. `--path` with nothing after
6135                    // lands in `flags`, same as before this refactor.
6136                    tool_args.flags.insert(flag_name.to_string());
6137                    return Ok(());
6138                }
6139                anyhow::bail!(
6140                    "--{flag_name} requires {consumes} argument{}, got {}",
6141                    if consumes == 1 { "" } else { "s" },
6142                    collected.len()
6143                );
6144            }
6145        }
6146    }
6147
6148    if consumes <= 1 {
6149        if let Some(v) = collected.pop() {
6150            if repeatable {
6151                push_repeatable_value(tool_args, flag_name, canonical, v)?;
6152            } else {
6153                tool_args.named.insert(canonical.to_string(), v);
6154            }
6155        }
6156        return Ok(());
6157    }
6158
6159    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
6160    let occ: Vec<serde_json::Value> = collected
6161        .iter()
6162        .map(|v| flag_value_to_json(canonical, v))
6163        .collect::<Result<Vec<_>>>()?;
6164    let entry = tool_args
6165        .named
6166        .entry(canonical.to_string())
6167        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6168    if let Value::Json(serde_json::Value::Array(outer)) = entry {
6169        outer.push(serde_json::Value::Array(occ));
6170    } else {
6171        anyhow::bail!(
6172            "--{flag_name}: named[{canonical}] already holds a non-array value"
6173        );
6174    }
6175    Ok(())
6176}
6177
6178/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
6179/// (GH #188) shared by `Kernel::build_args_async` (production) and the
6180/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
6181/// scatter/gather's own option parsing and the `#[cfg(test)]`
6182/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
6183/// pass: Kernel's evaluates full expressions (including `$(...)` command
6184/// substitution) and expands real globs/tilde; the reduced one can't recurse
6185/// into the async pipeline this early (scatter/gather's own flags bind
6186/// before any worker forks) so it evaluates a smaller expression subset and
6187/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
6188///
6189/// If a schema is provided, uses it to determine argument types:
6190/// - For `--flag` where schema says type is non-bool: consume next
6191///   positional(s) as value(s) (`consumes`/`repeatable`-aware).
6192/// - For `--flag` where schema says type is bool (or unknown): treat as a
6193///   boolean flag.
6194///
6195/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
6196pub(crate) async fn bind_tool_args(
6197    args: &[Arg],
6198    schema: Option<&crate::tools::ToolSchema>,
6199    source: &dyn ArgValueSource,
6200) -> Result<ToolArgs> {
6201    let mut tool_args = ToolArgs::new();
6202    let home = source.home().await;
6203
6204    // A glob-passthrough tool (`glob`) consumes patterns as data: skip
6205    // argv glob expansion so the pattern reaches the tool as written —
6206    // otherwise `glob **/*.rs` binds the first *matching path* as its
6207    // pattern. The eval fallback turns `Expr::GlobPattern` into its
6208    // literal string.
6209    let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
6210
6211    // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
6212    // in source order with types preserved — operators (`-f`, `=`, `!`) as
6213    // strings, operands keeping their `Value` — leaving `flags`/`named`
6214    // empty. A position-sensitive command needs the *true* argv: an operand
6215    // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
6216    // hoisted into the unordered flag set the normal binder splits into.
6217    // Globs still expand and `~` still resolves, matching normal positional
6218    // binding — so `test -f *.rs` errors on too many args, not a literal
6219    // pattern stat.
6220    if schema.is_some_and(|s| s.raw_argv) {
6221        for arg in args {
6222            match arg {
6223                Arg::Positional(expr) => {
6224                    let glob = if let Expr::GlobPattern(p) = expr {
6225                        (!glob_passthrough).then(|| p.clone())
6226                    } else {
6227                        None
6228                    };
6229                    if let Some(pattern) = glob {
6230                        match source.expand_glob(&pattern).await? {
6231                            Some(paths) => {
6232                                for path in paths {
6233                                    tool_args.positional.push(Value::String(path));
6234                                }
6235                            }
6236                            None => {
6237                                let value = source.eval(expr).await?.ok_or_else(|| {
6238                                    anyhow::anyhow!(
6239                                        "raw-argv positional could not be evaluated in this context"
6240                                    )
6241                                })?;
6242                                let value = apply_tilde_expansion(value, home.as_deref());
6243                                tool_args.positional.push(value);
6244                            }
6245                        }
6246                    } else {
6247                        let value = source.eval(expr).await?.ok_or_else(|| {
6248                            anyhow::anyhow!(
6249                                "raw-argv positional could not be evaluated in this context"
6250                            )
6251                        })?;
6252                        let value = apply_tilde_expansion(value, home.as_deref());
6253                        tool_args.positional.push(value);
6254                    }
6255                }
6256                Arg::ShortFlag(name) => {
6257                    tool_args.positional.push(Value::String(format!("-{name}")));
6258                }
6259                Arg::LongFlag(name) => {
6260                    tool_args.positional.push(Value::String(format!("--{name}")));
6261                }
6262                Arg::Named { key, value } => {
6263                    let val = source.eval(value).await?.ok_or_else(|| {
6264                        anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
6265                    })?;
6266                    let val = apply_tilde_expansion(val, home.as_deref());
6267                    // Loud on binary (GH #116): `test --k=$BIN` must not
6268                    // silently reassemble the placeholder into the raw-argv
6269                    // positional stream `test` binds against.
6270                    let val_str = crate::interpreter::value_to_text_sink_named(
6271                        &val,
6272                        "a --key=value argument",
6273                    )
6274                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6275                    tool_args
6276                        .positional
6277                        .push(Value::String(format!("--{key}={val_str}")));
6278                }
6279                Arg::WordAssign { key, value } => {
6280                    let val = source.eval(value).await?.ok_or_else(|| {
6281                        anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
6282                    })?;
6283                    let val = apply_tilde_expansion(val, home.as_deref());
6284                    // Loud on binary (GH #116): same reasoning as the Named
6285                    // arm above, for the bare `key=value` raw-argv form.
6286                    let val_str = crate::interpreter::value_to_text_sink_named(
6287                        &val,
6288                        "a key=value argument",
6289                    )
6290                    .map_err(|e| anyhow::anyhow!("{e}"))?;
6291                    tool_args
6292                        .positional
6293                        .push(Value::String(format!("{key}={val_str}")));
6294                }
6295                Arg::DoubleDash => {
6296                    tool_args.positional.push(Value::String("--".to_string()));
6297                }
6298            }
6299        }
6300        return Ok(tool_args);
6301    }
6302
6303    // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
6304    // schemas; pick the leaf the leading positionals route to and bind
6305    // flags against *its* params. Flat tools return the root. select_leaf
6306    // errors (fail loud) if a computed positional sits where a subcommand
6307    // selector is required.
6308    let leaf = match schema {
6309        Some(s) => Some(select_leaf(s, args)?),
6310        None => None,
6311    };
6312    // Bind against the leaf's params, but MERGE the root schema's params on
6313    // top as "global" flags: a value-flag declared at the tool's top level
6314    // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
6315    // it trails the subcommand path (`kj context retag a b --confirm <n>`).
6316    // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
6317    // merge is a harmless no-op.
6318    let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
6319    if let Some(l) = leaf {
6320        param_lookup.extend(schema_param_lookup(l));
6321    }
6322    // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
6323    // not the leaf — it's a property of the command, not the subcommand.
6324    let accepts_word_assign = schema
6325        .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
6326        .unwrap_or(false);
6327
6328    // Track which positional indices have been consumed as flag values
6329    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
6330    let mut past_double_dash = false;
6331
6332    // Indices a value-flag may consume as its value. Positionals always
6333    // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
6334    // itself treat `key=value` as an assignment (everything but
6335    // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
6336    // `-v`, rather than skipping it and grabbing the next positional (the
6337    // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
6338    // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
6339    let positional_indices: Vec<usize> = args
6340        .iter()
6341        .enumerate()
6342        .filter_map(|(i, a)| {
6343            let consumable = matches!(a, Arg::Positional(_))
6344                || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
6345            consumable.then_some(i)
6346        })
6347        .collect();
6348
6349    let mut i = 0;
6350    while i < args.len() {
6351        match &args[i] {
6352            Arg::DoubleDash => {
6353                past_double_dash = true;
6354            }
6355            Arg::Positional(expr) => {
6356                if !consumed.contains(&i) {
6357                    // Glob expansion: bare glob patterns expand to matching files
6358                    if let Expr::GlobPattern(pattern) = expr {
6359                        if !glob_passthrough {
6360                            if let Some(paths) = source.expand_glob(pattern).await? {
6361                                for path in paths {
6362                                    tool_args.positional.push(Value::String(path));
6363                                }
6364                                i += 1;
6365                                continue;
6366                            }
6367                        }
6368                    }
6369                    if let Some(value) = source.eval(expr).await? {
6370                        let value = apply_tilde_expansion(value, home.as_deref());
6371                        tool_args.positional.push(value);
6372                    }
6373                }
6374            }
6375            Arg::Named { key, value } => {
6376                if let Some(val) = source.eval(value).await? {
6377                    let val = apply_tilde_expansion(val, home.as_deref());
6378                    // A repeatable flag in `--flag=value` form must accumulate too,
6379                    // not overwrite — otherwise `--expression=A --expression=B`
6380                    // would silently keep only B, and mixing with the `-e` space
6381                    // form would clobber the array. Route it through the same
6382                    // accumulator the space form uses.
6383                    let is_declared_value_flag = param_lookup
6384                        .get(key.as_str())
6385                        .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6386                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6387                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
6388                    } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6389                        // Flagify at bind time (GH #189): `--flag=true`/
6390                        // `--flag=false` binds the same way the bare
6391                        // `--flag`/its absence already do (true → flag
6392                        // presence, false → dropped) instead of landing in
6393                        // `named` as a literal `Value::Bool` that a clap
6394                        // `bool` field's `SetTrue` action rejects
6395                        // (`seq --json=true` used to exit 2 with a clap
6396                        // parse error). Covers both a schema-declared bool
6397                        // param AND an undeclared flag — `--json` itself is
6398                        // deliberately excluded from every builtin's schema
6399                        // (`clap_schema::is_skipped`), so this is what makes
6400                        // `--json=true` work universally instead of only for
6401                        // the builtins that happen to call
6402                        // `ToolArgs::flagify_bool_named` themselves. A
6403                        // declared VALUE-taking flag's own `=true` literal
6404                        // (`spawn --command=true`) is excluded by
6405                        // `is_declared_value_flag` and still falls to
6406                        // `named` below.
6407                        if let Value::Bool(true) = val {
6408                            tool_args.flags.insert(key.clone());
6409                        }
6410                        // Value::Bool(false): absent == false, nothing to insert.
6411                    } else {
6412                        tool_args.named.insert(key.clone(), val);
6413                    }
6414                }
6415            }
6416            Arg::WordAssign { key, value } => {
6417                // Already pulled in as a preceding value-flag's argument
6418                // (`awk -v a=1`); don't also emit it as a positional.
6419                if consumed.contains(&i) {
6420                    i += 1;
6421                    continue;
6422                }
6423                if let Some(val) = source.eval(value).await? {
6424                    let val = apply_tilde_expansion(val, home.as_deref());
6425                    // Past `--`, EVERY token is raw data — including for
6426                    // export/alias, whose `key=value` is normally a shell
6427                    // assignment (GH #189). `export -- A=1` must bind `A=1`
6428                    // as a literal positional, not silently re-enter the
6429                    // named-assignment path `past_double_dash` exists to
6430                    // suppress for flags right above this arm.
6431                    if accepts_word_assign && !past_double_dash {
6432                        tool_args.named.insert(key.clone(), val);
6433                    } else {
6434                        // Stringify "key=value" and pass as a positional.
6435                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6436                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6437                        // must not silently become a path/operand literally named
6438                        // `foo=[binary: N bytes]`.
6439                        let val_str = crate::interpreter::value_to_text_sink_named(
6440                            &val,
6441                            "a key=value argument",
6442                        )
6443                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6444                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6445                    }
6446                }
6447            }
6448            Arg::ShortFlag(name) => {
6449                if past_double_dash {
6450                    tool_args.positional.push(Value::String(format!("-{name}")));
6451                } else if name.len() == 1 {
6452                    let flag_name = name.as_str();
6453                    let lookup = param_lookup.get(flag_name);
6454
6455                    // Same ambiguity guard as the `LongFlag` arm below (GH
6456                    // #189 item 4): an undeclared short flag immediately
6457                    // followed by an unconsumed positional under a
6458                    // map_positionals (backend/MCP) schema is exactly as
6459                    // ambiguous as the long-flag case — kaish can't tell a
6460                    // space-form value (`-t explorer`) from a bool flag
6461                    // sitting before a real positional (`-f file.txt`).
6462                    // Unlike `--flag`, there is no `-f=value` escape hatch to
6463                    // suggest: a glued `-f=val` is two tokens with a dangling
6464                    // `=` that the parser's no-token-pasting guard already
6465                    // rejects — the only fix is declaring the flag.
6466                    let ambiguous_value = (lookup.is_none()
6467                        && leaf.is_some_and(|s| s.map_positionals)
6468                        && !consumed.contains(&(i + 1)))
6469                        .then(|| match args.get(i + 1) {
6470                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6471                                Some(s.clone())
6472                            }
6473                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6474                            _ => None,
6475                        })
6476                        .flatten();
6477                    if let Some(val) = ambiguous_value {
6478                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6479                        anyhow::bail!(
6480                            "{tool}: -{name} is not a declared flag, so the \
6481                             space-separated value ({val:?}) would be silently \
6482                             dropped. Have {tool} declare -{name} in its schema \
6483                             (short flags have no -{name}=value form to fall \
6484                             back on)."
6485                        );
6486                    }
6487
6488                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6489
6490                    if is_bool {
6491                        tool_args.flags.insert(flag_name.to_string());
6492                    } else {
6493                        // Non-bool: consume `consumes` positionals as value(s)
6494                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6495                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6496                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6497                        consume_flag_positionals(
6498                            source,
6499                            home.as_deref(),
6500                            args,
6501                            name,
6502                            canonical,
6503                            consumes,
6504                            repeatable,
6505                            &positional_indices,
6506                            &mut consumed,
6507                            i,
6508                            &mut tool_args,
6509                        )
6510                        .await?;
6511                    }
6512                } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
6513                    // Multi-char short flag matches a schema param (POSIX style: -name value)
6514                    if is_bool_type(typ) {
6515                        tool_args.flags.insert(canonical.to_string());
6516                    } else {
6517                        consume_flag_positionals(
6518                            source,
6519                            home.as_deref(),
6520                            args,
6521                            name,
6522                            canonical,
6523                            consumes,
6524                            repeatable,
6525                            &positional_indices,
6526                            &mut consumed,
6527                            i,
6528                            &mut tool_args,
6529                        )
6530                        .await?;
6531                    }
6532                } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
6533                    .get(&name[..1])
6534                    .filter(|(_, typ, ..)| !is_bool_type(typ))
6535                {
6536                    // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
6537                    // `grep -A1`, `sed -e1d`. The first char is a declared
6538                    // value-taking short flag, so the rest of the token is its
6539                    // value — the coreutils idiom. The lexer's flag char class is
6540                    // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6541                    // (safe to slice) and the tail is a plain literal.
6542                    bind_glued_short_value(
6543                        &mut tool_args,
6544                        &name[..1],
6545                        canonical,
6546                        consumes,
6547                        repeatable,
6548                        name[1..].to_string(),
6549                    )?;
6550                } else {
6551                    // Multi-char combined short flags. Bool flags stack
6552                    // (`-la`), but the FIRST value-taking flag reached
6553                    // consumes the rest of the token as its glued value
6554                    // (`-ivC3` → C=3) or, if it is the last char, the next
6555                    // positional (`grep -ivC 3` → C=3). Before this, a
6556                    // trailing value-flag was silently treated as a bool,
6557                    // stranding its argument as a stray positional (arity
6558                    // error). Undeclared/bool chars stay bare flags, so a
6559                    // schemaless tool keeps the old all-boolean behavior.
6560                    // The first char being value-taking is handled by the
6561                    // glued arm above, so it never reaches here. The flag
6562                    // char class is ASCII, so byte indexing is char indexing
6563                    // (no `Vec<char>` allocation needed).
6564                    let bytes = name.as_bytes();
6565                    let mut p = 0;
6566                    while p < bytes.len() {
6567                        let key = &name[p..p + 1];
6568                        match param_lookup.get(key) {
6569                            Some(&(canonical, typ, consumes, repeatable))
6570                                if !is_bool_type(typ) =>
6571                            {
6572                                let glued = name[p + 1..].to_string();
6573                                if glued.is_empty() {
6574                                    // Value flag is the last char: take the
6575                                    // next positional. `consume_flag_positionals`
6576                                    // respects `consumes`.
6577                                    consume_flag_positionals(
6578                                        source,
6579                                        home.as_deref(),
6580                                        args,
6581                                        key,
6582                                        canonical,
6583                                        consumes,
6584                                        repeatable,
6585                                        &positional_indices,
6586                                        &mut consumed,
6587                                        i,
6588                                        &mut tool_args,
6589                                    )
6590                                    .await?;
6591                                } else {
6592                                    bind_glued_short_value(
6593                                        &mut tool_args,
6594                                        key,
6595                                        canonical,
6596                                        consumes,
6597                                        repeatable,
6598                                        glued,
6599                                    )?;
6600                                }
6601                                break;
6602                            }
6603                            _ => {
6604                                tool_args.flags.insert(key.to_string());
6605                                p += 1;
6606                            }
6607                        }
6608                    }
6609                }
6610            }
6611            Arg::LongFlag(name) => {
6612                if past_double_dash {
6613                    tool_args.positional.push(Value::String(format!("--{name}")));
6614                } else {
6615                    let lookup = param_lookup.get(name.as_str());
6616                    // An *undeclared* long flag under a `map_positionals`
6617                    // (backend/MCP) schema that is immediately followed by an
6618                    // unconsumed positional is ambiguous: kaish can't tell the
6619                    // space-form value (`--type explorer`) from a bool flag
6620                    // before a real positional (`--force file.txt`). Defaulting
6621                    // to bool here silently divorces the value and misroutes it
6622                    // — a privilege-escalation-by-typo against deny-by-default
6623                    // embedders. Fail loud instead of guessing.
6624                    let ambiguous_value = (lookup.is_none()
6625                        && leaf.is_some_and(|s| s.map_positionals)
6626                        && !consumed.contains(&(i + 1)))
6627                        .then(|| match args.get(i + 1) {
6628                            // Echo a concrete value for a copy-pasteable fix
6629                            // when it's a plain literal; fall back to VALUE.
6630                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6631                                Some(s.clone())
6632                            }
6633                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6634                            _ => None,
6635                        })
6636                        .flatten();
6637                    if let Some(val) = ambiguous_value {
6638                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6639                        anyhow::bail!(
6640                            "{tool}: --{name} is not a declared flag, so the \
6641                             space-separated value would be silently dropped. \
6642                             Use --{name}={val}, or have {tool} declare --{name} \
6643                             in its schema."
6644                        );
6645                    }
6646                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6647
6648                    if is_bool {
6649                        tool_args.flags.insert(name.clone());
6650                    } else {
6651                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6652                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6653                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6654                        consume_flag_positionals(
6655                            source,
6656                            home.as_deref(),
6657                            args,
6658                            name,
6659                            canonical,
6660                            consumes,
6661                            repeatable,
6662                            &positional_indices,
6663                            &mut consumed,
6664                            i,
6665                            &mut tool_args,
6666                        )
6667                        .await?;
6668                    }
6669                }
6670            }
6671        }
6672        i += 1;
6673    }
6674
6675    // Map remaining positionals to unfilled non-bool schema params (in order).
6676    // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6677    // Positionals that appeared after `--` are never mapped (they're raw data).
6678    // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6679    // Keyed off the routed leaf so a subcommand tool maps against the active
6680    // leaf's params (kj leaves keep map_positionals=false → block skipped).
6681    if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6682        let pre_dash_count = if past_double_dash {
6683            let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6684            positional_indices.iter()
6685                .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6686                .count()
6687        } else {
6688            tool_args.positional.len()
6689        };
6690
6691        let mut remaining = Vec::new();
6692        let mut positional_iter = tool_args.positional.drain(..).enumerate();
6693
6694        for param in &schema.params {
6695            if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
6696                continue;
6697            }
6698            if is_bool_type(&param.param_type) {
6699                continue;
6700            }
6701            loop {
6702                match positional_iter.next() {
6703                    Some((idx, val)) if idx < pre_dash_count => {
6704                        tool_args.named.insert(param.name.clone(), val);
6705                        break;
6706                    }
6707                    Some((_, val)) => {
6708                        remaining.push(val);
6709                    }
6710                    None => break,
6711                }
6712            }
6713        }
6714
6715        remaining.extend(positional_iter.map(|(_, v)| v));
6716        tool_args.positional = remaining;
6717    }
6718
6719    Ok(tool_args)
6720}
6721
6722#[async_trait]
6723impl CommandDispatcher for Kernel {
6724    /// Dispatch a command through the Kernel's full resolution chain.
6725    ///
6726    /// This is the single path for all command execution when called from
6727    /// the pipeline runner. It provides the full dispatch chain:
6728    /// user tools → builtins → .kai scripts → external commands → backend tools.
6729    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6730        self.dispatch_command(cmd, ctx).await
6731    }
6732
6733    /// Run a compound pipeline stage through the kernel's statement executor.
6734    async fn dispatch_stmt(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
6735        self.dispatch_statement(stmt, ctx).await
6736    }
6737
6738    /// Evaluate an expression through the kernel's async chain, including
6739    /// command substitution. Delegates to `eval_expr_async`, which snapshots
6740    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6741    /// only command output escapes. The `ctx` is unused here because the
6742    /// kernel evaluates against its own session state (a fork carries the
6743    /// pipeline stage's snapshot); var refs resolve against that scope.
6744    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6745        self.eval_expr_async(expr).await
6746    }
6747
6748    /// Produce a forked dispatcher with independent mutable state (detached).
6749    ///
6750    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6751    /// recursing into the trait method we're defining) and coerces the
6752    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6753    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6754        let fork: Arc<Kernel> = Kernel::fork(self).await;
6755        fork
6756    }
6757
6758    /// Produce a forked dispatcher with cancellation cascading from this kernel.
6759    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6760        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6761        fork
6762    }
6763}
6764
6765/// Apply the requested output format to a builtin's result, unless the tool
6766/// owns its own output — and even then, only on success.
6767///
6768/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6769/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6770/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6771/// gather never render a structured error themselves, so a failure
6772/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6773/// never "already rendered" by the tool. Skipping `apply_output_format` on
6774/// that path just leaked the raw diagnostic under `--json` instead of the
6775/// uniform `{"error","code"}` envelope every other builtin's failure gets
6776/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6777/// whole owns_output error-path class). Gating the skip on `result.ok()`
6778/// keeps the intentional success-path opt-out while closing that gap.
6779fn finalize_output(
6780    result: ExecResult,
6781    format: Option<crate::interpreter::OutputFormat>,
6782    owns_output: bool,
6783) -> ExecResult {
6784    match format {
6785        Some(_) if owns_output && result.ok() => result,
6786        Some(format) => apply_output_format(result, format),
6787        None => result,
6788    }
6789}
6790
6791/// Accumulate output from one result into another.
6792///
6793/// Appends stdout and stderr verbatim and updates the exit code to match the
6794/// new result. Used to preserve output from multiple statements, loop
6795/// iterations, and command chains. No separator is inserted between outputs —
6796/// each command's output concatenates raw, matching bash (`printf a; printf b`
6797/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6798/// when a command emits its own, as `echo` does).
6799fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6800    // Materialize lazy OutputData into .out before accumulating.
6801    // Without this, the first command's output stays in .output while
6802    // the second's text gets appended to .out, losing the first.
6803    accumulated.materialize();
6804    match new.out_bytes() {
6805        // A binary result must not be lossy-decoded by text_out(): concatenate
6806        // raw bytes so the combined output stays binary (this is the path every
6807        // top-level statement's result flows through). See docs/binary-data.md.
6808        Some(new_bytes) => {
6809            let mut combined: Vec<u8> = match accumulated.out_bytes() {
6810                Some(b) => b.to_vec(),
6811                None => accumulated.text_out().into_owned().into_bytes(),
6812            };
6813            combined.extend_from_slice(new_bytes);
6814            accumulated.set_out_bytes(combined);
6815        }
6816        None => accumulated.push_out(&new.text_out()),
6817    }
6818    accumulated.err.push_str(&new.err);
6819    accumulated.code = new.code;
6820    accumulated.data = new.data.clone();
6821    accumulated.did_spill = new.did_spill;
6822    accumulated.original_code = new.original_code;
6823    accumulated.content_type = new.content_type.clone();
6824    accumulated.baggage.clone_from(&new.baggage);
6825}
6826
6827/// Fold a block's accumulated output into a signal that is leaving the block.
6828///
6829/// Any block that builds up a result — a loop body, an `if`/`case` branch, the
6830/// left side of a `&&`/`||` chain — hands that result back when it finishes.
6831/// When `break`/`continue`/`return`/`exit` leaves early instead, the signal
6832/// replaces the result on the way up, so output printed before the signal
6833/// would otherwise be discarded. Leaving early stops the block; it does not
6834/// unprint what already ran. The block's output comes first (it ran before the
6835/// signal was raised), then the signal's already-carried output.
6836fn fold_block_output_into_flow(block_output: ExecResult, flow: &mut ControlFlow) {
6837    let carried = match flow {
6838        ControlFlow::Break { result, .. }
6839        | ControlFlow::Continue { result, .. }
6840        | ControlFlow::Exit { result, .. } => result,
6841        ControlFlow::Return { value } => value,
6842        ControlFlow::Normal(_) => return,
6843    };
6844    let mut merged = block_output;
6845    accumulate_result(&mut merged, carried);
6846    *carried = merged;
6847}
6848
6849/// Accumulate the output a break/continue signal carried (from inner loops it
6850/// propagated through) into the loop that finally handles it, so it survives
6851/// into that loop's result.
6852fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6853    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6854        accumulate_result(accumulated, result);
6855    }
6856}
6857
6858/// Check if a value is truthy.
6859fn is_truthy(value: &Value) -> bool {
6860    match value {
6861        Value::Null => false,
6862        Value::Bool(b) => *b,
6863        Value::Int(i) => *i != 0,
6864        Value::Float(f) => *f != 0.0,
6865        Value::String(s) => !s.is_empty(),
6866        Value::Json(json) => match json {
6867            serde_json::Value::Null => false,
6868            serde_json::Value::Array(arr) => !arr.is_empty(),
6869            serde_json::Value::Object(obj) => !obj.is_empty(),
6870            serde_json::Value::Bool(b) => *b,
6871            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6872            serde_json::Value::String(s) => !s.is_empty(),
6873        },
6874        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6875    }
6876}
6877
6878/// Apply tilde expansion to a value.
6879///
6880/// Only string values starting with `~` are expanded. `home` is the session
6881/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6882/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6883fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6884    match value {
6885        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6886        _ => value,
6887    }
6888}
6889
6890/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6891/// how the lexer tokenizes the equivalent minimally-quoted command string —
6892/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6893/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6894/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6895/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6896///
6897/// Classification matches the lexer's word classes:
6898/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6899///   the binder's `past_double_dash` arms, exactly as for the string door).
6900/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6901/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6902///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6903///   they fall through to a positional, not a flag).
6904/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6905///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6906///   `key=value` positional, per the command's word-assign allowlist).
6907/// - everything else → a literal [`Arg::Positional`].
6908///
6909/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6910/// positional — it can never be a flag — and rides through as-is. That is the
6911/// typed passthrough the string-native door cannot offer.
6912pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6913    argv.iter().map(classify_argv_token).collect()
6914}
6915
6916fn classify_argv_token(token: &Value) -> Arg {
6917    let Value::String(s) = token else {
6918        return Arg::Positional(Expr::Literal(token.clone()));
6919    };
6920
6921    if s == "--" {
6922        return Arg::DoubleDash;
6923    }
6924
6925    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6926    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6927    // literal word (GH #137), matching this classifier's own literal
6928    // fallback — so they fall through to a literal positional rather than a
6929    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6930    if let Some(rest) = s.strip_prefix("--") {
6931        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6932            return match rest.split_once('=') {
6933                Some((key, val)) => Arg::Named {
6934                    key: key.to_string(),
6935                    value: Expr::Literal(Value::String(val.to_string())),
6936                },
6937                None => Arg::LongFlag(rest.to_string()),
6938            };
6939        }
6940    } else if let Some(rest) = s.strip_prefix('-') {
6941        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6942        // token carrying any other char — notably `=` (`-k=v` is a parse error in
6943        // the string door) — or a leading digit (`-1` lexes as a number) is not a
6944        // short-flag word, so it falls through to a literal positional instead of
6945        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6946        if is_short_flag_body(rest) {
6947            return Arg::ShortFlag(rest.to_string());
6948        }
6949    }
6950
6951    if let Some((key, val)) = s.split_once('=') {
6952        if is_shell_identifier(key) {
6953            return Arg::WordAssign {
6954                key: key.to_string(),
6955                value: Expr::Literal(Value::String(val.to_string())),
6956            };
6957        }
6958    }
6959
6960    Arg::Positional(Expr::Literal(Value::String(s.clone())))
6961}
6962
6963/// A short-flag word: a leading ASCII letter, then only ASCII
6964/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6965/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6966/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6967/// (`=` is the assignment operator — a parse error in the string door), and
6968/// any non-ASCII tail (never produced by the lexer, and not safe for the
6969/// combined-short-flag binder's byte-index slicing) do not, so they fall
6970/// through to a literal positional instead of a malformed `ShortFlag`.
6971fn is_short_flag_body(s: &str) -> bool {
6972    s.starts_with(|c: char| c.is_ascii_alphabetic())
6973        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6974}
6975
6976/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6977fn is_shell_identifier(s: &str) -> bool {
6978    let mut chars = s.chars();
6979    match chars.next() {
6980        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6981        _ => return false,
6982    }
6983    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6984}
6985
6986/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6987/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6988/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6989/// must keep every value, not silently drop all but the last. Used by every flag
6990/// surface that can carry the same flag twice — the space form
6991/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6992/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6993/// ordered array.
6994/// Flatten a bound flag value to JSON, going LOUD on binary.
6995///
6996/// The accumulating flag forms (`jq --arg NAME VAL`, `sed -e EXPR -e EXPR`)
6997/// store their values as `serde_json::Value` rather than keeping the kaish
6998/// `Value`, and [`value_to_json`](crate::interpreter::value_to_json) renders a
6999/// `Value::Bytes` as the base64 envelope
7000/// (`{"_type":"bytes","encoding":"base64",…}`). That envelope is an internal
7001/// wire form, not the user's data: bound into `--arg x`, the tool sees the
7002/// envelope's literal JSON *text* where the bytes should be and reports
7003/// success, which is silent corruption (GH #223). Binary stops here instead,
7004/// with the same wording and exit 1 as every other text sink.
7005///
7006/// Valid-UTF-8 bytes coerce to their text, matching
7007/// [`value_to_text_sink_named`](crate::interpreter::value_to_text_sink_named);
7008/// in practice `Value::Bytes` only ever holds non-UTF-8, so this errors
7009/// whenever binary reaches a flag value. Gating on the kaish `Value` (not on
7010/// the envelope's JSON shape) is what keeps an envelope-shaped record the user
7011/// actually built — `fromjson '{"_type":"bytes",…}'` — a plain record: kaish
7012/// never sniffs JSON to decide a type.
7013fn flag_value_to_json(canonical: &str, v: &Value) -> Result<serde_json::Value> {
7014    match v {
7015        Value::Bytes(_) => crate::interpreter::value_to_text_sink_named(
7016            v,
7017            &format!("the value of the {canonical} flag"),
7018        )
7019        .map(serde_json::Value::String)
7020        .map_err(|e| anyhow::anyhow!("{e}")),
7021        other => Ok(crate::interpreter::value_to_json(other)),
7022    }
7023}
7024
7025pub(crate) fn push_repeatable_value(
7026    tool_args: &mut ToolArgs,
7027    flag_name: &str,
7028    canonical: &str,
7029    v: Value,
7030) -> anyhow::Result<()> {
7031    let occ = flag_value_to_json(canonical, &v)?;
7032    let entry = tool_args
7033        .named
7034        .entry(canonical.to_string())
7035        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
7036    if let Value::Json(serde_json::Value::Array(items)) = entry {
7037        items.push(occ);
7038        Ok(())
7039    } else {
7040        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
7041    }
7042}
7043
7044/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
7045/// is one token, so it carries a single value: a repeatable flag accumulates
7046/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
7047/// first-char glued arm and the combined-bundle arm so the two can't drift on
7048/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
7049/// is a loud error, not a silent single-value bind.
7050pub(crate) fn bind_glued_short_value(
7051    tool_args: &mut ToolArgs,
7052    flag_name: &str,
7053    canonical: &str,
7054    consumes: usize,
7055    repeatable: bool,
7056    value: String,
7057) -> anyhow::Result<()> {
7058    if consumes > 1 {
7059        anyhow::bail!(
7060            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
7061        );
7062    }
7063    if repeatable {
7064        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
7065    } else {
7066        tool_args
7067            .named
7068            .insert(canonical.to_string(), Value::String(value));
7069        Ok(())
7070    }
7071}
7072
7073/// Map a child's exit status to a shell-style exit code.
7074///
7075/// `ExitStatus::code()` is `None` when the process died from a signal rather
7076/// than exiting normally; in that case this maps to POSIX's `128 + signal`
7077/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
7078/// number. Shared by both external-command spawn sites — production
7079/// (`try_execute_external`, below) and the test-only twin
7080/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
7081/// this mapping again (GH #133 item 1).
7082#[cfg(feature = "subprocess")]
7083pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
7084    status.code().unwrap_or_else(|| {
7085        #[cfg(unix)]
7086        {
7087            use std::os::unix::process::ExitStatusExt;
7088            128 + status.signal().unwrap_or(0)
7089        }
7090        #[cfg(not(unix))]
7091        {
7092            -1
7093        }
7094    }) as i64
7095}
7096
7097/// Wait for a child to exit, killing it if `cancel` fires first.
7098///
7099/// `target` carries a Linux pidfd (when available) for race-free direct-child
7100/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
7101/// parameter is ignored and we use tokio's cross-platform `start_kill`.
7102#[cfg(all(unix, feature = "subprocess"))]
7103pub(crate) async fn wait_or_kill(
7104    child: &mut tokio::process::Child,
7105    target: Option<&crate::pidfd::KillTarget>,
7106    cancel: &tokio_util::sync::CancellationToken,
7107    grace: Duration,
7108) -> std::io::Result<std::process::ExitStatus> {
7109    tokio::select! {
7110        biased;
7111        status = child.wait() => status,
7112        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
7113    }
7114}
7115
7116#[cfg(all(not(unix), feature = "subprocess"))]
7117pub(crate) async fn wait_or_kill(
7118    child: &mut tokio::process::Child,
7119    _target: Option<&()>,
7120    cancel: &tokio_util::sync::CancellationToken,
7121    _grace: Duration,
7122) -> std::io::Result<std::process::ExitStatus> {
7123    tokio::select! {
7124        biased;
7125        status = child.wait() => status,
7126        _ = cancel.cancelled() => {
7127            let _ = child.start_kill();
7128            child.wait().await
7129        }
7130    }
7131}
7132
7133/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
7134///
7135/// Direct-child kill goes through `target.signal()`, which on Linux uses a
7136/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
7137/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
7138#[cfg(all(unix, feature = "subprocess"))]
7139pub(crate) async fn kill_with_grace(
7140    child: &mut tokio::process::Child,
7141    target: Option<&crate::pidfd::KillTarget>,
7142    grace: Duration,
7143) -> std::io::Result<std::process::ExitStatus> {
7144    use nix::sys::signal::Signal;
7145
7146    if let Some(t) = target {
7147        t.signal(Signal::SIGTERM);
7148        t.signal_pg(Signal::SIGTERM);
7149        if grace > Duration::ZERO
7150            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
7151        {
7152            return status;
7153        }
7154        t.signal(Signal::SIGKILL);
7155        t.signal_pg(Signal::SIGKILL);
7156    }
7157    child.wait().await
7158}
7159
7160#[cfg(test)]
7161#[allow(clippy::unwrap_used, clippy::expect_used)]
7162mod argv_classify_tests {
7163    use super::*;
7164
7165    /// A normalized, comparable view of one `Arg` representing its *logical
7166    /// argument* (what the command observably receives), not its exact AST shape:
7167    ///
7168    /// - Value-bearing arms compare by *stringified* value, so the parser's
7169    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
7170    ///   (`String("-1")`) count as the same argument.
7171    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
7172    ///   For every command except the `export`/`alias` allowlist, a bareword
7173    ///   `key=value` is stringified straight back to a `"key=value"` positional
7174    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
7175    ///   converge observably even when they disagree on the AST tag — e.g. the
7176    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
7177    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
7178    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
7179    ///   separately by `classifies_each_word_class`.
7180    ///
7181    /// Returns `None` for shapes we deliberately don't compare:
7182    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
7183    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
7184    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
7185    ///   string. That divergence is *intentional* — `execute_argv` preserves a
7186    ///   literal numeric string (pass `Value::Int` for a number), the string door
7187    ///   can only guess — so the property skips it rather than demanding the
7188    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
7189    ///   by `classifies_each_word_class`.
7190    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
7191        // Only a *string*-valued literal is comparable; a coerced number is not.
7192        let lit = |e: &Expr| match e {
7193            Expr::Literal(Value::String(s)) => Some(s.clone()),
7194            _ => None,
7195        };
7196        Some(match arg {
7197            Arg::DoubleDash => ("dash", String::new(), String::new()),
7198            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
7199            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
7200            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
7201            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
7202            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
7203        })
7204    }
7205
7206    /// Classify a single string token the way `execute_argv` would.
7207    fn classify(token: &str) -> Arg {
7208        classify_argv_token(&Value::String(token.to_string()))
7209    }
7210
7211    #[test]
7212    fn classifies_each_word_class() {
7213        assert_eq!(classify("--"), Arg::DoubleDash);
7214        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
7215        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
7216        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
7217        assert_eq!(
7218            classify("--key=value"),
7219            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
7220        );
7221        assert_eq!(
7222            classify("NAME=val"),
7223            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
7224        );
7225        // Digits after the first flag char are ordinary (kept verbatim).
7226        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
7227        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
7228        // Leading-digit dash is a number to the lexer, not a flag → positional.
7229        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
7230        // Numeric strings keep their literal text — `execute_argv` does NOT
7231        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
7232        // who wants a number passes `Value::Int`; a string stays the string.
7233        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
7234        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
7235        // A lone dash (stdin convention) is a positional, not a flag.
7236        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
7237        // Non-identifier LHS is not an assignment.
7238        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
7239        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
7240    }
7241
7242    #[test]
7243    fn typed_values_pass_through_as_literal_positionals() {
7244        // The whole point of the `&[Value]` signature: a non-string value is a
7245        // literal positional carrying the *exact* value, never stringified and
7246        // never flag-interpreted.
7247        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
7248        assert_eq!(
7249            classify_argv_token(&bytes),
7250            Arg::Positional(Expr::Literal(bytes.clone()))
7251        );
7252        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
7253        assert_eq!(
7254            classify_argv_token(&json),
7255            Arg::Positional(Expr::Literal(json.clone()))
7256        );
7257        // An integer token that *looks* like a flag is still a positional value
7258        // (only strings are inspected for a leading dash).
7259        assert_eq!(
7260            classify_argv_token(&Value::Int(-9)),
7261            Arg::Positional(Expr::Literal(Value::Int(-9)))
7262        );
7263    }
7264
7265    #[test]
7266    fn double_dash_only_matches_exactly() {
7267        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
7268        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
7269        // as a single argv token here it's likewise literal.
7270        assert_eq!(classify("--"), Arg::DoubleDash);
7271        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
7272        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
7273    }
7274
7275    #[test]
7276    fn malformed_flag_words_fall_back_to_literal_positionals() {
7277        // A token that isn't a well-formed flag word must NOT be silently misbound
7278        // into the arg binder (house rule: loud/visible over silent-wrong). Each
7279        // of these is a parse error or different tokenization in the string door,
7280        // so the argv door keeps them as literal positionals.
7281        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
7282        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
7283        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
7284        assert_eq!(classify("-k=v"), pos("-k=v"));
7285        assert_eq!(classify("-="), pos("-="));
7286        // Empty long-flag key.
7287        assert_eq!(classify("--=v"), pos("--=v"));
7288        // `--` followed by a non-letter is not a long flag.
7289        assert_eq!(classify("--1"), pos("--1"));
7290        // A bare dash and a number-dash are positionals (covered above too).
7291        assert_eq!(classify("-"), pos("-"));
7292        assert_eq!(classify("-9"), pos("-9"));
7293        // A non-ASCII tail is not part of the lexer's short-flag char class
7294        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
7295        // absorbs) — classifying it as `ShortFlag` would hand the combined
7296        // short-flag binder a byte string it (correctly, for real ASCII flag
7297        // words) slices by *byte* index, panicking on a multi-byte char
7298        // boundary. Fall back to a literal positional instead.
7299        assert_eq!(classify("-lé"), pos("-lé"));
7300        assert_eq!(classify("-é"), pos("-é"));
7301    }
7302
7303    #[tokio::test]
7304    async fn non_ascii_short_flag_bundle_does_not_panic() {
7305        // Regression: `execute_argv`'s combined-short-flag loop assumed the
7306        // flag body was ASCII (safe to byte-slice) because the lexer's
7307        // grammar guarantees that on the *string* door. The argv door's
7308        // classifier let a non-ASCII tail through as `ShortFlag`, so
7309        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
7310        let kernel = Kernel::transient().expect("failed to create kernel");
7311        let result = kernel
7312            .execute_argv("ls", &[Value::String("-lé".into())])
7313            .await
7314            .expect("execute_argv must not panic on a non-ASCII short-flag token");
7315        // Not a well-formed flag word, so it's a literal positional — `ls`
7316        // then reports it as a missing path rather than mangling flags.
7317        assert_ne!(result.code, 0);
7318    }
7319
7320    proptest::proptest! {
7321        /// The core correctness claim: the classifier mirrors the lexer/parser
7322        /// on metacharacter-free tokens. For any such single token, the `Arg`
7323        /// the classifier produces matches the one the real parser produces for
7324        /// the equivalent one-word command — so `execute_argv` reusing the
7325        /// string door's binder is sound. (First proptest in the workspace.)
7326        #[test]
7327        fn classifier_matches_parser_on_clean_tokens(
7328            // No digits: this property tests the *classification* boundary
7329            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
7330            // positional), not numeric coercion. The lexer coerces digit runs to
7331            // `Int`/`Float` and drops the literal text (even inside a colon-merged
7332            // word: `00:` → `0:`); the classifier intentionally preserves the raw
7333            // string. Those numeric edges are pinned exactly by the unit tests.
7334            // Non-ASCII is a word character now, so the generator has to
7335            // reach it — an ASCII-only strategy tests a shrinking slice of
7336            // what the classifier actually sees.
7337            token in "[a-zA-Z_=./@:+\\-\u{00e9}\u{540d}\u{1f600}]{1,8}"
7338        ) {
7339            let parsed = match parse(&format!("cmd {token}")) {
7340                Ok(p) => p,
7341                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
7342            };
7343            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
7344                return Ok(());
7345            };
7346            // Only compare when the token lexed as exactly one argument.
7347            let [arg] = cmd.args.as_slice() else { return Ok(()); };
7348
7349            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
7350                return Ok(()); // a non-literal parsed Expr we don't model — skip
7351            };
7352            proptest::prop_assert_eq!(
7353                ours, theirs,
7354                "classifier diverged from parser on token {:?}", token
7355            );
7356        }
7357    }
7358}
7359
7360#[cfg(all(test, feature = "subprocess"))]
7361#[allow(clippy::expect_used)]
7362mod tests {
7363    use super::*;
7364
7365    #[tokio::test]
7366    async fn test_kernel_transient() {
7367        let kernel = Kernel::transient().expect("failed to create kernel");
7368        assert_eq!(kernel.name(), "transient");
7369    }
7370
7371    #[tokio::test]
7372    async fn test_kernel_execute_echo() {
7373        let kernel = Kernel::transient().expect("failed to create kernel");
7374        let result = kernel.execute("echo hello").await.expect("execution failed");
7375        assert!(result.ok());
7376        assert_eq!(result.text_out().trim(), "hello");
7377    }
7378
7379    #[tokio::test]
7380    async fn test_multiple_statements_accumulate_output() {
7381        let kernel = Kernel::transient().expect("failed to create kernel");
7382        let result = kernel
7383            .execute("echo one\necho two\necho three")
7384            .await
7385            .expect("execution failed");
7386        assert!(result.ok());
7387        // Should have all three outputs separated by newlines
7388        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
7389        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
7390        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
7391    }
7392
7393    #[tokio::test]
7394    async fn test_and_chain_accumulates_output() {
7395        let kernel = Kernel::transient().expect("failed to create kernel");
7396        let result = kernel
7397            .execute("echo first && echo second")
7398            .await
7399            .expect("execution failed");
7400        assert!(result.ok());
7401        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
7402        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
7403    }
7404
7405    #[tokio::test]
7406    async fn test_for_loop_accumulates_output() {
7407        let kernel = Kernel::transient().expect("failed to create kernel");
7408        let result = kernel
7409            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
7410            .await
7411            .expect("execution failed");
7412        assert!(result.ok());
7413        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
7414        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
7415        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
7416    }
7417
7418    #[tokio::test]
7419    async fn test_while_loop_accumulates_output() {
7420        let kernel = Kernel::transient().expect("failed to create kernel");
7421        let result = kernel
7422            .execute(r#"
7423                N=3
7424                while [[ ${N} -gt 0 ]]; do
7425                    echo "N=${N}"
7426                    N=$((N - 1))
7427                done
7428            "#)
7429            .await
7430            .expect("execution failed");
7431        assert!(result.ok());
7432        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7433        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7434        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7435    }
7436
7437    #[tokio::test]
7438    async fn test_kernel_set_var() {
7439        let kernel = Kernel::transient().expect("failed to create kernel");
7440
7441        kernel.execute("X=42").await.expect("set failed");
7442
7443        let value = kernel.get_var("X").await;
7444        assert_eq!(value, Some(Value::Int(42)));
7445    }
7446
7447    #[tokio::test]
7448    async fn test_kernel_var_expansion() {
7449        let kernel = Kernel::transient().expect("failed to create kernel");
7450
7451        kernel.execute("NAME=\"world\"").await.expect("set failed");
7452        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7453
7454        assert!(result.ok());
7455        assert_eq!(result.text_out().trim(), "hello world");
7456    }
7457
7458    #[tokio::test]
7459    async fn test_kernel_last_result() {
7460        let kernel = Kernel::transient().expect("failed to create kernel");
7461
7462        kernel.execute("echo test").await.expect("echo failed");
7463
7464        let last = kernel.last_result().await;
7465        assert!(last.ok());
7466        assert_eq!(last.text_out().trim(), "test");
7467    }
7468
7469    #[tokio::test]
7470    async fn test_kernel_tool_not_found() {
7471        let kernel = Kernel::transient().expect("failed to create kernel");
7472
7473        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
7474        assert!(!result.ok());
7475        assert_eq!(result.code, 127);
7476        assert!(result.err.contains("command not found"));
7477    }
7478
7479    #[tokio::test]
7480    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
7481        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
7482        // engine, …) returns a `ToolResult` with structured `data` — this
7483        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
7484        // and `for r in $(embedder_tool)` see the typed value, not just
7485        // stdout text.
7486        use crate::backend::testing::MockBackend;
7487        use crate::backend::ToolResult;
7488        let (mock, _calls) = MockBackend::new();
7489        let backend = mock.with_tool_result(|_name| {
7490            let mut baggage = std::collections::BTreeMap::new();
7491            baggage.insert("trace_id".to_string(), "abc123".to_string());
7492            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
7493            // construct via with_data + the with_* setters, not a struct literal.
7494            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
7495                .with_content_type("application/json")
7496                .with_baggage(baggage))
7497        });
7498        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7499        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7500            .expect("with_backend kernel");
7501
7502        let result = kernel
7503            .execute("embedder_tool")
7504            .await
7505            .expect("execution failed");
7506        assert!(result.ok(), "backend tool call should succeed: {result:?}");
7507        assert_eq!(
7508            result.data,
7509            Some(Value::Json(serde_json::json!({"key": "value"}))),
7510            "backend tool's structured data must survive into ExecResult, not be dropped"
7511        );
7512        assert_eq!(
7513            result.content_type.as_deref(),
7514            Some("application/json"),
7515            "backend tool's content_type must survive into ExecResult"
7516        );
7517        assert_eq!(
7518            result.baggage.get("trace_id").map(String::as_str),
7519            Some("abc123"),
7520            "backend tool's baggage must survive into ExecResult"
7521        );
7522    }
7523
7524    #[tokio::test]
7525    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
7526        // A backend tool that IS found but fails during execution (`Io`,
7527        // `PermissionDenied`, …) must surface its real error, not get
7528        // misreported as exit-127 "command not found" — that masks a genuine
7529        // failure as a lookup miss.
7530        use crate::backend::testing::MockBackend;
7531        let (mock, _calls) = MockBackend::new();
7532        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
7533        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7534        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7535            .expect("with_backend kernel");
7536
7537        let result = kernel
7538            .execute("embedder_tool")
7539            .await
7540            .expect("execution failed");
7541        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
7542        assert!(!result.ok());
7543        assert!(
7544            result.err.contains("disk exploded"),
7545            "the real backend error must be visible, not masked: {result:?}"
7546        );
7547    }
7548
7549    #[tokio::test]
7550    async fn test_external_command_true() {
7551        // Use REPL config for passthrough filesystem access
7552        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7553
7554        // /bin/true should be available on any Unix system
7555        let result = kernel.execute("true").await.expect("execution failed");
7556        // This should use the builtin true, which returns 0
7557        assert!(result.ok(), "true should succeed: {:?}", result);
7558    }
7559
7560    #[tokio::test]
7561    async fn test_external_command_basic() {
7562        // Use REPL config for passthrough filesystem access
7563        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7564
7565        // Test with /bin/echo which is external
7566        // Note: kaish has a builtin echo, so this will use the builtin
7567        // Let's test with a command that's not a builtin
7568        // Actually, let's just test that PATH resolution works by checking the PATH var
7569        let path_var = std::env::var("PATH").unwrap_or_default();
7570        eprintln!("System PATH: {}", path_var);
7571
7572        // Set PATH in kernel to ensure it's available
7573        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
7574
7575        // Now try an external command like /usr/bin/env
7576        // But env is also a builtin... let's try uname
7577        let result = kernel.execute("uname").await.expect("execution failed");
7578        eprintln!("uname result: {:?}", result);
7579        // uname should succeed if external commands work
7580        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
7581    }
7582
7583    #[tokio::test]
7584    async fn test_kernel_reset() {
7585        let kernel = Kernel::transient().expect("failed to create kernel");
7586
7587        kernel.execute("X=1").await.expect("set failed");
7588        assert!(kernel.get_var("X").await.is_some());
7589
7590        kernel.reset().await.expect("reset failed");
7591        assert!(kernel.get_var("X").await.is_none());
7592    }
7593
7594    #[tokio::test]
7595    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7596        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7597            .expect("failed to create kernel");
7598
7599        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7600        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7601
7602        kernel.reset().await.expect("reset failed");
7603
7604        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7605        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7606        assert_eq!(
7607            kernel.get_var("HOME").await,
7608            Some(Value::String("/home/probe".into())),
7609            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7610        );
7611    }
7612
7613    #[tokio::test]
7614    async fn test_kernel_cwd() {
7615        let kernel = Kernel::transient().expect("failed to create kernel");
7616
7617        // Transient kernel uses sandboxed mode with cwd=$HOME
7618        let cwd = kernel.cwd().await;
7619        let home = std::env::var("HOME")
7620            .map(PathBuf::from)
7621            .unwrap_or_else(|_| PathBuf::from("/"));
7622        assert_eq!(cwd, home);
7623
7624        kernel.set_cwd(PathBuf::from("/tmp")).await;
7625        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7626    }
7627
7628    #[tokio::test]
7629    async fn test_kernel_list_vars() {
7630        let kernel = Kernel::transient().expect("failed to create kernel");
7631
7632        kernel.execute("A=1").await.ok();
7633        kernel.execute("B=2").await.ok();
7634
7635        let vars = kernel.list_vars().await;
7636        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7637        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7638    }
7639
7640    #[tokio::test]
7641    async fn test_is_truthy() {
7642        assert!(!is_truthy(&Value::Null));
7643        assert!(!is_truthy(&Value::Bool(false)));
7644        assert!(is_truthy(&Value::Bool(true)));
7645        assert!(!is_truthy(&Value::Int(0)));
7646        assert!(is_truthy(&Value::Int(1)));
7647        assert!(!is_truthy(&Value::String("".into())));
7648        assert!(is_truthy(&Value::String("x".into())));
7649    }
7650
7651    #[tokio::test]
7652    async fn test_jq_in_pipeline() {
7653        let kernel = Kernel::transient().expect("failed to create kernel");
7654        // kaish uses double quotes only; escape inner quotes
7655        let result = kernel
7656            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7657            .await
7658            .expect("execution failed");
7659        assert!(result.ok(), "jq pipeline failed: {}", result.err);
7660        assert_eq!(result.text_out().trim(), "Alice");
7661    }
7662
7663    #[tokio::test]
7664    async fn test_user_defined_tool() {
7665        let kernel = Kernel::transient().expect("failed to create kernel");
7666
7667        // Define a function
7668        kernel
7669            .execute(r#"greet() { echo "Hello, $1!" }"#)
7670            .await
7671            .expect("function definition failed");
7672
7673        // Call the function
7674        let result = kernel
7675            .execute(r#"greet "World""#)
7676            .await
7677            .expect("function call failed");
7678
7679        assert!(result.ok(), "greet failed: {}", result.err);
7680        assert_eq!(result.text_out().trim(), "Hello, World!");
7681    }
7682
7683    #[tokio::test]
7684    async fn test_user_tool_positional_args() {
7685        let kernel = Kernel::transient().expect("failed to create kernel");
7686
7687        // Define a function with positional param
7688        kernel
7689            .execute(r#"greet() { echo "Hi $1" }"#)
7690            .await
7691            .expect("function definition failed");
7692
7693        // Call with positional argument
7694        let result = kernel
7695            .execute(r#"greet "Amy""#)
7696            .await
7697            .expect("function call failed");
7698
7699        assert!(result.ok(), "greet failed: {}", result.err);
7700        assert_eq!(result.text_out().trim(), "Hi Amy");
7701    }
7702
7703    #[tokio::test]
7704    async fn test_function_shared_scope() {
7705        let kernel = Kernel::transient().expect("failed to create kernel");
7706
7707        // Set a variable in parent scope
7708        kernel
7709            .execute(r#"SECRET="hidden""#)
7710            .await
7711            .expect("set failed");
7712
7713        // Define a function that accesses and modifies parent variable
7714        kernel
7715            .execute(r#"access_parent() {
7716                echo "${SECRET}"
7717                SECRET="modified"
7718            }"#)
7719            .await
7720            .expect("function definition failed");
7721
7722        // Call the function - it SHOULD see SECRET (shared scope like sh)
7723        let result = kernel.execute("access_parent").await.expect("function call failed");
7724
7725        // Function should have access to parent scope
7726        assert!(
7727            result.text_out().contains("hidden"),
7728            "Function should access parent scope, got: {}",
7729            result.text_out()
7730        );
7731
7732        // Function should have modified the parent variable
7733        let secret = kernel.get_var("SECRET").await;
7734        assert_eq!(
7735            secret,
7736            Some(Value::String("modified".into())),
7737            "Function should modify parent scope"
7738        );
7739    }
7740
7741    #[tokio::test]
7742    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7743    async fn test_exec_builtin() {
7744        let kernel = Kernel::transient().expect("failed to create kernel");
7745        // argv is now a space-separated string or JSON array string
7746        let result = kernel
7747            .execute(r#"exec command="/bin/echo" argv="hello world""#)
7748            .await
7749            .expect("exec failed");
7750
7751        assert!(result.ok(), "exec failed: {}", result.err);
7752        assert_eq!(result.text_out().trim(), "hello world");
7753    }
7754
7755    #[tokio::test]
7756    async fn test_while_false_never_runs() {
7757        let kernel = Kernel::transient().expect("failed to create kernel");
7758
7759        // A while loop with false condition should never run
7760        let result = kernel
7761            .execute(r#"
7762                while false; do
7763                    echo "should not run"
7764                done
7765            "#)
7766            .await
7767            .expect("while false failed");
7768
7769        assert!(result.ok());
7770        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7771    }
7772
7773    #[tokio::test]
7774    async fn test_while_string_comparison() {
7775        let kernel = Kernel::transient().expect("failed to create kernel");
7776
7777        // Set a flag
7778        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7779
7780        // Use string comparison as condition (shell-compatible [[ ]] syntax)
7781        // Note: Put echo last so we can check the output
7782        let result = kernel
7783            .execute(r#"
7784                while [[ ${FLAG} == "go" ]]; do
7785                    FLAG="stop"
7786                    echo "running"
7787                done
7788            "#)
7789            .await
7790            .expect("while with string cmp failed");
7791
7792        assert!(result.ok());
7793        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7794
7795        // Verify flag was changed
7796        let flag = kernel.get_var("FLAG").await;
7797        assert_eq!(flag, Some(Value::String("stop".into())));
7798    }
7799
7800    #[tokio::test]
7801    async fn test_while_numeric_comparison() {
7802        let kernel = Kernel::transient().expect("failed to create kernel");
7803
7804        // Test > comparison (shell-compatible [[ ]] with -gt)
7805        kernel.execute("N=5").await.expect("set failed");
7806
7807        // Note: Put echo last so we can check the output
7808        let result = kernel
7809            .execute(r#"
7810                while [[ ${N} -gt 3 ]]; do
7811                    N=3
7812                    echo "N was greater"
7813                done
7814            "#)
7815            .await
7816            .expect("while with > failed");
7817
7818        assert!(result.ok());
7819        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7820    }
7821
7822    #[tokio::test]
7823    async fn test_break_in_while_loop() {
7824        let kernel = Kernel::transient().expect("failed to create kernel");
7825
7826        let result = kernel
7827            .execute(r#"
7828                I=0
7829                while true; do
7830                    I=1
7831                    echo "before break"
7832                    break
7833                    echo "after break"
7834                done
7835            "#)
7836            .await
7837            .expect("while with break failed");
7838
7839        assert!(result.ok());
7840        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7841        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7842
7843        // Verify we exited the loop
7844        let i = kernel.get_var("I").await;
7845        assert_eq!(i, Some(Value::Int(1)));
7846    }
7847
7848    #[tokio::test]
7849    async fn test_continue_in_while_loop() {
7850        let kernel = Kernel::transient().expect("failed to create kernel");
7851
7852        // Test continue in a while loop where variables persist
7853        // We use string state transition: "start" -> "middle" -> "end"
7854        // continue on "middle" should skip to next iteration
7855        // Shell-compatible: use [[ ]] for comparisons
7856        let result = kernel
7857            .execute(r#"
7858                STATE="start"
7859                AFTER_CONTINUE="no"
7860                while [[ ${STATE} != "done" ]]; do
7861                    if [[ ${STATE} == "start" ]]; then
7862                        STATE="middle"
7863                        continue
7864                        AFTER_CONTINUE="yes"
7865                    fi
7866                    if [[ ${STATE} == "middle" ]]; then
7867                        STATE="done"
7868                    fi
7869                done
7870            "#)
7871            .await
7872            .expect("while with continue failed");
7873
7874        assert!(result.ok());
7875
7876        // STATE should be "done" (we completed the loop)
7877        let state = kernel.get_var("STATE").await;
7878        assert_eq!(state, Some(Value::String("done".into())));
7879
7880        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7881        let after = kernel.get_var("AFTER_CONTINUE").await;
7882        assert_eq!(after, Some(Value::String("no".into())));
7883    }
7884
7885    #[tokio::test]
7886    async fn test_break_with_level() {
7887        let kernel = Kernel::transient().expect("failed to create kernel");
7888
7889        // Nested loop with break 2 to exit both loops
7890        // We verify by checking OUTER value:
7891        // - If break 2 works, OUTER stays at 1 (set before for loop)
7892        // - If break 2 fails, OUTER becomes 2 (set after for loop)
7893        let result = kernel
7894            .execute(r#"
7895                OUTER=0
7896                while true; do
7897                    OUTER=1
7898                    for X in "1 2"; do
7899                        break 2
7900                    done
7901                    OUTER=2
7902                done
7903            "#)
7904            .await
7905            .expect("nested break failed");
7906
7907        assert!(result.ok());
7908
7909        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7910        let outer = kernel.get_var("OUTER").await;
7911        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7912    }
7913
7914    #[tokio::test]
7915    async fn test_return_from_tool() {
7916        let kernel = Kernel::transient().expect("failed to create kernel");
7917
7918        // Define a function that returns early
7919        kernel
7920            .execute(r#"early_return() {
7921                if [[ $1 == 1 ]]; then
7922                    return 42
7923                fi
7924                echo "not returned"
7925            }"#)
7926            .await
7927            .expect("function definition failed");
7928
7929        // Call with arg=1 should return with exit code 42
7930        // (POSIX shell behavior: return N sets exit code, doesn't output N)
7931        let result = kernel
7932            .execute("early_return 1")
7933            .await
7934            .expect("function call failed");
7935
7936        // Exit code should be 42 (non-zero, so not ok())
7937        assert_eq!(result.code, 42);
7938        // Output should be empty (we returned before echo)
7939        assert!(result.text_out().is_empty());
7940    }
7941
7942    #[tokio::test]
7943    async fn test_return_without_value() {
7944        let kernel = Kernel::transient().expect("failed to create kernel");
7945
7946        // Define a function that returns without a value
7947        kernel
7948            .execute(r#"early_exit() {
7949                if [[ $1 == "stop" ]]; then
7950                    return
7951                fi
7952                echo "continued"
7953            }"#)
7954            .await
7955            .expect("function definition failed");
7956
7957        // Call with arg="stop" should return early
7958        let result = kernel
7959            .execute(r#"early_exit "stop""#)
7960            .await
7961            .expect("function call failed");
7962
7963        assert!(result.ok());
7964        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7965    }
7966
7967    #[tokio::test]
7968    async fn test_exit_stops_execution() {
7969        let kernel = Kernel::transient().expect("failed to create kernel");
7970
7971        // exit should stop further execution
7972        kernel
7973            .execute(r#"
7974                BEFORE="yes"
7975                exit 0
7976                AFTER="yes"
7977            "#)
7978            .await
7979            .expect("execution failed");
7980
7981        // BEFORE should be set, AFTER should not
7982        let before = kernel.get_var("BEFORE").await;
7983        assert_eq!(before, Some(Value::String("yes".into())));
7984
7985        let after = kernel.get_var("AFTER").await;
7986        assert!(after.is_none(), "AFTER should not be set after exit");
7987    }
7988
7989    #[tokio::test]
7990    async fn test_exit_with_code() {
7991        let kernel = Kernel::transient().expect("failed to create kernel");
7992
7993        // exit with code should propagate the exit code
7994        let result = kernel
7995            .execute("exit 42")
7996            .await
7997            .expect("exit failed");
7998
7999        assert_eq!(result.code, 42);
8000        assert!(result.text_out().is_empty(), "exit should not produce stdout");
8001    }
8002
8003    #[tokio::test]
8004    async fn test_set_e_stops_on_failure() {
8005        let kernel = Kernel::transient().expect("failed to create kernel");
8006
8007        // Enable error-exit mode
8008        kernel.execute("set -e").await.expect("set -e failed");
8009
8010        // Run a sequence where the middle command fails
8011        kernel
8012            .execute(r#"
8013                STEP1="done"
8014                false
8015                STEP2="done"
8016            "#)
8017            .await
8018            .expect("execution failed");
8019
8020        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
8021        let step1 = kernel.get_var("STEP1").await;
8022        assert_eq!(step1, Some(Value::String("done".into())));
8023
8024        let step2 = kernel.get_var("STEP2").await;
8025        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
8026    }
8027
8028    #[tokio::test]
8029    async fn test_set_plus_e_disables_error_exit() {
8030        let kernel = Kernel::transient().expect("failed to create kernel");
8031
8032        // Enable then disable error-exit mode
8033        kernel.execute("set -e").await.expect("set -e failed");
8034        kernel.execute("set +e").await.expect("set +e failed");
8035
8036        // Now failure should NOT stop execution
8037        kernel
8038            .execute(r#"
8039                STEP1="done"
8040                false
8041                STEP2="done"
8042            "#)
8043            .await
8044            .expect("execution failed");
8045
8046        // Both should be set since +e disables error exit
8047        let step1 = kernel.get_var("STEP1").await;
8048        assert_eq!(step1, Some(Value::String("done".into())));
8049
8050        let step2 = kernel.get_var("STEP2").await;
8051        assert_eq!(step2, Some(Value::String("done".into())));
8052    }
8053
8054    #[tokio::test]
8055    async fn test_set_ignores_unknown_bare_flags_but_rejects_o_pipefail() {
8056        let kernel = Kernel::transient().expect("failed to create kernel");
8057
8058        // Bash idiom: set -euo pipefail. kaish implements -e, silently
8059        // ignores the bare -u (no fixed set to check it against), and now
8060        // fails loudly on -o pipefail — kaish has no pipefail (limits.md
8061        // documents it as a deliberate omission), so this must not
8062        // silently no-op.
8063        //
8064        // Not asserted here: `result.err`. When the same statement both
8065        // enables -e and fails, `Stmt::Command`'s `-e` check replaces the
8066        // result with `ControlFlow::exit_code(result.code)`
8067        // (`control_flow.rs`), which carries only the numeric code and
8068        // discards the failing result's error text — a pre-existing,
8069        // general bug (confirmed with `cat` on a missing file too, nothing
8070        // specific to `set`) outside this fix's scope. `set_option_tests.rs`
8071        // covers the message text without `-e` in the mix.
8072        let result = kernel
8073            .execute("set -e -u -o pipefail")
8074            .await
8075            .expect("set with unknown options failed");
8076
8077        assert!(!result.ok(), "set -o pipefail must fail, not silently no-op");
8078
8079        // -e should still be enabled: it's a separate flag applied before
8080        // the positional loop reaches the failing -o pipefail.
8081        kernel
8082            .execute(r#"
8083                BEFORE="yes"
8084                false
8085                AFTER="yes"
8086            "#)
8087            .await
8088            .ok();
8089
8090        let after = kernel.get_var("AFTER").await;
8091        assert!(after.is_none(), "-e should be enabled despite the -o pipefail failure");
8092    }
8093
8094    #[tokio::test]
8095    async fn test_set_no_args_shows_settings() {
8096        let kernel = Kernel::transient().expect("failed to create kernel");
8097
8098        // Enable -e
8099        kernel.execute("set -e").await.expect("set -e failed");
8100
8101        // Call set with no args to see settings
8102        let result = kernel.execute("set").await.expect("set failed");
8103
8104        assert!(result.ok());
8105        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
8106    }
8107
8108    #[tokio::test]
8109    async fn test_set_e_in_pipeline() {
8110        let kernel = Kernel::transient().expect("failed to create kernel");
8111
8112        kernel.execute("set -e").await.expect("set -e failed");
8113
8114        // Pipeline failure should trigger exit
8115        kernel
8116            .execute(r#"
8117                BEFORE="yes"
8118                false | cat
8119                AFTER="yes"
8120            "#)
8121            .await
8122            .ok();
8123
8124        let before = kernel.get_var("BEFORE").await;
8125        assert_eq!(before, Some(Value::String("yes".into())));
8126
8127        // AFTER should not be set if pipeline failure triggers exit
8128        // Note: The exit code of a pipeline is the exit code of the last command
8129        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
8130        // To test pipeline failure, we need the last command to fail.
8131    }
8132
8133    #[tokio::test]
8134    async fn test_set_e_with_and_chain() {
8135        let kernel = Kernel::transient().expect("failed to create kernel");
8136
8137        kernel.execute("set -e").await.expect("set -e failed");
8138
8139        // Commands in && chain should not trigger -e on the first failure
8140        // because && explicitly handles the error
8141        kernel
8142            .execute(r#"
8143                RESULT="initial"
8144                false && RESULT="chained"
8145                RESULT="continued"
8146            "#)
8147            .await
8148            .ok();
8149
8150        // In bash, commands in && don't trigger -e. The chain handles the failure.
8151        // Our implementation may differ - let's verify current behavior.
8152        let result = kernel.get_var("RESULT").await;
8153        // If we follow bash semantics, RESULT should be "continued"
8154        // If we trigger -e on the false, RESULT stays "initial"
8155        assert!(result.is_some(), "RESULT should be set");
8156    }
8157
8158    #[tokio::test]
8159    async fn test_set_e_exits_in_for_loop() {
8160        let kernel = Kernel::transient().expect("failed to create kernel");
8161
8162        kernel.execute("set -e").await.expect("set -e failed");
8163
8164        kernel
8165            .execute(r#"
8166                REACHED="no"
8167                for x in 1 2 3; do
8168                    false
8169                    REACHED="yes"
8170                done
8171            "#)
8172            .await
8173            .ok();
8174
8175        // With set -e, false should trigger exit; REACHED should remain "no"
8176        let reached = kernel.get_var("REACHED").await;
8177        assert_eq!(reached, Some(Value::String("no".into())),
8178            "set -e should exit on failure in for loop body");
8179    }
8180
8181    #[tokio::test]
8182    async fn test_for_loop_continues_without_set_e() {
8183        let kernel = Kernel::transient().expect("failed to create kernel");
8184
8185        // Without set -e, for loop should continue normally
8186        kernel
8187            .execute(r#"
8188                COUNT=0
8189                for x in 1 2 3; do
8190                    false
8191                    COUNT=$((COUNT + 1))
8192                done
8193            "#)
8194            .await
8195            .ok();
8196
8197        let count = kernel.get_var("COUNT").await;
8198        // Arithmetic produces Int values; accept either Int or String representation
8199        let count_val = match &count {
8200            Some(Value::Int(n)) => *n,
8201            Some(Value::String(s)) => s.parse().unwrap_or(-1),
8202            _ => -1,
8203        };
8204        assert_eq!(count_val, 3,
8205            "without set -e, loop should complete all iterations (got {:?})", count);
8206    }
8207
8208    // ═══════════════════════════════════════════════════════════════════════════
8209    // Source Tests
8210    // ═══════════════════════════════════════════════════════════════════════════
8211
8212    #[tokio::test]
8213    async fn test_source_sets_variables() {
8214        let kernel = Kernel::transient().expect("failed to create kernel");
8215
8216        // Write a script to the VFS
8217        kernel
8218            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
8219            .await
8220            .expect("write failed");
8221
8222        // Source the script
8223        let result = kernel
8224            .execute(r#"source "/test.kai""#)
8225            .await
8226            .expect("source failed");
8227
8228        assert!(result.ok(), "source should succeed");
8229
8230        // Variable should be set in current scope
8231        let foo = kernel.get_var("FOO").await;
8232        assert_eq!(foo, Some(Value::String("bar".into())));
8233    }
8234
8235    #[tokio::test]
8236    async fn test_source_with_dot_alias() {
8237        let kernel = Kernel::transient().expect("failed to create kernel");
8238
8239        // Write a script to the VFS
8240        kernel
8241            .execute(r#"write "/vars.kai" 'X=42'"#)
8242            .await
8243            .expect("write failed");
8244
8245        // Source using . alias
8246        let result = kernel
8247            .execute(r#". "/vars.kai""#)
8248            .await
8249            .expect(". failed");
8250
8251        assert!(result.ok(), ". should succeed");
8252
8253        // Variable should be set in current scope
8254        let x = kernel.get_var("X").await;
8255        assert_eq!(x, Some(Value::Int(42)));
8256    }
8257
8258    #[tokio::test]
8259    async fn test_source_not_found() {
8260        let kernel = Kernel::transient().expect("failed to create kernel");
8261
8262        // Try to source a non-existent file
8263        let result = kernel
8264            .execute(r#"source "/nonexistent.kai""#)
8265            .await
8266            .expect("source should not fail with error");
8267
8268        assert!(!result.ok(), "source of non-existent file should fail");
8269        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
8270    }
8271
8272    #[tokio::test]
8273    async fn test_source_missing_filename() {
8274        let kernel = Kernel::transient().expect("failed to create kernel");
8275
8276        // Call source with no arguments
8277        let result = kernel
8278            .execute("source")
8279            .await
8280            .expect("source should not fail with error");
8281
8282        assert!(!result.ok(), "source without filename should fail");
8283        assert!(result.err.contains("missing filename"), "error should mention missing filename");
8284    }
8285
8286    #[tokio::test]
8287    async fn test_source_executes_multiple_statements() {
8288        let kernel = Kernel::transient().expect("failed to create kernel");
8289
8290        // Write a script with multiple statements
8291        kernel
8292            .execute(r#"write "/multi.kai" 'A=1
8293B=2
8294C=3'"#)
8295            .await
8296            .expect("write failed");
8297
8298        // Source it
8299        kernel
8300            .execute(r#"source "/multi.kai""#)
8301            .await
8302            .expect("source failed");
8303
8304        // All variables should be set
8305        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
8306        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
8307        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
8308    }
8309
8310    #[tokio::test]
8311    async fn test_source_can_define_functions() {
8312        let kernel = Kernel::transient().expect("failed to create kernel");
8313
8314        // Write a script that defines a function
8315        kernel
8316            .execute(r#"write "/functions.kai" 'greet() {
8317    echo "Hello, $1!"
8318}'"#)
8319            .await
8320            .expect("write failed");
8321
8322        // Source it
8323        kernel
8324            .execute(r#"source "/functions.kai""#)
8325            .await
8326            .expect("source failed");
8327
8328        // Use the defined function
8329        let result = kernel
8330            .execute(r#"greet "World""#)
8331            .await
8332            .expect("greet failed");
8333
8334        assert!(result.ok());
8335        assert!(result.text_out().contains("Hello, World!"));
8336    }
8337
8338    #[tokio::test]
8339    async fn test_source_inherits_error_exit() {
8340        let kernel = Kernel::transient().expect("failed to create kernel");
8341
8342        // Enable error exit
8343        kernel.execute("set -e").await.expect("set -e failed");
8344
8345        // Write a script that has a failure
8346        kernel
8347            .execute(r#"write "/fail.kai" 'BEFORE="yes"
8348false
8349AFTER="yes"'"#)
8350            .await
8351            .expect("write failed");
8352
8353        // Source it (should exit on false due to set -e)
8354        kernel
8355            .execute(r#"source "/fail.kai""#)
8356            .await
8357            .ok();
8358
8359        // BEFORE should be set, AFTER should NOT be set due to error exit
8360        let before = kernel.get_var("BEFORE").await;
8361        assert_eq!(before, Some(Value::String("yes".into())));
8362
8363        // Note: This test depends on whether error exit is checked within source
8364        // Currently our implementation checks per-statement in the main kernel
8365    }
8366
8367    // ═══════════════════════════════════════════════════════════════════════════
8368    // set -e with && / || chains
8369    // ═══════════════════════════════════════════════════════════════════════════
8370
8371    #[tokio::test]
8372    async fn test_set_e_and_chain_left_fails() {
8373        // set -e; false && echo hi; REACHED=1 → REACHED should be set
8374        let kernel = Kernel::transient().expect("failed to create kernel");
8375        kernel.execute("set -e").await.expect("set -e failed");
8376
8377        kernel
8378            .execute("false && echo hi; REACHED=1")
8379            .await
8380            .expect("execution failed");
8381
8382        let reached = kernel.get_var("REACHED").await;
8383        assert_eq!(
8384            reached,
8385            Some(Value::Int(1)),
8386            "set -e should not trigger on left side of &&"
8387        );
8388    }
8389
8390    #[tokio::test]
8391    async fn test_set_e_and_chain_right_fails() {
8392        // set -e; true && false; REACHED=1 → REACHED should NOT be set
8393        let kernel = Kernel::transient().expect("failed to create kernel");
8394        kernel.execute("set -e").await.expect("set -e failed");
8395
8396        kernel
8397            .execute("true && false; REACHED=1")
8398            .await
8399            .expect("execution failed");
8400
8401        let reached = kernel.get_var("REACHED").await;
8402        assert!(
8403            reached.is_none(),
8404            "set -e should trigger when right side of && fails"
8405        );
8406    }
8407
8408    #[tokio::test]
8409    async fn test_set_e_or_chain_recovers() {
8410        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
8411        let kernel = Kernel::transient().expect("failed to create kernel");
8412        kernel.execute("set -e").await.expect("set -e failed");
8413
8414        kernel
8415            .execute("false || echo recovered; REACHED=1")
8416            .await
8417            .expect("execution failed");
8418
8419        let reached = kernel.get_var("REACHED").await;
8420        assert_eq!(
8421            reached,
8422            Some(Value::Int(1)),
8423            "set -e should not trigger when || recovers the failure"
8424        );
8425    }
8426
8427    #[tokio::test]
8428    async fn test_set_e_or_chain_both_fail() {
8429        // set -e; false || false; REACHED=1 → REACHED should NOT be set
8430        let kernel = Kernel::transient().expect("failed to create kernel");
8431        kernel.execute("set -e").await.expect("set -e failed");
8432
8433        kernel
8434            .execute("false || false; REACHED=1")
8435            .await
8436            .expect("execution failed");
8437
8438        let reached = kernel.get_var("REACHED").await;
8439        assert!(
8440            reached.is_none(),
8441            "set -e should trigger when || chain ultimately fails"
8442        );
8443    }
8444
8445    // ═══════════════════════════════════════════════════════════════════════════
8446    // Cancellation Tests
8447    // ═══════════════════════════════════════════════════════════════════════════
8448
8449    /// Helper: schedule a cancel after a delay from a background thread.
8450    /// Uses std::thread because cancel() is sync and Kernel is not Send.
8451    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
8452        let k = Arc::clone(kernel);
8453        std::thread::spawn(move || {
8454            std::thread::sleep(delay);
8455            k.cancel();
8456        });
8457    }
8458
8459    #[tokio::test]
8460    async fn test_cancel_interrupts_for_loop() {
8461        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8462
8463        // Schedule cancel after a short delay from a background OS thread
8464        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8465
8466        // #149: a bare `X=$i` body has no await point, so the for-loop's
8467        // cancellation checkpoint (checked once per iteration, see the
8468        // `Stmt::For` arm above) never gets a chance to run mid-body — under
8469        // host load, 100_000 trivial iterations could complete and return
8470        // before the background thread's 10ms sleep ever elapsed, racing a
8471        // natural exit-0 completion against the scheduled cancel. Rather than
8472        // widen the margin (there's no bound on how slow "under load" can be),
8473        // make completion deterministically impossible inside the test
8474        // window: `sleep` is a real interruptible await point (it races
8475        // `tokio::time::sleep` against the same cancellation token — see
8476        // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
8477        // cancellation somewhere to land almost immediately AND, at enough
8478        // iterations, makes natural completion take far longer than the
8479        // bounded wait below. The outer timeout is the "must not hang CI if
8480        // cancellation is broken" backstop: it fails loudly well before the
8481        // loop could ever finish on its own.
8482        const ITERATIONS: u32 = 2000;
8483        const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
8484        let bound = std::time::Duration::from_secs(10);
8485        let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
8486
8487        let result = tokio::time::timeout(bound, kernel.execute(&script))
8488            .await
8489            .unwrap_or_else(|_| {
8490                panic!(
8491                    "for-loop did not return within {bound:?} — cancellation support looks \
8492                     broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
8493                     longer than this bound)",
8494                    ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
8495                )
8496            })
8497            .expect("execute failed");
8498
8499        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
8500
8501        // The loop variable should be set to something well short of the full
8502        // iteration count — i.e. cancellation landed long before the loop
8503        // could complete on its own.
8504        let x = kernel.get_var("X").await;
8505        if let Some(Value::Int(n)) = x {
8506            assert!(
8507                n < i64::from(ITERATIONS),
8508                "loop should have been interrupted before finishing, got X={n}"
8509            );
8510        }
8511    }
8512
8513    #[tokio::test]
8514    async fn test_cancel_interrupts_while_loop() {
8515        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8516        kernel.execute("COUNT=0").await.expect("init failed");
8517
8518        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8519
8520        let result = kernel
8521            .execute("while true; do COUNT=$((COUNT + 1)); done")
8522            .await
8523            .expect("execute failed");
8524
8525        assert_eq!(result.code, 130);
8526
8527        let count = kernel.get_var("COUNT").await;
8528        if let Some(Value::Int(n)) = count {
8529            assert!(n > 0, "loop should have run at least once");
8530        }
8531    }
8532
8533    #[tokio::test]
8534    async fn test_reset_after_cancel() {
8535        // After cancellation, the next execute() should work normally
8536        let kernel = Kernel::transient().expect("failed to create kernel");
8537        kernel.cancel(); // cancel with nothing running
8538
8539        let result = kernel.execute("echo hello").await.expect("execute failed");
8540        assert!(result.ok(), "execute after cancel should succeed");
8541        assert_eq!(result.text_out().trim(), "hello");
8542    }
8543
8544    #[tokio::test]
8545    async fn test_cancel_interrupts_statement_sequence() {
8546        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8547
8548        // Schedule cancel after the first statement runs but before sleep finishes
8549        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
8550
8551        let result = kernel
8552            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
8553            .await
8554            .expect("execute failed");
8555
8556        assert_eq!(result.code, 130);
8557
8558        // STEP should be 1 (set before sleep), not 2 or 3
8559        let step = kernel.get_var("STEP").await;
8560        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
8561    }
8562
8563    // ═══════════════════════════════════════════════════════════════════════════
8564    // Case Statement Tests
8565    // ═══════════════════════════════════════════════════════════════════════════
8566
8567    #[tokio::test]
8568    async fn test_case_simple_match() {
8569        let kernel = Kernel::transient().expect("failed to create kernel");
8570
8571        let result = kernel
8572            .execute(r#"
8573                case "hello" in
8574                    hello) echo "matched hello" ;;
8575                    world) echo "matched world" ;;
8576                esac
8577            "#)
8578            .await
8579            .expect("case failed");
8580
8581        assert!(result.ok());
8582        assert_eq!(result.text_out().trim(), "matched hello");
8583    }
8584
8585    #[tokio::test]
8586    async fn test_case_wildcard_match() {
8587        let kernel = Kernel::transient().expect("failed to create kernel");
8588
8589        let result = kernel
8590            .execute(r#"
8591                case "main.rs" in
8592                    *.py) echo "Python" ;;
8593                    *.rs) echo "Rust" ;;
8594                    *) echo "Unknown" ;;
8595                esac
8596            "#)
8597            .await
8598            .expect("case failed");
8599
8600        assert!(result.ok());
8601        assert_eq!(result.text_out().trim(), "Rust");
8602    }
8603
8604    #[tokio::test]
8605    async fn test_case_default_match() {
8606        let kernel = Kernel::transient().expect("failed to create kernel");
8607
8608        let result = kernel
8609            .execute(r#"
8610                case "unknown.xyz" in
8611                    *.py) echo "Python" ;;
8612                    *.rs) echo "Rust" ;;
8613                    *) echo "Default" ;;
8614                esac
8615            "#)
8616            .await
8617            .expect("case failed");
8618
8619        assert!(result.ok());
8620        assert_eq!(result.text_out().trim(), "Default");
8621    }
8622
8623    #[tokio::test]
8624    async fn test_case_no_match() {
8625        let kernel = Kernel::transient().expect("failed to create kernel");
8626
8627        // Case with no default branch and no match
8628        let result = kernel
8629            .execute(r#"
8630                case "nope" in
8631                    "yes") echo "yes" ;;
8632                    "no") echo "no" ;;
8633                esac
8634            "#)
8635            .await
8636            .expect("case failed");
8637
8638        assert!(result.ok());
8639        assert!(result.text_out().is_empty(), "no match should produce empty output");
8640    }
8641
8642    #[tokio::test]
8643    async fn test_case_with_variable() {
8644        let kernel = Kernel::transient().expect("failed to create kernel");
8645
8646        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8647
8648        let result = kernel
8649            .execute(r#"
8650                case ${LANG} in
8651                    python) echo "snake" ;;
8652                    rust) echo "crab" ;;
8653                    go) echo "gopher" ;;
8654                esac
8655            "#)
8656            .await
8657            .expect("case failed");
8658
8659        assert!(result.ok());
8660        assert_eq!(result.text_out().trim(), "crab");
8661    }
8662
8663    #[tokio::test]
8664    async fn test_case_multiple_patterns() {
8665        let kernel = Kernel::transient().expect("failed to create kernel");
8666
8667        let result = kernel
8668            .execute(r#"
8669                case "yes" in
8670                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8671                    "n"|"no"|"N"|"NO") echo "negative" ;;
8672                esac
8673            "#)
8674            .await
8675            .expect("case failed");
8676
8677        assert!(result.ok());
8678        assert_eq!(result.text_out().trim(), "affirmative");
8679    }
8680
8681    #[tokio::test]
8682    async fn test_case_glob_question_mark() {
8683        let kernel = Kernel::transient().expect("failed to create kernel");
8684
8685        let result = kernel
8686            .execute(r#"
8687                case "test1" in
8688                    test?) echo "matched test?" ;;
8689                    *) echo "default" ;;
8690                esac
8691            "#)
8692            .await
8693            .expect("case failed");
8694
8695        assert!(result.ok());
8696        assert_eq!(result.text_out().trim(), "matched test?");
8697    }
8698
8699    #[tokio::test]
8700    async fn test_case_char_class() {
8701        let kernel = Kernel::transient().expect("failed to create kernel");
8702
8703        let result = kernel
8704            .execute(r#"
8705                case "Yes" in
8706                    [Yy]*) echo "yes-like" ;;
8707                    [Nn]*) echo "no-like" ;;
8708                esac
8709            "#)
8710            .await
8711            .expect("case failed");
8712
8713        assert!(result.ok());
8714        assert_eq!(result.text_out().trim(), "yes-like");
8715    }
8716
8717    // ═══════════════════════════════════════════════════════════════════════════
8718    // Cat Stdin Tests
8719    // ═══════════════════════════════════════════════════════════════════════════
8720
8721    #[tokio::test]
8722    async fn test_cat_from_pipeline() {
8723        let kernel = Kernel::transient().expect("failed to create kernel");
8724
8725        let result = kernel
8726            .execute(r#"echo "piped text" | cat"#)
8727            .await
8728            .expect("cat pipeline failed");
8729
8730        assert!(result.ok(), "cat failed: {}", result.err);
8731        assert_eq!(result.text_out().trim(), "piped text");
8732    }
8733
8734    #[tokio::test]
8735    async fn test_cat_from_pipeline_multiline() {
8736        let kernel = Kernel::transient().expect("failed to create kernel");
8737
8738        let result = kernel
8739            .execute(r#"echo "line1\nline2" | cat -n"#)
8740            .await
8741            .expect("cat pipeline failed");
8742
8743        assert!(result.ok(), "cat failed: {}", result.err);
8744        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8745    }
8746
8747    // ═══════════════════════════════════════════════════════════════════════════
8748    // Heredoc Tests
8749    // ═══════════════════════════════════════════════════════════════════════════
8750
8751    #[tokio::test]
8752    async fn test_heredoc_basic() {
8753        let kernel = Kernel::transient().expect("failed to create kernel");
8754
8755        let result = kernel
8756            .execute("cat <<EOF\nhello\nEOF")
8757            .await
8758            .expect("heredoc failed");
8759
8760        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8761        assert_eq!(result.text_out().trim(), "hello");
8762    }
8763
8764    #[tokio::test]
8765    async fn test_arithmetic_in_string() {
8766        let kernel = Kernel::transient().expect("failed to create kernel");
8767
8768        let result = kernel
8769            .execute(r#"echo "result: $((1 + 2))""#)
8770            .await
8771            .expect("arithmetic in string failed");
8772
8773        assert!(result.ok(), "echo failed: {}", result.err);
8774        assert_eq!(result.text_out().trim(), "result: 3");
8775    }
8776
8777    #[tokio::test]
8778    async fn test_heredoc_multiline() {
8779        let kernel = Kernel::transient().expect("failed to create kernel");
8780
8781        let result = kernel
8782            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8783            .await
8784            .expect("heredoc failed");
8785
8786        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8787        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8788        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8789        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8790    }
8791
8792    #[tokio::test]
8793    async fn test_heredoc_variable_expansion() {
8794        // Bug N: unquoted heredoc should expand variables
8795        let kernel = Kernel::transient().expect("failed to create kernel");
8796
8797        kernel.execute("GREETING=hello").await.expect("set var");
8798
8799        let result = kernel
8800            .execute("cat <<EOF\n$GREETING world\nEOF")
8801            .await
8802            .expect("heredoc expansion failed");
8803
8804        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8805        assert_eq!(result.text_out().trim(), "hello world");
8806    }
8807
8808    #[tokio::test]
8809    async fn test_heredoc_quoted_no_expansion() {
8810        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8811        let kernel = Kernel::transient().expect("failed to create kernel");
8812
8813        kernel.execute("GREETING=hello").await.expect("set var");
8814
8815        let result = kernel
8816            .execute("cat <<'EOF'\n$GREETING world\nEOF")
8817            .await
8818            .expect("quoted heredoc failed");
8819
8820        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8821        assert_eq!(result.text_out().trim(), "$GREETING world");
8822    }
8823
8824    #[tokio::test]
8825    async fn test_heredoc_default_value_expansion() {
8826        // Bug N: ${VAR:-default} should expand in unquoted heredocs
8827        let kernel = Kernel::transient().expect("failed to create kernel");
8828
8829        let result = kernel
8830            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8831            .await
8832            .expect("heredoc default expansion failed");
8833
8834        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8835        assert_eq!(result.text_out().trim(), "fallback");
8836    }
8837
8838    // ═══════════════════════════════════════════════════════════════════════════
8839    // Read Builtin Tests
8840    // ═══════════════════════════════════════════════════════════════════════════
8841
8842    #[tokio::test]
8843    async fn test_read_from_pipeline() {
8844        let kernel = Kernel::transient().expect("failed to create kernel");
8845
8846        // Pipe input to read
8847        let result = kernel
8848            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8849            .await
8850            .expect("read pipeline failed");
8851
8852        assert!(result.ok(), "read failed: {}", result.err);
8853        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8854    }
8855
8856    #[tokio::test]
8857    async fn test_read_multiple_vars_from_pipeline() {
8858        let kernel = Kernel::transient().expect("failed to create kernel");
8859
8860        let result = kernel
8861            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8862            .await
8863            .expect("read pipeline failed");
8864
8865        assert!(result.ok(), "read failed: {}", result.err);
8866        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8867    }
8868
8869    // ═══════════════════════════════════════════════════════════════════════════
8870    // Shell-Style Function Tests
8871    // ═══════════════════════════════════════════════════════════════════════════
8872
8873    #[tokio::test]
8874    async fn test_posix_function_with_positional_params() {
8875        let kernel = Kernel::transient().expect("failed to create kernel");
8876
8877        // Define POSIX-style function
8878        kernel
8879            .execute(r#"greet() { echo "Hello, $1!" }"#)
8880            .await
8881            .expect("function definition failed");
8882
8883        // Call the function
8884        let result = kernel
8885            .execute(r#"greet "Amy""#)
8886            .await
8887            .expect("function call failed");
8888
8889        assert!(result.ok(), "greet failed: {}", result.err);
8890        assert_eq!(result.text_out().trim(), "Hello, Amy!");
8891    }
8892
8893    #[tokio::test]
8894    async fn test_posix_function_multiple_args() {
8895        let kernel = Kernel::transient().expect("failed to create kernel");
8896
8897        // Define function using $1 and $2
8898        kernel
8899            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8900            .await
8901            .expect("function definition failed");
8902
8903        // Call the function
8904        let result = kernel
8905            .execute(r#"add_greeting "Hello" "World""#)
8906            .await
8907            .expect("function call failed");
8908
8909        assert!(result.ok(), "function failed: {}", result.err);
8910        assert_eq!(result.text_out().trim(), "Hello World!");
8911    }
8912
8913    #[tokio::test]
8914    async fn test_bash_function_with_positional_params() {
8915        let kernel = Kernel::transient().expect("failed to create kernel");
8916
8917        // Define bash-style function (function keyword, no parens)
8918        kernel
8919            .execute(r#"function greet { echo "Hi $1" }"#)
8920            .await
8921            .expect("function definition failed");
8922
8923        // Call the function
8924        let result = kernel
8925            .execute(r#"greet "Bob""#)
8926            .await
8927            .expect("function call failed");
8928
8929        assert!(result.ok(), "greet failed: {}", result.err);
8930        assert_eq!(result.text_out().trim(), "Hi Bob");
8931    }
8932
8933    #[tokio::test]
8934    async fn test_shell_function_with_all_args() {
8935        let kernel = Kernel::transient().expect("failed to create kernel");
8936
8937        // Define function using $@ (all args)
8938        kernel
8939            .execute(r#"echo_all() { echo "args: $@" }"#)
8940            .await
8941            .expect("function definition failed");
8942
8943        // Call with multiple args
8944        let result = kernel
8945            .execute(r#"echo_all "a" "b" "c""#)
8946            .await
8947            .expect("function call failed");
8948
8949        assert!(result.ok(), "function failed: {}", result.err);
8950        assert_eq!(result.text_out().trim(), "args: a b c");
8951    }
8952
8953    #[tokio::test]
8954    async fn test_shell_function_with_arg_count() {
8955        let kernel = Kernel::transient().expect("failed to create kernel");
8956
8957        // Define function using $# (arg count)
8958        kernel
8959            .execute(r#"count_args() { echo "count: $#" }"#)
8960            .await
8961            .expect("function definition failed");
8962
8963        // Call with three args
8964        let result = kernel
8965            .execute(r#"count_args "x" "y" "z""#)
8966            .await
8967            .expect("function call failed");
8968
8969        assert!(result.ok(), "function failed: {}", result.err);
8970        assert_eq!(result.text_out().trim(), "count: 3");
8971    }
8972
8973    #[tokio::test]
8974    async fn test_shell_function_shared_scope() {
8975        let kernel = Kernel::transient().expect("failed to create kernel");
8976
8977        // Set a variable in parent scope
8978        kernel
8979            .execute(r#"PARENT_VAR="visible""#)
8980            .await
8981            .expect("set failed");
8982
8983        // Define shell function that reads and writes parent variable
8984        kernel
8985            .execute(r#"modify_parent() {
8986                echo "saw: ${PARENT_VAR}"
8987                PARENT_VAR="changed by function"
8988            }"#)
8989            .await
8990            .expect("function definition failed");
8991
8992        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8993        let result = kernel.execute("modify_parent").await.expect("function failed");
8994
8995        assert!(
8996            result.text_out().contains("visible"),
8997            "Shell function should access parent scope, got: {}",
8998            result.text_out()
8999        );
9000
9001        // Parent variable should be modified
9002        let var = kernel.get_var("PARENT_VAR").await;
9003        assert_eq!(
9004            var,
9005            Some(Value::String("changed by function".into())),
9006            "Shell function should modify parent scope"
9007        );
9008    }
9009
9010    // ═══════════════════════════════════════════════════════════════════════════
9011    // Script Execution via PATH Tests
9012    // ═══════════════════════════════════════════════════════════════════════════
9013
9014    #[tokio::test]
9015    async fn test_script_execution_from_path() {
9016        let kernel = Kernel::transient().expect("failed to create kernel");
9017
9018        // Create /bin directory and script
9019        kernel.execute(r#"mkdir "/bin""#).await.ok();
9020        kernel
9021            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
9022            .await
9023            .expect("write script failed");
9024
9025        // Set PATH to /bin
9026        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9027
9028        // Call script by name (without .kai extension)
9029        let result = kernel
9030            .execute("hello")
9031            .await
9032            .expect("script execution failed");
9033
9034        assert!(result.ok(), "script failed: {}", result.err);
9035        assert_eq!(result.text_out().trim(), "Hello from script!");
9036    }
9037
9038    #[tokio::test]
9039    async fn test_script_with_args() {
9040        let kernel = Kernel::transient().expect("failed to create kernel");
9041
9042        // Create script that uses positional params
9043        kernel.execute(r#"mkdir "/bin""#).await.ok();
9044        kernel
9045            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
9046            .await
9047            .expect("write script failed");
9048
9049        // Set PATH
9050        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9051
9052        // Call script with arg
9053        let result = kernel
9054            .execute(r#"greet "World""#)
9055            .await
9056            .expect("script execution failed");
9057
9058        assert!(result.ok(), "script failed: {}", result.err);
9059        assert_eq!(result.text_out().trim(), "Hello, World!");
9060    }
9061
9062    #[tokio::test]
9063    async fn test_script_not_found() {
9064        let kernel = Kernel::transient().expect("failed to create kernel");
9065
9066        // Set empty PATH
9067        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
9068
9069        // Call non-existent script
9070        let result = kernel
9071            .execute("noscript")
9072            .await
9073            .expect("execution failed");
9074
9075        assert!(!result.ok(), "should fail with command not found");
9076        assert_eq!(result.code, 127);
9077        assert!(result.err.contains("command not found"));
9078    }
9079
9080    #[tokio::test]
9081    async fn test_script_path_search_order() {
9082        let kernel = Kernel::transient().expect("failed to create kernel");
9083
9084        // Create two directories with same-named script
9085        // Note: using "myscript" not "test" to avoid conflict with test builtin
9086        kernel.execute(r#"mkdir "/first""#).await.ok();
9087        kernel.execute(r#"mkdir "/second""#).await.ok();
9088        kernel
9089            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
9090            .await
9091            .expect("write failed");
9092        kernel
9093            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
9094            .await
9095            .expect("write failed");
9096
9097        // Set PATH with first before second
9098        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
9099
9100        // Should find first one
9101        let result = kernel
9102            .execute("myscript")
9103            .await
9104            .expect("script execution failed");
9105
9106        assert!(result.ok(), "script failed: {}", result.err);
9107        assert_eq!(result.text_out().trim(), "from first");
9108    }
9109
9110    // ═══════════════════════════════════════════════════════════════════════════
9111    // Special Variable Tests ($?, $$, unset vars)
9112    // ═══════════════════════════════════════════════════════════════════════════
9113
9114    #[tokio::test]
9115    async fn test_last_exit_code_success() {
9116        let kernel = Kernel::transient().expect("failed to create kernel");
9117
9118        // true exits with 0
9119        let result = kernel.execute("true; echo $?").await.expect("execution failed");
9120        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
9121    }
9122
9123    #[tokio::test]
9124    async fn test_last_exit_code_failure() {
9125        let kernel = Kernel::transient().expect("failed to create kernel");
9126
9127        // false exits with 1
9128        let result = kernel.execute("false; echo $?").await.expect("execution failed");
9129        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
9130    }
9131
9132    #[tokio::test]
9133    async fn test_current_pid() {
9134        let kernel = Kernel::transient().expect("failed to create kernel");
9135
9136        let result = kernel.execute("echo $$").await.expect("execution failed");
9137        // PID should be a positive number
9138        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
9139        assert!(pid > 0, "PID should be positive");
9140    }
9141
9142    #[tokio::test]
9143    async fn test_unset_variable_expands_to_empty() {
9144        let kernel = Kernel::transient().expect("failed to create kernel");
9145
9146        // Unset variable in interpolation should be empty
9147        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
9148        assert_eq!(result.text_out().trim(), "prefix::suffix");
9149    }
9150
9151    #[tokio::test]
9152    async fn test_eq_ne_operators() {
9153        let kernel = Kernel::transient().expect("failed to create kernel");
9154
9155        // Test -eq operator
9156        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
9157        assert_eq!(result.text_out().trim(), "eq works");
9158
9159        // Test -ne operator
9160        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
9161        assert_eq!(result.text_out().trim(), "ne works");
9162
9163        // Test -eq with different values
9164        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
9165        assert_eq!(result.text_out().trim(), "correct");
9166    }
9167
9168    #[tokio::test]
9169    async fn test_escaped_dollar_in_string() {
9170        let kernel = Kernel::transient().expect("failed to create kernel");
9171
9172        // \$ should produce literal $
9173        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
9174        assert_eq!(result.text_out().trim(), "$100");
9175    }
9176
9177    #[tokio::test]
9178    async fn test_special_vars_in_interpolation() {
9179        let kernel = Kernel::transient().expect("failed to create kernel");
9180
9181        // Test $? in string interpolation
9182        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
9183        assert_eq!(result.text_out().trim(), "exit: 0");
9184
9185        // Test $$ in string interpolation
9186        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
9187        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
9188        let text = result.text_out();
9189        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
9190        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
9191    }
9192
9193    // ═══════════════════════════════════════════════════════════════════════════
9194    // Command Substitution Tests
9195    // ═══════════════════════════════════════════════════════════════════════════
9196
9197    #[tokio::test]
9198    async fn test_command_subst_assignment() {
9199        let kernel = Kernel::transient().expect("failed to create kernel");
9200
9201        // Command substitution in assignment
9202        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
9203        assert_eq!(result.text_out().trim(), "hello");
9204    }
9205
9206    #[tokio::test]
9207    async fn test_command_subst_with_args() {
9208        let kernel = Kernel::transient().expect("failed to create kernel");
9209
9210        // Command substitution with string argument
9211        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
9212        assert_eq!(result.text_out().trim(), "a b c");
9213    }
9214
9215    #[tokio::test]
9216    async fn test_command_subst_nested_vars() {
9217        let kernel = Kernel::transient().expect("failed to create kernel");
9218
9219        // Variables inside command substitution
9220        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
9221        assert_eq!(result.text_out().trim(), "hello world");
9222    }
9223
9224    #[tokio::test]
9225    async fn test_background_job_basic() {
9226        use std::time::Duration;
9227
9228        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
9229
9230        // Run a simple background command, redirecting its output to a
9231        // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
9232        // live); the redirect is what this test asserts on.
9233        let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
9234        assert!(result.ok(), "background command should succeed: {}", result.err);
9235        assert!(result.err.contains("[1]"), "announcement rides stderr: {:?}", result.err);
9236
9237        // Give the job time to complete
9238        tokio::time::sleep(Duration::from_millis(100)).await;
9239
9240        // Check job status
9241        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
9242        assert!(status.ok(), "status should succeed: {}", status.err);
9243        assert!(
9244            status.text_out().contains("done:") || status.text_out().contains("running"),
9245            "should have valid status: {}",
9246            status.text_out()
9247        );
9248
9249        // Check the redirected output
9250        let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
9251        assert!(stdout.ok());
9252        assert!(stdout.text_out().contains("hello"));
9253    }
9254
9255    #[tokio::test]
9256    async fn test_heredoc_piped_to_command() {
9257        // Bug 4: heredoc content should pipe through to next command
9258        let kernel = Kernel::transient().expect("kernel");
9259        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
9260        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
9261        assert_eq!(result.text_out().trim(), "hello world");
9262    }
9263
9264    /// A transient kernel paired with a real, auto-cleaning tempdir. The
9265    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
9266    /// tests need actual files on disk. Hold the returned `TempDir` for the
9267    /// test's lifetime: it removes the directory tree on drop — including on
9268    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
9269    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
9270    /// as a string for interpolation into scripts.
9271    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
9272        let kernel = Kernel::transient().expect("kernel");
9273        let tmp = tempfile::tempdir().expect("tempdir");
9274        let dir = tmp.path().display().to_string();
9275        (kernel, tmp, dir)
9276    }
9277
9278    #[tokio::test]
9279    async fn test_for_loop_glob_iterates() {
9280        // Bug 1: for F in $(glob ...) should iterate per file, not once
9281        let (kernel, _tmp, dir) = transient_with_tempdir();
9282        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9283        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9284        let result = kernel.execute(&format!(r#"
9285            N=0
9286            for F in $(glob "{dir}/*.txt"); do
9287                N=$((N + 1))
9288            done
9289            echo $N
9290        "#)).await.unwrap();
9291        assert!(result.ok(), "for glob failed: {}", result.err);
9292        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
9293    }
9294
9295    #[tokio::test]
9296    async fn test_bare_glob_expansion_echo() {
9297        let (kernel, _tmp, dir) = transient_with_tempdir();
9298        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9299        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9300        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
9301        kernel.execute(&format!("cd {dir}")).await.unwrap();
9302        let result = kernel.execute("echo *.txt").await.unwrap();
9303        assert!(result.ok(), "echo *.txt failed: {}", result.err);
9304        let out = result.text_out();
9305        let out = out.trim();
9306        // Should contain both .txt files (order may vary)
9307        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
9308        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
9309        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
9310    }
9311
9312    #[tokio::test]
9313    async fn test_bare_glob_no_matches_errors() {
9314        let (kernel, _tmp, dir) = transient_with_tempdir();
9315        kernel.execute(&format!("cd {dir}")).await.unwrap();
9316        let result = kernel.execute("echo *.nonexistent").await;
9317        match &result {
9318            Ok(exec) => {
9319                // No-match glob should produce a non-zero exit code
9320                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
9321                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
9322            }
9323            Err(e) => {
9324                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
9325            }
9326        }
9327    }
9328
9329    #[tokio::test]
9330    async fn test_bare_glob_disabled_with_set() {
9331        let (kernel, _tmp, dir) = transient_with_tempdir();
9332        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9333        kernel.execute(&format!("cd {dir}")).await.unwrap();
9334        // Disable glob expansion
9335        kernel.execute("set +o glob").await.unwrap();
9336        let result = kernel.execute("echo *.txt").await.unwrap();
9337        // With glob disabled, *.txt should be passed as literal string
9338        assert!(result.ok(), "echo should succeed: {}", result.err);
9339        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
9340    }
9341
9342    #[tokio::test]
9343    async fn test_bare_glob_quoted_not_expanded() {
9344        let (kernel, _tmp, dir) = transient_with_tempdir();
9345        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9346        kernel.execute(&format!("cd {dir}")).await.unwrap();
9347        // Quoted globs should NOT expand
9348        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
9349        assert!(result.ok(), "echo should succeed: {}", result.err);
9350        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
9351    }
9352
9353    #[tokio::test]
9354    async fn test_bare_glob_for_loop() {
9355        let (kernel, _tmp, dir) = transient_with_tempdir();
9356        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9357        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9358        kernel.execute(&format!("cd {dir}")).await.unwrap();
9359        let result = kernel.execute(r#"
9360            N=0
9361            for f in *.txt; do
9362                N=$((N + 1))
9363            done
9364            echo $N
9365        "#).await.unwrap();
9366        assert!(result.ok(), "for loop failed: {}", result.err);
9367        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
9368    }
9369
9370    #[tokio::test]
9371    async fn test_glob_in_assignment_is_literal() {
9372        let kernel = Kernel::transient().expect("kernel");
9373        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
9374        assert!(result.ok());
9375        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
9376    }
9377
9378    #[tokio::test]
9379    async fn test_glob_in_test_expr_is_literal() {
9380        let kernel = Kernel::transient().expect("kernel");
9381        let result = kernel.execute(r#"
9382            if [[ *.txt == "*.txt" ]]; then
9383                echo "match"
9384            else
9385                echo "no"
9386            fi
9387        "#).await.unwrap();
9388        assert!(result.ok());
9389        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
9390    }
9391
9392    #[tokio::test]
9393    async fn test_command_subst_echo_not_iterable() {
9394        // Regression guard: $(echo "a b c") must remain a single string
9395        let kernel = Kernel::transient().expect("kernel");
9396        let result = kernel.execute(r#"
9397            N=0
9398            for X in $(echo "a b c"); do N=$((N + 1)); done
9399            echo $N
9400        "#).await.unwrap();
9401        assert!(result.ok());
9402        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
9403    }
9404
9405    // -- accumulate_result / newline tests --
9406
9407    #[test]
9408    fn test_accumulate_preserves_own_newlines() {
9409        // Outputs concatenate verbatim — a command's own trailing newline is
9410        // kept, none is invented.
9411        let mut acc = ExecResult::success("line1\n");
9412        let new = ExecResult::success("line2\n");
9413        accumulate_result(&mut acc, &new);
9414        assert_eq!(&*acc.text_out(), "line1\nline2\n");
9415        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
9416    }
9417
9418    #[test]
9419    fn test_accumulate_inserts_no_separator() {
9420        // No artificial separator: `printf a; printf b` style concatenates to
9421        // `ab`, matching bash (regression for the 2026-06-09 finding).
9422        let mut acc = ExecResult::success("line1");
9423        let new = ExecResult::success("line2");
9424        accumulate_result(&mut acc, &new);
9425        assert_eq!(&*acc.text_out(), "line1line2");
9426    }
9427
9428    #[test]
9429    fn test_accumulate_empty_into_nonempty() {
9430        let mut acc = ExecResult::success("");
9431        let new = ExecResult::success("hello\n");
9432        accumulate_result(&mut acc, &new);
9433        assert_eq!(&*acc.text_out(), "hello\n");
9434    }
9435
9436    #[test]
9437    fn test_accumulate_nonempty_into_empty() {
9438        let mut acc = ExecResult::success("hello\n");
9439        let new = ExecResult::success("");
9440        accumulate_result(&mut acc, &new);
9441        assert_eq!(&*acc.text_out(), "hello\n");
9442    }
9443
9444    #[test]
9445    fn test_accumulate_stderr_no_double_newlines() {
9446        let mut acc = ExecResult::failure(1, "err1\n");
9447        let new = ExecResult::failure(1, "err2\n");
9448        accumulate_result(&mut acc, &new);
9449        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
9450    }
9451
9452    #[tokio::test]
9453    async fn test_multiple_echo_no_blank_lines() {
9454        let kernel = Kernel::transient().expect("kernel");
9455        let result = kernel
9456            .execute("echo one\necho two\necho three")
9457            .await
9458            .expect("execution failed");
9459        assert!(result.ok());
9460        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
9461    }
9462
9463    #[tokio::test]
9464    async fn test_for_loop_no_blank_lines() {
9465        let kernel = Kernel::transient().expect("kernel");
9466        let result = kernel
9467            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
9468            .await
9469            .expect("execution failed");
9470        assert!(result.ok());
9471        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
9472    }
9473
9474    #[tokio::test]
9475    async fn test_for_command_subst_no_blank_lines() {
9476        let kernel = Kernel::transient().expect("kernel");
9477        let result = kernel
9478            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
9479            .await
9480            .expect("execution failed");
9481        assert!(result.ok());
9482        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
9483    }
9484
9485    // ------------------------------------------------------------------
9486    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
9487    // ------------------------------------------------------------------
9488
9489    /// Helper: a throwaway schema with one `--pair` param declared as
9490    /// consuming two positionals per occurrence. Modelled after what
9491    /// jq_native will declare for `--arg` / `--argjson`.
9492    fn multi_consume_schema() -> crate::tools::ToolSchema {
9493        use crate::tools::{ParamSchema, ToolSchema};
9494        ToolSchema::new("test", "multi-consume smoke")
9495            .param(
9496                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
9497                    .consumes(2),
9498            )
9499    }
9500
9501    fn pos(s: &str) -> Arg {
9502        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
9503    }
9504
9505    #[tokio::test]
9506    async fn build_args_multi_consume_single_occurrence() {
9507        let kernel = Kernel::transient().expect("kernel");
9508        let schema = multi_consume_schema();
9509        // Simulates:  test --pair NAME VALUE filter
9510        let args = vec![
9511            Arg::LongFlag("pair".into()),
9512            pos("NAME"),
9513            pos("VALUE"),
9514            pos("filter"),
9515        ];
9516        let built = kernel
9517            .build_args_async(&args, Some(&schema))
9518            .await
9519            .expect("build_args should succeed");
9520
9521        // `--pair` + its two positionals are consumed into named["pair"],
9522        // which becomes an outer array of one inner 2-element array.
9523        let pair = built.named.get("pair").expect("named[pair] missing");
9524        match pair {
9525            Value::Json(serde_json::Value::Array(occurrences)) => {
9526                assert_eq!(occurrences.len(), 1, "expected one occurrence");
9527                match &occurrences[0] {
9528                    serde_json::Value::Array(values) => {
9529                        assert_eq!(values.len(), 2, "pair must have 2 values");
9530                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
9531                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
9532                    }
9533                    other => panic!("expected inner array, got {other:?}"),
9534                }
9535            }
9536            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
9537        }
9538
9539        // The un-consumed positional ("filter") remains in `positional`.
9540        assert_eq!(built.positional.len(), 1);
9541        assert_eq!(built.positional[0], Value::String("filter".into()));
9542    }
9543    #[tokio::test]
9544    async fn build_args_multi_consume_two_occurrences_accumulate() {
9545        let kernel = Kernel::transient().expect("kernel");
9546        let schema = multi_consume_schema();
9547        // Simulates:  test --pair A 1 --pair B 2 filter
9548        let args = vec![
9549            Arg::LongFlag("pair".into()),
9550            pos("A"),
9551            pos("1"),
9552            Arg::LongFlag("pair".into()),
9553            pos("B"),
9554            pos("2"),
9555            pos("filter"),
9556        ];
9557        let built = kernel
9558            .build_args_async(&args, Some(&schema))
9559            .await
9560            .expect("build_args should succeed");
9561
9562        let pair = built.named.get("pair").expect("named[pair] missing");
9563        match pair {
9564            Value::Json(serde_json::Value::Array(occurrences)) => {
9565                assert_eq!(occurrences.len(), 2, "expected two occurrences");
9566                // Preserved in invocation order.
9567                match &occurrences[0] {
9568                    serde_json::Value::Array(values) => {
9569                        assert_eq!(values[0], serde_json::Value::String("A".into()));
9570                        assert_eq!(values[1], serde_json::Value::String("1".into()));
9571                    }
9572                    other => panic!("expected inner array, got {other:?}"),
9573                }
9574                match &occurrences[1] {
9575                    serde_json::Value::Array(values) => {
9576                        assert_eq!(values[0], serde_json::Value::String("B".into()));
9577                        assert_eq!(values[1], serde_json::Value::String("2".into()));
9578                    }
9579                    other => panic!("expected inner array, got {other:?}"),
9580                }
9581            }
9582            other => panic!("expected Json(Array(...)), got {other:?}"),
9583        }
9584    }
9585
9586    // ── undeclared space-form flag under map_positionals (kj --type val) ──
9587    //
9588    // A backend/MCP tool whose schema does NOT declare a flag must not let
9589    // `--flag value` (space form) silently divorce the value: that was a
9590    // privilege-escalation-by-typo against kaijutsu.
9591    // kaish fails loud rather than guessing.
9592
9593    use crate::tools::{ParamSchema, ToolSchema};
9594
9595    /// Backend-style schema (map_positionals) declaring only a `name`
9596    /// positional — `--type` is intentionally undeclared.
9597    fn kj_like_schema() -> ToolSchema {
9598        ToolSchema::new("kj", "incomplete backend schema")
9599            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9600            .with_positional_mapping()
9601    }
9602
9603    #[tokio::test]
9604    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9605        let kernel = Kernel::transient().expect("kernel");
9606        let schema = kj_like_schema();
9607        // kj context create exp --type explorer
9608        let args = vec![
9609            pos("context"),
9610            pos("create"),
9611            pos("exp"),
9612            Arg::LongFlag("type".into()),
9613            pos("explorer"),
9614        ];
9615        let err = kernel
9616            .build_args_async(&args, Some(&schema))
9617            .await
9618            .expect_err("undeclared --type with a space value must fail loud");
9619        let msg = err.to_string();
9620        assert!(msg.contains("--type"), "message should name the flag: {msg}");
9621        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9622        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9623    }
9624
9625    #[tokio::test]
9626    async fn build_args_declared_space_flag_still_binds() {
9627        let kernel = Kernel::transient().expect("kernel");
9628        // Same tool, but now the schema DECLARES --type as a string param.
9629        let schema = ToolSchema::new("kj", "complete schema")
9630            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9631            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9632            .with_positional_mapping();
9633        let args = vec![
9634            pos("exp"),
9635            Arg::LongFlag("type".into()),
9636            pos("explorer"),
9637        ];
9638        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9639        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9640    }
9641
9642    #[tokio::test]
9643    async fn build_args_equals_form_binds_for_undeclared_flag() {
9644        let kernel = Kernel::transient().expect("kernel");
9645        let schema = kj_like_schema();
9646        // The unambiguous `=` form must keep working even when undeclared.
9647        let args = vec![
9648            pos("exp"),
9649            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9650        ];
9651        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9652        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9653    }
9654
9655    #[tokio::test]
9656    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9657        let kernel = Kernel::transient().expect("kernel");
9658        let schema = kj_like_schema();
9659        // No positional follows --force → unambiguously a bare flag.
9660        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9661        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9662        assert!(built.flags.contains("force"));
9663    }
9664
9665    #[tokio::test]
9666    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9667        let kernel = Kernel::transient().expect("kernel");
9668        let schema = kj_like_schema();
9669        // --verbose is followed by a flag, not a positional → not ambiguous.
9670        let args = vec![
9671            Arg::LongFlag("verbose".into()),
9672            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9673        ];
9674        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9675        assert!(built.flags.contains("verbose"));
9676    }
9677
9678    #[tokio::test]
9679    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9680        let kernel = Kernel::transient().expect("kernel");
9681        // Builtins set map_positionals=false; the ambiguity guard must not
9682        // fire there (clap validates their flags separately).
9683        let schema = ToolSchema::new("frobnicate", "builtin-style")
9684            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9685        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9686        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9687        assert!(built.flags.contains("frob"));
9688    }
9689
9690    // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9691    //
9692    // The long-flag guard above was closed by GH #188; an undeclared SHORT
9693    // flag under a map_positionals schema was still silently defaulting to
9694    // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9695    // same way the long-flag case used to.
9696
9697    #[tokio::test]
9698    async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9699        let kernel = Kernel::transient().expect("kernel");
9700        let schema = kj_like_schema();
9701        // kj exp -t explorer
9702        let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9703        let err = kernel
9704            .build_args_async(&args, Some(&schema))
9705            .await
9706            .expect_err("undeclared -t with a space value must fail loud");
9707        let msg = err.to_string();
9708        assert!(msg.contains("-t"), "message should name the flag: {msg}");
9709        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9710    }
9711
9712    #[tokio::test]
9713    async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9714        let kernel = Kernel::transient().expect("kernel");
9715        // Builtins set map_positionals=false; the ambiguity guard must not
9716        // fire there.
9717        let schema = ToolSchema::new("frobnicate", "builtin-style")
9718            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9719        let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9720        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9721        assert!(built.flags.contains("t"));
9722    }
9723
9724    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9725    //
9726    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9727    // params, not the root's. The subcommand-path positionals stay positional
9728    // (kj re-parses them with its own clap), and a value flag declared only on
9729    // a deep leaf still binds in space form.
9730
9731    /// kj → context (alias ctx) → create{--type value, --force bool}.
9732    /// map_positionals defaults false on every node (builtin/kj style).
9733    fn kj_tree_schema() -> ToolSchema {
9734        ToolSchema::new("kj", "subcommand tool").subcommand(
9735            ToolSchema::new("context", "context ops")
9736                .with_command_aliases(["ctx"])
9737                .subcommand(
9738                    ToolSchema::new("create", "create context")
9739                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9740                        .param(ParamSchema::new("force", "bool")),
9741                ),
9742        )
9743    }
9744
9745    #[tokio::test]
9746    async fn build_args_binds_deep_leaf_value_flag_space_form() {
9747        let kernel = Kernel::transient().expect("kernel");
9748        let schema = kj_tree_schema();
9749        // kj context create --type explorer
9750        let args = vec![
9751            pos("context"),
9752            pos("create"),
9753            Arg::LongFlag("type".into()),
9754            pos("explorer"),
9755        ];
9756        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9757        // --type (declared only on the create leaf) binds in space form.
9758        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9759        // The subcommand path survives as positionals for kj to re-parse.
9760        let positionals: Vec<&str> = built
9761            .positional
9762            .iter()
9763            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9764            .collect();
9765        assert_eq!(positionals, vec!["context", "create"]);
9766    }
9767
9768    #[tokio::test]
9769    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9770        let kernel = Kernel::transient().expect("kernel");
9771        let schema = kj_tree_schema();
9772        // kj context create --force somearg  → --force is a leaf bool flag,
9773        // it must NOT consume `somearg`.
9774        let args = vec![
9775            pos("context"),
9776            pos("create"),
9777            Arg::LongFlag("force".into()),
9778            pos("somearg"),
9779        ];
9780        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9781        assert!(built.flags.contains("force"), "force should be a bare flag");
9782        let positionals: Vec<&str> = built
9783            .positional
9784            .iter()
9785            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9786            .collect();
9787        assert_eq!(positionals, vec!["context", "create", "somearg"]);
9788    }
9789
9790    #[tokio::test]
9791    async fn build_args_alias_routed_leaf_binds_value_flag() {
9792        let kernel = Kernel::transient().expect("kernel");
9793        let schema = kj_tree_schema();
9794        // kj ctx create -t explorer  → command alias + short flag alias.
9795        let args = vec![
9796            pos("ctx"),
9797            pos("create"),
9798            Arg::ShortFlag("t".into()),
9799            pos("explorer"),
9800        ];
9801        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9802        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9803    }
9804
9805    #[tokio::test]
9806    async fn build_args_computed_subcommand_selector_fails_loud() {
9807        let kernel = Kernel::transient().expect("kernel");
9808        let schema = kj_tree_schema();
9809        // kj $(echo context) — routing can't see the value; fail loud.
9810        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9811            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9812        )]))];
9813        let err = kernel
9814            .build_args_async(&args, Some(&schema))
9815            .await
9816            .expect_err("computed subcommand selector must error");
9817        assert!(
9818            err.to_string().contains("subcommand name is required"),
9819            "got: {err}"
9820        );
9821    }
9822
9823    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9824
9825    #[test]
9826    fn finalize_output_renders_when_kernel_owns_it() {
9827        use crate::interpreter::{OutputData, OutputFormat};
9828        let r = ExecResult::with_output(OutputData::text("RAW"));
9829        let out = finalize_output(r, Some(OutputFormat::Json), false);
9830        // Kernel renders the typed OutputData → JSON; text is no longer bare.
9831        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9832    }
9833
9834    #[test]
9835    fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9836        use crate::interpreter::{OutputData, OutputFormat};
9837        let r = ExecResult::with_output(OutputData::text("RAW"));
9838        let out = finalize_output(r, Some(OutputFormat::Json), true);
9839        // owns_output + success: the tool already rendered; kernel leaves bytes
9840        // untouched.
9841        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9842    }
9843
9844    #[test]
9845    fn finalize_output_renders_owns_output_failure() {
9846        // scatter/gather (the only owns_output tools) never render their own
9847        // JSONL/array on a FAILURE path — their error returns are plain-text
9848        // `ExecResult::failure(code, msg)`, identical in shape to any other
9849        // builtin's. owns_output means "the tool already rendered its own
9850        // SUCCESS output", not "never touch this tool's bytes" — a failure
9851        // must still get the uniform --json error envelope like every other
9852        // builtin (kaibo review finding on merged PR #215; confirmed
9853        // pre-existing for scatter/gather's whole error-path class, including
9854        // the clap-parse-failure path).
9855        use crate::interpreter::OutputFormat;
9856        let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9857        let out = finalize_output(r, Some(OutputFormat::Json), true);
9858        let parsed: serde_json::Value =
9859            serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9860        assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9861        assert_eq!(parsed["code"], 2);
9862    }
9863
9864    #[test]
9865    fn finalize_output_no_format_is_noop() {
9866        use crate::interpreter::OutputData;
9867        let r = ExecResult::with_output(OutputData::text("RAW"));
9868        let out = finalize_output(r, None, false);
9869        assert_eq!(out.text_out(), "RAW");
9870    }
9871
9872    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9873
9874    #[tokio::test]
9875    async fn test_initial_vars_set_and_exported() {
9876        let config = KernelConfig::transient()
9877            .with_var("INIT_FOO", Value::String("bar".into()));
9878        let kernel = Kernel::new(config).expect("failed to create kernel");
9879
9880        assert_eq!(
9881            kernel.get_var("INIT_FOO").await,
9882            Some(Value::String("bar".into()))
9883        );
9884        assert!(
9885            kernel.scope.read().await.is_exported("INIT_FOO"),
9886            "initial_vars entries must be marked exported"
9887        );
9888    }
9889
9890    #[tokio::test]
9891    async fn test_execute_with_vars_overlay_visible() {
9892        let kernel = Kernel::transient().expect("failed to create kernel");
9893        let mut overlay = HashMap::new();
9894        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9895
9896        let result = kernel
9897            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9898            .await
9899            .expect("execute failed");
9900
9901        assert!(result.ok());
9902        assert_eq!(result.text_out().trim(), "yes");
9903    }
9904
9905    #[tokio::test]
9906    async fn test_execute_with_vars_overlay_cleanup() {
9907        let kernel = Kernel::transient().expect("failed to create kernel");
9908        let mut overlay = HashMap::new();
9909        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9910
9911        kernel
9912            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9913            .await
9914            .expect("execute failed");
9915
9916        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9917        assert!(
9918            !kernel.scope.read().await.is_exported("EPHEMERAL"),
9919            "overlay-only export must be cleared on return"
9920        );
9921    }
9922
9923    #[tokio::test]
9924    async fn test_execute_with_vars_does_not_clobber_existing_export() {
9925        let kernel = Kernel::transient().expect("failed to create kernel");
9926        kernel
9927            .execute("export OUTER=outer")
9928            .await
9929            .expect("export failed");
9930
9931        let mut overlay = HashMap::new();
9932        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9933        let result = kernel
9934            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9935            .await
9936            .expect("execute failed");
9937        assert_eq!(result.text_out().trim(), "inner");
9938
9939        assert_eq!(
9940            kernel.get_var("OUTER").await,
9941            Some(Value::String("outer".into())),
9942            "outer value must reappear after pop"
9943        );
9944        assert!(
9945            kernel.scope.read().await.is_exported("OUTER"),
9946            "outer export must survive overlay"
9947        );
9948    }
9949
9950    #[tokio::test]
9951    async fn test_execute_with_vars_inner_assignment_is_local() {
9952        let kernel = Kernel::transient().expect("failed to create kernel");
9953        let mut overlay = HashMap::new();
9954        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9955
9956        // Variable assignment inside a single statement uses set() (innermost
9957        // frame), not set_global() — this matches bash function-local semantics.
9958        // We explicitly use `local FOO=...` style by relying on the pushed
9959        // frame; the assignment in the script body modifies the same frame.
9960        let result = kernel
9961            .execute_with_options(
9962                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9963                ExecuteOptions::new().with_vars(overlay),
9964            )
9965            .await
9966            .expect("execute failed");
9967        assert!(result.ok());
9968
9969        // After the call the frame is popped, so LOCAL_FOO is gone regardless
9970        // of how the script reassigned it.
9971        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9972    }
9973
9974    #[tokio::test]
9975    async fn test_external_command_sees_exported_var() {
9976        let kernel = Kernel::transient().expect("failed to create kernel");
9977        // PATH must be in scope to resolve the external `printenv` — the kernel
9978        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9979        // what a frontend does through initial_vars.
9980        let path = std::env::var("PATH").unwrap_or_default();
9981        let result = kernel
9982            .execute(&format!(
9983                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9984            ))
9985            .await
9986            .expect("execute failed");
9987
9988        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9989        assert_eq!(result.text_out().trim(), "bar");
9990    }
9991
9992    #[tokio::test]
9993    async fn test_external_command_does_not_see_unexported_var() {
9994        let kernel = Kernel::transient().expect("failed to create kernel");
9995
9996        // Set without exporting; printenv must not see it (exit code != 0,
9997        // empty stdout per printenv semantics).
9998        let result = kernel
9999            .execute("EXT_BAR=hidden; printenv EXT_BAR")
10000            .await
10001            .expect("execute failed");
10002
10003        assert!(!result.ok(), "printenv should fail when var is unexported");
10004        assert!(
10005            result.text_out().trim().is_empty(),
10006            "no stdout when var is missing, got: {}",
10007            result.text_out()
10008        );
10009    }
10010
10011    #[tokio::test]
10012    async fn test_external_command_does_not_see_os_env() {
10013        // The kernel is hermetic: it never reads std::env::vars() and only
10014        // exports what it has been told to export. Cargo always sets PATH for
10015        // tests, so PATH is reliably present in the OS env — but a transient
10016        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
10017        // inside the kernel must fail.
10018        assert!(
10019            std::env::var_os("PATH").is_some(),
10020            "test precondition: cargo should set PATH"
10021        );
10022
10023        let kernel = Kernel::transient().expect("failed to create kernel");
10024        let result = kernel
10025            .execute("printenv PATH")
10026            .await
10027            .expect("execute failed");
10028
10029        assert!(
10030            !result.ok(),
10031            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
10032            result.text_out()
10033        );
10034        assert!(
10035            result.text_out().trim().is_empty(),
10036            "no PATH in subprocess env, got stdout={:?}",
10037            result.text_out()
10038        );
10039    }
10040
10041    #[tokio::test]
10042    async fn test_execute_with_vars_overlay_reaches_subprocess() {
10043        let kernel = Kernel::transient().expect("failed to create kernel");
10044        let mut overlay = HashMap::new();
10045        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
10046        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
10047        overlay.insert(
10048            "PATH".to_string(),
10049            Value::String(std::env::var("PATH").unwrap_or_default()),
10050        );
10051
10052        let result = kernel
10053            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
10054            .await
10055            .expect("execute failed");
10056
10057        assert!(
10058            result.ok(),
10059            "printenv should succeed: code={} stdout={:?} stderr={:?}",
10060            result.code,
10061            result.text_out(),
10062            result.err
10063        );
10064        assert_eq!(result.text_out().trim(), "subproc");
10065    }
10066
10067    #[tokio::test]
10068    async fn test_classify_command_builtin() {
10069        let kernel = Kernel::transient().expect("failed to create kernel");
10070        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
10071        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
10072    }
10073
10074    #[tokio::test]
10075    async fn test_classify_command_special_forms() {
10076        let kernel = Kernel::transient().expect("failed to create kernel");
10077        for name in ["true", "false", "source", "."] {
10078            assert_eq!(
10079                kernel.classify_command(name).await,
10080                CommandKind::Special,
10081                "{name} should be a special-form",
10082            );
10083        }
10084    }
10085
10086    #[tokio::test]
10087    async fn test_classify_command_dynamic() {
10088        let kernel = Kernel::transient().expect("failed to create kernel");
10089        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
10090        assert_eq!(
10091            kernel.classify_command("$(pick)").await,
10092            CommandKind::Dynamic
10093        );
10094    }
10095
10096    #[tokio::test]
10097    async fn test_classify_command_external() {
10098        let kernel = Kernel::transient().expect("failed to create kernel");
10099        // Not a builtin, user function, or special-form → escapes to PATH.
10100        assert_eq!(
10101            kernel.classify_command("definitely_not_a_kaish_builtin").await,
10102            CommandKind::External
10103        );
10104        // `readonly` is *not* a kaish special-form despite the validator's
10105        // warning heuristic — at runtime it resolves to an external command, so
10106        // a consent gate must see it as External (regression guard against the
10107        // validator/runtime divergence).
10108        assert_eq!(
10109            kernel.classify_command("readonly").await,
10110            CommandKind::External
10111        );
10112        assert!(kernel.classify_command("readonly").await.escapes_kernel());
10113    }
10114
10115    #[tokio::test]
10116    async fn test_classify_command_user_tool_shadows_builtin() {
10117        let kernel = Kernel::transient().expect("failed to create kernel");
10118        kernel
10119            .execute(r#"greet() { echo "hi" }"#)
10120            .await
10121            .expect("function definition failed");
10122        assert_eq!(
10123            kernel.classify_command("greet").await,
10124            CommandKind::UserTool
10125        );
10126
10127        // A user function named after a builtin classifies as UserTool, matching
10128        // the interpreter's user-tools-first resolution.
10129        kernel
10130            .execute(r#"cat() { echo "shadowed" }"#)
10131            .await
10132            .expect("function definition failed");
10133        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10134    }
10135
10136    #[tokio::test]
10137    async fn test_classify_command_alias_to_external_is_external() {
10138        let kernel = Kernel::transient().expect("failed to create kernel");
10139        // An alias whose head is an external binary must NOT report as the
10140        // builtin it shadows — execution expands the alias, so a consent gate
10141        // would otherwise be told an external command is internal.
10142        kernel
10143            .execute("alias cat='/usr/bin/whatever'")
10144            .await
10145            .expect("alias failed");
10146        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
10147        assert!(kernel.classify_command("cat").await.escapes_kernel());
10148    }
10149
10150    #[tokio::test]
10151    async fn test_classify_command_alias_to_builtin() {
10152        let kernel = Kernel::transient().expect("failed to create kernel");
10153        kernel.execute("alias g=grep").await.expect("alias failed");
10154        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
10155    }
10156
10157    #[tokio::test]
10158    async fn test_classify_command_alias_to_special_form() {
10159        let kernel = Kernel::transient().expect("failed to create kernel");
10160        kernel.execute("alias t=true").await.expect("alias failed");
10161        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
10162    }
10163
10164    #[tokio::test]
10165    async fn test_classify_command_braced_var_is_dynamic() {
10166        let kernel = Kernel::transient().expect("failed to create kernel");
10167        // The string API can be handed a `${VAR}` head; it must not be mistaken
10168        // for an external named literally "${VAR}".
10169        assert_eq!(
10170            kernel.classify_command("${CMD}").await,
10171            CommandKind::Dynamic
10172        );
10173    }
10174
10175    /// Drift guard: `classify_command` must agree with what the executor
10176    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
10177    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
10178    /// precedence, alias expansion); without this test those copies could diverge
10179    /// silently — the exact failure class `classify_command` exists to prevent,
10180    /// just moved inside the kernel. Each case asserts the classification AND
10181    /// observes the real resolution, so a future change to one side without the
10182    /// other fails here.
10183    #[tokio::test]
10184    async fn classify_command_matches_executor() {
10185        let kernel = Kernel::transient().expect("failed to create kernel");
10186
10187        // (1) Special-forms. `SpecialForm::from_name` is the single source of
10188        // truth: classify reports Special via it, and the executor matches the
10189        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
10190        // form won't build until both sides handle it). This test pins the other
10191        // half — that each form classifies Special AND actually short-circuits at
10192        // runtime rather than escaping to `PATH`. Every form is executed (not just
10193        // `true`/`false`): an external miss in this PATH-less kernel would be exit
10194        // 127, so a non-127 result that matches the form's own behavior proves the
10195        // short-circuit fired.
10196        for name in ["true", "false", "source", "."] {
10197            assert_eq!(
10198                kernel.classify_command(name).await,
10199                CommandKind::Special,
10200                "{name} should classify Special",
10201            );
10202        }
10203        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
10204        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
10205        // `source`/`.` short-circuit to execute_source, which (no filename) fails
10206        // with its own message — exit 1, never the 127 of an unresolved external.
10207        for name in ["source", "."] {
10208            let r = kernel.execute(name).await.expect("run source form");
10209            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
10210            assert!(
10211                r.err.contains("source: missing filename"),
10212                "{name} did not route to execute_source: {:?}",
10213                r.err,
10214            );
10215        }
10216
10217        // (2) Builtin: classify Builtin AND the executor runs the builtin.
10218        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
10219        let r = kernel.execute("echo hi").await.expect("run echo");
10220        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
10221
10222        // (3) User function shadows a builtin: classify UserTool AND the executor
10223        // runs the function body, not the `cat` builtin.
10224        kernel
10225            .execute(r#"cat() { echo SHADOWED }"#)
10226            .await
10227            .expect("define cat()");
10228        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10229        let r = kernel.execute("cat").await.expect("run shadowed cat");
10230        assert_eq!(
10231            r.text_out().trim(),
10232            "SHADOWED",
10233            "executor ran the builtin instead of the shadowing function",
10234        );
10235
10236        // (4) Alias whose head is external: classify External AND the executor
10237        // resolves through the alias to a missing external (not a builtin).
10238        kernel
10239            .execute("alias x='/nonexistent/binary'")
10240            .await
10241            .expect("define alias x");
10242        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
10243        let r = kernel.execute("x").await.expect("run alias x");
10244        assert!(
10245            !r.ok(),
10246            "alias to a missing external should fail, not resolve internally",
10247        );
10248    }
10249}