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::error::{classify_execute_error, KernelError};
104use 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};
105use crate::parser::parse;
106use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
107use crate::tools::{
108    external_commands_unavailable_error, global_flag_value_is_truthy, register_builtins,
109    ExecContext, ExternalCommandOutcome, ExternalCommandsUnavailable, GlobalFlags, ToolArgs,
110    ToolRegistry,
111};
112#[cfg(feature = "subprocess")]
113use crate::tools::{resolve_in_path, virtual_cwd_error};
114use crate::validator::{Severity, Validator};
115#[cfg(feature = "localfs")]
116use crate::vfs::LocalFs;
117use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
118use kaish_vfs::ByteBudget;
119#[cfg(all(feature = "localfs", feature = "overlay"))]
120use kaish_vfs::OverlayFs;
121
122/// VFS mount mode determines how the local filesystem is exposed.
123///
124/// Different modes trade off convenience vs. security:
125/// - `Passthrough` gives native path access (best for human REPL use)
126/// - `Sandboxed` restricts access to a subtree (safer for agents)
127/// - `NoLocal` provides complete isolation (tests, pure memory mode)
128#[derive(Debug, Clone)]
129#[non_exhaustive]
130pub enum VfsMountMode {
131    /// LocalFs at "/" — native paths work directly.
132    ///
133    /// Full filesystem access. Use for human-operated REPL sessions where
134    /// native paths like `/home/user/project` should just work.
135    ///
136    /// Mounts:
137    /// - `/` → LocalFs("/")
138    /// - `/v` → MemoryFs (blob storage)
139    #[cfg(feature = "localfs")]
140    Passthrough,
141
142    /// Transparent sandbox — paths look native but access is restricted.
143    ///
144    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
145    /// so `/home/user/src/project` just works. But paths outside the sandbox
146    /// root are not accessible.
147    ///
148    /// **Note:** This only restricts VFS (builtin) operations. External commands
149    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
150    ///
151    /// Mounts:
152    /// - `/` → MemoryFs (catches paths outside sandbox)
153    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
154    /// - `/tmp` → LocalFs("/tmp")
155    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
156    /// - `/v` → MemoryFs (blob storage)
157    #[cfg(feature = "localfs")]
158    Sandboxed {
159        /// Root path for local filesystem. Defaults to `$HOME`.
160        /// Can be restricted further, e.g., `~/src`.
161        root: Option<PathBuf>,
162    },
163
164    /// No local filesystem. Memory only.
165    ///
166    /// Complete isolation — no access to the host filesystem.
167    /// Useful for tests or pure sandboxed execution.
168    ///
169    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
170    /// for this mode at kernel construction: with no host filesystem mounted,
171    /// large output must not write a host spill file (`paths::spill_dir()`
172    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
173    ///
174    /// Mounts:
175    /// - `/` → MemoryFs
176    /// - `/tmp` → MemoryFs
177    /// - `/v` → MemoryFs
178    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
179    NoLocal,
180}
181
182#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
183impl Default for VfsMountMode {
184    fn default() -> Self {
185        #[cfg(feature = "localfs")]
186        { VfsMountMode::Sandboxed { root: None } }
187        #[cfg(not(feature = "localfs"))]
188        { VfsMountMode::NoLocal }
189    }
190}
191
192/// Configuration for kernel initialization.
193#[derive(Clone)]
194pub struct KernelConfig {
195    /// Name of this kernel (for identification).
196    pub name: String,
197
198    /// VFS mount mode — controls how local filesystem is exposed.
199    pub vfs_mode: VfsMountMode,
200
201    /// Initial working directory (VFS path).
202    pub cwd: PathBuf,
203
204    /// Whether to skip pre-execution validation.
205    ///
206    /// When false (default), scripts are validated before execution to catch
207    /// errors early. Set to true to skip validation for performance or to
208    /// allow dynamic/external commands.
209    pub skip_validation: bool,
210
211    /// When true, standalone external commands inherit stdio for real-time output.
212    ///
213    /// Set by script runner and REPL for human-visible output.
214    /// Not set by MCP server (output must be captured for structured responses).
215    pub interactive: bool,
216
217    /// Ignore file configuration for file-walking tools.
218    pub ignore_config: crate::ignore_config::IgnoreConfig,
219
220    /// Output size limit configuration for agent safety.
221    pub output_limit: crate::output_limit::OutputLimitConfig,
222
223    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
224    ///
225    /// When `true` (default), commands not found as builtins are resolved via PATH
226    /// and executed as child processes. When `false`, only kaish builtins and
227    /// backend-registered tools are available.
228    ///
229    /// **Security:** External commands bypass the VFS sandbox entirely — they see
230    /// the real filesystem, network, and environment. Set to `false` when running
231    /// untrusted input.
232    pub allow_external_commands: bool,
233
234
235    /// Enable trash-on-delete for rm (set -o trash).
236    ///
237    /// When enabled, small files are moved to freedesktop.org Trash instead of
238    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
239    /// or via `KAISH_TRASH=1`.
240    pub trash_enabled: bool,
241
242    /// Enable errexit (`set -e`) at kernel construction (set -o errexit default).
243    ///
244    /// When enabled, a script aborts at the first statement that exits
245    /// nonzero instead of continuing to the next one. **Off by default** —
246    /// standard shell behavior, so an embedder upgrading kaish sees no
247    /// change. There is one piece of state behind this
248    /// (`Scope::error_exit_enabled`), seeded from this field and mutated at
249    /// runtime by `set -e`/`set +e`: this only picks the *starting* value,
250    /// a script's own `set -e`/`set +e` still applies afterward regardless
251    /// of what this was, and `set -o` always reports the true, single
252    /// answer no matter which one set it. `ExecuteOptions::errexit`
253    /// overrides this for one call.
254    pub errexit_enabled: bool,
255
256    /// Variables to populate the root scope with at construction, all marked
257    /// for export to child processes.
258    ///
259    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
260    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
261    /// from `std::env::vars()`. Embedders that want isolation pass nothing
262    /// (or only the keys they curate).
263    pub initial_vars: HashMap<String, Value>,
264
265    /// Default per-request timeout. When `Some`, every `execute_with_options`
266    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
267    /// When elapsed, the kernel cancels the request, kills any external
268    /// children with the configured grace, and returns exit code 124.
269    ///
270    /// `None` means no default timeout — only explicit per-call timeouts apply.
271    pub request_timeout: Option<Duration>,
272
273    /// Grace period between SIGTERM and SIGKILL when killing an external
274    /// child on cancellation or timeout.
275    ///
276    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
277    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
278    pub kill_grace: Duration,
279
280    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
281    ///
282    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
283    /// construction and handed to every `MemoryFs` the kernel builds in
284    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
285    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
286    /// `StorageFull` — an in-band error a model reads and adapts to; fail
287    /// loud over quietly eating RAM.
288    ///
289    /// **Why the agent preset is bounded by default:** an agent embedder
290    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
291    /// is per-call, not per-session. Embedders that know their workload needs
292    /// more opt out with `without_vfs_budget()` or raise the cap with
293    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
294    /// All other profiles default to `None` (unbounded).
295    ///
296    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
297    pub vfs_budget_bytes: Option<u64>,
298
299    /// Enable copy-on-write overlay mode (opt-in).
300    ///
301    /// When `true`, the primary local filesystem mount is wrapped in an
302    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
303    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
304    /// overlay transaction.
305    ///
306    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
307    /// **Sandboxed{root}:** the `{root}` mount becomes
308    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
309    /// mounts stay as real `LocalFs` (real writes escape the transaction —
310    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
311    /// **NoLocal:** incompatible — construction fails loudly (everything is
312    /// already virtual; an overlay adds no value and no lower layer to wrap).
313    /// **with_backend:** incompatible — the embedder controls the VFS; the
314    /// kernel cannot wrap it without bypassing the embedder's semantics.
315    ///
316    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
317    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
318    /// in the same call as the writes, or the transaction is discarded on drop.
319    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
320    pub overlay: bool,
321
322    /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
323    /// fresh one, so every kernel owns its own job table.
324    ///
325    /// Supply one to share a single job table across kernels. An embedder that
326    /// builds a kernel per request (kaijutsu builds one per tool call) has no
327    /// other way to keep a `cmd &` job reachable: ids, status, and output
328    /// streams all live on the manager, so a per-kernel manager takes them
329    /// down with the kernel that made it. One manager held by the embedder and
330    /// handed to every kernel keeps `&` usable across calls, and keeps job ids
331    /// unique because they are minted from the manager's own counter.
332    ///
333    /// **A shared manager carries shared settings.** `kill_grace` and
334    /// `persist_output_files` are stamped onto the manager at kernel
335    /// construction, so the last kernel built wins for both: a hermetic kernel
336    /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
337    /// off for every kernel on that manager, and each kernel's
338    /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
339    /// between kernels configured alike, or accept the last writer.
340    ///
341    /// Set through [`Self::with_job_manager`].
342    pub job_manager: Option<Arc<JobManager>>,
343
344    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
345    /// spawns, so the OS kills the child the instant this process dies —
346    /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
347    ///
348    /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
349    /// the same "protection on by default for the agent preset, opt in
350    /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
351    ///
352    /// **Why not unconditional.** kaish already puts every child in its own
353    /// process group and kills through a pidfd on cancel, and drops it with
354    /// `kill_on_drop`. All three need this process to still be running code,
355    /// so none of them survive a hard kill — that is the gap this closes. But
356    /// closing it costs something a human at a REPL may not want: an armed
357    /// child cannot outlive its shell, at all, and the child has no way to
358    /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
359    /// escape). A REPL user who backgrounds a long download and exits expects
360    /// it to keep going. An agent embedder expects the opposite — an
361    /// invisible orphaned `cargo build` is the failure — so the presets
362    /// differ rather than one behavior being forced on both.
363    ///
364    /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
365    /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
366    /// process). This flag is accepted and has no effect there, rather than
367    /// being faked with something weaker.
368    ///
369    /// Set through [`Self::with_kill_children_on_parent_death`].
370    pub kill_children_on_parent_death: bool,
371}
372
373/// Get the default sandbox root ($HOME).
374#[cfg(feature = "localfs")]
375fn default_sandbox_root() -> PathBuf {
376    std::env::var("HOME")
377        .map(PathBuf::from)
378        .unwrap_or_else(|_| PathBuf::from("/"))
379}
380
381impl Default for KernelConfig {
382    fn default() -> Self {
383        #[cfg(feature = "localfs")]
384        {
385            let home = default_sandbox_root();
386            Self {
387                name: "default".to_string(),
388                vfs_mode: VfsMountMode::Sandboxed { root: None },
389                cwd: home,
390                skip_validation: false,
391                interactive: false,
392                ignore_config: crate::ignore_config::IgnoreConfig::none(),
393                output_limit: crate::output_limit::OutputLimitConfig::none(),
394                allow_external_commands: cfg!(feature = "subprocess"),
395                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
396                errexit_enabled: false,
397                initial_vars: HashMap::new(),
398                request_timeout: None,
399                kill_grace: Duration::from_secs(2),
400                vfs_budget_bytes: None,
401                overlay: false,
402                job_manager: None,
403                kill_children_on_parent_death: false,
404            }
405        }
406        #[cfg(not(feature = "localfs"))]
407        {
408            Self {
409                name: "default".to_string(),
410                vfs_mode: VfsMountMode::NoLocal,
411                cwd: PathBuf::from("/"),
412                skip_validation: false,
413                interactive: false,
414                ignore_config: crate::ignore_config::IgnoreConfig::none(),
415                output_limit: crate::output_limit::OutputLimitConfig::none(),
416                allow_external_commands: false,
417                trash_enabled: false,
418                errexit_enabled: false,
419                initial_vars: HashMap::new(),
420                request_timeout: None,
421                kill_grace: Duration::from_secs(2),
422                vfs_budget_bytes: None,
423                overlay: false,
424                job_manager: None,
425                kill_children_on_parent_death: false,
426            }
427        }
428    }
429}
430
431impl KernelConfig {
432    /// Create a transient kernel config (sandboxed, for temporary use).
433    #[cfg(feature = "localfs")]
434    pub fn transient() -> Self {
435        let home = default_sandbox_root();
436        Self {
437            name: "transient".to_string(),
438            vfs_mode: VfsMountMode::Sandboxed { root: None },
439            cwd: home,
440            skip_validation: false,
441            interactive: false,
442            ignore_config: crate::ignore_config::IgnoreConfig::none(),
443            output_limit: crate::output_limit::OutputLimitConfig::none(),
444            allow_external_commands: cfg!(feature = "subprocess"),
445            trash_enabled: false,
446            errexit_enabled: false,
447            initial_vars: HashMap::new(),
448            request_timeout: None,
449            kill_grace: Duration::from_secs(2),
450            vfs_budget_bytes: None,
451            overlay: false,
452            job_manager: None,
453            kill_children_on_parent_death: false,
454        }
455    }
456
457    /// Create a transient kernel config (isolated, no-default-features).
458    #[cfg(not(feature = "localfs"))]
459    pub fn transient() -> Self {
460        Self::isolated()
461    }
462
463    /// Create a kernel config with the given name (sandboxed by default).
464    #[cfg(feature = "localfs")]
465    pub fn named(name: &str) -> Self {
466        let home = default_sandbox_root();
467        Self {
468            name: name.to_string(),
469            vfs_mode: VfsMountMode::Sandboxed { root: None },
470            cwd: home,
471            skip_validation: false,
472            interactive: false,
473            ignore_config: crate::ignore_config::IgnoreConfig::none(),
474            output_limit: crate::output_limit::OutputLimitConfig::none(),
475            allow_external_commands: cfg!(feature = "subprocess"),
476            trash_enabled: false,
477            errexit_enabled: false,
478            initial_vars: HashMap::new(),
479            request_timeout: None,
480            kill_grace: Duration::from_secs(2),
481            vfs_budget_bytes: None,
482            overlay: false,
483            job_manager: None,
484            kill_children_on_parent_death: false,
485        }
486    }
487
488    /// Create a kernel config with the given name (isolated, no-default-features).
489    #[cfg(not(feature = "localfs"))]
490    pub fn named(name: &str) -> Self {
491        Self {
492            name: name.to_string(),
493            ..Self::isolated()
494        }
495    }
496
497    /// Create a REPL config with passthrough filesystem access.
498    ///
499    /// Native paths like `/home/user/project` work directly.
500    /// The cwd is set to the actual current working directory.
501    #[cfg(feature = "localfs")]
502    pub fn repl() -> Self {
503        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
504        Self {
505            name: "repl".to_string(),
506            vfs_mode: VfsMountMode::Passthrough,
507            cwd,
508            skip_validation: false,
509            interactive: false,
510            // Ignore-aware by default (GH #134): .gitignore + default ignores
511            // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
512            ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
513            output_limit: crate::output_limit::OutputLimitConfig::none(),
514            allow_external_commands: cfg!(feature = "subprocess"),
515            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
516            errexit_enabled: false,
517            initial_vars: HashMap::new(),
518            request_timeout: None,
519            kill_grace: Duration::from_secs(2),
520            vfs_budget_bytes: None,
521            overlay: false,
522            job_manager: None,
523            kill_children_on_parent_death: false,
524        }
525    }
526
527    /// Create a sandboxed-agent config with sandboxed filesystem access.
528    ///
529    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
530    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
531    /// memory and output. Local filesystem is accessible at its real path (e.g.,
532    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
533    /// accessible through builtins. External commands still access the real
534    /// filesystem — use `.with_allow_external_commands(false)` to block them.
535    ///
536    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
537    /// embedder typically creates a fresh kernel per call). Raise or remove with
538    /// `with_vfs_budget` / `without_vfs_budget`.
539    #[cfg(feature = "localfs")]
540    pub fn agent() -> Self {
541        let home = default_sandbox_root();
542        Self {
543            name: "agent".to_string(),
544            vfs_mode: VfsMountMode::Sandboxed { root: None },
545            cwd: home,
546            skip_validation: false,
547            interactive: false,
548            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
549            output_limit: crate::output_limit::OutputLimitConfig::agent(),
550            allow_external_commands: cfg!(feature = "subprocess"),
551            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
552            errexit_enabled: false,
553            initial_vars: HashMap::new(),
554            request_timeout: None,
555            kill_grace: Duration::from_secs(2),
556            vfs_budget_bytes: Some(64 * 1024 * 1024),
557            overlay: false,
558            job_manager: None,
559            // An agent embedder must never leave an invisible `cargo build` running
560            // after its process is hard-killed; see the field doc for why this is
561            // not the default everywhere.
562            kill_children_on_parent_death: true,
563        }
564    }
565
566    /// Create a sandboxed-agent config with a custom sandbox root.
567    ///
568    /// Use this to restrict access to a subdirectory like `~/src`.
569    ///
570    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
571    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
572    #[cfg(feature = "localfs")]
573    pub fn agent_with_root(root: PathBuf) -> Self {
574        Self {
575            name: "agent".to_string(),
576            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
577            cwd: root,
578            skip_validation: false,
579            interactive: false,
580            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
581            output_limit: crate::output_limit::OutputLimitConfig::agent(),
582            allow_external_commands: cfg!(feature = "subprocess"),
583            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
584            errexit_enabled: false,
585            initial_vars: HashMap::new(),
586            request_timeout: None,
587            kill_grace: Duration::from_secs(2),
588            vfs_budget_bytes: Some(64 * 1024 * 1024),
589            overlay: false,
590            job_manager: None,
591            // Same reasoning as `agent()`.
592            kill_children_on_parent_death: true,
593        }
594    }
595
596    /// Create a config with no local filesystem (memory only).
597    ///
598    /// Complete isolation: no local filesystem and external commands are disabled.
599    /// Useful for tests or pure sandboxed execution.
600    pub fn isolated() -> Self {
601        Self {
602            name: "isolated".to_string(),
603            vfs_mode: VfsMountMode::NoLocal,
604            cwd: PathBuf::from("/"),
605            skip_validation: false,
606            interactive: false,
607            ignore_config: crate::ignore_config::IgnoreConfig::none(),
608            output_limit: crate::output_limit::OutputLimitConfig::none(),
609            allow_external_commands: false,
610            trash_enabled: false,
611            errexit_enabled: false,
612            initial_vars: HashMap::new(),
613            request_timeout: None,
614            kill_grace: Duration::from_secs(2),
615            vfs_budget_bytes: None,
616            overlay: false,
617            job_manager: None,
618            kill_children_on_parent_death: false,
619        }
620    }
621
622    /// Set the VFS mount mode.
623    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
624        self.vfs_mode = mode;
625        self
626    }
627
628    /// Set the initial working directory.
629    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
630        self.cwd = cwd;
631        self
632    }
633
634    /// Skip pre-execution validation.
635    pub fn with_skip_validation(mut self, skip: bool) -> Self {
636        self.skip_validation = skip;
637        self
638    }
639
640    /// Enable interactive mode (external commands inherit stdio).
641    pub fn with_interactive(mut self, interactive: bool) -> Self {
642        self.interactive = interactive;
643        self
644    }
645
646    /// Set the ignore file configuration.
647    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
648        self.ignore_config = config;
649        self
650    }
651
652    /// Set the output limit configuration.
653    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
654        self.output_limit = config;
655        self
656    }
657
658    /// Set whether external command execution is allowed.
659    ///
660    /// When `false`, commands not found as builtins report that external
661    /// commands are disabled on this shell — distinct from "command not
662    /// found", which stays reserved for a name that genuinely isn't
663    /// resolvable — instead of searching PATH. Backend-registered tools
664    /// (MCP, an embedder's own registry) are unaffected and still resolve.
665    /// The `exec` and `spawn` builtins also refuse, with the same wording.
666    /// Use this to prevent VFS sandbox bypass via external binaries.
667    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
668        self.allow_external_commands = allow;
669        self
670    }
671
672    /// Enable or disable trash-on-delete at startup.
673    pub fn with_trash(mut self, enabled: bool) -> Self {
674        self.trash_enabled = enabled;
675        self
676    }
677
678    /// Enable or disable errexit (`set -e`) at startup. See `errexit_enabled`
679    /// for precedence against `ExecuteOptions::errexit` and runtime `set -e`.
680    pub fn with_errexit(mut self, enabled: bool) -> Self {
681        self.errexit_enabled = enabled;
682        self
683    }
684
685    /// Add a single initial variable; marked exported when the kernel boots.
686    ///
687    /// Repeated calls add (last write wins on key collision).
688    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
689        self.initial_vars.insert(name.into(), value);
690        self
691    }
692
693    /// Replace the entire initial-vars map. All entries are marked exported.
694    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
695        self.initial_vars = vars;
696        self
697    }
698
699    /// Extend the initial-vars map with the given entries (last write wins).
700    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
701        self.initial_vars.extend(vars);
702        self
703    }
704
705    /// Set the default per-request timeout (kernel-wide).
706    ///
707    /// Each `execute_with_options` call without an explicit timeout uses
708    /// this. On elapsed, the kernel cancels and returns exit code 124.
709    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
710        self.request_timeout = Some(timeout);
711        self
712    }
713
714    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
715    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
716        self.kill_grace = grace;
717        self
718    }
719
720    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
721    /// kaish process cannot orphan them (Linux only — read
722    /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
723    /// gap).
724    pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
725        self.kill_children_on_parent_death = on;
726        self
727    }
728
729    /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
730    /// so background jobs outlive the kernel that started them. Read
731    /// [`Self::job_manager`] before sharing one manager between kernels that
732    /// are configured differently.
733    pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
734        self.job_manager = Some(jobs);
735        self
736    }
737
738    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
739    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
740    /// created at kernel construction and passed to every `MemoryFs` the
741    /// kernel builds (see `setup_vfs` and `with_backend`).
742    ///
743    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
744    /// in-band error a model reads and adapts to; fail loud over quietly eating
745    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
746    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
747        self.vfs_budget_bytes = Some(bytes);
748        self
749    }
750
751    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
752    ///
753    /// Use when the caller knows the workload and the default 64 MiB cap
754    /// (set by `KernelConfig::agent`) is too conservative.
755    pub fn without_vfs_budget(mut self) -> Self {
756        self.vfs_budget_bytes = None;
757        self
758    }
759
760    /// Enable or disable copy-on-write overlay mode.
761    ///
762    /// When `true`, the primary local filesystem mount is wrapped in an
763    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
764    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
765    /// and `with_backend` kernels (same — the embedder controls the VFS).
766    pub fn with_overlay(mut self, overlay: bool) -> Self {
767        self.overlay = overlay;
768        self
769    }
770
771}
772
773
774/// Handle to an active overlay session, kept on the kernel and shared to
775/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
776///
777/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
778/// `/home/user`); `commit_root` is the real filesystem path the overlay's
779/// lower is backed by (used as the target for `kaish-vfs commit`).
780#[cfg(all(feature = "localfs", feature = "overlay"))]
781#[derive(Clone)]
782pub struct OverlayHandle {
783    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
784    /// methods without holding a VfsRouter lock.
785    pub fs: Arc<OverlayFs>,
786    /// VFS path this overlay is mounted at (e.g. `/home/user`).
787    pub mount_path: PathBuf,
788    /// Real filesystem root to commit into. Same as the lower's root.
789    pub commit_root: PathBuf,
790}
791
792/// The Kernel (核) — executes kaish code.
793///
794/// This is the primary interface for running kaish commands. It owns all
795/// the runtime state: variables, tools, VFS, jobs, and persistence.
796pub struct Kernel {
797    /// Kernel name.
798    name: String,
799    /// Variable scope.
800    scope: RwLock<Scope>,
801    /// Tool registry.
802    tools: Arc<ToolRegistry>,
803    /// User-defined tools (from `tool name { body }` statements).
804    user_tools: RwLock<HashMap<String, ToolDef>>,
805    /// Virtual filesystem router.
806    vfs: Arc<VfsRouter>,
807    /// Background job manager.
808    jobs: Arc<JobManager>,
809    /// Pipeline runner.
810    runner: PipelineRunner,
811    /// Execution context (cwd, stdin, etc.).
812    exec_ctx: RwLock<ExecContext>,
813    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
814    /// retained past construction so `reset()` can re-seed them into the fresh
815    /// scope instead of silently dropping them.
816    initial_vars: HashMap<String, Value>,
817    /// Whether to skip pre-execution validation.
818    skip_validation: bool,
819    /// When true, standalone external commands inherit stdio for real-time output.
820    interactive: bool,
821    /// Whether external command execution is allowed.
822    allow_external_commands: bool,
823    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
824    ///
825    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
826    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
827    /// the same pool — a background job's writes reduce the same cap as
828    /// foreground writes, which is the correct behaviour.
829    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
830    /// Active overlay session handle, if this kernel was constructed with
831    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
832    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
833    /// holding a kernel write lock. Propagated to forks via `fork_inner`
834    /// and `child_for_pipeline` so `kaish-vfs` works inside background
835    /// jobs, scatter workers, and pipeline stages.
836    #[cfg(all(feature = "localfs", feature = "overlay"))]
837    overlay_handle: Option<Arc<OverlayHandle>>,
838    /// Default per-request timeout (None = no default).
839    request_timeout: Option<Duration>,
840    /// Receiver for the kernel stderr stream.
841    ///
842    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
843    /// The kernel drains this after each statement in `execute_streaming`.
844    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
845    /// Cancellation token for interrupting execution (Ctrl-C).
846    ///
847    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
848    /// needs sync access. Each `execute()` call gets a fresh child token;
849    /// `cancel()` cancels the current token and replaces it.
850    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
851    /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
852    /// installed for the duration of an `execute_with_options` call and
853    /// cleared on exit. Consulted by `is_cancelled()` so every existing
854    /// cancellation checkpoint gains interrupt awareness without new wiring.
855    /// std Mutex for the same sync-access reason as `cancel_token`.
856    interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
857    /// Terminal state for job control (interactive mode only, Unix only).
858    #[cfg(all(unix, feature = "subprocess"))]
859    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
860    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
861    ///
862    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
863    /// through the full Kernel resolution chain.
864    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
865    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
866    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
867    /// queue. Background jobs, scatter workers, and concurrent pipeline
868    /// stages do NOT take this lock — they run against a *forked* Kernel
869    /// (see [`Kernel::fork`]) so they never contend with the foreground.
870    execute_lock: tokio::sync::Mutex<()>,
871    /// Current dynamic statement-engine re-entry depth — incremented on entry
872    /// to command substitution, a shell-function call, or a `.kai` source, and
873    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
874    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
875    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
876    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
877    /// the recursion chain is single-threaded (top-level `execute` is
878    /// serialized by `execute_lock`; concurrency happens on forks).
879    recursion_depth: AtomicUsize,
880}
881
882/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
883/// (in `enter_recursion`) and decrements on drop, so a cancelled or
884/// error-unwound re-entry can never leave the counter inflated (which would
885/// spuriously trip later, unrelated recursions).
886struct RecursionGuard<'a> {
887    counter: &'a AtomicUsize,
888}
889
890impl Drop for RecursionGuard<'_> {
891    fn drop(&mut self) {
892        self.counter.fetch_sub(1, Ordering::Relaxed);
893    }
894}
895
896/// Internal result of [`Kernel::setup_vfs`].
897struct VfsSetupResult {
898    vfs: VfsRouter,
899    budget: Option<Arc<ByteBudget>>,
900    #[cfg(all(feature = "localfs", feature = "overlay"))]
901    overlay_handle: Option<Arc<OverlayHandle>>,
902}
903
904impl Kernel {
905    /// Create a new kernel with the given configuration.
906    pub fn new(config: KernelConfig) -> Result<Self> {
907        let mut setup = Self::setup_vfs(&config)?;
908        // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
909        // (see `KernelConfig::job_manager`); with none, this kernel owns its
910        // own job table exactly as before.
911        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
912        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
913        // kill builtin bounds its wait-for-death on the same number (GH #244).
914        jobs.set_kill_grace(config.kill_grace);
915
916        // Mount JobFs for job observability at /v/jobs
917        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
918
919        #[cfg(all(feature = "localfs", feature = "overlay"))]
920        let overlay_handle = setup.overlay_handle.take();
921
922        // Mode-based construction: the kernel owns its host mounts, so whether
923        // host side channels are allowed is decided by the VFS mode inside
924        // `assemble` (NoLocal forbids them).
925        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
926            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
927        })?;
928
929        #[cfg(all(feature = "localfs", feature = "overlay"))]
930        {
931            let mut kernel = kernel;
932            kernel.overlay_handle = overlay_handle;
933            // Also set it on the ExecContext so builtins can access it.
934            if let Some(ref handle) = kernel.overlay_handle {
935                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
936            }
937            return Ok(kernel);
938        }
939
940        #[allow(unreachable_code)]
941        Ok(kernel)
942    }
943
944    /// Set up VFS based on mount mode.
945    ///
946    /// Returns the router, the budget handle (if bounded), and an optional
947    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
948    /// every `MemoryFs` the kernel creates here holds a clone of the same
949    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
950    /// in-memory content across all kernel-owned memory mounts.
951    ///
952    /// # Errors
953    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
954    /// (overlay is meaningless when everything is already virtual — there is
955    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
956    /// this as an `anyhow::Error`.
957    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
958        let mut vfs = VfsRouter::new();
959
960        // One budget for all memory mounts this kernel owns — labeled so the
961        // error message tells the user exactly which knob to raise.
962        let budget: Option<Arc<ByteBudget>> = config
963            .vfs_budget_bytes
964            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
965
966        /// Helper: construct a `MemoryFs` wired to `budget` if present.
967        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
968            match budget {
969                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
970                None => MemoryFs::new(),
971            }
972        }
973
974        // Overlay handle — populated below if config.overlay is true.
975        #[cfg(all(feature = "localfs", feature = "overlay"))]
976        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
977
978        match &config.vfs_mode {
979            #[cfg(feature = "localfs")]
980            VfsMountMode::Passthrough => {
981                #[cfg(feature = "overlay")]
982                if config.overlay {
983                    // Wrap "/" in an OverlayFs so writes are virtual.
984                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
985                    let overlay_fs = Arc::new(match &budget {
986                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
987                        None => OverlayFs::over(lower),
988                    });
989                    let handle = Arc::new(OverlayHandle {
990                        fs: Arc::clone(&overlay_fs),
991                        mount_path: PathBuf::from("/"),
992                        commit_root: PathBuf::from("/"),
993                    });
994                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
995                    overlay_handle = Some(handle);
996                } else {
997                    // LocalFs at "/" — native paths work directly
998                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
999                }
1000                #[cfg(not(feature = "overlay"))]
1001                {
1002                    if config.overlay {
1003                        return Err(anyhow::anyhow!(
1004                            "overlay=true requires the `overlay` feature, but this build \
1005                             was compiled without it. Recompile with --features overlay \
1006                             (or the default feature set) to enable overlay mode."
1007                        ));
1008                    }
1009                    // LocalFs at "/" — native paths work directly
1010                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
1011                }
1012                // Memory for blobs
1013                vfs.mount("/v", mem(&budget));
1014            }
1015            #[cfg(feature = "localfs")]
1016            VfsMountMode::Sandboxed { root } => {
1017                // Memory at root for safety (catches paths outside sandbox).
1018                // Note: /tmp and the XDG runtime dir are LocalFs — writes
1019                // there escape the VFS budget and are NOT virtual. This is
1020                // intentional: /tmp interop with other processes matters more
1021                // than accounting for scratch files there.
1022                vfs.mount("/", mem(&budget));
1023                vfs.mount("/v", mem(&budget));
1024
1025                // Synthetic /dev: the host's real /dev isn't reachable here, so
1026                // /dev/null and /dev/zero are software-backed (see DevFs).
1027                vfs.mount("/dev", DevFs::new());
1028
1029                // Real /tmp for interop with other processes
1030                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1031
1032                // Mount XDG runtime dir for spill files and socket access
1033                let runtime = crate::paths::xdg_runtime_dir();
1034                if runtime.exists() {
1035                    let runtime_str = runtime.to_string_lossy().to_string();
1036                    vfs.mount(&runtime_str, LocalFs::new(runtime));
1037                }
1038
1039                // Resolve the sandbox root (defaults to $HOME)
1040                let local_root = root.clone().unwrap_or_else(|| {
1041                    std::env::var("HOME")
1042                        .map(PathBuf::from)
1043                        .unwrap_or_else(|_| PathBuf::from("/"))
1044                });
1045
1046                let mount_point = local_root.to_string_lossy().to_string();
1047
1048                #[cfg(feature = "overlay")]
1049                if config.overlay {
1050                    // Wrap the sandbox root in an OverlayFs.
1051                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1052                    let overlay_fs = Arc::new(match &budget {
1053                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1054                        None => OverlayFs::over(lower),
1055                    });
1056                    let handle = Arc::new(OverlayHandle {
1057                        fs: Arc::clone(&overlay_fs),
1058                        mount_path: PathBuf::from(&mount_point),
1059                        commit_root: local_root,
1060                    });
1061                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1062                    overlay_handle = Some(handle);
1063                } else {
1064                    // Mount at the real path for transparent access
1065                    // e.g., /home/atobey → LocalFs("/home/atobey")
1066                    // so /home/atobey/src/kaish just works
1067                    vfs.mount(&mount_point, LocalFs::new(local_root));
1068                }
1069                #[cfg(not(feature = "overlay"))]
1070                {
1071                    if config.overlay {
1072                        return Err(anyhow::anyhow!(
1073                            "overlay=true requires the `overlay` feature, but this build \
1074                             was compiled without it. Recompile with --features overlay \
1075                             (or the default feature set) to enable overlay mode."
1076                        ));
1077                    }
1078                    // Mount at the real path for transparent access
1079                    vfs.mount(&mount_point, LocalFs::new(local_root));
1080                }
1081            }
1082            VfsMountMode::NoLocal => {
1083                if config.overlay {
1084                    return Err(anyhow::anyhow!(
1085                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
1086                         everything is already virtual, there is no real lower layer \
1087                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1088                         or Sandboxed VFS mode."
1089                    ));
1090                }
1091                // Pure memory mode — no local filesystem
1092                vfs.mount("/", mem(&budget));
1093                vfs.mount("/tmp", mem(&budget));
1094                vfs.mount("/v", mem(&budget));
1095                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1096                vfs.mount("/dev", DevFs::new());
1097            }
1098        }
1099
1100        Ok(VfsSetupResult {
1101            vfs,
1102            budget,
1103            #[cfg(all(feature = "localfs", feature = "overlay"))]
1104            overlay_handle,
1105        })
1106    }
1107
1108    /// Create a transient kernel (no persistence).
1109    pub fn transient() -> Result<Self> {
1110        Self::new(KernelConfig::transient())
1111    }
1112
1113    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1114    ///
1115    /// This is the constructor for embedding kaish in other systems that provide
1116    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1117    ///
1118    /// A `VirtualOverlayBackend` routes paths automatically:
1119    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1120    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1121    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1122    ///   read-only
1123    /// - Everything else → Your custom backend
1124    ///
1125    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1126    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1127    ///
1128    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1129    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1130    /// `skip_validation`, and `interactive`.
1131    ///
1132    /// # Example
1133    ///
1134    /// ```ignore
1135    /// // Simple: default /v/* mounts only
1136    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1137    ///
1138    /// // With custom mounts
1139    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1140    ///     vfs.mount_arc("/v/docs", docs_fs);
1141    ///     vfs.mount_arc("/v/g", git_fs);
1142    /// }, |_| {})?;
1143    ///
1144    /// // With custom tools
1145    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1146    ///     tools.register(MyCustomTool::new());
1147    /// })?;
1148    /// ```
1149    pub fn with_backend(
1150        backend: Arc<dyn KernelBackend>,
1151        config: KernelConfig,
1152        configure_vfs: impl FnOnce(&mut VfsRouter),
1153        configure_tools: impl FnOnce(&mut ToolRegistry),
1154    ) -> Result<Self> {
1155        use crate::backend::VirtualOverlayBackend;
1156
1157        // overlay=true is incompatible with with_backend: the embedder controls
1158        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1159        // semantics. Fail loudly rather than silently ignoring the flag.
1160        if config.overlay {
1161            return Err(anyhow::anyhow!(
1162                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1163                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1164                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1165            ));
1166        }
1167
1168        let mut vfs = VfsRouter::new();
1169        // See `Kernel::new` — the embedder's manager wins here too.
1170        let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1171        // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1172        // kill builtin bounds its wait-for-death on the same number (GH #244).
1173        jobs.set_kill_grace(config.kill_grace);
1174
1175        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1176        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1177        // kernel-owned memory mount here — embedders own the rest of the VFS.
1178        let vfs_budget: Option<Arc<ByteBudget>> = config
1179            .vfs_budget_bytes
1180            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1181
1182        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1183        let blobs_fs = match &vfs_budget {
1184            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1185            None => MemoryFs::new(),
1186        };
1187        vfs.mount("/v/blobs", blobs_fs);
1188
1189        // /dev/null and friends are software-backed (see DevFs) and must not
1190        // depend on the embedder's backend — a read-only embedder backend
1191        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1192        // to /dev/null as a filesystem error instead of discarding them.
1193        vfs.mount("/dev", DevFs::new());
1194
1195        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1196        configure_vfs(&mut vfs);
1197
1198        // A custom-backend kernel owns no host mounts — the embedder supplies
1199        // the entire VFS — so any kernel write to a host filesystem via
1200        // `std::fs` (output spill, job output files) bypasses that VFS and its
1201        // read-only guarantees. Forbid host side channels unconditionally.
1202        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1203            let overlay: Arc<dyn KernelBackend> =
1204                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1205            ExecContext::with_backend(overlay)
1206        })
1207    }
1208
1209    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1210    ///
1211    /// The `make_ctx` closure receives the VFS and tools so backends that need
1212    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1213    /// that already have their own storage can ignore these parameters.
1214    fn assemble(
1215        config: KernelConfig,
1216        mut vfs: VfsRouter,
1217        jobs: Arc<JobManager>,
1218        no_host_filesystem: bool,
1219        vfs_budget: Option<Arc<ByteBudget>>,
1220        configure_tools: impl FnOnce(&mut ToolRegistry),
1221        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1222    ) -> Result<Self> {
1223        // A kernel with no host filesystem of its own must never write to one
1224        // through a side channel. Two paths bypass the VFS by going straight to
1225        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1226        // background-job output files (`Job::write_output_file` → host temp).
1227        // Both would punch through the isolation, so force them off:
1228        // in-memory truncation for spill, no host file for job output.
1229        //
1230        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1231        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1232        // VFS, so the kernel controls no host mounts and any host write is a
1233        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1234        // when there is no kernel-owned host filesystem to spill to.
1235        let no_host_side_channel =
1236            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1237
1238        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, errexit_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1239
1240        if no_host_side_channel {
1241            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1242            jobs.set_persist_output_files(false);
1243        }
1244
1245        let mut tools = ToolRegistry::new();
1246        register_builtins(&mut tools);
1247        configure_tools(&mut tools);
1248        let tools = Arc::new(tools);
1249
1250        // Mount BuiltinFs so `ls /v/bin` lists builtins
1251        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1252
1253        let vfs = Arc::new(vfs);
1254
1255        let runner = PipelineRunner::new(tools.clone());
1256
1257        let (stderr_writer, stderr_receiver) = stderr_stream();
1258
1259        let mut exec_ctx = make_ctx(&vfs, &tools);
1260        let initial_cwd = cwd.clone();
1261        exec_ctx.set_cwd(cwd);
1262        exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1263        exec_ctx.kill_grace = kill_grace;
1264        exec_ctx.set_job_manager(jobs.clone());
1265        exec_ctx.set_tool_schemas(tools.schemas());
1266        exec_ctx.set_tools(tools.clone());
1267        #[cfg(feature = "os-integration")]
1268        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1269        exec_ctx.stderr = Some(stderr_writer);
1270        exec_ctx.ignore_config = ignore_config;
1271        exec_ctx.output_limit = output_limit;
1272        exec_ctx.allow_external_commands = allow_external_commands;
1273        exec_ctx.vfs_budget = vfs_budget.clone();
1274
1275        Ok(Self {
1276            name,
1277            scope: RwLock::new({
1278                let mut scope = Scope::new();
1279                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1280                // HOME is NOT read from the host env here — the kernel is
1281                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1282                // below (from `std::env::vars()`); a hermetic embedder leaves
1283                // `initial_vars` empty and gets no HOME (tilde stays literal).
1284                // Apply caller-supplied initial variables, all marked exported.
1285                // Frontends (REPL, MCP) populate this from std::env::vars()
1286                // for shell-like UX; embedders that want hermetic behavior
1287                // simply leave it empty.
1288                for (name, value) in initial_vars.clone() {
1289                    scope.set_exported(name, value);
1290                }
1291                scope.set_trash_enabled(trash_enabled);
1292                scope.set_error_exit(errexit_enabled);
1293                // `$PWD` before any `cd`. Seeded HERE, not just on `exec_ctx`:
1294                // this is the scope execution reads, and `exec_ctx.scope` is
1295                // overwritten from it at every dispatch, so a value written
1296                // only there never survives to be read.
1297                scope.set_global(
1298                    "PWD",
1299                    Value::String(initial_cwd.to_string_lossy().into_owned()),
1300                );
1301                // `$OLDPWD` is DROPPED rather than seeded. There is no previous
1302                // directory yet, and an inherited one describes the invoking
1303                // shell's history, not this session's — `cd -` already refuses
1304                // with "OLDPWD not set", and the variable must not contradict
1305                // it by naming a directory `cd -` will not go to.
1306                scope.remove("OLDPWD");
1307                scope
1308            }),
1309            initial_vars,
1310            tools,
1311            user_tools: RwLock::new(HashMap::new()),
1312            vfs,
1313            jobs,
1314            runner,
1315            exec_ctx: RwLock::new(exec_ctx),
1316            skip_validation,
1317            interactive,
1318            allow_external_commands,
1319            vfs_budget,
1320            request_timeout,
1321            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1322            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1323            interrupt: std::sync::Mutex::new(None),
1324            #[cfg(all(unix, feature = "subprocess"))]
1325            terminal_state: None,
1326            self_weak: std::sync::OnceLock::new(),
1327            execute_lock: tokio::sync::Mutex::new(()),
1328            recursion_depth: AtomicUsize::new(0),
1329            // Overlay handle is set by Kernel::new after assemble returns;
1330            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1331            // with_backend always has None (overlay=true is rejected above).
1332            #[cfg(all(feature = "localfs", feature = "overlay"))]
1333            overlay_handle: None,
1334        })
1335    }
1336
1337    /// Plan every statement of `source` without executing anything —
1338    /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1339    /// embedder holding a kernel can pair the plans with `get_var` lookups
1340    /// against this kernel's live state.
1341    ///
1342    /// # Errors
1343    ///
1344    /// Returns the parse errors when `source` does not parse.
1345    pub fn plan_program(
1346        &self,
1347        source: &str,
1348    ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1349        crate::ast::plan::plan_program(source)
1350    }
1351
1352    /// Expand one heredoc body against a scope the caller supplies —
1353    /// [`expand_fragment`](crate::fragment::expand_fragment) as a method.
1354    ///
1355    /// The scope is the caller's, not this kernel's: pair it with `get_var`
1356    /// when the session's values are the ones to judge against, and supply
1357    /// different values when they are not. Nothing executes, and a `$(…)` in
1358    /// the body comes back as a [`Hole`](kaish_types::plan::Hole) rather than
1359    /// running here.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns a [`FragmentError`](crate::fragment::FragmentError) when the
1364    /// source does not parse, the address names no heredoc, or the body reads
1365    /// something the supplied scope does not carry.
1366    pub fn expand_fragment(
1367        &self,
1368        source: &str,
1369        addr: kaish_types::plan::FragmentAddr,
1370        scope: &[(String, Value)],
1371    ) -> Result<kaish_types::plan::Expansion, crate::fragment::FragmentError> {
1372        crate::fragment::expand_fragment(source, addr, scope)
1373    }
1374
1375    /// Get the kernel name.
1376    pub fn name(&self) -> &str {
1377        &self.name
1378    }
1379
1380    /// Wrap this Kernel in an Arc and initialize its self-reference.
1381    ///
1382    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1383    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1384    /// commands through the full resolution chain (user tools → builtins →
1385    /// .kai scripts → external commands).
1386    pub fn into_arc(self) -> Arc<Self> {
1387        let arc = Arc::new(self);
1388        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1389        arc
1390    }
1391
1392    /// Fork a subsidiary kernel for concurrent execution.
1393    ///
1394    /// The fork is a fully-functional `Kernel` that:
1395    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1396    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1397    ///   the fork do NOT propagate back to the parent — matching bash
1398    ///   subshell / background-job semantics.
1399    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1400    ///   registry, the VFS router, and the job manager. A job registered by
1401    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1402    ///   sees the same VFS mounts.
1403    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1404    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1405    ///   `false` and `terminal_state` is `None`.
1406    ///
1407    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1408    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1409    /// routes through the fork itself, not the parent — which is essential
1410    /// for concurrency safety.
1411    ///
1412    /// Use this for **detached** background concurrency where the fork should
1413    /// survive parent cancellation: the `&` background-job operator and any
1414    /// other "fire and forget" worker. The fork gets a fresh, independent
1415    /// cancellation token.
1416    ///
1417    /// For foreground concurrency (scatter workers, concurrent pipeline
1418    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1419    /// into the fork's external children, use [`Self::fork_attached`].
1420    pub async fn fork(&self) -> Arc<Self> {
1421        let background_job = self.exec_ctx.read().await.background_job;
1422        self.fork_inner(tokio_util::sync::CancellationToken::new(), background_job)
1423            .await
1424    }
1425
1426    /// Fork attached to the parent's cancellation.
1427    ///
1428    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1429    /// the parent's. When the parent cancels (request timeout, embedder
1430    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1431    /// turn kills any external children spawned in the fork via the
1432    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1433    pub async fn fork_attached(&self) -> Arc<Self> {
1434        let child_token = {
1435            #[allow(clippy::expect_used)]
1436            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1437            parent.child_token()
1438        };
1439        let background_job = self.exec_ctx.read().await.background_job;
1440        self.fork_inner(child_token, background_job).await
1441    }
1442
1443    /// Fork for a background job, stamping the job id so external commands
1444    /// spawned anywhere beneath it record their process groups on that job
1445    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1446    /// `JobManager::cancel`.
1447    pub async fn fork_for_background(
1448        &self,
1449        cancel: tokio_util::sync::CancellationToken,
1450        job_id: crate::scheduler::JobId,
1451    ) -> Arc<Self> {
1452        self.fork_inner(cancel, Some(job_id)).await
1453    }
1454
1455    /// Shared fork implementation. Caller decides the cancellation token and
1456    /// which background job (if any) this fork runs on behalf of.
1457    async fn fork_inner(
1458        &self,
1459        cancel: tokio_util::sync::CancellationToken,
1460        background_job: Option<crate::scheduler::JobId>,
1461    ) -> Arc<Self> {
1462        let scope_snapshot = self.scope.read().await.clone();
1463        let user_tools_snapshot = self.user_tools.read().await.clone();
1464
1465        // Snapshot exec_ctx by cloning the cloneable fields, then override
1466        // the ones that should not carry over (stderr channel, dispatcher,
1467        // interactive flag, terminal state, cancel — set from `cancel` arg).
1468        let mut fork_ctx = {
1469            let parent_ctx = self.exec_ctx.read().await;
1470            parent_ctx.child_for_pipeline()
1471        };
1472        let (stderr_writer, stderr_receiver) = stderr_stream();
1473        fork_ctx.stderr = Some(stderr_writer);
1474        // Clear dispatcher; dispatch_command will repopulate it to point at
1475        // the fork on the first dispatch call.
1476        fork_ctx.dispatcher = None;
1477        fork_ctx.interactive = false;
1478        fork_ctx.cancel = cancel.clone();
1479        fork_ctx.background_job = background_job;
1480        #[cfg(all(unix, feature = "subprocess"))]
1481        {
1482            fork_ctx.terminal_state = None;
1483        }
1484
1485        let fork = Self {
1486            name: format!("{}:fork", self.name),
1487            scope: RwLock::new(scope_snapshot),
1488            initial_vars: self.initial_vars.clone(),
1489            tools: Arc::clone(&self.tools),
1490            user_tools: RwLock::new(user_tools_snapshot),
1491            vfs: Arc::clone(&self.vfs),
1492            jobs: Arc::clone(&self.jobs),
1493            runner: self.runner.clone(),
1494            exec_ctx: RwLock::new(fork_ctx),
1495            skip_validation: self.skip_validation,
1496            // Forks are never the TTY owner — they run in the background.
1497            interactive: false,
1498            allow_external_commands: self.allow_external_commands,
1499            // Arc-clone the budget so the fork draws from the same pool as the
1500            // parent — background jobs and scatter workers count against the same
1501            // cap as foreground writes.
1502            vfs_budget: self.vfs_budget.clone(),
1503            request_timeout: self.request_timeout,
1504            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1505            cancel_token: std::sync::Mutex::new(cancel),
1506            interrupt: std::sync::Mutex::new(None),
1507            #[cfg(all(unix, feature = "subprocess"))]
1508            terminal_state: None,
1509            self_weak: std::sync::OnceLock::new(),
1510            execute_lock: tokio::sync::Mutex::new(()),
1511            // A fork runs on a fresh stack (spawned task) — its recursion
1512            // budget is independent of the parent's current depth (GH #46).
1513            recursion_depth: AtomicUsize::new(0),
1514            // Arc-clone the overlay handle so forks (background jobs, scatter
1515            // workers, pipeline stages) can reach the same overlay transaction
1516            // via `kaish-vfs status/diff/commit/reset`.
1517            #[cfg(all(feature = "localfs", feature = "overlay"))]
1518            overlay_handle: self.overlay_handle.clone(),
1519        };
1520
1521        fork.into_arc()
1522    }
1523
1524    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1525    ///
1526    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1527    /// have been dropped (the `Weak` can no longer upgrade).
1528    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1529        self.self_weak
1530            .get()
1531            .and_then(|weak| weak.upgrade())
1532            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1533    }
1534
1535    /// Initialize terminal state for interactive job control.
1536    ///
1537    /// Call this after kernel creation when running as an interactive REPL
1538    /// and stdin is a TTY. Sets up process groups and signal handling.
1539    #[cfg(all(unix, feature = "subprocess"))]
1540    pub fn init_terminal(&mut self) {
1541        if !self.interactive {
1542            return;
1543        }
1544        match crate::terminal::TerminalState::init() {
1545            Ok(state) => {
1546                let state = Arc::new(state);
1547                self.terminal_state = Some(state.clone());
1548                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1549                self.exec_ctx.get_mut().terminal_state = Some(state);
1550                tracing::debug!("terminal job control initialized");
1551            }
1552            Err(e) => {
1553                tracing::warn!("failed to initialize terminal job control: {}", e);
1554            }
1555        }
1556    }
1557
1558    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1559    ///
1560    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1561    /// built with the `os-integration` feature. Embedders and tests can swap
1562    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1563    /// it — with trash enabled but no backend present, `rm` fails loud
1564    /// rather than falling through to permanent delete.
1565    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1566        self.exec_ctx.get_mut().trash_backend = backend;
1567    }
1568
1569    /// Cancel the current execution.
1570    ///
1571    /// This cancels the current cancellation token, causing any execution
1572    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1573    /// A fresh token is installed for the next `execute()` call.
1574    pub fn cancel(&self) {
1575        #[allow(clippy::expect_used)]
1576        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1577        token.cancel();
1578    }
1579
1580    /// Check if the current execution has been cancelled.
1581    ///
1582    /// Also the polling point for `ExecuteOptions::interrupt`: when the
1583    /// embedder's check reports true, the internal token fires here, so every
1584    /// call site of this method is an interrupt checkpoint for free.
1585    pub fn is_cancelled(&self) -> bool {
1586        let interrupted = {
1587            #[allow(clippy::expect_used)]
1588            let check = self.interrupt.lock().expect("interrupt poisoned");
1589            check.as_ref().is_some_and(|f| f())
1590        };
1591        if interrupted {
1592            self.cancel();
1593        }
1594        #[allow(clippy::expect_used)]
1595        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1596        token.is_cancelled()
1597    }
1598
1599    /// Reset the cancellation token (called at the start of each execute).
1600    ///
1601    /// A `Kernel::cancel()` that arrives while nothing is running is dropped
1602    /// here: the next `execute()` replaces the cancelled token and runs
1603    /// normally, and nothing in that call's result reports a cancel was
1604    /// discarded. An embedder can see the pending cancel before it is dropped —
1605    /// `is_cancelled()` reports true until the next `execute()` clears it — but
1606    /// a call that must start already cancelled has to supply its own
1607    /// `ExecuteOptions::cancel_token`, which is a read-only input and is never
1608    /// reset; a pre-cancelled one stops the call at its first checkpoint.
1609    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1610        #[allow(clippy::expect_used)]
1611        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1612        if token.is_cancelled() {
1613            *token = tokio_util::sync::CancellationToken::new();
1614        }
1615        token.clone()
1616    }
1617
1618    /// Acquire the per-Kernel execute lock, warning on contention.
1619    ///
1620    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1621    /// the lock is already held, emit a warning so the silent serialization
1622    /// is observable in logs — if you need real parallelism, fork the kernel.
1623    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1624        match self.execute_lock.try_lock() {
1625            Ok(guard) => guard,
1626            Err(_) => {
1627                tracing::warn!(
1628                    target: "kaish::kernel::concurrency",
1629                    kernel = %self.name,
1630                    "execute() contended — serializing concurrent caller; \
1631                     use Kernel::fork() for parallelism instead of sharing"
1632                );
1633                self.execute_lock.lock().await
1634            }
1635        }
1636    }
1637
1638    /// Execute kaish source code with default options.
1639    ///
1640    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1641    /// Returns the result of the last statement executed.
1642    ///
1643    /// # Errors
1644    ///
1645    /// Returns [`KernelError`] when the program was rejected before running
1646    /// (a lex/parse failure or a validator rejection) or faulted while
1647    /// running. See [`KernelError::is_rejected`] to route on that
1648    /// distinction. A nonzero exit from the *script itself* — a failed
1649    /// command, `set -e` — is not an `Err`; it comes back as `Ok` with the
1650    /// exit code folded into the returned [`ExecResult`].
1651    pub async fn execute(&self, input: &str) -> Result<ExecResult, KernelError> {
1652        self.run_inner(input, ExecuteOptions::default(), None, None)
1653            .await
1654            .map_err(classify_execute_error)
1655    }
1656
1657    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1658    /// are **already tokenized**.
1659    ///
1660    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1661    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1662    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1663    /// into a string just to have the lexer split it apart again — a round-trip
1664    /// that is lossy for typed values, since `to_argv()` stringifies
1665    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1666    ///
1667    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1668    /// command substitution, no word splitting — the "single-quoted word"
1669    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1670    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1671    /// does still apply, for consistency with the string door: a leading `~` is
1672    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1673    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1674    /// non-string `Value`
1675    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1676    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1677    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1678    /// sees a stringified value; the typed-passthrough win fully lands only for
1679    /// builtins that read `args.positional` directly — the documented pattern.)
1680    ///
1681    /// This is a *peer*, not a subset: a command string can carry pipelines,
1682    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1683    /// doors converge **late** (at the shared dispatch chain) rather than one
1684    /// wrapping the other. From argv classification onward `execute_argv` reuses
1685    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1686    /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1687    /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1688    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1689    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1690    ///
1691    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1692    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1693    /// external is interrupted at the deadline with exit code 124, the same as the
1694    /// string door). There is no per-call options surface yet — a future
1695    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1696    ///
1697    /// # Errors
1698    ///
1699    /// Returns [`KernelError`], always [`KernelError::Execution`] — argv has
1700    /// no shell syntax to reject, so `execute_argv` never returns
1701    /// [`KernelError::Parse`] or [`KernelError::Validation`] (a tool's own
1702    /// `validate()`/clap parse at dispatch still surfaces here, as an
1703    /// execution failure).
1704    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1705    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult, KernelError> {
1706        let _guard = self.acquire_execute_lock().await;
1707        self.execute_argv_locked(name, argv).await.map_err(classify_execute_error)
1708    }
1709
1710    /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1711    async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1712        // Fresh cancel surface for this call: `execute_pipeline` reads
1713        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1714        // replaced first. The returned clone is the token the watchdog cancels on
1715        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1716        // cascading SIGTERM/SIGKILL to any external child.
1717        let cancel = self.reset_cancel();
1718
1719        // Honor the kernel-configured request timeout for parity with `execute`.
1720        let timeout = self.request_timeout;
1721        if timeout == Some(Duration::ZERO) {
1722            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1723        }
1724
1725        let command = crate::ast::Command {
1726            name: name.to_string(),
1727            args: argv_to_args(argv),
1728            redirects: Vec::new(),
1729        };
1730
1731        let pipeline = crate::ast::Pipeline {
1732            stages: vec![crate::ast::PipelineStage::Command(command)],
1733            background: false,
1734        };
1735        let work = async {
1736            let result = self.execute_pipeline(&pipeline).await?;
1737            // A gate raised while evaluating inside the dispatched tool — a
1738            // user tool body's `$(…)` — surfaces as this call's own held
1739            // result, and must not strand in the slot for the next serialized
1740            // call to mis-take.
1741            Ok(result)
1742        };
1743        let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1744        self.update_last_result(&result).await;
1745        Ok(result)
1746    }
1747
1748    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1749    /// string door ([`Self::execute_with_options`]) and the argv door
1750    /// ([`Self::execute_argv`]).
1751    ///
1752    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1753    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1754    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1755    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1756    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1757    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1758    /// stale handle would silently suspend nothing). Callers must short-circuit a
1759    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1760    async fn run_under_watchdog<F>(
1761        &self,
1762        timeout: Option<Duration>,
1763        cancel: &tokio_util::sync::CancellationToken,
1764        work: F,
1765    ) -> Result<ExecResult>
1766    where
1767        F: std::future::Future<Output = Result<ExecResult>>,
1768    {
1769        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1770        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1771        {
1772            let mut ec = self.exec_ctx.write().await;
1773            ec.watchdog = watchdog.clone();
1774        }
1775
1776        let result = if let Some(d) = timeout {
1777            #[allow(clippy::expect_used)]
1778            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1779            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1780            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1781            let r = work.await;
1782            timer.abort();
1783            match r {
1784                Ok(mut res) => {
1785                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1786                        res.code = 124;
1787                        if res.err.is_empty() {
1788                            res.err =
1789                                ExecResult::terminate_diagnostic(format!("timeout: timed out after {:?}", d));
1790                        }
1791                    }
1792                    Ok(res)
1793                }
1794                Err(e) => Err(e),
1795            }
1796        } else {
1797            work.await
1798        };
1799
1800        // The timer task is gone (fired or aborted); drop the stale handle.
1801        {
1802            let mut ec = self.exec_ctx.write().await;
1803            ec.watchdog = None;
1804        }
1805        result
1806    }
1807
1808    /// Execute with per-call options. The primary entry point for embedders
1809    /// that don't need per-statement output streaming.
1810    ///
1811    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1812    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1813    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1814    ///
1815    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1816    /// against the kernel's internal token. Either firing cancels and kills
1817    /// external children. The embedder's token is read-only — kernel
1818    /// timeouts do NOT propagate into it. Distinguish via the returned
1819    /// `code`: 124 = timeout, 130 = cancellation.
1820    ///
1821    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1822    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1823    ///
1824    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1825    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1826    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1827    ///
1828    /// # Errors
1829    ///
1830    /// Returns [`KernelError`] when the program was rejected before running
1831    /// or faulted while running — see [`KernelError::is_rejected`].
1832    pub async fn execute_with_options(
1833        &self,
1834        input: &str,
1835        opts: ExecuteOptions,
1836    ) -> Result<ExecResult, KernelError> {
1837        self.run_inner(input, opts, None, None).await.map_err(classify_execute_error)
1838    }
1839
1840    /// Same as [`Self::execute_with_options`] but with a per-statement output
1841    /// callback. The callback fires after each top-level statement so the
1842    /// embedder (REPL, MCP streaming) can flush output incrementally.
1843    ///
1844    /// # Errors
1845    ///
1846    /// See [`Self::execute_with_options`].
1847    pub async fn execute_with_options_streaming(
1848        &self,
1849        input: &str,
1850        opts: ExecuteOptions,
1851        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1852    ) -> Result<ExecResult, KernelError> {
1853        self.run_inner(input, opts, None, Some(on_output)).await.map_err(classify_execute_error)
1854    }
1855
1856    /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1857    ///
1858    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1859    /// forces the input to be drained before execution: the reader seeds the
1860    /// first top-level command's `pipe_stdin`, and a command that does not read
1861    /// stdin (`echo`) returns without touching it. This is the seam a
1862    /// non-interactive frontend uses to forward an *open* process stdin without
1863    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1864    ///
1865    /// Embedders that already hold a complete buffer (text or binary) should
1866    /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1867    ///
1868    /// # Errors
1869    ///
1870    /// See [`Self::execute_with_options`].
1871    pub async fn execute_with_pipe_stdin(
1872        &self,
1873        input: &str,
1874        opts: ExecuteOptions,
1875        pipe_stdin: crate::scheduler::PipeReader,
1876    ) -> Result<ExecResult, KernelError> {
1877        self.run_inner(input, opts, Some(pipe_stdin), None).await.map_err(classify_execute_error)
1878    }
1879
1880    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1881    /// `-c`/script frontend uses this to print output incrementally while
1882    /// feeding a lazy process-stdin pipe.
1883    ///
1884    /// # Errors
1885    ///
1886    /// See [`Self::execute_with_options`].
1887    pub async fn execute_with_pipe_stdin_streaming(
1888        &self,
1889        input: &str,
1890        opts: ExecuteOptions,
1891        pipe_stdin: crate::scheduler::PipeReader,
1892        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1893    ) -> Result<ExecResult, KernelError> {
1894        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output))
1895            .await
1896            .map_err(classify_execute_error)
1897    }
1898
1899    /// Execute kaish source code with a transient overlay of exported variables.
1900    ///
1901    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1902    /// should use that method directly:
1903    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1904    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1905    pub async fn execute_with_vars(
1906        &self,
1907        input: &str,
1908        vars: HashMap<String, Value>,
1909    ) -> Result<ExecResult, KernelError> {
1910        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None)
1911            .await
1912            .map_err(classify_execute_error)
1913    }
1914
1915    /// Execute kaish source code with a per-statement callback.
1916    ///
1917    /// Deprecated thin wrapper. New code should use
1918    /// [`Self::execute_with_options_streaming`].
1919    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1920    pub async fn execute_streaming(
1921        &self,
1922        input: &str,
1923        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1924    ) -> Result<ExecResult, KernelError> {
1925        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output))
1926            .await
1927            .map_err(classify_execute_error)
1928    }
1929
1930    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1931    ///
1932    /// The `#[instrument]` execution span resolves its parent from the *current*
1933    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1934    /// captured when the span is first entered — not when the future is
1935    /// constructed. So a thread-local `attach()` scoped to construction is too
1936    /// early to be seen (the integration test confirms this). `with_context`
1937    /// re-attaches the embedder's context on *every* poll of the inner future,
1938    /// so the context is current at first-enter and survives runtime thread
1939    /// hops. With no embedder trace context, the future runs unwrapped.
1940    async fn run_inner(
1941        &self,
1942        input: &str,
1943        opts: ExecuteOptions,
1944        pipe_stdin: Option<crate::scheduler::PipeReader>,
1945        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1946    ) -> Result<ExecResult> {
1947        use opentelemetry::context::FutureExt;
1948
1949        // Capture the embedder's baggage before `opts` is consumed so it can be
1950        // echoed back onto the result on egress (see `merge_egress_baggage`).
1951        let embedder_baggage = opts.baggage.clone();
1952
1953        let result = match crate::telemetry::extract_parent(&opts) {
1954            Some(parent) => self
1955                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1956                .with_context(parent)
1957                .await,
1958            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1959        };
1960
1961        result.map(|mut r| {
1962            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1963            r
1964        })
1965    }
1966
1967    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1968    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1969    /// cwd override, and timeout race.
1970    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1971    async fn execute_with_options_inner(
1972        &self,
1973        input: &str,
1974        opts: ExecuteOptions,
1975        pipe_stdin: Option<crate::scheduler::PipeReader>,
1976        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1977    ) -> Result<ExecResult> {
1978        let _guard = self.acquire_execute_lock().await;
1979
1980        // Always reset to a fresh internal token; this is the kernel's own
1981        // cancel surface for embedders calling `Kernel::cancel()`. The
1982        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1983        // is NOT written into `self.cancel_token`, because doing so would
1984        // (a) leak the embedder's token past this call's lifetime,
1985        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1986        // (c) extend the token's lifetime via the kernel's strong clone.
1987        let internal = self.reset_cancel();
1988
1989        // Install the per-call polled interrupt for `is_cancelled()` to
1990        // consult. The guard clears it on every exit path — a stale check
1991        // must not outlive its call and fire into a later one.
1992        struct ClearInterrupt<'a>(&'a Kernel);
1993        impl Drop for ClearInterrupt<'_> {
1994            fn drop(&mut self) {
1995                if let Ok(mut slot) = self.0.interrupt.lock() {
1996                    *slot = None;
1997                }
1998            }
1999        }
2000        {
2001            #[allow(clippy::expect_used)]
2002            let mut slot = self.interrupt.lock().expect("interrupt poisoned");
2003            *slot = opts.interrupt.clone();
2004        }
2005        let _interrupt_guard = ClearInterrupt(self);
2006
2007        // Race the embedder token against the kernel's internal token via a
2008        // tracked watcher task. We hold the JoinHandle so we can abort the
2009        // task at function exit — otherwise it would wait forever for either
2010        // token to fire and leak per call.
2011        let (effective_cancel, watcher_handle): (
2012            tokio_util::sync::CancellationToken,
2013            Option<tokio::task::JoinHandle<()>>,
2014        ) = if let Some(ext) = opts.cancel_token {
2015            let combined = tokio_util::sync::CancellationToken::new();
2016            let combined_writer = combined.clone();
2017            let i = internal.clone();
2018            let handle = tokio::spawn(async move {
2019                tokio::select! {
2020                    _ = i.cancelled() => combined_writer.cancel(),
2021                    _ = ext.cancelled() => combined_writer.cancel(),
2022                }
2023            });
2024            (combined, Some(handle))
2025        } else {
2026            (internal, None)
2027        };
2028
2029        // Effective timeout: per-call wins over kernel-config default.
2030        let timeout = opts.timeout.or(self.request_timeout);
2031
2032        // ZERO timeout: return 124 immediately without spawning anything.
2033        if timeout == Some(Duration::ZERO) {
2034            if let Some(h) = watcher_handle {
2035                h.abort();
2036            }
2037            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
2038        }
2039
2040        // Apply per-call vars overlay (push frame + set_exported), wrapped in
2041        // an RAII guard so a panic inside `execute_streaming_inner` still
2042        // pops the frame and unexports the temporarily-exported names.
2043        struct VarsFrameGuard<'a> {
2044            kernel: &'a Kernel,
2045            newly_exported: Vec<String>,
2046        }
2047        impl Drop for VarsFrameGuard<'_> {
2048            fn drop(&mut self) {
2049                // Best-effort cleanup using try_write. The execute_lock held
2050                // throughout execute_with_options means there is no concurrent
2051                // foreground caller; forks have their own scope and won't
2052                // block this. blocking_write would deadlock the runtime when
2053                // called from a tokio worker thread, so we explicitly do NOT
2054                // fall back to it — if try_write fails (which we've never
2055                // seen in practice), log loudly and accept the leak rather
2056                // than deadlock the entire kernel.
2057                let Ok(mut scope) = self.kernel.scope.try_write() else {
2058                    tracing::error!(
2059                        "vars frame guard: scope lock unexpectedly busy; \
2060                         skipping pop_frame to avoid runtime deadlock — \
2061                         transient vars may leak"
2062                    );
2063                    return;
2064                };
2065                scope.pop_frame();
2066                for name in self.newly_exported.drain(..) {
2067                    scope.unexport(&name);
2068                }
2069            }
2070        }
2071
2072        // Per-call cwd override: save current cwd, set the new one, restore
2073        // on Drop so the kernel's persistent cwd doesn't leak between calls.
2074        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
2075        struct CwdGuard<'a> {
2076            kernel: &'a Kernel,
2077            saved: PathBuf,
2078        }
2079        impl Drop for CwdGuard<'_> {
2080            fn drop(&mut self) {
2081                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2082                    tracing::error!(
2083                        "cwd guard: exec_ctx lock unexpectedly busy; \
2084                         skipping cwd restore — kernel cwd may be wrong for next call"
2085                    );
2086                    return;
2087                };
2088                ec.cwd = std::mem::take(&mut self.saved);
2089            }
2090        }
2091        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
2092            let mut ec = self.exec_ctx.write().await;
2093            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
2094            drop(ec);
2095            Some(CwdGuard { kernel: self, saved })
2096        } else {
2097            None
2098        };
2099
2100        // Per-call stdin: seed the persistent exec_ctx so the first top-level
2101        // command that reads stdin consumes it (it's `take()`n at dispatch).
2102        // Restore the prior value on Drop — normally `None`, so this also drops
2103        // any residual seed an stdin-less program never consumed, keeping it
2104        // from bleeding into the next call. Same RAII pattern as CwdGuard.
2105        struct StdinGuard<'a> {
2106            kernel: &'a Kernel,
2107            saved: Option<Vec<u8>>,
2108        }
2109        impl Drop for StdinGuard<'_> {
2110            fn drop(&mut self) {
2111                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2112                    tracing::error!(
2113                        "stdin guard: exec_ctx lock unexpectedly busy; \
2114                         skipping stdin restore — stale stdin may leak to next call"
2115                    );
2116                    return;
2117                };
2118                ec.stdin = self.saved.take();
2119            }
2120        }
2121        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2122            let mut ec = self.exec_ctx.write().await;
2123            let saved = ec.stdin.replace(stdin);
2124            drop(ec);
2125            Some(StdinGuard { kernel: self, saved })
2126        } else {
2127            None
2128        };
2129
2130        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2131        // persistent exec_ctx so the first stdin-reading command drains it (it's
2132        // `take()`n at pipeline build). The RAII guard restores the prior value
2133        // on Drop (normally `None`), so an unread reader doesn't bleed into the
2134        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2135        struct PipeStdinGuard<'a> {
2136            kernel: &'a Kernel,
2137            saved: Option<crate::scheduler::PipeReader>,
2138        }
2139        impl Drop for PipeStdinGuard<'_> {
2140            fn drop(&mut self) {
2141                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2142                    tracing::error!(
2143                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2144                         skipping restore — stale pipe stdin may leak to next call"
2145                    );
2146                    return;
2147                };
2148                ec.pipe_stdin = self.saved.take();
2149            }
2150        }
2151        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2152            let mut ec = self.exec_ctx.write().await;
2153            let saved = ec.pipe_stdin.replace(reader);
2154            drop(ec);
2155            Some(PipeStdinGuard { kernel: self, saved })
2156        } else {
2157            None
2158        };
2159
2160        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2161            let mut scope = self.scope.write().await;
2162            scope.push_frame();
2163            let mut newly = Vec::with_capacity(opts.vars.len());
2164            for (name, value) in opts.vars {
2165                if !scope.is_exported(&name) {
2166                    newly.push(name.clone());
2167                }
2168                scope.set_exported(name, value);
2169            }
2170            drop(scope);
2171            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2172        } else {
2173            None
2174        };
2175
2176        // Per-call errexit override (`ExecuteOptions::errexit`): save the
2177        // kernel's current errexit state, apply the override, restore the
2178        // saved value on Drop so it doesn't leak into the next call. `None`
2179        // leaves errexit exactly as the kernel already has it — no save, no
2180        // restore. Same RAII pattern as CwdGuard/StdinGuard.
2181        struct ErrexitGuard<'a> {
2182            kernel: &'a Kernel,
2183            saved: bool,
2184        }
2185        impl Drop for ErrexitGuard<'_> {
2186            fn drop(&mut self) {
2187                let Ok(mut scope) = self.kernel.scope.try_write() else {
2188                    tracing::error!(
2189                        "errexit guard: scope lock unexpectedly busy; \
2190                         skipping errexit restore — override may leak to next call"
2191                    );
2192                    return;
2193                };
2194                scope.set_error_exit(self.saved);
2195            }
2196        }
2197        let _errexit_guard: Option<ErrexitGuard<'_>> = if let Some(enabled) = opts.errexit {
2198            let mut scope = self.scope.write().await;
2199            // The RAW flag, not `error_exit_enabled()`: that one is false
2200            // while errexit is suppressed inside a `&&`/`||` left side, and
2201            // restoring from it would turn `set -e` off for good.
2202            let saved = scope.error_exit_flag();
2203            scope.set_error_exit(enabled);
2204            drop(scope);
2205            Some(ErrexitGuard { kernel: self, saved })
2206        } else {
2207            None
2208        };
2209
2210        // Sync the effective cancel into self.exec_ctx so try_execute_external
2211        // (which reads via self.cancel_token) sees cancellation. We also need
2212        // builtins to see it via ctx.cancel — handled in execute_command.
2213        // For simplicity here we mirror effective_cancel into self.cancel_token
2214        // for the duration of this call, then restore the internal token at
2215        // the end (so a later Kernel::cancel still hits our internal surface).
2216        {
2217            #[allow(clippy::expect_used)]
2218            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2219            *cur = effective_cancel.clone();
2220        }
2221
2222        // Run the script under the movable-deadline watchdog (shared with the
2223        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2224        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2225        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2226        // already handled by the early return above.
2227        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2228        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2229            Some(cb) => cb,
2230            None => &mut *noop_cb,
2231        };
2232
2233        let result = self
2234            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2235            .await;
2236
2237        // Restore self.cancel_token to a fresh, uncancelled token so the
2238        // embedder's view of `Kernel::cancel()` stays predictable on the
2239        // next call (it cancels the kernel's own token, not whatever was
2240        // left over from this call's combined token).
2241        {
2242            #[allow(clippy::expect_used)]
2243            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2244            *cur = tokio_util::sync::CancellationToken::new();
2245        }
2246
2247        // Tear down the embedder-token race watcher (if any). Leaving it
2248        // alive would idle forever waiting for tokens that may never fire.
2249        if let Some(h) = watcher_handle {
2250            h.abort();
2251        }
2252
2253        // VarsFrameGuard drops here on the success path and on early-return
2254        // paths above (error path included). Panic safety preserved.
2255        result
2256    }
2257
2258    /// The actual body of `execute_streaming`, run while holding the execute lock.
2259    ///
2260    /// Split out so internal kernel paths that are already under the lock can
2261    /// call this without deadlocking on re-entry. External callers must go
2262    /// through [`Self::execute_streaming`] so they acquire the lock.
2263    async fn execute_streaming_inner(
2264        &self,
2265        input: &str,
2266        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2267    ) -> Result<ExecResult> {
2268        let program = parse(input).map_err(|errors| {
2269            let msg = errors
2270                .iter()
2271                .map(|e| e.format(input))
2272                .collect::<Vec<_>>()
2273                .join("\n");
2274            let message = format!("parse error:\n{}", msg);
2275            // Tagged so `classify_execute_error` can recover the structured
2276            // rejection at the public execute-surface boundary; every other
2277            // `?` in this function propagates a plain, untagged `anyhow::Error`.
2278            anyhow::Error::from(KernelError::Parse { errors, message })
2279        })?;
2280
2281        // AST display mode: show AST instead of executing
2282        {
2283            let scope = self.scope.read().await;
2284            if scope.show_ast() {
2285                let output = format!("{:#?}\n", program);
2286                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2287            }
2288        }
2289
2290        // Pre-execution validation. Most warnings stay trace-only (every
2291        // external command fires an `UndefinedCommand` warning), but a warning
2292        // whose code opts into agent surfacing is collected here and prepended
2293        // to the result's stderr at each return point below.
2294        let mut surfaced_warnings = String::new();
2295        if !self.skip_validation {
2296            // Catalog first: neither guard should ride the other's await, and
2297            // `validate()` is synchronous, so neither rides one after this.
2298            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
2299            let user_tools = self.user_tools.read().await;
2300            let validator = Validator::new(&self.tools, &user_tools, &catalog);
2301            let issues = validator.validate(&program);
2302
2303            // Collect errors (warnings are logged but don't prevent execution)
2304            let errors: Vec<_> = issues
2305                .iter()
2306                .filter(|i| i.severity == Severity::Error)
2307                .collect();
2308
2309            if !errors.is_empty() {
2310                let error_msg = errors
2311                    .iter()
2312                    .map(|e| e.format(input))
2313                    .collect::<Vec<_>>()
2314                    .join("\n");
2315                let message = format!("validation failed:\n{}", error_msg);
2316                let issues: Vec<crate::validator::ValidationIssue> =
2317                    errors.into_iter().cloned().collect();
2318                // Tagged the same way as the parse rejection above.
2319                return Err(anyhow::Error::from(KernelError::Validation { issues, message }));
2320            }
2321
2322            // Log warnings via tracing (trace level to avoid noise); surface the
2323            // opted-in ones to the agent so the guidance is actually seen.
2324            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2325                tracing::trace!("validation: {}", warning.format(input));
2326                if warning.code.surfaces_to_agent() {
2327                    surfaced_warnings.push_str(&warning.format(input));
2328                    surfaced_warnings.push('\n');
2329                }
2330            }
2331        }
2332
2333        // Surface opted-in validation warnings to the streaming frontend once,
2334        // before any command output. The streaming consumer (`-c`, REPL) prints
2335        // per `on_output` and ignores the returned aggregate err; non-streaming
2336        // callers (`kernel.execute`) use a noop callback and read the aggregate
2337        // `result.err` (prepended at each return below). The two paths are
2338        // disjoint, so this prints the advisory exactly once on each.
2339        if !surfaced_warnings.is_empty() {
2340            let mut advisory = ExecResult::success("");
2341            advisory.err = surfaced_warnings.clone();
2342            on_output(&advisory);
2343        }
2344
2345        let mut result = ExecResult::success("");
2346
2347        // Reset cancellation token for this execution.
2348        let cancel = self.reset_cancel();
2349
2350        for stmt in program.statements.into_iter() {
2351            if matches!(stmt, Stmt::Empty) {
2352                continue;
2353            }
2354
2355            // Cancellation checkpoint
2356            if cancel.is_cancelled() {
2357                result.code = 130;
2358                return Ok(result);
2359            }
2360
2361            // The statement tap and gate (spec §C.6) — one of exactly two
2362            // sites. It runs before `execute_stmt_flow`, so a held statement
2363            // has run *nothing*: no substitution, no redirect opened, no
2364            let flow_result = self.execute_stmt_flow(&stmt).await;
2365            let flow = flow_result?;
2366
2367            // Drain any stderr written by pipeline stages during this statement.
2368            // This captures stderr from intermediate pipeline stages that would
2369            // otherwise be lost (only the last stage's result is returned).
2370            let drained_stderr = {
2371                let mut receiver = self.stderr_receiver.lock().await;
2372                receiver.drain_lossy()
2373            };
2374
2375            match flow {
2376                ControlFlow::Normal(mut r) => {
2377                    if !drained_stderr.is_empty() {
2378                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2379                            r.err.push('\n');
2380                        }
2381                        // Prepend pipeline stderr before the last stage's stderr
2382                        let combined = format!("{}{}", drained_stderr, r.err);
2383                        r.err = combined;
2384                    }
2385                    on_output(&r);
2386                    // Carry the last statement's structured output for MCP TOON encoding.
2387                    // Must be done here (not in accumulate_result) because accumulate_result
2388                    // is also used in loops where per-iteration output would be wrong.
2389                    let last_output = r.output().cloned();
2390                    accumulate_result(&mut result, &r);
2391                    result.set_output(last_output);
2392                }
2393                ControlFlow::Exit { code, result: carried } => {
2394                    if !drained_stderr.is_empty() {
2395                        result.err.push_str(&drained_stderr);
2396                    }
2397                    // Output produced before the exit — e.g. by the loop the
2398                    // `exit` ran inside — arrives on the signal. Emit it like
2399                    // any other statement's, then let `code` decide the status.
2400                    on_output(&carried);
2401                    accumulate_result(&mut result, &carried);
2402                    result.code = code;
2403                    if !surfaced_warnings.is_empty() {
2404                        result.err = format!("{surfaced_warnings}{}", result.err);
2405                    }
2406                    return Ok(result);
2407                }
2408                ControlFlow::Return { mut value } => {
2409                    if !drained_stderr.is_empty() {
2410                        value.err = format!("{}{}", drained_stderr, value.err);
2411                    }
2412                    on_output(&value);
2413                    // A top-level `return` stops the script, like `exit` —
2414                    // it must not discard prior statements' accumulated
2415                    // output nor let execution continue past it.
2416                    accumulate_result(&mut result, &value);
2417                    if !surfaced_warnings.is_empty() {
2418                        result.err = format!("{surfaced_warnings}{}", result.err);
2419                    }
2420                    return Ok(result);
2421                }
2422                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2423                    if !drained_stderr.is_empty() {
2424                        r.err = format!("{}{}", drained_stderr, r.err);
2425                    }
2426                    on_output(&r);
2427                    accumulate_result(&mut result, &r);
2428                }
2429            }
2430        }
2431
2432        if !surfaced_warnings.is_empty() {
2433            result.err = format!("{surfaced_warnings}{}", result.err);
2434        }
2435        Ok(result)
2436    }
2437
2438    /// Execute a single statement, returning control flow information.
2439    fn execute_stmt_flow<'a>(
2440        &'a self,
2441        stmt: &'a Stmt,
2442    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2443        // No per-statement span here: `execute_stmt_flow` is the largest future
2444        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2445        // the span's state through every `.await` at every level, costing native
2446        // stack per level (GH #48). Coarser spans on the outer execute entries
2447        // remain. See item 3 of the #48 burndown.
2448        Box::pin(async move {
2449        match stmt {
2450            Stmt::Assignment(assign) => {
2451                // An assignment with no command name takes the exit status of
2452                // the last command substitution in its value, or 0 if there
2453                // was none (bash's rule, re-probed). Clear the note first so
2454                // a substitution from an earlier statement cannot leak in —
2455                // `false; x=5` must be 0, not stale.
2456                {
2457                    let mut scope = self.scope.write().await;
2458                    scope.clear_cmdsubst_code();
2459                }
2460                // Use async evaluator to support command substitution
2461                let value = self.eval_expr_async(&assign.value).await
2462                    .context("failed to evaluate assignment")?;
2463                let mut scope = self.scope.write().await;
2464                if assign.path.segments.len() == 1 {
2465                    // Plain `NAME=value` — no subscript, so `local` applies.
2466                    if assign.local {
2467                        // local: set in innermost (current function) frame
2468                        scope.set(assign.name(), value.clone());
2469                    } else {
2470                        // non-local: update existing or create in root frame
2471                        scope.set_global(assign.name(), value.clone());
2472                    }
2473                } else {
2474                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2475                    // mutates the existing root wherever it lives, so `local`
2476                    // has nothing to declare. See docs/LANGUAGE.md,
2477                    // "Assignment — bracket-path lvalues".
2478                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2479                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2480                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2481                        ),
2482                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2483                    })?;
2484                }
2485                drop(scope);
2486
2487                // Assignments don't produce output (like sh), but they are a
2488                // command: they write `$?` and honor `set -e` (bash: `set -e;
2489                // x=$(false)` exits). The code is the last substitution's, or
2490                // 0 — this is what lets `x="$(cmd)" || x="FALLBACK"` fire.
2491                let subst_code = {
2492                    let mut scope = self.scope.write().await;
2493                    scope.take_cmdsubst_code()
2494                };
2495                let result = match subst_code {
2496                    None | Some(0) => ExecResult::success(""),
2497                    Some(code) => ExecResult::failure(code, ""),
2498                };
2499                self.update_last_result(&result).await;
2500                if !result.ok() {
2501                    let scope = self.scope.read().await;
2502                    if scope.error_exit_enabled() {
2503                        // `-e` aborts the statement list, but the reason the
2504                        // command died must survive with it — carry `result`
2505                        // (its `out`/`err`/`data`) into the Exit signal instead
2506                        // of `ControlFlow::exit_code`'s empty placeholder.
2507                        let code = result.code;
2508                        return Ok(ControlFlow::Exit { code, result });
2509                    }
2510                }
2511                Ok(ControlFlow::ok(result))
2512            }
2513            Stmt::Command(cmd) => {
2514                // Route single commands through execute_pipeline for a unified path.
2515                // This ensures all commands go through the dispatcher chain.
2516                let pipeline = crate::ast::Pipeline {
2517                    stages: vec![crate::ast::PipelineStage::Command(cmd.clone())],
2518                    background: false,
2519                };
2520                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2521                self.update_last_result(&result).await;
2522
2523                // Check for error exit mode (set -e)
2524                if !result.ok() {
2525                    let scope = self.scope.read().await;
2526                    if scope.error_exit_enabled() {
2527                        // `-e` aborts the statement list, but the reason the
2528                        // command died must survive with it — carry `result`
2529                        // (its `out`/`err`/`data`) into the Exit signal instead
2530                        // of `ControlFlow::exit_code`'s empty placeholder.
2531                        let code = result.code;
2532                        return Ok(ControlFlow::Exit { code, result });
2533                    }
2534                }
2535
2536                Ok(ControlFlow::ok(result))
2537            }
2538            Stmt::Pipeline(pipeline) => {
2539                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2540                self.update_last_result(&result).await;
2541
2542                // Check for error exit mode (set -e)
2543                if !result.ok() {
2544                    let scope = self.scope.read().await;
2545                    if scope.error_exit_enabled() {
2546                        // `-e` aborts the statement list, but the reason the
2547                        // command died must survive with it — carry `result`
2548                        // (its `out`/`err`/`data`) into the Exit signal instead
2549                        // of `ControlFlow::exit_code`'s empty placeholder.
2550                        let code = result.code;
2551                        return Ok(ControlFlow::Exit { code, result });
2552                    }
2553                }
2554
2555                Ok(ControlFlow::ok(result))
2556            }
2557            Stmt::If(if_stmt) => {
2558                // The statement's result is built BEFORE the condition runs,
2559                // because a condition's own stdout is the first thing in it —
2560                // see `eval_condition_async`. (An `elif` is a nested `Stmt::If`
2561                // in `else_branch`, so it takes this same path.)
2562                let mut result = ExecResult::success("");
2563                let cond_value = self
2564                    .eval_condition_async(&if_stmt.condition, &mut result)
2565                    .await?;
2566
2567                let branch = if is_truthy(&cond_value) {
2568                    &if_stmt.then_branch
2569                } else {
2570                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2571                };
2572
2573                for stmt in branch {
2574                    let flow = self.execute_stmt_flow(stmt).await?;
2575                    match flow {
2576                        ControlFlow::Normal(r) => {
2577                            // Drain BEFORE accumulating, as the `while` arm
2578                            // does: the stream holds the condition's stderr,
2579                            // which was written first and must read first.
2580                            // Appending `r.err` ahead of the drain put the
2581                            // branch's diagnostic before the condition's.
2582                            self.drain_stderr_into(&mut result).await;
2583                            accumulate_result(&mut result, &r);
2584                        }
2585                        mut other => {
2586                            self.drain_stderr_into(&mut result).await;
2587                            fold_block_output_into_flow(std::mem::take(&mut result), &mut other);
2588                            return Ok(other);
2589                        }
2590                    }
2591                }
2592                // A compound statement is a command: it writes `$?` whether or
2593                // not a body statement ran. Without this, `if false; then …; fi`
2594                // leaves the PREVIOUS statement's status visible to `$?` — a
2595                // failure that did not happen. Idempotent when a body did run:
2596                // the body's own arm already wrote the same code.
2597                self.update_last_result(&result).await;
2598                Ok(ControlFlow::ok(result))
2599            }
2600            Stmt::For(for_loop) => {
2601                // Evaluate all items and collect values for iteration
2602                // Use async evaluator to support command substitution like $(seq 1 5)
2603                let mut items: Vec<Value> = Vec::new();
2604                for item_expr in &for_loop.items {
2605                    // Glob expansion in for-loop items: `for f in *.txt`
2606                    if let Expr::GlobPattern(pattern) = item_expr {
2607                        let glob_enabled = {
2608                            let scope = self.scope.read().await;
2609                            scope.glob_enabled()
2610                        };
2611                        if glob_enabled {
2612                            let (paths, cwd) = {
2613                                let ctx = self.exec_ctx.read().await;
2614                                let paths = ctx.expand_glob(pattern).await
2615                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2616                                let cwd = ctx.resolve_path(".");
2617                                (paths, cwd)
2618                            };
2619                            if paths.is_empty() {
2620                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2621                            }
2622                            for path in paths {
2623                                let display = if !pattern.starts_with('/') {
2624                                    path.strip_prefix(&cwd)
2625                                        .unwrap_or(&path)
2626                                        .to_string_lossy().into_owned()
2627                                } else {
2628                                    path.to_string_lossy().into_owned()
2629                                };
2630                                items.push(Value::String(display));
2631                            }
2632                            continue;
2633                        }
2634                    }
2635                    // Track whether this item came from $(cmd); that's the
2636                    // only position where multi-line stdout auto-splits per
2637                    // line. Arrays still spread element-by-element; bare
2638                    // $VAR is rejected upstream by validator E012. See
2639                    // docs/LANGUAGE.md.
2640                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2641                    let item = self.eval_expr_async(item_expr).await?;
2642                    match item {
2643                        // JSON arrays iterate over elements (preferred path
2644                        // when builtins emit .data — seq, jq, cut, find, …)
2645                        Value::Json(serde_json::Value::Array(arr)) => {
2646                            for elem in arr {
2647                                // Envelope-free: an element that happens to be
2648                                // envelope-shaped (e.g. from `fromjson`) is
2649                                // external data, not an internal bytes round-trip,
2650                                // so it must NOT be re-decoded to Value::Bytes.
2651                                items.push(json_to_value_no_envelope(elem));
2652                            }
2653                        }
2654                        // Strings from $(cmd): empty → 0 iterations,
2655                        // multi-line → split per line (trimming trailing
2656                        // newlines and per-line trailing \r), single-line
2657                        // → one iteration. Whitespace within a line is
2658                        // NOT split — the "$VAR with spaces just works"
2659                        // promise is preserved because this only fires
2660                        // in CommandSubst position.
2661                        Value::String(s) if from_command_subst => {
2662                            let trimmed = s.trim_end_matches(['\n', '\r']);
2663                            if trimmed.is_empty() {
2664                                continue;
2665                            }
2666                            if trimmed.contains('\n') {
2667                                for line in trimmed.split('\n') {
2668                                    let line = line.trim_end_matches('\r');
2669                                    items.push(Value::String(line.to_string()));
2670                                }
2671                            } else {
2672                                items.push(Value::String(trimmed.to_string()));
2673                            }
2674                        }
2675                        // Binary isn't iterable — fail loud rather than loop
2676                        // once over an opaque byte blob.
2677                        Value::Bytes(_) => {
2678                            anyhow::bail!(
2679                                "for: cannot iterate over binary data — decode it \
2680                                 (base64/xxd) first"
2681                            );
2682                        }
2683                        // Strings not from $(cmd) stay as one value.
2684                        other => items.push(other),
2685                    }
2686                }
2687
2688                let mut result = ExecResult::success("");
2689                {
2690                    let mut scope = self.scope.write().await;
2691                    scope.push_frame();
2692                }
2693
2694                'outer: for item in items {
2695                    // Cancellation checkpoint per iteration
2696                    if self.is_cancelled() {
2697                        {
2698                            let mut scope = self.scope.write().await;
2699                            scope.pop_frame();
2700                        }
2701                        result.code = 130;
2702                        self.update_last_result(&result).await;
2703                        return Ok(ControlFlow::ok(result));
2704                    }
2705                    {
2706                        let mut scope = self.scope.write().await;
2707                        scope.set(&for_loop.variable, item);
2708                    }
2709                    for stmt in &for_loop.body {
2710                        let mut flow = match self.execute_stmt_flow(stmt).await {
2711                            Ok(f) => f,
2712                            Err(e) => {
2713                                let mut scope = self.scope.write().await;
2714                                scope.pop_frame();
2715                                return Err(e);
2716                            }
2717                        };
2718                        self.drain_stderr_into(&mut result).await;
2719                        match &mut flow {
2720                            ControlFlow::Normal(r) => {
2721                                accumulate_result(&mut result, r);
2722                                if !r.ok() {
2723                                    let scope = self.scope.read().await;
2724                                    if scope.error_exit_enabled() {
2725                                        drop(scope);
2726                                        let mut scope = self.scope.write().await;
2727                                        scope.pop_frame();
2728                                        // `result` already carries `r`'s out/err
2729                                        // via accumulate_result above — hand it to
2730                                        // the Exit signal so `-e` still aborts the
2731                                        // loop but the reason survives.
2732                                        let code = r.code;
2733                                        return Ok(ControlFlow::Exit {
2734                                            code,
2735                                            result: std::mem::take(&mut result),
2736                                        });
2737                                    }
2738                                }
2739                            }
2740                            ControlFlow::Break { .. } => {
2741                                if flow.decrement_level() {
2742                                    accumulate_flow_output(&mut result, &flow);
2743                                    break 'outer;
2744                                }
2745                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2746                                let mut scope = self.scope.write().await;
2747                                scope.pop_frame();
2748                                return Ok(flow);
2749                            }
2750                            ControlFlow::Continue { .. } => {
2751                                if flow.decrement_level() {
2752                                    accumulate_flow_output(&mut result, &flow);
2753                                    continue 'outer;
2754                                }
2755                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2756                                let mut scope = self.scope.write().await;
2757                                scope.pop_frame();
2758                                return Ok(flow);
2759                            }
2760                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2761                                fold_block_output_into_flow(
2762                                    std::mem::take(&mut result),
2763                                    &mut flow,
2764                                );
2765                                let mut scope = self.scope.write().await;
2766                                scope.pop_frame();
2767                                return Ok(flow);
2768                            }
2769                        }
2770                    }
2771                }
2772
2773                {
2774                    let mut scope = self.scope.write().await;
2775                    scope.pop_frame();
2776                }
2777                // Zero iterations still writes `$?` — see the `Stmt::If` arm.
2778                // `for x in $(grep …)` with no matches must not leave grep's 1
2779                // standing as the loop's status.
2780                self.update_last_result(&result).await;
2781                Ok(ControlFlow::ok(result))
2782            }
2783            Stmt::While(while_loop) => {
2784                let mut result = ExecResult::success("");
2785
2786                'outer: loop {
2787                    // Evaluate condition - use async to support command substitution
2788                    // Cancellation checkpoint per iteration
2789                    if self.is_cancelled() {
2790                        result.code = 130;
2791                        self.update_last_result(&result).await;
2792                        return Ok(ControlFlow::ok(result));
2793                    }
2794
2795                    // Per iteration, so the condition's stdout interleaves with
2796                    // the body's rather than arriving in one block up front.
2797                    let cond_value = self
2798                        .eval_condition_async(&while_loop.condition, &mut result)
2799                        .await?;
2800
2801                    if !is_truthy(&cond_value) {
2802                        break;
2803                    }
2804
2805                    // Execute body
2806                    for stmt in &while_loop.body {
2807                        let mut flow = self.execute_stmt_flow(stmt).await?;
2808                        self.drain_stderr_into(&mut result).await;
2809                        match &mut flow {
2810                            ControlFlow::Normal(r) => {
2811                                accumulate_result(&mut result, r);
2812                                if !r.ok() {
2813                                    let scope = self.scope.read().await;
2814                                    if scope.error_exit_enabled() {
2815                                        // `result` already carries `r`'s out/err
2816                                        // via accumulate_result above — hand it to
2817                                        // the Exit signal so `-e` still aborts the
2818                                        // loop but the reason survives.
2819                                        let code = r.code;
2820                                        return Ok(ControlFlow::Exit {
2821                                            code,
2822                                            result: std::mem::take(&mut result),
2823                                        });
2824                                    }
2825                                }
2826                            }
2827                            ControlFlow::Break { .. } => {
2828                                if flow.decrement_level() {
2829                                    accumulate_flow_output(&mut result, &flow);
2830                                    break 'outer;
2831                                }
2832                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2833                                return Ok(flow);
2834                            }
2835                            ControlFlow::Continue { .. } => {
2836                                if flow.decrement_level() {
2837                                    accumulate_flow_output(&mut result, &flow);
2838                                    continue 'outer;
2839                                }
2840                                fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2841                                return Ok(flow);
2842                            }
2843                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2844                                fold_block_output_into_flow(
2845                                    std::mem::take(&mut result),
2846                                    &mut flow,
2847                                );
2848                                return Ok(flow);
2849                            }
2850                        }
2851                    }
2852                }
2853
2854                // A condition that is false on the first evaluation runs no
2855                // body — see the `Stmt::If` arm.
2856                self.update_last_result(&result).await;
2857                Ok(ControlFlow::ok(result))
2858            }
2859            Stmt::Case(case_stmt) => {
2860                // Evaluate the expression to match against. Text sink: a
2861                // `case $bin in ...)` pattern match on binary goes loud
2862                // rather than glob-matching against the `[binary: N bytes]`
2863                // placeholder (Decision E — same class as `==`/`in`).
2864                let match_value = {
2865                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2866                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2867                };
2868
2869                // Try each branch until we find a match
2870                for branch in &case_stmt.branches {
2871                    let matched = branch.patterns.iter().any(|pattern| {
2872                        glob_match(pattern, &match_value)
2873                    });
2874
2875                    if matched {
2876                        // Execute the branch body
2877                        let mut result = ExecResult::success("");
2878                        for stmt in &branch.body {
2879                            let flow = self.execute_stmt_flow(stmt).await?;
2880                            match flow {
2881                                ControlFlow::Normal(r) => {
2882                                    accumulate_result(&mut result, &r);
2883                                    self.drain_stderr_into(&mut result).await;
2884                                }
2885                                mut other => {
2886                                    self.drain_stderr_into(&mut result).await;
2887                                    fold_block_output_into_flow(
2888                                        std::mem::take(&mut result),
2889                                        &mut other,
2890                                    );
2891                                    return Ok(other);
2892                                }
2893                            }
2894                        }
2895                        self.update_last_result(&result).await;
2896                        return Ok(ControlFlow::ok(result));
2897                    }
2898                }
2899
2900                // No match - return success with empty output (like sh), and
2901                // write it to `$?` — see the `Stmt::If` arm.
2902                let result = ExecResult::success("");
2903                self.update_last_result(&result).await;
2904                Ok(ControlFlow::ok(result))
2905            }
2906            Stmt::Break(levels) => {
2907                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2908            }
2909            Stmt::Continue(levels) => {
2910                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2911            }
2912            Stmt::Return(expr) => {
2913                // return [N] - N becomes the exit code, NOT stdout
2914                // Shell semantics: return sets exit code, doesn't produce output
2915                let result = if let Some(e) = expr {
2916                    let val = self.eval_expr_async(e).await?;
2917                    let code = crate::interpreter::value_to_exit_code(&val)
2918                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2919                    ExecResult::from_parts(code, String::new(), String::new(), None)
2920                } else {
2921                    ExecResult::success("")
2922                };
2923                Ok(ControlFlow::return_value(result))
2924            }
2925            Stmt::Exit(expr) => {
2926                let code = if let Some(e) = expr {
2927                    let val = self.eval_expr_async(e).await?;
2928                    crate::interpreter::value_to_exit_code(&val)
2929                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2930                } else {
2931                    0
2932                };
2933                Ok(ControlFlow::exit_code(code))
2934            }
2935            Stmt::ToolDef(tool_def) => {
2936                let mut user_tools = self.user_tools.write().await;
2937                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2938                Ok(ControlFlow::ok(ExecResult::success("")))
2939            }
2940            Stmt::AndChain { left, right } => {
2941                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2942                // Suppress errexit for the left side — && handles failure itself.
2943                {
2944                    let mut scope = self.scope.write().await;
2945                    scope.suppress_errexit();
2946                }
2947                let left_flow = match self.execute_stmt_flow(left).await {
2948                    Ok(f) => f,
2949                    Err(e) => {
2950                        let mut scope = self.scope.write().await;
2951                        scope.unsuppress_errexit();
2952                        return Err(e);
2953                    }
2954                };
2955                {
2956                    let mut scope = self.scope.write().await;
2957                    scope.unsuppress_errexit();
2958                }
2959                match left_flow {
2960                    ControlFlow::Normal(mut left_result) => {
2961                        self.drain_stderr_into(&mut left_result).await;
2962                        self.update_last_result(&left_result).await;
2963                        // The left operand is consumed as a boolean to
2964                        // decide whether the right one runs, and a fault has
2965                        // no boolean to give. Abort instead of reading it as
2966                        // failure — a fallback chosen from a comparison that
2967                        // never happened is a wrong conclusion, not a
2968                        // recovery. The RIGHT operand is not guarded: its
2969                        // value becomes the chain's value, so nothing
2970                        // consumes it as a boolean and it reports exit 2.
2971                        if left_result.fault {
2972                            return Err(anyhow::anyhow!("{}", left_result.err.trim_end()));
2973                        }
2974                        // Pending is not failure (spec §I.5) — see the
2975                        // `OrChain` twin. The stash check matters here for a
2976                        // hold swallowed into an apparent success below.
2977                        if left_result.ok() {
2978                            let right_flow = self.execute_stmt_flow(right).await?;
2979                            match right_flow {
2980                                ControlFlow::Normal(mut right_result) => {
2981                                    self.drain_stderr_into(&mut right_result).await;
2982                                    self.update_last_result(&right_result).await;
2983                                    let mut combined = left_result;
2984                                    accumulate_result(&mut combined, &right_result);
2985                                    Ok(ControlFlow::ok(combined))
2986                                }
2987                                mut other => {
2988                                    // The left side already ran and printed;
2989                                    // a signal out of the right side must not
2990                                    // unprint it.
2991                                    fold_block_output_into_flow(left_result, &mut other);
2992                                    Ok(other)
2993                                }
2994                            }
2995                        } else {
2996                            Ok(ControlFlow::ok(left_result))
2997                        }
2998                    }
2999                    _ => Ok(left_flow),
3000                }
3001            }
3002            Stmt::OrChain { left, right } => {
3003                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
3004                // Suppress errexit for the left side — || handles failure itself.
3005                {
3006                    let mut scope = self.scope.write().await;
3007                    scope.suppress_errexit();
3008                }
3009                let left_flow = match self.execute_stmt_flow(left).await {
3010                    Ok(f) => f,
3011                    Err(e) => {
3012                        let mut scope = self.scope.write().await;
3013                        scope.unsuppress_errexit();
3014                        return Err(e);
3015                    }
3016                };
3017                {
3018                    let mut scope = self.scope.write().await;
3019                    scope.unsuppress_errexit();
3020                }
3021                match left_flow {
3022                    ControlFlow::Normal(mut left_result) => {
3023                        self.drain_stderr_into(&mut left_result).await;
3024                        self.update_last_result(&left_result).await;
3025                        // The left operand is consumed as a boolean to
3026                        // decide whether the right one runs, and a fault has
3027                        // no boolean to give. Abort instead of reading it as
3028                        // failure — a fallback chosen from a comparison that
3029                        // never happened is a wrong conclusion, not a
3030                        // recovery. The RIGHT operand is not guarded: its
3031                        // value becomes the chain's value, so nothing
3032                        // consumes it as a boolean and it reports exit 2.
3033                        if left_result.fault {
3034                            return Err(anyhow::anyhow!("{}", left_result.err.trim_end()));
3035                        }
3036                        // Pending is not failure (spec §I.5): a fallback
3037                        // written for failure must not run on a decision
3038                        // nobody has made yet — and running it would also
3039                        // overwrite the request in the accumulated result.
3040                        // The stash check covers a hold whose typed error a
3041                        // layer below already stringified out of the result.
3042                        // On a stash-based hold the returned `left_result` is
3043                        // that stringified failure, not the held result — the
3044                        // statement boundary discards it and surfaces the
3045                        // slot's result instead. Do not "fix" this by taking
3046                        // the slot here: only statement boundaries take it.
3047                        if !left_result.ok() {
3048                            let right_flow = self.execute_stmt_flow(right).await?;
3049                            match right_flow {
3050                                ControlFlow::Normal(mut right_result) => {
3051                                    self.drain_stderr_into(&mut right_result).await;
3052                                    self.update_last_result(&right_result).await;
3053                                    let mut combined = left_result;
3054                                    accumulate_result(&mut combined, &right_result);
3055                                    Ok(ControlFlow::ok(combined))
3056                                }
3057                                mut other => {
3058                                    // The left side already ran and printed;
3059                                    // a signal out of the right side must not
3060                                    // unprint it.
3061                                    fold_block_output_into_flow(left_result, &mut other);
3062                                    Ok(other)
3063                                }
3064                            }
3065                        } else {
3066                            Ok(ControlFlow::ok(left_result))
3067                        }
3068                    }
3069                    _ => Ok(left_flow), // Propagate non-normal flow
3070                }
3071            }
3072            Stmt::Test(test_expr) => {
3073                // A type error is a RESULT here, not an escape: exit 2 with
3074                // the message, matching `(( ))` below and the `test` builtin.
3075                // Escaping as `Err` collapsed the code to 1, which made a bad
3076                // operand indistinguishable from a false comparison.
3077                let result = match self.eval_test_async(test_expr).await {
3078                    Ok(true) => ExecResult::success(""),
3079                    Ok(false) => ExecResult::failure(1, ""),
3080                    Err(e) => ExecResult::failure(2, format!("{e:#}")).into_fault(),
3081                };
3082                // A bare test writes `$?` and honors `set -e` like any command
3083                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
3084                // safe: the chain arms suppress errexit around their left side,
3085                // and `if`/`while` conditions evaluate as expressions, never
3086                // through this statement arm — so a fault in a CONDITION still
3087                // aborts, where it has no exit-code channel to report through.
3088                self.update_last_result(&result).await;
3089                if !result.ok() {
3090                    let scope = self.scope.read().await;
3091                    if scope.error_exit_enabled() {
3092                        // `-e` aborts the statement list, but the reason the
3093                        // command died must survive with it — carry `result`
3094                        // (its `out`/`err`/`data`) into the Exit signal instead
3095                        // of `ControlFlow::exit_code`'s empty placeholder.
3096                        let code = result.code;
3097                        return Ok(ControlFlow::Exit { code, result });
3098                    }
3099                }
3100                Ok(ControlFlow::ok(result))
3101            }
3102            // `(( expr ))` — the sibling of `Test` above, and it reports an
3103            // evaluation fault (division by zero, an unset variable) the same
3104            // way: exit 2 with the error as the message, like any other
3105            // command that ran and failed.
3106            Stmt::Arith(expr_str) => {
3107                let result = match self.eval_arithmetic_async(expr_str).await {
3108                    Ok(n) if n != 0 => ExecResult::success(""),
3109                    Ok(_) => ExecResult::failure(1, ""),
3110                    Err(e) => ExecResult::failure(2, e.to_string()).into_fault(),
3111                };
3112                self.update_last_result(&result).await;
3113                if !result.ok() {
3114                    let scope = self.scope.read().await;
3115                    if scope.error_exit_enabled() {
3116                        let code = result.code;
3117                        return Ok(ControlFlow::Exit { code, result });
3118                    }
3119                }
3120                Ok(ControlFlow::ok(result))
3121            }
3122            Stmt::EnvScoped { assignments, body } => {
3123                // Inline env prefix (`NAME=value ... command`): apply the
3124                // assignments as EXPORTED vars in a fresh frame so the command
3125                // — and its subprocess environment — sees them, then unwind so
3126                // they do NOT persist (bash-style command-scoped env). Values
3127                // evaluate left-to-right with earlier ones already in scope, so
3128                // `A=1 B=$A cmd` works.
3129                {
3130                    let mut scope = self.scope.write().await;
3131                    scope.push_frame();
3132                }
3133                let mut prior_export: Vec<(String, bool)> =
3134                    Vec::with_capacity(assignments.len());
3135                let mut setup_err: Option<anyhow::Error> = None;
3136                for assign in assignments {
3137                    match self.eval_expr_async(&assign.value).await {
3138                        Ok(value) => {
3139                            let mut scope = self.scope.write().await;
3140                            prior_export
3141                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
3142                            scope.set_exported(assign.name(), value);
3143                        }
3144                        Err(e) => {
3145                            setup_err = Some(e);
3146                            break;
3147                        }
3148                    }
3149                }
3150
3151                let flow = if setup_err.is_none() {
3152                    self.execute_stmt_flow(body).await
3153                } else {
3154                    Ok(ControlFlow::ok(ExecResult::success("")))
3155                };
3156
3157                // Unwind the env frame and restore export marks unconditionally
3158                // (names that were not exported before must not stay exported).
3159                {
3160                    let mut scope = self.scope.write().await;
3161                    scope.pop_frame();
3162                    for (name, was_exported) in &prior_export {
3163                        if !*was_exported {
3164                            scope.unexport(name);
3165                        }
3166                    }
3167                }
3168
3169                match setup_err {
3170                    Some(e) => Err(e),
3171                    None => flow,
3172                }
3173            }
3174            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
3175        }
3176        })
3177    }
3178
3179    /// Build a boxed per-command `ExecContext` snapshot from the persistent
3180    /// kernel state (`ec`/`scope`, both already locked by the caller).
3181    ///
3182    /// Sync on purpose: the ~30 field clones live in this transient frame rather
3183    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
3184    /// — not the 960-byte struct — rides the dispatch await at every recursion
3185    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
3186    /// per-site differences (the pipeline runner uses the kernel's own cancel
3187    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
3188    /// they're parameters; every other field is snapshotted identically.
3189    fn snapshot_exec_ctx(
3190        &self,
3191        ec: &ExecContext,
3192        scope: &Scope,
3193        pipeline_position: PipelinePosition,
3194        cancel: tokio_util::sync::CancellationToken,
3195    ) -> Box<ExecContext> {
3196        Box::new(ExecContext {
3197            backend: ec.backend.clone(),
3198            scope: scope.clone(),
3199            cwd: ec.cwd.clone(),
3200            prev_cwd: ec.prev_cwd.clone(),
3201            stdin: ec.stdin.clone(),
3202            stdin_data: ec.stdin_data.clone(),
3203            stdin_data_rx: None,
3204            pipe_stdin: None,
3205            pipe_stdout: None,
3206            stderr: ec.stderr.clone(),
3207            tool_schemas: ec.tool_schemas.clone(),
3208            tools: ec.tools.clone(),
3209            job_manager: ec.job_manager.clone(),
3210            pipeline_position,
3211            interactive: self.interactive,
3212            // The kernel-wide setting; a snapshot inherits it like `interactive`.
3213            kill_children_on_parent_death: ec.kill_children_on_parent_death,
3214            kill_grace: ec.kill_grace,
3215            background_job: ec.background_job,
3216            aliases: ec.aliases.clone(),
3217            ignore_config: ec.ignore_config.clone(),
3218            output_limit: ec.output_limit.clone(),
3219            allow_external_commands: self.allow_external_commands,
3220            trash_backend: ec.trash_backend.clone(),
3221            #[cfg(all(unix, feature = "subprocess"))]
3222            terminal_state: ec.terminal_state.clone(),
3223            dispatcher: self.dispatcher(),
3224            cancel,
3225            output_format: None,
3226            vfs_budget: self.vfs_budget.clone(),
3227            watchdog: ec.watchdog.clone(),
3228            #[cfg(all(feature = "localfs", feature = "overlay"))]
3229            overlay_handle: self.overlay_handle.clone(),
3230            // Correlate this command's requests with the background job it
3231            // runs for, if any — the ONE place `job_id` is stamped.
3232            // A replay correlation belongs to exactly one dispatch. Moved
3233            // (not cloned) out of the parent context at the dispatch seam —
3234            // see the stdin hand-off below, which takes it under the same
3235            // write lock — so the gate this snapshot reaches is the only one
3236            // that can adopt it.
3237            // A forked or backgrounded execution keeps its parenthood: a
3238            // gate reached from inside a gated statement is nested under it
3239            // (spec §A.7).
3240        })
3241    }
3242
3243    /// Execute a pipeline.
3244    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3245        if pipeline.stages.is_empty() {
3246            return Ok(ExecResult::success(""));
3247        }
3248
3249        // Handle background execution (`&` operator)
3250        if pipeline.background {
3251            return self.execute_background(pipeline).await;
3252        }
3253
3254        // All commands go through the runner with the Kernel as dispatcher.
3255        // This is the single execution path — no fast path for single commands.
3256        //
3257        // IMPORTANT: We snapshot exec_ctx into a local context and release the
3258        // lock before running. This prevents deadlocks when dispatch_command
3259        // is called from within the pipeline and recursively triggers another
3260        // pipeline (e.g., via user-defined tools).
3261        let (mut ctx, has_pipe_stdin) = {
3262            let ec = self.exec_ctx.read().await;
3263            let scope = self.scope.read().await;
3264            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
3265            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
3266            // the consume-once block below, so note its presence here.
3267            let has_pipe_stdin = ec.pipe_stdin.is_some();
3268            // The pipeline runner drives stage 0 with the first stage's stdin
3269            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
3270            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
3271            // and uses the kernel's own cancel token so a `cancel()` reaches the
3272            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
3273            let cancel = {
3274                #[allow(clippy::expect_used)]
3275                let token = self.cancel_token.lock().expect("cancel_token poisoned");
3276                token.clone()
3277            };
3278            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
3279        }; // locks released
3280
3281        // Consume-once: move/clear the seeded stdin sources from the persistent
3282        // exec_ctx now that this pipeline's ctx owns them, so a later statement
3283        // in the same call (`cat ; cat`) does not re-receive them — matching
3284        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
3285        // (the ctx above was built with `pipe_stdin: None`).
3286        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
3287            let mut ec = self.exec_ctx.write().await;
3288            ctx.pipe_stdin = ec.pipe_stdin.take();
3289            ec.stdin = None;
3290            ec.stdin_data = None;
3291        }
3292
3293        // Park the enclosing command's write end and sideband receiver here for
3294        // the duration. `ec` is one shared slot and the snapshot above zeroes
3295        // both, so a nested dispatch — `$(…)` in a command's own arguments, a
3296        // function body, a `source`d file — overwrites whatever is left in it.
3297        // `echo $(echo sub) | cat` printed nothing at exit 0;
3298        // `seq 1 3 | jq -c $(echo .)` fell back to reading the pipe as text.
3299        //
3300        // Here rather than at each re-entering caller: this is the one path
3301        // they all take. The shared slot is the actual defect — threading a
3302        // ctx through the interpreter would retire this whole dance.
3303        {
3304            let mut ec = self.exec_ctx.write().await;
3305            ctx.pipe_stdout = ec.pipe_stdout.take();
3306            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3307        }
3308
3309        let mut result = self.runner.run(&pipeline.stages, &mut ctx, self).await;
3310
3311        // `set -o pipefail`: the pipeline answers with the RIGHTMOST non-zero
3312        // stage, not the first. bash's `set -o pipefail; (exit 3) | (exit 4) |
3313        // true` is 4, and reading the first non-zero would have said 3.
3314        //
3315        // Applied BEFORE the spill contract so a spilled pipeline still reports
3316        // 3 and keeps the pipefail status as `original_code`, rather than the
3317        // last stage's — the spill remap is about output size and should
3318        // override whatever the pipeline decided its status was, not race it.
3319        //
3320        // Read back from the scope the runner just wrote rather than threaded
3321        // separately: PIPESTATUS is the one record of what each stage did, so
3322        // the mode and the variable cannot disagree about the same pipeline.
3323        if ctx.scope.pipefail_enabled() {
3324            if let Some(code) = ctx.scope.pipestatus_rightmost_failure() {
3325                result.code = code;
3326            }
3327        }
3328
3329        // Post-hoc spill check + exit-3 remap (catches builtins and fast
3330        // external commands; also catches a ring overflow that already
3331        // flipped `did_spill` even when the limit itself is disabled, GH
3332        // #191). This is the shared contract every execution surface must
3333        // apply — see `apply_spill_contract`'s doc comment (GH #212).
3334        crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
3335
3336        // Sync changes back from context
3337        {
3338            let mut ec = self.exec_ctx.write().await;
3339            ec.cwd = ctx.cwd.clone();
3340            ec.prev_cwd = ctx.prev_cwd.clone();
3341            ec.aliases = ctx.aliases.clone();
3342            ec.ignore_config = ctx.ignore_config.clone();
3343            ec.output_limit = ctx.output_limit.clone();
3344            // Unconsumed stdin goes back to the session, or it dies here with
3345            // `ctx`. A partial read (`read` takes one line) leaves the rest
3346            // split across two places: the bytes it over-read sit in `stdin`,
3347            // and the pipe still holds everything past them. Dropping the
3348            // reader discards that tail with no error — `read x; wc -c` over
3349            // 100 KiB counted 8187 bytes and said nothing.
3350            //
3351            // A multi-stage pipeline reaches here with the remainder already
3352            // returned by `run_pipeline`'s join, so this carries the
3353            // single-command and the pipeline case alike.
3354            ec.stdin = ctx.stdin.take();
3355            ec.pipe_stdin = ctx.pipe_stdin.take();
3356            // The parked handles go home. Stages get writers the runner owns,
3357            // so what is here is what was carried in.
3358            ec.pipe_stdout = ctx.pipe_stdout.take();
3359            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3360        }
3361        {
3362            let mut scope = self.scope.write().await;
3363            *scope = ctx.scope.clone();
3364        }
3365
3366        Ok(result)
3367    }
3368
3369    /// Execute a pipeline in the background.
3370    ///
3371    /// The command is spawned as a tokio task and registered with the
3372    /// JobManager. The job is observable via `/v/jobs/{id}/status`,
3373    /// `/v/jobs/{id}/command`, and — while it is
3374    /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
3375    ///
3376    /// GH #240 removed those two nodes because they filled once, at
3377    /// completion, while the docs promised a live stream. They are back on
3378    /// the terms the docs always claimed: `try_execute_external` tees each
3379    /// 8 KiB chunk into the job's stream as the child emits it. See
3380    /// `Job::stdout_stream` for exactly which bytes reach them.
3381    ///
3382    /// Returns immediately with a job ID like "[1]".
3383    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.stages.len()))]
3384    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3385        use tokio::sync::oneshot;
3386
3387        // Format the command for display in /v/jobs/{id}/command
3388        let command_str = self.format_pipeline(pipeline);
3389
3390        // Create channel for result notification
3391        let (tx, rx) = oneshot::channel();
3392
3393        // Register with JobManager to get job ID and create VFS entries
3394        let job_id = self.jobs.register(command_str.clone(), rx).await;
3395
3396        // Fork the kernel for this background job. The fork snapshots the
3397        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3398        // while sharing the job manager, VFS, and tool registry. The fork's
3399        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3400        // is available here — something BackendDispatcher couldn't provide.
3401        //
3402        // The fork gets its own cancellation token (recorded on the job so
3403        // `kill %N` can stop the job — including a pure-builtin job with no OS
3404        // process group) and is stamped with the job id so any external
3405        // command it spawns records its process group for `kill -<sig> %N`.
3406        let cancel = tokio_util::sync::CancellationToken::new();
3407        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3408        let jobs = self.jobs.clone();
3409        let fork = self.fork_for_background(cancel, job_id).await;
3410        let runner = self.runner.clone();
3411        let stages = pipeline.stages.clone();
3412
3413        // Snapshot the fork's exec_ctx for the spawned task. We have to do
3414        // this before tokio::spawn because the fork's exec_ctx is behind a
3415        // tokio RwLock and we want the spawned task to own its ctx.
3416        let mut bg_ctx = {
3417            let ec = fork.exec_ctx.read().await;
3418            ec.child_for_pipeline()
3419        };
3420        bg_ctx.scope = fork.scope.read().await.clone();
3421        // The fork's dispatcher points at the fork itself; set it here so
3422        // builtins inside the background task (e.g. timeout) re-dispatch
3423        // through the fork, not the parent.
3424        bg_ctx.dispatcher = fork.dispatcher();
3425
3426        // Spawn the background task. Propagate the embedder's trace context
3427        // across the spawn boundary so the job's spans stay in the same trace.
3428        tokio::spawn(crate::telemetry::bind_current_context(async move {
3429            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3430            // gives us that (Kernel implements CommandDispatcher).
3431            let mut result = runner.run(&stages, &mut bg_ctx, fork.as_ref()).await;
3432
3433            // A background task is its own statement boundary. Pipeline stages
3434            // and command substitutions flush stderr to the fork's stderr
3435            // channel exactly as they would in the foreground, but the
3436            // statement-boundary drains live in `Kernel::execute`, which this
3437            // task never runs. Drain here, or the job's stderr never reaches
3438            // its result: a substitution's failure reason is lost and
3439            // `/v/jobs/{id}/stderr` stays empty.
3440            fork.drain_stderr_into(&mut result).await;
3441
3442            // Apply the same spill/exit-3 contract the foreground path gets
3443            // (`execute_pipeline`'s `apply_spill_contract` call) — without
3444            // this, a background job whose output overflows the capture ring
3445            // or trips the output limit reports the child's ORIGINAL exit
3446            // code to JobManager, so `[N] done:0`/`Job::status()` silently
3447            // read success even though the output was capped (GH #212).
3448            crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3449
3450            // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3451            // drain tasks already fed live is left alone (re-writing the
3452            // aggregate would duplicate every byte), an untouched one takes
3453            // the captured result, and both close. Before `tx.send`, so a
3454            // reader that observes a terminal `status` also observes a
3455            // finished stream — never a `done:0` job whose output is still
3456            // arriving.
3457            jobs.finalize_streams(job_id, &result).await;
3458
3459            // Send result to JobManager (ignore error if receiver dropped)
3460            let _ = tx.send(result);
3461        }));
3462
3463        // The announcement is a shell message, not command output: bash writes
3464        // it to stderr, and stdout stays clean so `$(cmd &)` captures no shell
3465        // metadata. Terminated like every kaish diagnostic (#363).
3466        let mut announcement = ExecResult::success("");
3467        announcement.err = ExecResult::terminate_diagnostic(format!("[{job_id}]"));
3468        Ok(announcement)
3469    }
3470
3471    /// Format a pipeline as a command string for display.
3472    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3473        pipeline
3474            .stages
3475            .iter()
3476            .map(|stage| {
3477                let cmd = match stage {
3478                    crate::ast::PipelineStage::Command(cmd) => cmd,
3479                    // A compound stage renders through the plan renderer,
3480                    // which already knows every statement form.
3481                    crate::ast::PipelineStage::Compound(stmt) => {
3482                        return crate::ast::plan::render_stmt(stmt)
3483                    }
3484                };
3485                let mut parts = vec![cmd.name.clone()];
3486                for arg in &cmd.args {
3487                    match arg {
3488                        Arg::Positional(expr) => {
3489                            parts.push(self.format_expr(expr));
3490                        }
3491                        Arg::Named { key, value } => {
3492                            parts.push(format!("--{}={}", key, self.format_expr(value)));
3493                        }
3494                        Arg::WordAssign { key, value } => {
3495                            parts.push(format!("{}={}", key, self.format_expr(value)));
3496                        }
3497                        Arg::ShortFlag(name) => {
3498                            parts.push(format!("-{}", name));
3499                        }
3500                        Arg::LongFlag(name) => {
3501                            parts.push(format!("--{}", name));
3502                        }
3503                        Arg::DoubleDash => {
3504                            parts.push("--".to_string());
3505                        }
3506                    }
3507                }
3508                parts.join(" ")
3509            })
3510            .collect::<Vec<_>>()
3511            .join(" | ")
3512    }
3513
3514    /// Format an expression as a string for display.
3515    fn format_expr(&self, expr: &Expr) -> String {
3516        match expr {
3517            Expr::Literal(Value::String(s)) => {
3518                if s.contains(' ') || s.contains('"') {
3519                    format!("'{}'", s.replace('\'', "\\'"))
3520                } else {
3521                    s.clone()
3522                }
3523            }
3524            Expr::Literal(Value::Int(i)) => i.to_string(),
3525            Expr::Literal(Value::Float(f)) => f.to_string(),
3526            Expr::Literal(Value::Bool(b)) => b.to_string(),
3527            Expr::Literal(Value::Null) => "null".to_string(),
3528            // Show the source text, not the typed value.
3529            Expr::NumericLiteral { raw, .. } => raw.clone(),
3530            Expr::VarRef(path) => {
3531                let mut name = String::new();
3532                for (i, seg) in path.segments.iter().enumerate() {
3533                    match seg {
3534                        crate::ast::VarSegment::Field(f) => {
3535                            if i > 0 {
3536                                name.push('.');
3537                            }
3538                            name.push_str(f);
3539                        }
3540                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3541                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3542                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3543                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3544                            "[{}:{}]",
3545                            a.map(|n| n.to_string()).unwrap_or_default(),
3546                            b.map(|n| n.to_string()).unwrap_or_default()
3547                        )),
3548                    }
3549                }
3550                format!("${{{}}}", name)
3551            }
3552            Expr::Interpolated(_) => "\"...\"".to_string(),
3553            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3554            _ => "...".to_string(),
3555        }
3556    }
3557
3558    /// Execute a single command.
3559    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3560        self.execute_command_depth(name, args, 0).await
3561    }
3562
3563    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3564        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3565        // most-recursed function on the ring, so wrapping its future in
3566        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3567        // every level (GH #48, item 3). A `trace!` event records the command name
3568        // without living in the future.
3569        tracing::trace!(command = %name, alias_depth, "dispatch");
3570        // Special built-ins. `SpecialForm::from_name` is the single source of
3571        // truth (shared with `classify_command` via `is_runtime_special_form`),
3572        // and this match on the enum is *exhaustive* — adding a special-form is a
3573        // compile error until both the name mapping and the behavior here are
3574        // updated. A name that is not a special-form falls through to alias /
3575        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3576        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3577            return match form {
3578                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3579                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3580                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3581            };
3582        }
3583
3584        // Alias expansion (with recursion limit)
3585        if alias_depth < 10 {
3586            let alias_value = {
3587                let ctx = self.exec_ctx.read().await;
3588                ctx.aliases.get(name).cloned()
3589            };
3590            if let Some(alias_val) = alias_value {
3591                // Split alias value into command + args
3592                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3593                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3594                    let mut new_args: Vec<Arg> = alias_args
3595                        .iter()
3596                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3597                        .collect();
3598                    new_args.extend_from_slice(args);
3599                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3600                }
3601            }
3602        }
3603
3604        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3605        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3606            return match self.tools.get(builtin_name) {
3607                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3608                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3609            };
3610        }
3611
3612        // Check user-defined tools first
3613        {
3614            let user_tools = self.user_tools.read().await;
3615            if let Some(tool_def) = user_tools.get(name) {
3616                let tool_def = tool_def.clone();
3617                drop(user_tools);
3618                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3619            }
3620        }
3621
3622        // Look up builtin tool
3623        let tool = match self.tools.get(name) {
3624            Some(t) => t,
3625            None => {
3626                // Try executing as .kai script from PATH
3627                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3628                    return Ok(result);
3629                }
3630                // Try executing as external command from PATH — boxed because its
3631                // future is the heaviest branch here (holds a `tokio::process::Command`,
3632                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3633                // it inline fattens every `execute_command_depth` frame on the recursion
3634                // ring even when the command is a builtin.
3635                //
3636                // A refusal (compiled without `subprocess`, or configured off)
3637                // does NOT return here — a backend-registered tool of the same
3638                // name is a separate capability and still gets a chance below.
3639                // `unavailable` carries the reason forward so the final "nothing
3640                // claimed this name" message can name it, instead of the
3641                // fallthrough re-deriving the wrong "command not found".
3642                let mut unavailable = None;
3643                match Box::pin(self.try_execute_external(name, args)).await? {
3644                    ExternalCommandOutcome::Ran(result) => return Ok(*result),
3645                    ExternalCommandOutcome::NotFound => {}
3646                    ExternalCommandOutcome::Unavailable(reason) => unavailable = Some(reason),
3647                }
3648
3649                // Try backend-registered tools (embedder engines, etc.)
3650                // Look up tool schema for positional→named mapping.
3651                // Clone backend and drop read lock before awaiting (may involve network I/O).
3652                // Backend tools expect named JSON params, so enable positional mapping.
3653                let backend = self.exec_ctx.read().await.backend.clone();
3654                let tool_schema = backend
3655                    .get_tool(name)
3656                    .await
3657                    .unwrap_or_else(|e| {
3658                        // Schema lookup failing just means positionals won't
3659                        // get name-mapped below — `call_tool` is still
3660                        // attempted. Trace it so the degradation is visible
3661                        // rather than silently swallowed.
3662                        tracing::debug!("backend get_tool error for {name}: {e}");
3663                        None
3664                    })
3665                    .map(|t| {
3666                    let mut s = t.schema;
3667                    // Flat backend/MCP tools expect named JSON params, so map
3668                    // bare positionals onto named params. Subcommand-aware tools
3669                    // route positionals through the subcommand path and declare
3670                    // map_positionals per leaf (kj keeps it false so it re-parses
3671                    // the argv with its own clap) — don't blanket-override them.
3672                    if s.subcommands.is_empty() {
3673                        s.map_positionals = true;
3674                    }
3675                    s
3676                });
3677                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3678                let mut ctx = self.exec_ctx.write().await;
3679                {
3680                    let scope = self.scope.read().await;
3681                    ctx.scope = scope.clone();
3682                }
3683                let backend = ctx.backend.clone();
3684                match backend.call_tool(name, tool_args, &mut *ctx).await {
3685                    Ok(tool_result) => {
3686                        let mut scope = self.scope.write().await;
3687                        *scope = ctx.scope.clone();
3688                        // Preserve every field (data/content_type/baggage,
3689                        // not just stdout text) — this is the embedder seam:
3690                        // `x=$(embedder_tool)` and structured iteration over
3691                        // its result depend on `.data` surviving the crossing
3692                        // back into the kernel.
3693                        let mut result = ExecResult::from(tool_result);
3694                        // The same rule the builtin dispatch applies, applied
3695                        // here too — this seam returns before it. Without this
3696                        // an embedder tool that hands back a value got its
3697                        // `$(…)` capture stringified, which is precisely what
3698                        // the comment above promises does not happen.
3699                        let data_is_only_output = result.data.is_some()
3700                            && !result.has_output()
3701                            && result.text_out().is_empty();
3702                        result.data_is_value |= data_is_only_output
3703                            || tool_schema
3704                                .as_ref()
3705                                .is_some_and(|s| s.typed_substitution);
3706                        return Ok(result);
3707                    }
3708                    Err(BackendError::ToolNotFound(_)) => {
3709                        // The backend confirms no such tool exists — fall
3710                        // through to "command not found" below.
3711                    }
3712                    Err(e) => {
3713                        // The tool was found (dispatch reached real
3714                        // execution) but running it failed — a genuine
3715                        // execution error, not "command not found". Surface
3716                        // it loudly instead of masking it as exit-127.
3717                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3718                    }
3719                }
3720
3721                return Ok(match unavailable {
3722                    Some(reason) => external_commands_unavailable_error(name, reason),
3723                    None => ExecResult::failure(127, format!("command not found: {}", name)),
3724                });
3725            }
3726        };
3727
3728        // Build arguments (async to support command substitution, schema-aware
3729        // for flag values), then decide `--help` and `owns_output` — all three
3730        // read the tool's schema and nothing after this block does, so the whole
3731        // schema borrow is scoped here and cannot ride the `tool.execute` await
3732        // below (GH #48, item 7).
3733        let (tool_args, wants_help, owns_output, raw_argv, typed_substitution) = {
3734            // Prefer the kernel's schema catalog over `tool.schema()`: for a
3735            // clap-derived builtin, `schema()` rebuilds the entire clap
3736            // `Command` and reflects it into a fresh `ToolSchema` — ~34
3737            // allocations per command, 18% of all allocations in the GH #48
3738            // many-small-commands profile — to produce exactly what the catalog
3739            // already holds. The catalog is seeded from this same registry in
3740            // `Kernel::assemble` and is name-sorted, so this is a binary search
3741            // with no allocation at all. `owned` covers a tool the catalog
3742            // doesn't list (registered after assembly, or whose schema name
3743            // differs from its dispatch name): the fallback calls the same
3744            // `schema()` and is equivalent, just not free.
3745            let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3746            let owned;
3747            let schema: &crate::tools::ToolSchema =
3748                match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3749                    Ok(i) => &catalog[i],
3750                    Err(_) => {
3751                        owned = tool.schema();
3752                        &owned
3753                    }
3754                };
3755
3756            let tool_args = self.build_args_async(args, Some(schema)).await?;
3757
3758            // --help / -h: show the generic whole-tool help, unless either the tool's
3759            // root schema claims that flag OR the tool owns its output. Owned-output
3760            // tools re-parse their own argv and route their own `--help` — including
3761            // leaf/subcommand help — through their internal (clap) parser, so the root
3762            // schema can't express "this leaf claims help" and intercepting here would
3763            // render top-level help and return before `execute()` ever sees the
3764            // request (#51). Pass it through and let the tool render its own help.
3765            let schema_claims = |flag: &str| -> bool {
3766                let bare = flag.trim_start_matches('-');
3767                schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3768            };
3769            let wants_help = !schema.owns_output
3770                && ((tool_args.flags.contains("help") && !schema_claims("help"))
3771                    || (tool_args.flags.contains("h") && !schema_claims("-h")));
3772
3773            (tool_args, wants_help, schema.owns_output, schema.raw_argv, schema.typed_substitution)
3774        };
3775
3776        if wants_help {
3777            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3778            let ctx = self.exec_ctx.read().await;
3779            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3780            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3781        }
3782
3783        // Snapshot exec_ctx into a local context and release the write lock
3784        // before calling tool.execute. Holding the write across tool execution
3785        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3786        // (timeout, scatter) — the inner dispatch_command needs its own
3787        // exec_ctx.write() and would block forever.
3788        let mut ctx = {
3789            let ec = self.exec_ctx.write().await;
3790            let scope = self.scope.read().await;
3791            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3792            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3793            // child token — e.g. timeout's — reaches the spawned external via
3794            // wait_or_kill; it falls back to the kernel's own token on a
3795            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3796            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3797        }; // both locks released — tool.execute can re-dispatch safely
3798
3799        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3800        // semantics): take() so a later dispatch doesn't see stale stdin.
3801        // Done after the snapshot above so we hold the write briefly.
3802        {
3803            let mut ec = self.exec_ctx.write().await;
3804            ctx.stdin = ec.stdin.take();
3805            ctx.stdin_data = ec.stdin_data.take();
3806            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3807            ctx.pipe_stdin = ec.pipe_stdin.take();
3808            ctx.pipe_stdout = ec.pipe_stdout.take();
3809            // Same take-don't-clone discipline as stdin, and for the same
3810            // reason: these belong to exactly one dispatch, and a copy left
3811            // behind would let the next command adopt it.
3812        }
3813
3814        // Honor --json before the builtin runs so its setting survives a clap
3815        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3816        // --json on the floor when `try_parse_from` returns Err early).
3817        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3818        GlobalFlags::apply_from_args(&tool_args, raw_argv, &mut *ctx);
3819
3820        let mut result = tool.execute(tool_args, &mut *ctx).await;
3821        // A command substitution binds `.data` only when it is the result's
3822        // VALUE. `--json` and the pipeline sideband read `.data` either way,
3823        // so this marks the ONE consumer whose answer is a matter of taste.
3824        //
3825        // Two ways to qualify. A tool that printed NOTHING has only its data,
3826        // so that data is trivially its value — this is `fromjson`'s shape and
3827        // an out-of-tree tool's, and it needs no declaration, which is what
3828        // keeps this from breaking embedder tools. A tool that printed text
3829        // AND attached data has to say which one it means, because the two
3830        // readings are equally defensible: `jq` means the data, `cut` means
3831        // the text it printed.
3832        let data_is_only_output = result.data.is_some()
3833            && !result.has_output()
3834            && result.text_out().is_empty();
3835        // OR, never assign: a wrapper that re-dispatches an inner command
3836        // returns the INNER result, and stamping it from the wrapper's own
3837        // schema erased what the inner tool declared —
3838        // `$(timeout 5 fromjson '[1,2]')` bound text while `$(fromjson …)`
3839        // bound a list. A result arrives here fresh from `tool.execute`, so a
3840        // marker already set is one the tool or its inner dispatch meant.
3841        result.data_is_value |= typed_substitution || data_is_only_output;
3842
3843        // Sync mutations back. Tools may have changed scope (set/cd),
3844        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3845        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3846        // hands them back to the pipeline runner — the runner uses
3847        // stage_ctx.pipe_stdout to write the result to the next stage when
3848        // the tool itself didn't take and write to it.
3849        {
3850            let mut scope = self.scope.write().await;
3851            *scope = ctx.scope.clone();
3852        }
3853        {
3854            let mut ec = self.exec_ctx.write().await;
3855            ec.cwd = ctx.cwd;
3856            ec.prev_cwd = ctx.prev_cwd;
3857            ec.aliases = ctx.aliases;
3858            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3859            // mutate the runtime output limit; without this sync the change is
3860            // dropped here and never reaches dispatch_command's read-back, so
3861            // it would not survive past the current statement.
3862            ec.output_limit = ctx.output_limit.clone();
3863            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3864            // was missing from this sync, so every runtime ignore mutation
3865            // silently died at the end of its own statement — including the
3866            // documented `kaish-ignore add .gitignore` rc-file recipe.
3867            ec.ignore_config = ctx.ignore_config.clone();
3868            ec.pipe_stdin = ctx.pipe_stdin.take();
3869            ec.pipe_stdout = ctx.pipe_stdout.take();
3870            // What a partial read left behind goes back too: `read` takes one
3871            // line and keeps the rest, and that remainder belongs to the next
3872            // reader. Without this it dies with the tool's context and
3873            // `read x; read y` loses the second line.
3874            ec.stdin = ctx.stdin.take();
3875            // The sideband is stdin in typed form and returns by the same
3876            // rule; taken in above, an unconsumed value would die here.
3877            ec.stdin_data = ctx.stdin_data.take();
3878            ec.stdin_data_rx = ctx.stdin_data_rx.take();
3879        }
3880
3881        // Builtins parse --json via the GlobalFlags flatten in their clap
3882        // struct and write ctx.output_format. The kernel applies it — unless the
3883        // tool owns its own output (renders --json itself), in which case we
3884        // leave its bytes untouched.
3885        let result = finalize_output(result, ctx.output_format, owns_output);
3886
3887        Ok(result)
3888    }
3889
3890    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3891    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3892    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3893    /// unexpanded rather than leaking the host home directory.
3894    async fn scope_home(&self) -> Option<String> {
3895        match self.scope.read().await.get("HOME") {
3896            Some(Value::String(s)) => Some(s.clone()),
3897            _ => None,
3898        }
3899    }
3900
3901    /// Build tool arguments from AST args.
3902    ///
3903    /// Uses async evaluation to support command substitution in arguments.
3904    /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3905    /// now only supplies the evaluator — `self` implements `ArgValueSource`
3906    /// against the kernel's own session state (full recursion through the
3907    /// async pipeline, real glob expansion, tilde expansion). Before this,
3908    /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3909    /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3910    /// scatter/gather's own option parsing and the `#[cfg(test)]`
3911    /// `BackendDispatcher`) that could — and did — drift from this method,
3912    /// the same drift-class GH #133 fixed for the external-command spawn
3913    /// sites. Now both paths call the one `bind_tool_args` core, differing
3914    /// only in which `ArgValueSource` they hand it.
3915    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3916        bind_tool_args(args, schema, self).await
3917    }
3918
3919    /// Build arguments as flat string list for external commands.
3920    ///
3921    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3922    /// this preserves the original flag format as strings for external commands:
3923    /// - `-l` stays as `-l`
3924    /// - `--verbose` stays as `--verbose`
3925    /// - `key=value` stays as `key=value`
3926    ///
3927    /// This is what external commands expect in their argv.
3928    #[cfg(feature = "subprocess")]
3929    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3930        let mut argv = Vec::new();
3931        let home = self.scope_home().await;
3932        for arg in args {
3933            match arg {
3934                Arg::Positional(expr) => {
3935                    // Glob expansion for external commands
3936                    if let Expr::GlobPattern(pattern) = expr {
3937                        let glob_enabled = {
3938                            let scope = self.scope.read().await;
3939                            scope.glob_enabled()
3940                        };
3941                        if glob_enabled {
3942                            let (paths, cwd) = {
3943                                let ctx = self.exec_ctx.read().await;
3944                                let paths = ctx.expand_glob(pattern).await
3945                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3946                                let cwd = ctx.resolve_path(".");
3947                                (paths, cwd)
3948                            };
3949                            if paths.is_empty() {
3950                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3951                            }
3952                            for path in paths {
3953                                let display = if !pattern.starts_with('/') {
3954                                    path.strip_prefix(&cwd)
3955                                        .unwrap_or(&path)
3956                                        .to_string_lossy().into_owned()
3957                                } else {
3958                                    path.to_string_lossy().into_owned()
3959                                };
3960                                argv.push(display);
3961                            }
3962                            continue;
3963                        }
3964                    }
3965                    // The exact word the author typed reaches the external
3966                    // process, matching what `ast::plan::render_expr` showed.
3967                    // Skips `eval_expr_async`: no `Value` reproduces `raw`.
3968                    if let Expr::NumericLiteral { raw, .. } = expr {
3969                        argv.push(raw.clone());
3970                        continue;
3971                    }
3972                    let value = self.eval_expr_async(expr).await?;
3973                    // Decision D: a bare collection can't cross the external
3974                    // process boundary as an argv element — refuse rather than
3975                    // silently JSON-serializing it. A quoted `"$x"` already
3976                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3977                    // so only a live, un-interpolated `$x` trips this.
3978                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3979                        return Err(anyhow::anyhow!(msg));
3980                    }
3981                    let value = apply_tilde_expansion(value, home.as_deref());
3982                    // External-command argv is a text sink: a bare `$BIN` binary
3983                    // word goes loud, never the `[binary: N bytes]` placeholder.
3984                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3985                }
3986                Arg::Named { key, value } => {
3987                    if let Expr::NumericLiteral { raw, .. } = value {
3988                        argv.push(format!("--{key}={raw}"));
3989                        continue;
3990                    }
3991                    let val = self.eval_expr_async(value).await?;
3992                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3993                        return Err(anyhow::anyhow!(msg));
3994                    }
3995                    let val = apply_tilde_expansion(val, home.as_deref());
3996                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3997                    argv.push(format!("--{key}={val_str}"));
3998                }
3999                Arg::WordAssign { key, value } => {
4000                    if let Expr::NumericLiteral { raw, .. } = value {
4001                        argv.push(format!("{key}={raw}"));
4002                        continue;
4003                    }
4004                    let val = self.eval_expr_async(value).await?;
4005                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
4006                        return Err(anyhow::anyhow!(msg));
4007                    }
4008                    let val = apply_tilde_expansion(val, home.as_deref());
4009                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
4010                    argv.push(format!("{key}={val_str}"));
4011                }
4012                Arg::ShortFlag(name) => {
4013                    // Preserve original format: -l, -la (combined flags)
4014                    argv.push(format!("-{}", name));
4015                }
4016                Arg::LongFlag(name) => {
4017                    // Preserve original format: --verbose
4018                    argv.push(format!("--{}", name));
4019                }
4020                Arg::DoubleDash => {
4021                    // Preserve the -- marker
4022                    argv.push("--".to_string());
4023                }
4024            }
4025        }
4026        Ok(argv)
4027    }
4028
4029    /// Async expression evaluator that supports command substitution.
4030    ///
4031    /// This is used for contexts where expressions may contain `$(...)` command
4032    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
4033    /// Evaluate an `if`/`while` condition, folding whatever its commands print
4034    /// into `out`.
4035    ///
4036    /// A condition's stdout belongs to the enclosing statement, the same rule
4037    /// `Expr::Command` already applies to its stderr. [`Self::eval_expr_async`]
4038    /// returns only a `Value`, so those bytes had nowhere to go and
4039    /// `if echo COND; then echo BODY; fi` printed only `BODY`. Handing them
4040    /// back to the caller keeps the decision there: the `if`/`while` arm folds
4041    /// them into the statement's own result, and every consumer of that result
4042    /// — a pipe, a `$(…)` capture, a redirect — carries them without learning
4043    /// what a condition is. A shared slot on `ExecContext` would have done it
4044    /// the other way; that is the pattern GH #369 exists to remove.
4045    ///
4046    /// Only the forms that can hold a command in STATEMENT position are handled
4047    /// here. Everything else is [`Self::eval_expr_async`]'s, `$(…)` above all:
4048    /// a substitution's stdout IS its value, so folding it in as well would
4049    /// print it twice.
4050    fn eval_condition_async<'a>(
4051        &'a self,
4052        expr: &'a Expr,
4053        out: &'a mut ExecResult,
4054    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4055        Box::pin(async move {
4056            match expr {
4057                Expr::Command(cmd) => {
4058                    let mut result = self.execute_command(&cmd.name, &cmd.args).await?;
4059                    self.emit_cmdsubst_stderr(&result.err).await;
4060                    // Truthiness comes from the command's OWN code, read before
4061                    // the spill contract can remap it. A capped `if seq 1
4062                    // 100000` succeeded; only its output was too big to keep,
4063                    // and reading the remapped 3 would send it to `else`.
4064                    // A fault has no boolean to give. Aborting here is the
4065                    // rule `[[ ]]` and `(( ))` already follow in this position;
4066                    // reading it as false would let `else` run on a comparison
4067                    // that never happened.
4068                    if result.fault {
4069                        self.emit_cmdsubst_stderr(&result.err).await;
4070                        return Err(anyhow::anyhow!("{}", result.err.trim_end()));
4071                    }
4072                    let truthy = result.code == 0;
4073                    // Carrying the stdout made this arm one of the surfaces
4074                    // that produce a raw `ExecResult`, and it reaches
4075                    // `execute_command` below the pipeline layer that applies
4076                    // the contract — so it applies it here, as
4077                    // `apply_spill_contract`'s "ONE seam" note requires.
4078                    // Without this a condition handed back its full output with
4079                    // no limit at all.
4080                    let limit = self.exec_ctx.read().await.output_limit.clone();
4081                    crate::output_limit::apply_spill_contract(&mut result, &limit).await;
4082                    push_stdout_of(out, &result);
4083                    Ok(Value::Bool(truthy))
4084                }
4085                // Short-circuits exactly as the `eval_expr_async` arm does, and
4086                // yields the operand's own value rather than a coerced bool. A
4087                // side that short-circuits never runs, so it prints nothing.
4088                Expr::BinaryOp { left, op, right } => {
4089                    let left_val = self.eval_condition_async(left, &mut *out).await?;
4090                    let short_circuits = match op {
4091                        BinaryOp::And => !is_truthy(&left_val),
4092                        BinaryOp::Or => is_truthy(&left_val),
4093                    };
4094                    if short_circuits {
4095                        return Ok(left_val);
4096                    }
4097                    self.eval_condition_async(right, out).await
4098                }
4099                // The negated command still RUNS, so its output belongs to the
4100                // statement exactly as an un-negated one's does. Routing this
4101                // through `eval_expr_async` would drop it.
4102                Expr::Not(inner) => {
4103                    let value = self.eval_condition_async(inner, out).await?;
4104                    Ok(Value::Bool(!is_truthy(&value)))
4105                }
4106                other => self.eval_expr_async(other).await,
4107            }
4108        })
4109    }
4110
4111    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4112        Box::pin(async move {
4113        match expr {
4114            Expr::Not(inner) => {
4115                let value = self.eval_expr_async(inner).await?;
4116                Ok(Value::Bool(!is_truthy(&value)))
4117            }
4118            Expr::Literal(value) => Ok(value.clone()),
4119            // Typed evaluation only needs `value`; `raw` is for argv and
4120            // plan text sinks that read the `Expr` directly.
4121            Expr::NumericLiteral { value, .. } => Ok(value.clone()),
4122            Expr::VarRef(path) => {
4123                let scope = self.scope.read().await;
4124                match scope.resolve_path(path) {
4125                    Ok(v) => Ok(v),
4126                    Err(PathError::UndefinedRoot(_)) => {
4127                        Err(anyhow::anyhow!("undefined variable"))
4128                    }
4129                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4130                        Err(anyhow::anyhow!(msg))
4131                    }
4132                }
4133            }
4134            Expr::Interpolated(parts) => {
4135                let mut result = String::new();
4136                for part in parts {
4137                    result.push_str(&self.eval_string_part_async(part).await?);
4138                }
4139                Ok(Value::String(result))
4140            }
4141            Expr::HereDocBody { parts, strip_tabs } => {
4142                // Assemble part-by-part so `<<-` tab stripping applies to the
4143                // literal source, not to tabs from a `$var` value (bash strips
4144                // source-line tabs before parameter expansion).
4145                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
4146                for sp in parts {
4147                    match &sp.part {
4148                        StringPart::Literal(s) => asm.push_literal(s),
4149                        other => {
4150                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
4151                        }
4152                    }
4153                }
4154                Ok(Value::String(asm.into_string()))
4155            }
4156            Expr::BinaryOp { left, op, right } => match op {
4157                BinaryOp::And => {
4158                    let left_val = self.eval_expr_async(left).await?;
4159                    if !is_truthy(&left_val) {
4160                        return Ok(left_val);
4161                    }
4162                    self.eval_expr_async(right).await
4163                }
4164                BinaryOp::Or => {
4165                    let left_val = self.eval_expr_async(left).await?;
4166                    if is_truthy(&left_val) {
4167                        return Ok(left_val);
4168                    }
4169                    self.eval_expr_async(right).await
4170                }
4171            },
4172            Expr::CommandSubst(stmts) => {
4173                // Snapshot scope, cwd, and session config before running —
4174                // only output escapes, not side effects like `cd`, variable
4175                // assignments, or config mutations (`kaish-ignore`,
4176                // `kaish-output-limit`, `alias`/`unalias`) — matching how
4177                // every other execution context (background forks, scatter
4178                // workers) already isolates mutations (GH #139).
4179                // Boxed: this ~470 B scope snapshot is held across the nested
4180                // `$(…)` recursion await below, so inlining it grows every
4181                // command-substitution level's future (GH #48, item 4).
4182                let saved_scope = Box::new(self.scope.read().await.clone());
4183                let saved_ec = {
4184                    let ec = self.exec_ctx.read().await;
4185                    (
4186                        ec.cwd.clone(),
4187                        ec.prev_cwd.clone(),
4188                        ec.aliases.clone(),
4189                        ec.ignore_config.clone(),
4190                        ec.output_limit.clone(),
4191                    )
4192                };
4193
4194                // Capture result without `?` — restore state unconditionally
4195                let run_result = self.execute_block_capturing(stmts).await;
4196
4197                // Restore scope and cwd regardless of success/failure
4198                {
4199                    let mut scope = self.scope.write().await;
4200                    *scope = *saved_scope;
4201                    if let Ok(ref r) = run_result {
4202                        scope.set_last_result(r.clone());
4203                        scope.note_cmdsubst_code(r.code);
4204                    }
4205                }
4206                {
4207                    let mut ec = self.exec_ctx.write().await;
4208                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4209                    ec.cwd = cwd;
4210                    ec.prev_cwd = prev_cwd;
4211                    ec.aliases = aliases;
4212                    ec.ignore_config = ignore_config;
4213                    ec.output_limit = output_limit;
4214                }
4215
4216                // A substitution's stderr belongs to the enclosing statement,
4217                // never to its value. Emit it before the value is built.
4218                if let Ok(ref r) = run_result {
4219                    self.emit_cmdsubst_stderr(&r.err).await;
4220                }
4221
4222                // Now propagate the error
4223                let result = run_result?;
4224
4225                // A held body stops the enclosing statement before its
4226                // missing output is used (spec §I.5) — the request rides up
4227                // as a typed error the statement loop converts back into a
4228                // held result, and is stashed for the boundary in case an
4229                // intermediate catch stringifies the error.
4230
4231                // A binary result is preserved as bytes — never lossy-decoded to
4232                // a string. No trailing-newline trim (every byte is significant).
4233                if let Some(bytes) = result.out_bytes() {
4234                    Ok(Value::Bytes(bytes.to_vec()))
4235                // Prefer structured data (enables `for i in $(cmd)` iteration)
4236                } else if let Some(data) = &result.data {
4237                    Ok(data.clone())
4238                } else if let Some(output) = result.output() {
4239                    // Flat non-text node lists (glob, ls, tree) → iterable array
4240                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
4241                        let items: Vec<serde_json::Value> = output.root.iter()
4242                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
4243                            .collect();
4244                        Ok(Value::Json(serde_json::Value::Array(items)))
4245                    } else {
4246                        // Strip trailing newlines only (POSIX command-subst),
4247                        // not all trailing whitespace — spaces/tabs are
4248                        // significant. Use the exact same trim as the quoted
4249                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
4250                        // `trim_end_matches('\n')`) so bare and quoted command
4251                        // substitution agree.
4252                        Ok(Value::String(
4253                            result.text_out().trim_end_matches('\n').to_string(),
4254                        ))
4255                    }
4256                } else {
4257                    // Otherwise return stdout as single string (NO implicit splitting)
4258                    Ok(Value::String(
4259                        result.text_out().trim_end_matches('\n').to_string(),
4260                    ))
4261                }
4262            }
4263            Expr::Test(test_expr) => {
4264                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
4265            }
4266            // `(( expr ))` in condition position (`if`/`while`). Unlike the
4267            // standalone `Stmt::Arith` form, a condition has no exit-code
4268            // channel separate from true/false, so an evaluation fault
4269            // propagates like `Expr::Test`'s does — it aborts the
4270            // enclosing statement rather than silently reading false.
4271            Expr::Arith(expr_str) => {
4272                let n = self
4273                    .eval_arithmetic_async(expr_str)
4274                    .await
4275                    .context("arithmetic condition")?;
4276                Ok(Value::Bool(n != 0))
4277            }
4278            Expr::Positional(n) => {
4279                let scope = self.scope.read().await;
4280                match scope.get_positional(*n) {
4281                    Some(s) => Ok(Value::String(s.to_string())),
4282                    None => Ok(Value::String(String::new())),
4283                }
4284            }
4285            Expr::AllArgs => {
4286                let scope = self.scope.read().await;
4287                Ok(Value::String(scope.all_args().join(" ")))
4288            }
4289            Expr::ArgCount => {
4290                let scope = self.scope.read().await;
4291                Ok(Value::Int(scope.arg_count() as i64))
4292            }
4293            Expr::VarLength(path) => {
4294                let scope = self.scope.read().await;
4295                crate::interpreter::resolve_length(&scope, path)
4296                    .map(Value::Int)
4297                    .map_err(|msg| anyhow::anyhow!(msg))
4298            }
4299            Expr::VarWithDefault { path, default } => {
4300                // Resolve inside a scoped guard so the lock is released before the
4301                // recursive default evaluation.
4302                let resolved = {
4303                    let scope = self.scope.read().await;
4304                    crate::interpreter::resolve_default(&scope, path)
4305                        .map_err(|msg| anyhow::anyhow!(msg))?
4306                };
4307                match resolved {
4308                    Some(value) => Ok(value),
4309                    None => self.eval_string_parts_async(default).await.map(Value::String),
4310                }
4311            }
4312            Expr::Arithmetic(expr_str) => {
4313                self.eval_arithmetic_async(expr_str).await.map(Value::Int)
4314            }
4315            Expr::Command(cmd) => {
4316                // A command in expression position — an `if`/`while`
4317                // condition, or a side of `&&`/`||` inside one. Its VALUE is
4318                // the exit code's truthiness, but its OUTPUT belongs to the
4319                // enclosing statement, exactly as `Expr::CommandSubst` says of
4320                // a substitution's stderr. Dropping the `ExecResult` here made
4321                // `if cat /nonexistent; then …` print nothing at all, so every
4322                // condition that failed for a reason failed silently.
4323                let result = self.execute_command(&cmd.name, &cmd.args).await?;
4324                self.emit_cmdsubst_stderr(&result.err).await;
4325                Ok(Value::Bool(result.code == 0))
4326            }
4327            Expr::LastExitCode => {
4328                let scope = self.scope.read().await;
4329                Ok(Value::Int(scope.last_result().code))
4330            }
4331            Expr::CurrentPid => {
4332                let scope = self.scope.read().await;
4333                Ok(Value::Int(scope.pid() as i64))
4334            }
4335            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4336            Expr::ListLiteral(elems) => {
4337                // Spread must itself be a list — a scalar/record spread is a
4338                // loud error, never silently coerced or dropped (mirrors the
4339                // sync `Evaluator::eval_list_literal`; wording shared via
4340                // `spread_non_list_message` so the two paths can't diverge).
4341                let mut out = Vec::with_capacity(elems.len());
4342                for elem in elems {
4343                    match elem {
4344                        ListElem::Item(e) => {
4345                            let value = self.eval_expr_async(e).await?;
4346                            out.push(crate::interpreter::value_to_json(&value));
4347                        }
4348                        ListElem::Spread(e) => {
4349                            let value = self.eval_expr_async(e).await?;
4350                            match value {
4351                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4352                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4353                            }
4354                        }
4355                    }
4356                }
4357                Ok(Value::Json(serde_json::Value::Array(out)))
4358            }
4359            Expr::RecordLiteral(entries) => {
4360                // Insertion order preserved (workspace serde_json has
4361                // `preserve_order`); a duplicate key keeps the last value
4362                // written, matching plain map-insert semantics.
4363                let mut map = serde_json::Map::new();
4364                for entry in entries {
4365                    let key = match &entry.key {
4366                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4367                        // `{"$k": v}` resolves like any double-quoted string
4368                        // (used to silently create a literal "$k" key).
4369                        RecordKey::Interpolated(parts) => {
4370                            self.eval_string_parts_async(parts).await?
4371                        }
4372                    };
4373                    let value = self.eval_expr_async(&entry.value).await?;
4374                    map.insert(key, crate::interpreter::value_to_json(&value));
4375                }
4376                Ok(Value::Json(serde_json::Value::Object(map)))
4377            }
4378        }
4379        })
4380    }
4381
4382    /// Async helper to evaluate multiple StringParts into a single string.
4383    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4384        Box::pin(async move {
4385            let mut result = String::new();
4386            for part in parts {
4387                result.push_str(&self.eval_string_part_async(part).await?);
4388            }
4389            Ok(result)
4390        })
4391    }
4392
4393    /// Async helper to evaluate a StringPart.
4394    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4395    /// through the VFS backend instead of using raw `std::path`.
4396    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4397        Box::pin(async move {
4398            match test_expr {
4399                TestExpr::FileTest { op, path } => {
4400                    let path_value = self.eval_expr_async(path).await?;
4401                    // Expand `~` against the session HOME before stat'ing, the
4402                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4403                    // stats the literal `~/x` and is always false.
4404                    let home = self.scope_home().await;
4405                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
4406                    // A binary `[[ -f $bin ]]` operand goes loud rather than
4407                    // silently stat'ing a file literally named
4408                    // `[binary: N bytes]` (the same path-positional guard
4409                    // builtins like `stat`/`cp` use).
4410                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4411                        .map_err(|e| anyhow::anyhow!("{e}"))?;
4412                    // The empty path names no file. Resolving it lands on the
4413                    // working directory, so `[[ -e "" ]]` answered true — bash
4414                    // says false, and so does every reading of "does this file
4415                    // exist". Same guard as `test`'s `file_test`; the two
4416                    // spellings of a file test must not disagree about a path,
4417                    // and `test_compound_tests` pins that they agree.
4418                    if path_str.is_empty() {
4419                        return Ok(false);
4420                    }
4421                    // Resolve against the *session* cwd, not the process cwd, so a
4422                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
4423                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
4424                    // relative path against the process cwd otherwise.
4425                    let (resolved, backend) = {
4426                        let ctx = self.exec_ctx.read().await;
4427                        (ctx.resolve_path(&path_str), ctx.backend.clone())
4428                    };
4429                    // `-r`/`-w`/`-x` go through `path_access`, never through
4430                    // the raw mode bits: the mount's read-only state is half
4431                    // the answer and `stat` does not carry it. The `test`
4432                    // builtin's `file_test` reads the same query, and
4433                    // `file_test_writable_tests` runs every case through both
4434                    // spellings — this mirror has drifted before.
4435                    Ok(match op {
4436                        FileTestOp::Exists => backend.stat(&resolved).await.is_ok(),
4437                        FileTestOp::IsFile => {
4438                            backend.stat(&resolved).await.is_ok_and(|e| e.is_file())
4439                        }
4440                        FileTestOp::IsDir => {
4441                            backend.stat(&resolved).await.is_ok_and(|e| e.is_dir())
4442                        }
4443                        FileTestOp::Readable => backend
4444                            .path_access(&resolved)
4445                            .await
4446                            .is_ok_and(|access| access.readable),
4447                        FileTestOp::Writable => backend
4448                            .path_access(&resolved)
4449                            .await
4450                            .is_ok_and(|access| access.writable),
4451                        FileTestOp::Executable => backend
4452                            .path_access(&resolved)
4453                            .await
4454                            .is_ok_and(|access| access.executable),
4455                        // lstat: what is at this name. A dangling link is true
4456                        // here and false under `-e`.
4457                        FileTestOp::IsSymlink => {
4458                            backend.lstat(&resolved).await.is_ok_and(|e| e.is_symlink())
4459                        }
4460                    })
4461                }
4462                TestExpr::StringTest { op, value } => match op {
4463                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4464                        let val = self.eval_expr_async(value).await?;
4465                        // Decision E: a collection operand is a loud Shape error
4466                        // here too — must not diverge from the sync path in
4467                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
4468                        let symbol = match op {
4469                            crate::ast::StringTestOp::IsEmpty => "-z",
4470                            crate::ast::StringTestOp::IsNonEmpty => "-n",
4471                            crate::ast::StringTestOp::IsList
4472                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4473                        };
4474                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4475                            anyhow::bail!(msg);
4476                        }
4477                        let s = value_to_string(&val);
4478                        Ok(match op {
4479                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4480                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4481                            crate::ast::StringTestOp::IsList
4482                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4483                        })
4484                    }
4485                    // Shape guard: propagates eval errors like -z/-n (a bare
4486                    // `$unset` is an undefined-variable error, not a silent
4487                    // false). A defined-but-wrong-shaped value is false. Must
4488                    // not diverge from the sync path in interpreter/eval.rs.
4489                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4490                        let val = self.eval_expr_async(value).await?;
4491                        Ok(op.matches_shape(&val))
4492                    }
4493                },
4494                TestExpr::Comparison { left, op, right } => {
4495                    // Evaluate operands async (handles $(cmd)), then compare sync
4496                    let left_val = self.eval_expr_async(left).await?;
4497                    let right_val = self.eval_expr_async(right).await?;
4498                    let resolved = TestExpr::Comparison {
4499                        left: Box::new(Expr::Literal(left_val)),
4500                        op: *op,
4501                        right: Box::new(Expr::Literal(right_val)),
4502                    };
4503                    let expr = Expr::Test(Box::new(resolved));
4504                    let mut scope = self.scope.write().await;
4505                    let value = eval_expr(&expr, &mut scope)
4506                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4507                    Ok(value_to_bool(&value))
4508                }
4509                TestExpr::And { left, right } => {
4510                    if !self.eval_test_async(left).await? {
4511                        Ok(false)
4512                    } else {
4513                        self.eval_test_async(right).await
4514                    }
4515                }
4516                TestExpr::Or { left, right } => {
4517                    if self.eval_test_async(left).await? {
4518                        Ok(true)
4519                    } else {
4520                        self.eval_test_async(right).await
4521                    }
4522                }
4523                TestExpr::Not { expr } => {
4524                    Ok(!self.eval_test_async(expr).await?)
4525                }
4526                TestExpr::In { left, right } => {
4527                    let left_val = self.eval_expr_async(left).await?;
4528                    let right_val = self.eval_expr_async(right).await?;
4529                    let resolved = TestExpr::In {
4530                        left: Box::new(Expr::Literal(left_val)),
4531                        right: Box::new(Expr::Literal(right_val)),
4532                    };
4533                    let expr = Expr::Test(Box::new(resolved));
4534                    let mut scope = self.scope.write().await;
4535                    let value = eval_expr(&expr, &mut scope)
4536                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4537                    Ok(value_to_bool(&value))
4538                }
4539                TestExpr::NotIn { left, right } => {
4540                    let left_val = self.eval_expr_async(left).await?;
4541                    let right_val = self.eval_expr_async(right).await?;
4542                    let resolved = TestExpr::NotIn {
4543                        left: Box::new(Expr::Literal(left_val)),
4544                        right: Box::new(Expr::Literal(right_val)),
4545                    };
4546                    let expr = Expr::Test(Box::new(resolved));
4547                    let mut scope = self.scope.write().await;
4548                    let value = eval_expr(&expr, &mut scope)
4549                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4550                    Ok(value_to_bool(&value))
4551                }
4552            }
4553        })
4554    }
4555
4556    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4557        Box::pin(async move {
4558            match part {
4559                StringPart::Literal(s) => Ok(s.clone()),
4560                StringPart::Var(path) => {
4561                    let scope = self.scope.read().await;
4562                    match scope.resolve_path(path) {
4563                        // Text sink: binary goes loud, never the placeholder —
4564                        // a `b=$(cat blob)` capture holds real bytes; splicing
4565                        // `[binary: N bytes]` into "$b" would be silent loss.
4566                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4567                        // Unset vars expand to empty; loud path errors surface.
4568                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4569                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4570                            Err(anyhow::anyhow!(msg))
4571                        }
4572                    }
4573                }
4574                StringPart::VarWithDefault { path, default } => {
4575                    let resolved = {
4576                        let scope = self.scope.read().await;
4577                        crate::interpreter::resolve_default(&scope, path)
4578                            .map_err(|msg| anyhow::anyhow!(msg))?
4579                    };
4580                    match resolved {
4581                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4582                        None => self.eval_string_parts_async(default).await,
4583                    }
4584                }
4585            StringPart::VarLength(path) => {
4586                let scope = self.scope.read().await;
4587                crate::interpreter::resolve_length(&scope, path)
4588                    .map(|n| n.to_string())
4589                    .map_err(|msg| anyhow::anyhow!(msg))
4590            }
4591            StringPart::Positional(n) => {
4592                let scope = self.scope.read().await;
4593                match scope.get_positional(*n) {
4594                    Some(s) => Ok(s.to_string()),
4595                    None => Ok(String::new()),
4596                }
4597            }
4598            StringPart::AllArgs => {
4599                let scope = self.scope.read().await;
4600                Ok(scope.all_args().join(" "))
4601            }
4602            StringPart::ArgCount => {
4603                let scope = self.scope.read().await;
4604                Ok(scope.arg_count().to_string())
4605            }
4606            StringPart::Arithmetic(expr) => {
4607                // Loud on purpose (GH #183): this used to be `Err(_) =>
4608                // Ok(String::new())`, silently splicing in "" for e.g.
4609                // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
4610                // at exit 0 instead of failing. Matches the bare (non-string)
4611                // `Expr::Arithmetic` arm above, which already propagates.
4612                self.eval_arithmetic_async(expr).await.map(|value| value.to_string())
4613            }
4614            StringPart::CommandSubst(stmts) => {
4615                // Snapshot scope, cwd, and session config — command
4616                // substitution in strings must not leak side effects (e.g.,
4617                // `"dir: $(cd /; pwd)"` must not change cwd, and
4618                // `"$(kaish-ignore clear)"` must not change the session's
4619                // ignore config) — matching how every other execution
4620                // context (background forks, scatter workers) already
4621                // isolates mutations (GH #139).
4622                // Boxed: this ~470 B scope snapshot is held across the nested
4623                // `$(…)` recursion await below, so inlining it grows every
4624                // command-substitution level's future (GH #48, item 4).
4625                let saved_scope = Box::new(self.scope.read().await.clone());
4626                let saved_ec = {
4627                    let ec = self.exec_ctx.read().await;
4628                    (
4629                        ec.cwd.clone(),
4630                        ec.prev_cwd.clone(),
4631                        ec.aliases.clone(),
4632                        ec.ignore_config.clone(),
4633                        ec.output_limit.clone(),
4634                    )
4635                };
4636
4637                // Capture result without `?` — restore state unconditionally
4638                let run_result = self.execute_block_capturing(stmts).await;
4639
4640                // Restore scope and cwd regardless of success/failure
4641                {
4642                    let mut scope = self.scope.write().await;
4643                    *scope = *saved_scope;
4644                    if let Ok(ref r) = run_result {
4645                        scope.set_last_result(r.clone());
4646                        scope.note_cmdsubst_code(r.code);
4647                    }
4648                }
4649                {
4650                    let mut ec = self.exec_ctx.write().await;
4651                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4652                    ec.cwd = cwd;
4653                    ec.prev_cwd = prev_cwd;
4654                    ec.aliases = aliases;
4655                    ec.ignore_config = ignore_config;
4656                    ec.output_limit = output_limit;
4657                }
4658
4659                // A substitution's stderr belongs to the enclosing statement,
4660                // never to its value. Emit it before the value is built.
4661                if let Ok(ref r) = run_result {
4662                    self.emit_cmdsubst_stderr(&r.err).await;
4663                }
4664
4665                // Now propagate the error
4666                let result = run_result?;
4667
4668                // A held body stops the enclosing statement before its
4669                // missing output is spliced in (spec §I.5) — same conversion
4670                // and stash as the bare `$(…)` arm.
4671
4672                // Embedding binary into a string is a text context: fail loud
4673                // rather than splice in U+FFFD garbage.
4674                match result.try_text_out() {
4675                    // Text wins when present — unchanged behavior.
4676                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4677                    // `.out` is empty: a builtin/tool that set only structured
4678                    // `.data` must not silently evaporate to "" (SILENT DATA
4679                    // LOSS). Render it the same way a bare `"$x"`
4680                    // collection-valued variable renders — compact JSON for
4681                    // lists/records, plain form for scalars — by reusing
4682                    // `value_to_string` (the exact `StringPart::Var` helper
4683                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4684                    // identically. No trailing-newline trim here: that's a
4685                    // text-path artifact, not applicable to a freshly
4686                    // rendered JSON/scalar string.
4687                    Ok(_) => match &result.data {
4688                        Some(data) => Ok(value_to_string(data)),
4689                        None => Ok(String::new()),
4690                    },
4691                    Err(e) => anyhow::bail!(
4692                        "command substitution in a string produced binary data ({e}) — \
4693                         pipe through base64/xxd"
4694                    ),
4695                }
4696            }
4697            StringPart::LastExitCode => {
4698                let scope = self.scope.read().await;
4699                Ok(scope.last_result().code.to_string())
4700            }
4701            StringPart::CurrentPid => {
4702                let scope = self.scope.read().await;
4703                Ok(scope.pid().to_string())
4704            }
4705        }
4706        })
4707    }
4708
4709    /// Update the last result in scope.
4710    async fn update_last_result(&self, result: &ExecResult) {
4711        let mut scope = self.scope.write().await;
4712        scope.set_last_result(result.clone());
4713    }
4714
4715    /// Drain accumulated pipeline stderr into a result.
4716    ///
4717    /// Called after each sub-statement inside control structures (`if`, `for`,
4718    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4719    /// than batching until the entire structure finishes.
4720    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4721        let drained = {
4722            let mut receiver = self.stderr_receiver.lock().await;
4723            receiver.drain_lossy()
4724        };
4725        if !drained.is_empty() {
4726            if !result.err.is_empty() && !result.err.ends_with('\n') {
4727                result.err.push('\n');
4728            }
4729            result.err.push_str(&drained);
4730        }
4731    }
4732
4733    /// Execute a user-defined function with local variable scoping.
4734    ///
4735    /// Functions push a new scope frame for local variables. Variables declared
4736    /// with `local` are scoped to the function; other assignments modify outer
4737    /// scopes (or create in root if new).
4738    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4739        let _depth = self.enter_recursion("a shell function")?;
4740
4741        // 1. Build function args from AST args (async to support command substitution)
4742        let tool_args = self.build_args_async(args, None).await?;
4743
4744        // 2. Push a new scope frame for local variables
4745        {
4746            let mut scope = self.scope.write().await;
4747            scope.push_frame();
4748        }
4749
4750        // 3. Save current positional parameters and set new ones for this function
4751        let saved_positional = {
4752            let mut scope = self.scope.write().await;
4753            let saved = scope.save_positional();
4754
4755            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4756            // `$1` is a text sink like any argv word, and `value_to_string`
4757            // cannot reproduce the source text — see `ToolArgs::positional_raw`.
4758            let positional_args: Vec<String> = tool_args.positional
4759                .iter()
4760                .enumerate()
4761                .map(|(i, v)| {
4762                    tool_args
4763                        .positional_raw
4764                        .get(&i)
4765                        .cloned()
4766                        .unwrap_or_else(|| value_to_string(v))
4767                })
4768                .collect();
4769            scope.set_positional(&def.name, positional_args);
4770
4771            saved
4772        };
4773
4774        // 3. Execute body statements with control flow handling
4775        // Accumulate output across statements (like sh)
4776        // Accumulate stdout as raw bytes so a binary-producing statement in a
4777        // function body survives instead of being lossy-decoded here.
4778        let mut accumulated_out: Vec<u8> = Vec::new();
4779        let mut accumulated_err = String::new();
4780        let mut last_code = 0i64;
4781        let mut last_data: Option<Value> = None;
4782
4783        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4784            match r.out_bytes() {
4785                Some(b) => buf.extend_from_slice(b),
4786                None => buf.extend_from_slice(r.text_out().as_bytes()),
4787            }
4788        }
4789
4790        // Track execution error for propagation after cleanup
4791        let mut exec_error: Option<anyhow::Error> = None;
4792        let mut exit_code: Option<i64> = None;
4793
4794        for stmt in &def.body {
4795            match self.execute_stmt_flow(stmt).await {
4796                Ok(flow) => {
4797                    // Drain pipeline stderr after each sub-statement.
4798                    let drained = {
4799                        let mut receiver = self.stderr_receiver.lock().await;
4800                        receiver.drain_lossy()
4801                    };
4802                    if !drained.is_empty() {
4803                        accumulated_err.push_str(&drained);
4804                    }
4805
4806                    match flow {
4807                        ControlFlow::Normal(r) => {
4808                            push_out(&mut accumulated_out, &r);
4809                            accumulated_err.push_str(&r.err);
4810                            last_code = r.code;
4811                            // A structured VIEW of printed text does not escape as the
4812                    // substitution's value — `$(cut -f2 f)` is the text `cut`
4813                    // printed, the same as `$(awk '{print $2}' f)`.
4814                    last_data = if r.data_is_value { r.data } else { None };
4815                        }
4816                        ControlFlow::Return { value } => {
4817                            push_out(&mut accumulated_out, &value);
4818                            accumulated_err.push_str(&value.err);
4819                            last_code = value.code;
4820                            last_data = if value.data_is_value { value.data } else { None };
4821                            break;
4822                        }
4823                        ControlFlow::Exit { code, result: r } => {
4824                            push_out(&mut accumulated_out, &r);
4825                            accumulated_err.push_str(&r.err);
4826                            exit_code = Some(code);
4827                            break;
4828                        }
4829                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4830                            push_out(&mut accumulated_out, &r);
4831                            accumulated_err.push_str(&r.err);
4832                            last_code = r.code;
4833                            last_data = if r.data_is_value { r.data } else { None };
4834                        }
4835                    }
4836                }
4837                Err(e) => {
4838                    exec_error = Some(e);
4839                    break;
4840                }
4841            }
4842        }
4843
4844        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4845        {
4846            let mut scope = self.scope.write().await;
4847            scope.pop_frame();
4848            scope.set_positional(saved_positional.0, saved_positional.1);
4849        }
4850
4851        // 5. Propagate error or exit after cleanup
4852        if let Some(e) = exec_error {
4853            return Err(e);
4854        }
4855        let code = exit_code.unwrap_or(last_code);
4856        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4857        result.err = accumulated_err;
4858        // Whatever survived the gate above IS a value, so the result says so
4859        // and a further `$( )` around this one keeps it typed.
4860        result.data_is_value = last_data.is_some();
4861        result.data = last_data;
4862        Ok(result)
4863    }
4864
4865    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4866        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4867        let guard = RecursionGuard { counter: &self.recursion_depth };
4868        if depth > MAX_RECURSION_DEPTH {
4869            return Err(anyhow::anyhow!(
4870                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4871                 a runaway or mutually recursive script (deeply nested $(…), or \
4872                 functions/scripts that call each other without a base case) was \
4873                 stopped before it could overflow the stack"
4874            ));
4875        }
4876        Ok(guard)
4877    }
4878
4879    /// Hand a finished command substitution's stderr to the kernel's stderr
4880    /// stream.
4881    ///
4882    /// bash gives `$(…)` the shell's own fd 2, so a substitution's stderr goes
4883    /// straight to the terminal and is never captured alongside its stdout.
4884    /// kaish runs the block captured, so the equivalent is to write the block's
4885    /// stderr to the same channel pipeline stages use: the enclosing
4886    /// statement's drain folds it into that statement's `err`, ahead of the
4887    /// statement's own output. `x=$(cat /nope)` kept the exit code and lost the
4888    /// reason until this existed.
4889    ///
4890    /// Nesting composes without a stack. Each level drains at its own statement
4891    /// boundary, so an inner substitution's stderr is already inside the outer
4892    /// block's result by the time this runs for the outer one — which is why it
4893    /// is written exactly once, here, rather than also accumulated by callers.
4894    /// Hand a nested command's stderr to the enclosing statement.
4895    ///
4896    /// Two callers, one rule: a command substitution's stderr is not part of
4897    /// its value, and a condition command's stderr is not part of its
4898    /// truthiness. Both belong to the statement the author wrote.
4899    async fn emit_cmdsubst_stderr(&self, err: &str) {
4900        if err.is_empty() {
4901            return;
4902        }
4903        // Terminate the chunk. Builtins are inconsistent about a trailing
4904        // newline (`cat`'s failure message has none), and two substitutions in
4905        // one statement would otherwise concatenate into a single unreadable
4906        // line: `x="$(cat /a)$(cat /b)"` produced both messages run together.
4907        // The statement drain already normalizes this boundary the same way
4908        // when it joins drained stderr to a statement's own.
4909        let terminated;
4910        let err = if err.ends_with('\n') {
4911            err
4912        } else {
4913            terminated = format!("{err}\n");
4914            &terminated
4915        };
4916        match self.exec_ctx.read().await.stderr.as_ref() {
4917            Some(stream) => stream.write_str(err),
4918            // The kernel seeds this stream in both `new` and `fork`, so it is
4919            // always present on the kernel's own context; the `Option` exists
4920            // for tool contexts built elsewhere. If it is ever absent there is
4921            // no channel to carry the bytes and no drain to collect them, which
4922            // is the same condition under which every pipeline stage's stderr
4923            // is dropped — so record it rather than failing an interactive
4924            // shell over it.
4925            None => tracing::warn!("command substitution stderr dropped: no stderr stream"),
4926        }
4927    }
4928
4929    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4930        let _depth = self.enter_recursion("command substitution")?;
4931        // Accumulate stdout as raw bytes so a binary-producing statement
4932        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4933        // caller can preserve it. The final result is text iff valid UTF-8.
4934        let mut accumulated_out: Vec<u8> = Vec::new();
4935        let mut accumulated_err = String::new();
4936        let mut last_code = 0i64;
4937        let mut last_data: Option<Value> = None;
4938
4939        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4940        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4941            match r.out_bytes() {
4942                Some(b) => buf.extend_from_slice(b),
4943                None => buf.extend_from_slice(r.text_out().as_bytes()),
4944            }
4945        }
4946
4947        for stmt in stmts {
4948            let flow = self.execute_stmt_flow(stmt).await?;
4949
4950            // Drain pipeline stderr after each sub-statement (incremental, like
4951            // the control-structure and function-body executors).
4952            let drained = {
4953                let mut receiver = self.stderr_receiver.lock().await;
4954                receiver.drain_lossy()
4955            };
4956            if !drained.is_empty() {
4957                accumulated_err.push_str(&drained);
4958            }
4959
4960            match flow {
4961                ControlFlow::Normal(r)
4962                | ControlFlow::Break { result: r, .. }
4963                | ControlFlow::Continue { result: r, .. } => {
4964                    push_out(&mut accumulated_out, &r);
4965                    accumulated_err.push_str(&r.err);
4966                    last_code = r.code;
4967                    last_data = if r.data_is_value { r.data } else { None };
4968                }
4969                ControlFlow::Return { value } => {
4970                    push_out(&mut accumulated_out, &value);
4971                    accumulated_err.push_str(&value.err);
4972                    last_code = value.code;
4973                    last_data = if value.data_is_value { value.data } else { None };
4974                    break;
4975                }
4976                ControlFlow::Exit { code, result: r } => {
4977                    push_out(&mut accumulated_out, &r);
4978                    accumulated_err.push_str(&r.err);
4979                    last_code = code;
4980                    break;
4981                }
4982            }
4983        }
4984
4985        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4986        result.err = accumulated_err;
4987        // Whatever survived the gate above IS a value, so the result says so
4988        // and a further `$( )` around this one keeps it typed.
4989        result.data_is_value = last_data.is_some();
4990        result.data = last_data;
4991        Ok(result)
4992    }
4993
4994    /// Evaluate `$(( text ))`'s content. Takes the sync fast path
4995    /// (`arithmetic::eval_sync` under one scope read lock) when no `$(...)`
4996    /// is reachable in the parsed tree; otherwise walks it with
4997    /// `Self::eval_arith_expr_async`, which can run a `$(...)` operand and
4998    /// never runs one on the unselected side of `&&`/`||`/`?:`.
4999    async fn eval_arithmetic_async(&self, text: &str) -> Result<i64> {
5000        let ast = crate::arithmetic::parse(text).map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?;
5001        if ast.contains_command_subst() {
5002            self.eval_arith_expr_async(&ast).await
5003        } else {
5004            let scope = self.scope.read().await;
5005            crate::arithmetic::eval_sync(&ast, &scope).map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5006        }
5007    }
5008
5009    /// Async recursive walk over a parsed `$(( ))` tree. Boxed for the same
5010    /// reason as `Self::eval_expr_async`: the recursion is unbounded by
5011    /// the type system, so a fixed-depth stack frame can't hold it. Each
5012    /// leaf takes its own short `self.scope` read lock rather than one held
5013    /// across the whole walk — `Expansion::CommandSubst` runs through
5014    /// `Self::execute_block_capturing`, which takes its own scope lock
5015    /// internally, so a lock held here across that await would deadlock.
5016    fn eval_arith_expr_async<'a>(
5017        &'a self,
5018        expr: &'a crate::arithmetic::ArithExpr,
5019    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64>> + Send + 'a>> {
5020        use crate::arithmetic::{ArithExpr, BinOp};
5021        Box::pin(async move {
5022            match expr {
5023                ArithExpr::Int(n) => Ok(*n),
5024                ArithExpr::Expansion(e) => self.eval_arith_expansion_async(e).await,
5025                ArithExpr::Subscript { root, indices } => {
5026                    let mut values = Vec::with_capacity(indices.len());
5027                    for index in indices {
5028                        values.push(self.eval_arith_expr_async(index).await?);
5029                    }
5030                    let scope = self.scope.read().await;
5031                    crate::arithmetic::resolve_subscript_sync(&scope, root, &values)
5032                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5033                }
5034                ArithExpr::BasedExpansion { base, expansion } => {
5035                    let text = self.eval_arith_expansion_text_async(expansion).await?;
5036                    let (label, verb) = crate::arithmetic::expansion_label(expansion);
5037                    crate::arithmetic::based_value(*base, &text, &label, verb, false)
5038                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5039                }
5040                // `-base#$expansion`: fold the sign into the range check
5041                // (see `arithmetic::based_value`'s doc comment) instead of
5042                // evaluating positive then negating, which can never reach
5043                // i64::MIN.
5044                ArithExpr::Unary { op: crate::arithmetic::UnOp::Neg, operand }
5045                    if matches!(operand.as_ref(), ArithExpr::BasedExpansion { .. }) =>
5046                {
5047                    let ArithExpr::BasedExpansion { base, expansion } = operand.as_ref() else {
5048                        unreachable!("guarded by the match arm's pattern")
5049                    };
5050                    let text = self.eval_arith_expansion_text_async(expansion).await?;
5051                    let (label, verb) = crate::arithmetic::expansion_label(expansion);
5052                    crate::arithmetic::based_value(*base, &text, &label, verb, true)
5053                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5054                }
5055                ArithExpr::Unary { op, operand } => {
5056                    let v = self.eval_arith_expr_async(operand).await?;
5057                    crate::arithmetic::apply_unary(*op, v).map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5058                }
5059                // `&&`/`||` short-circuit: the unselected side's `$(...)`
5060                // must not run (docs/LANGUAGE.md, "Operators").
5061                ArithExpr::Binary { op: BinOp::And, left, right } => {
5062                    if self.eval_arith_expr_async(left).await? == 0 {
5063                        Ok(0)
5064                    } else {
5065                        Ok(if self.eval_arith_expr_async(right).await? != 0 { 1 } else { 0 })
5066                    }
5067                }
5068                ArithExpr::Binary { op: BinOp::Or, left, right } => {
5069                    if self.eval_arith_expr_async(left).await? != 0 {
5070                        Ok(1)
5071                    } else {
5072                        Ok(if self.eval_arith_expr_async(right).await? != 0 { 1 } else { 0 })
5073                    }
5074                }
5075                ArithExpr::Binary { op, left, right } => {
5076                    let l = self.eval_arith_expr_async(left).await?;
5077                    let r = self.eval_arith_expr_async(right).await?;
5078                    crate::arithmetic::apply_binary(*op, l, r).map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5079                }
5080                ArithExpr::Ternary { cond, then_branch, else_branch } => {
5081                    if self.eval_arith_expr_async(cond).await? != 0 {
5082                        self.eval_arith_expr_async(then_branch).await
5083                    } else {
5084                        self.eval_arith_expr_async(else_branch).await
5085                    }
5086                }
5087            }
5088        })
5089    }
5090
5091    fn eval_arith_expansion_async<'a>(
5092        &'a self,
5093        expansion: &'a crate::arithmetic::Expansion,
5094    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i64>> + Send + 'a>> {
5095        use crate::arithmetic::Expansion;
5096        Box::pin(async move {
5097            match expansion {
5098                Expansion::Var(name) => {
5099                    let scope = self.scope.read().await;
5100                    crate::arithmetic::resolve_var_sync(&scope, name)
5101                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5102                }
5103                Expansion::BracedPath { root, brackets } => {
5104                    let scope = self.scope.read().await;
5105                    let value = crate::arithmetic::braced_path_value(&scope, root, brackets)
5106                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?;
5107                    crate::arithmetic::value_to_arith(&value, root)
5108                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5109                }
5110                Expansion::BracedDefault { root, brackets, default } => {
5111                    let resolved = {
5112                        let scope = self.scope.read().await;
5113                        crate::arithmetic::braced_default_operand(&scope, root, brackets)
5114                            .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?
5115                    };
5116                    match resolved {
5117                        None => {
5118                            let default_expr = crate::arithmetic::parse(default)
5119                                .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?;
5120                            self.eval_arith_expr_async(&default_expr).await
5121                        }
5122                        Some(value) => crate::arithmetic::value_to_arith(&value, root)
5123                            .map_err(|e| anyhow::anyhow!("arithmetic error: {e}")),
5124                    }
5125                }
5126                Expansion::LastExitCode => {
5127                    let scope = self.scope.read().await;
5128                    Ok(scope.last_result().code)
5129                }
5130                Expansion::CurrentPid => {
5131                    let scope = self.scope.read().await;
5132                    Ok(scope.pid() as i64)
5133                }
5134                Expansion::CommandSubst(stmts) => self.run_arith_command_subst(stmts).await,
5135                Expansion::Nested(inner) => self.eval_arith_expr_async(inner).await,
5136            }
5137        })
5138    }
5139
5140    /// The expansion's rendered VALUE, for `base#<expansion>` — mirrors
5141    /// `crate::arithmetic::expansion_text_sync`, async so `$(...)` can
5142    /// run for real. Never routes through `Self::eval_arith_expansion_async`
5143    /// (the arithmetically-coerced form): that coercion refuses a leading
5144    /// zero, which is exactly what `10#$m`/`10#$(date +%m)` exist to escape.
5145    fn eval_arith_expansion_text_async<'a>(
5146        &'a self,
5147        expansion: &'a crate::arithmetic::Expansion,
5148    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
5149        use crate::arithmetic::Expansion;
5150        Box::pin(async move {
5151            match expansion {
5152                Expansion::Var(name) => {
5153                    let scope = self.scope.read().await;
5154                    if let Ok(index) = name.parse::<usize>() {
5155                        return match scope.get_positional(index) {
5156                            Some(s) => Ok(s.to_string()),
5157                            None => Err(anyhow::anyhow!(
5158                                "arithmetic error: {}",
5159                                crate::arithmetic::unset_error(name)
5160                            )),
5161                        };
5162                    }
5163                    match scope.get(name) {
5164                        Some(v) => Ok(value_to_string(v)),
5165                        None => Err(anyhow::anyhow!(
5166                            "arithmetic error: {}",
5167                            crate::arithmetic::unset_error(name)
5168                        )),
5169                    }
5170                }
5171                Expansion::BracedPath { root, brackets } => {
5172                    let scope = self.scope.read().await;
5173                    crate::arithmetic::braced_path_value(&scope, root, brackets)
5174                        .map(|v| value_to_string(&v))
5175                        .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5176                }
5177                Expansion::BracedDefault { root, brackets, default } => {
5178                    let resolved = {
5179                        let scope = self.scope.read().await;
5180                        crate::arithmetic::braced_default_operand(&scope, root, brackets)
5181                            .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?
5182                    };
5183                    match resolved {
5184                        // Stays in TEXT mode when the default is itself a
5185                        // single expansion — see the sync twin,
5186                        // `expansion_text_sync`, for why.
5187                        None => {
5188                            let default_expr = crate::arithmetic::parse(default)
5189                                .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))?;
5190                            match default_expr {
5191                                crate::arithmetic::ArithExpr::Expansion(e) => {
5192                                    self.eval_arith_expansion_text_async(&e).await
5193                                }
5194                                default_expr => {
5195                                    let n = self.eval_arith_expr_async(&default_expr).await?;
5196                                    Ok(n.to_string())
5197                                }
5198                            }
5199                        }
5200                        Some(value) => Ok(value_to_string(&value)),
5201                    }
5202                }
5203                Expansion::LastExitCode => {
5204                    let scope = self.scope.read().await;
5205                    Ok(scope.last_result().code.to_string())
5206                }
5207                Expansion::CurrentPid => {
5208                    let scope = self.scope.read().await;
5209                    Ok(scope.pid().to_string())
5210                }
5211                Expansion::CommandSubst(stmts) => self.run_arith_command_subst_text(stmts).await,
5212                Expansion::Nested(inner) => {
5213                    let n = self.eval_arith_expr_async(inner).await?;
5214                    Ok(n.to_string())
5215                }
5216            }
5217        })
5218    }
5219
5220    /// Run a `$(...)` operand inside `$(( ))`. Mirrors `Expr::CommandSubst`'s
5221    /// isolation (scope/cwd/config snapshot-and-restore, stderr forwarded to
5222    /// the enclosing statement) — the same substitution mechanism, just
5223    /// coerced to an integer instead of spliced in as text.
5224    async fn run_arith_command_subst(&self, stmts: &[Stmt]) -> Result<i64> {
5225        let text = self.run_arith_command_subst_text(stmts).await?;
5226        crate::arithmetic::parse_command_output(&text, "$(...)")
5227            .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
5228    }
5229
5230    /// Run a `$(...)` operand and return its printed text — the shared half
5231    /// of `Self::run_arith_command_subst` (a bare operand, coerced to an
5232    /// integer) and the `base#$(...)` case (the text is read as digits in a
5233    /// base, never coerced first — see `crate::arithmetic::based_value`).
5234    async fn run_arith_command_subst_text(&self, stmts: &[Stmt]) -> Result<String> {
5235        let saved_scope = Box::new(self.scope.read().await.clone());
5236        let saved_ec = {
5237            let ec = self.exec_ctx.read().await;
5238            (
5239                ec.cwd.clone(),
5240                ec.prev_cwd.clone(),
5241                ec.aliases.clone(),
5242                ec.ignore_config.clone(),
5243                ec.output_limit.clone(),
5244            )
5245        };
5246
5247        let run_result = self.execute_block_capturing(stmts).await;
5248
5249        {
5250            let mut scope = self.scope.write().await;
5251            *scope = *saved_scope;
5252            if let Ok(ref r) = run_result {
5253                scope.set_last_result(r.clone());
5254                scope.note_cmdsubst_code(r.code);
5255            }
5256        }
5257        {
5258            let mut ec = self.exec_ctx.write().await;
5259            let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
5260            ec.cwd = cwd;
5261            ec.prev_cwd = prev_cwd;
5262            ec.aliases = aliases;
5263            ec.ignore_config = ignore_config;
5264            ec.output_limit = output_limit;
5265        }
5266
5267        if let Ok(ref r) = run_result {
5268            self.emit_cmdsubst_stderr(&r.err).await;
5269        }
5270
5271        let result = run_result?;
5272        if result.out_bytes().is_some() {
5273            return Err(anyhow::anyhow!(
5274                "arithmetic error: `$(...)` printed binary data; the command must print one integer"
5275            ));
5276        }
5277        Ok(result.text_out().trim_end_matches('\n').to_string())
5278    }
5279
5280    /// Execute the `source` / `.` command to include and run a script.
5281    ///
5282    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
5283    /// allowing the sourced script to set variables and modify shell state.
5284    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
5285        // `source`/`.` is the fourth dynamic re-entry point: it runs the
5286        // sourced file's statements inline via `execute_stmt_flow`, so a file
5287        // that sources itself recurses unbounded just like a runaway function
5288        // (GH #46). It's intercepted as a special form *before* the other
5289        // guarded paths, so it needs its own guard.
5290        let _depth = self.enter_recursion("source")?;
5291
5292        // Get the file path from the first positional argument
5293        let tool_args = self.build_args_async(args, None).await?;
5294        let path = match tool_args.positional.first() {
5295            Some(Value::String(s)) => s.clone(),
5296            Some(v) => value_to_string(v),
5297            None => {
5298                return Ok(ExecResult::failure(1, "source: missing filename"));
5299            }
5300        };
5301
5302        // Resolve path relative to cwd
5303        let full_path = {
5304            let ctx = self.exec_ctx.read().await;
5305            if path.starts_with('/') {
5306                std::path::PathBuf::from(&path)
5307            } else {
5308                ctx.cwd.join(&path)
5309            }
5310        };
5311
5312        // Read file content via backend
5313        let content = {
5314            let ctx = self.exec_ctx.read().await;
5315            match ctx.backend.read(&full_path, None).await {
5316                Ok(bytes) => {
5317                    String::from_utf8(bytes).map_err(|e| {
5318                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
5319                    })?
5320                }
5321                Err(e) => {
5322                    return Ok(ExecResult::failure(
5323                        1,
5324                        format!("source: {}: {}", path, e),
5325                    ));
5326                }
5327            }
5328        };
5329
5330        // Parse the content
5331        let program = match crate::parser::parse(&content) {
5332            Ok(p) => p,
5333            Err(errors) => {
5334                let msg = errors
5335                    .iter()
5336                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
5337                    .collect::<Vec<_>>()
5338                    .join("\n");
5339                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
5340            }
5341        };
5342
5343        // Execute each statement in the CURRENT scope (not isolated), accumulating
5344        // stdout/stderr across statements like `execute_user_tool` — a sourced
5345        // script's earlier statements must not be silently dropped in favor of
5346        // just the last one.
5347        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
5348            match r.out_bytes() {
5349                Some(b) => buf.extend_from_slice(b),
5350                None => buf.extend_from_slice(r.text_out().as_bytes()),
5351            }
5352        }
5353
5354        let mut accumulated_out: Vec<u8> = Vec::new();
5355        let mut accumulated_err = String::new();
5356        let mut last_code = 0i64;
5357        let mut last_data: Option<Value> = None;
5358
5359        for stmt in program.statements {
5360            if matches!(stmt, crate::ast::Stmt::Empty) {
5361                continue;
5362            }
5363
5364            match self.execute_stmt_flow(&stmt).await {
5365                Ok(flow) => {
5366                    let drained = {
5367                        let mut receiver = self.stderr_receiver.lock().await;
5368                        receiver.drain_lossy()
5369                    };
5370                    if !drained.is_empty() {
5371                        accumulated_err.push_str(&drained);
5372                    }
5373                    match flow {
5374                        ControlFlow::Normal(r) => {
5375                            push_out(&mut accumulated_out, &r);
5376                            accumulated_err.push_str(&r.err);
5377                            last_code = r.code;
5378                            last_data = if r.data_is_value { r.data.clone() } else { None };
5379                            self.update_last_result(&r).await;
5380                        }
5381                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
5382                            return Err(anyhow::anyhow!(
5383                                "source: {}: unexpected break/continue outside loop",
5384                                path
5385                            ));
5386                        }
5387                        ControlFlow::Return { value } => {
5388                            push_out(&mut accumulated_out, &value);
5389                            accumulated_err.push_str(&value.err);
5390                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
5391                                .with_code(value.code);
5392                            result.err = accumulated_err;
5393                            result.data = value.data;
5394                            return Ok(result);
5395                        }
5396                        ControlFlow::Exit { code, result: r } => {
5397                            push_out(&mut accumulated_out, &r);
5398                            accumulated_err.push_str(&r.err);
5399                            let mut result =
5400                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5401                            result.err = accumulated_err;
5402                            // Whatever survived the gate above IS a value, so the result says so
5403        // and a further `$( )` around this one keeps it typed.
5404        result.data_is_value = last_data.is_some();
5405        result.data = last_data;
5406                            return Ok(result);
5407                        }
5408                    }
5409                }
5410                Err(e) => {
5411                    return Err(e.context(format!("source: {}", path)));
5412                }
5413            }
5414        }
5415
5416        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
5417        result.err = accumulated_err;
5418        // Whatever survived the gate above IS a value, so the result says so
5419        // and a further `$( )` around this one keeps it typed.
5420        result.data_is_value = last_data.is_some();
5421        result.data = last_data;
5422        Ok(result)
5423    }
5424
5425    /// Try to execute a script from PATH directories.
5426    ///
5427    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
5428    /// (like user-defined tools). Returns None if no script is found.
5429    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5430        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
5431        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
5432        // A non-script command pays only a transient, balanced increment during
5433        // the probe before falling through to the external path.
5434        let _depth = self.enter_recursion("a .kai script")?;
5435
5436        // Get PATH from scope (default to "/bin")
5437        let path_value = {
5438            let scope = self.scope.read().await;
5439            scope
5440                .get("PATH")
5441                .map(value_to_string)
5442                .unwrap_or_else(|| "/bin".to_string())
5443        };
5444
5445        // Search PATH directories for script
5446        for dir in path_value.split(':') {
5447            if dir.is_empty() {
5448                continue;
5449            }
5450
5451            // Build script path: {dir}/{name}.kai
5452            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
5453
5454            // Check if script exists
5455            let exists = {
5456                let ctx = self.exec_ctx.read().await;
5457                ctx.backend.exists(&script_path).await
5458            };
5459
5460            if !exists {
5461                continue;
5462            }
5463
5464            // Read script content
5465            let content = {
5466                let ctx = self.exec_ctx.read().await;
5467                match ctx.backend.read(&script_path, None).await {
5468                    Ok(bytes) => match String::from_utf8(bytes) {
5469                        Ok(s) => s,
5470                        Err(e) => {
5471                            return Ok(Some(ExecResult::failure(
5472                                1,
5473                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
5474                            )));
5475                        }
5476                    },
5477                    Err(e) => {
5478                        return Ok(Some(ExecResult::failure(
5479                            1,
5480                            format!("{}: {}", script_path.display(), e),
5481                        )));
5482                    }
5483                }
5484            };
5485
5486            // Parse the script
5487            let program = match crate::parser::parse(&content) {
5488                Ok(p) => p,
5489                Err(errors) => {
5490                    let msg = errors
5491                        .iter()
5492                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
5493                        .collect::<Vec<_>>()
5494                        .join("\n");
5495                    return Ok(Some(ExecResult::failure(1, msg)));
5496                }
5497            };
5498
5499            // Build tool_args from args (async for command substitution support)
5500            let tool_args = self.build_args_async(args, None).await?;
5501
5502            // Create isolated scope (like user tools). The trash rail and
5503            // errexit are NOT session state a script may shed: a `.kai`
5504            // script starting from a blank scope would otherwise overwrite
5505            // and delete without the recovery net `set -o trash` promised,
5506            // or run past a gating failure the caller turned errexit on for.
5507            // Carry both.
5508            let mut isolated_scope = Scope::new();
5509            {
5510                let scope = self.scope.read().await;
5511                isolated_scope.set_pid(scope.pid());
5512                isolated_scope.set_trash_enabled(scope.trash_enabled());
5513                isolated_scope.set_trash_max_size(scope.trash_max_size());
5514                isolated_scope.set_error_exit(scope.error_exit_enabled());
5515            }
5516
5517            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
5518            // Same source-text fidelity as the function-call site above.
5519            let positional_args: Vec<String> = tool_args.positional
5520                .iter()
5521                .enumerate()
5522                .map(|(i, v)| {
5523                    tool_args
5524                        .positional_raw
5525                        .get(&i)
5526                        .cloned()
5527                        .unwrap_or_else(|| value_to_string(v))
5528                })
5529                .collect();
5530            isolated_scope.set_positional(name, positional_args);
5531
5532            // Save current scope and swap with isolated scope
5533            let original_scope = {
5534                let mut scope = self.scope.write().await;
5535                std::mem::replace(&mut *scope, isolated_scope)
5536            };
5537
5538            // Execute script statements — accumulate stdout/stderr across
5539            // statements like `execute_user_tool`, rather than keeping only the
5540            // last one's result.
5541            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
5542                match r.out_bytes() {
5543                    Some(b) => buf.extend_from_slice(b),
5544                    None => buf.extend_from_slice(r.text_out().as_bytes()),
5545                }
5546            }
5547
5548            let mut accumulated_out: Vec<u8> = Vec::new();
5549            let mut accumulated_err = String::new();
5550            let mut last_code = 0i64;
5551            let mut last_data: Option<Value> = None;
5552            let mut exec_error: Option<anyhow::Error> = None;
5553            let mut exit_code: Option<i64> = None;
5554
5555            for stmt in program.statements {
5556                if matches!(stmt, crate::ast::Stmt::Empty) {
5557                    continue;
5558                }
5559
5560                match self.execute_stmt_flow(&stmt).await {
5561                    Ok(flow) => {
5562                        let drained = {
5563                            let mut receiver = self.stderr_receiver.lock().await;
5564                            receiver.drain_lossy()
5565                        };
5566                        if !drained.is_empty() {
5567                            accumulated_err.push_str(&drained);
5568                        }
5569                        match flow {
5570                            ControlFlow::Normal(r) => {
5571                                push_out(&mut accumulated_out, &r);
5572                                accumulated_err.push_str(&r.err);
5573                                last_code = r.code;
5574                                last_data = if r.data_is_value { r.data } else { None };
5575                            }
5576                            ControlFlow::Return { value } => {
5577                                push_out(&mut accumulated_out, &value);
5578                                accumulated_err.push_str(&value.err);
5579                                last_code = value.code;
5580                                last_data = if value.data_is_value { value.data } else { None };
5581                                break;
5582                            }
5583                            ControlFlow::Exit { code, result: r } => {
5584                                push_out(&mut accumulated_out, &r);
5585                                accumulated_err.push_str(&r.err);
5586                                exit_code = Some(code);
5587                                break;
5588                            }
5589                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
5590                                push_out(&mut accumulated_out, &r);
5591                                accumulated_err.push_str(&r.err);
5592                                last_code = r.code;
5593                                last_data = if r.data_is_value { r.data } else { None };
5594                            }
5595                        }
5596                    }
5597                    Err(e) => {
5598                        exec_error = Some(e);
5599                        break;
5600                    }
5601                }
5602            }
5603
5604            // Restore original scope unconditionally
5605            {
5606                let mut scope = self.scope.write().await;
5607                *scope = original_scope;
5608            }
5609
5610            // Propagate error or exit after cleanup
5611            if let Some(e) = exec_error {
5612                return Err(e.context(format!("script: {}", script_path.display())));
5613            }
5614            let code = exit_code.unwrap_or(last_code);
5615            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5616            result.err = accumulated_err;
5617            // Whatever survived the gate above IS a value, so the result says so
5618        // and a further `$( )` around this one keeps it typed.
5619        result.data_is_value = last_data.is_some();
5620        result.data = last_data;
5621            return Ok(Some(result));
5622        }
5623
5624        // No script found
5625        Ok(None)
5626    }
5627
5628    /// Try to execute an external command from PATH.
5629    ///
5630    /// This is the fallback when no builtin or user-defined tool matches.
5631    /// External commands receive a clean argv (flags preserved in their original format).
5632    ///
5633    /// # Requirements
5634    /// - Command must be found in PATH
5635    /// - Current working directory must be on a real filesystem (not virtual like /v)
5636    ///
5637    /// # Returns
5638    /// - [`ExternalCommandOutcome::Ran`] if a command was resolved and run (any exit code)
5639    /// - [`ExternalCommandOutcome::NotFound`] if nothing on PATH matches — the
5640    ///   caller should still try a backend-registered tool of the same name
5641    /// - [`ExternalCommandOutcome::Unavailable`] if kaish will not attempt a
5642    ///   PATH lookup or spawn at all; a backend tool of the same name is a
5643    ///   separate capability and is still tried by the caller
5644    /// - `Err` on execution errors
5645    #[cfg(not(feature = "subprocess"))]
5646    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<ExternalCommandOutcome> {
5647        Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::NotCompiled))
5648    }
5649
5650    /// Try to execute an external command from PATH.
5651    #[cfg(feature = "subprocess")]
5652    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<ExternalCommandOutcome> {
5653        if !self.allow_external_commands {
5654            return Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::ConfiguredOff));
5655        }
5656        Ok(match Box::pin(self.try_execute_external_on_path(name, args)).await? {
5657            Some(result) => ExternalCommandOutcome::Ran(Box::new(result)),
5658            None => ExternalCommandOutcome::NotFound,
5659        })
5660    }
5661
5662    /// The actual PATH lookup + spawn, once the caller has confirmed external
5663    /// commands are allowed at all. Unchanged from before the disabled/
5664    /// not-compiled cases were split out — still `Option`-shaped: `None`
5665    /// means "bare name, nothing on PATH", the one case where the caller
5666    /// should keep looking elsewhere.
5667    #[cfg(feature = "subprocess")]
5668    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
5669    async fn try_execute_external_on_path(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5670        // Get the shell's cwd and its real filesystem location, if any. A
5671        // `None` real path means the cwd is virtual (a CoW overlay, an
5672        // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
5673        // process to run. Don't bail out here: a bare command name that isn't
5674        // in PATH at all is a genuine "not found" regardless of cwd, and the
5675        // virtual-cwd error would blame the wrong thing for that case. Once
5676        // the command actually resolves, `real_cwd` is checked again below
5677        // and the honest reason is given then (issue #181).
5678        let (cwd, real_cwd) = {
5679            let ctx = self.exec_ctx.read().await;
5680            (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
5681        };
5682
5683        let executable = if name.contains('/') {
5684            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
5685            let resolved = if std::path::Path::new(name).is_absolute() {
5686                std::path::PathBuf::from(name)
5687            } else {
5688                match &real_cwd {
5689                    Some(real_cwd) => real_cwd.join(name),
5690                    // A relative path can't be resolved without a real cwd to
5691                    // join against, so we can't even tell whether it would
5692                    // exist — name the actual blocker instead of a
5693                    // misleading "No such file or directory".
5694                    None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5695                }
5696            };
5697            if !resolved.exists() {
5698                return Ok(Some(ExecResult::failure(
5699                    127,
5700                    format!("{}: No such file or directory", name),
5701                )));
5702            }
5703            if !resolved.is_file() {
5704                return Ok(Some(ExecResult::failure(
5705                    126,
5706                    format!("{}: Is a directory", name),
5707                )));
5708            }
5709            #[cfg(unix)]
5710            {
5711                use std::os::unix::fs::PermissionsExt;
5712                let mode = std::fs::metadata(&resolved)
5713                    .map(|m| m.permissions().mode())
5714                    .unwrap_or(0);
5715                if mode & 0o111 == 0 {
5716                    return Ok(Some(ExecResult::failure(
5717                        126,
5718                        format!("{}: Permission denied", name),
5719                    )));
5720                }
5721            }
5722            resolved.to_string_lossy().into_owned()
5723        } else {
5724            // Get PATH from scope only. The kernel never reads OS env: a
5725            // frontend that wants host PATH seeds it via initial_vars (the REPL
5726            // does, with os_env_vars()). No PATH in scope → nothing resolves.
5727            let path_var = {
5728                let scope = self.scope.read().await;
5729                scope.get("PATH").map(value_to_string).unwrap_or_default()
5730            };
5731
5732            // Resolve command in PATH
5733            match resolve_in_path(name, &path_var) {
5734                Some(path) => path,
5735                None => return Ok(None), // Not found - let caller handle error
5736            }
5737        };
5738
5739        // The executable resolved — found in PATH, or a path that exists and
5740        // is executable — but there's still nowhere to run it without a real
5741        // cwd to spawn the child process in.
5742        let real_cwd = match real_cwd {
5743            Some(p) => p,
5744            None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5745        };
5746
5747        tracing::debug!(executable = %executable, "resolved external command");
5748
5749        // Build flat argv (preserves flag format)
5750        let argv = self.build_args_flat(args).await?;
5751
5752        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5753        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5754        // byte vector. Take both out under the lock but do NOT drain here — a
5755        // pipe read can block on its producer (a still-running upstream stage),
5756        // so draining before spawn would serialize the pipeline (deadlocking
5757        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5758        // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
5759        // are mutually exclusive in practice; prefer the pipe.
5760        let (pipe_stdin, stdin_bytes) = {
5761            let mut ctx = self.exec_ctx.write().await;
5762            (ctx.pipe_stdin.take(), ctx.take_stdin())
5763        };
5764        let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
5765
5766        // The cancel token, the kill grace, and the background job all come
5767        // from `self.exec_ctx`, which `dispatch_command` populates from the
5768        // inbound ctx on every dispatch. That is what makes the `timeout`
5769        // builtin's swapped child token reach the wait_or_kill discipline —
5770        // reading `self.cancel_token` would give the kernel-wide token and
5771        // miss the timeout's child cascade.
5772        //
5773        // In interactive mode, standalone or last-in-pipeline commands inherit
5774        // the terminal's stdout/stderr so output streams in real-time.
5775        // First/middle commands must capture stdout for the pipe — same as bash.
5776        let (pipeline_position, spawn_ctx) = {
5777            let ctx = self.exec_ctx.read().await;
5778            (
5779                ctx.pipeline_position,
5780                crate::spawn::SpawnContext::from_exec_context(&ctx),
5781            )
5782        };
5783        let inherit_output = self.interactive
5784            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5785
5786        // Hermetic env: the child sees only kaish's exported vars, not the
5787        // kaish process's OS env. Frontends that want OS-env passthrough
5788        // (REPL, MCP) populate it via KernelConfig::initial_vars.
5789        let env = {
5790            let scope = self.scope.read().await;
5791            crate::spawn::hermetic_env(&scope)?
5792        };
5793
5794        let stdin = if has_stdin {
5795            crate::spawn::StdinPolicy::Piped {
5796                prefix: stdin_bytes,
5797                pipe: pipe_stdin,
5798            }
5799        } else if self.interactive {
5800            crate::spawn::StdinPolicy::Inherit
5801        } else {
5802            crate::spawn::StdinPolicy::Null
5803        };
5804
5805        // `self.interactive` and `self.terminal_state`, not the context's:
5806        // the persistent `exec_ctx` never carries the kernel's interactive
5807        // flag (only per-dispatch snapshots do), so the inherit decision is
5808        // made here and handed to the spawn as a policy.
5809        let output = if inherit_output {
5810            crate::spawn::OutputPolicy::Inherit {
5811                #[cfg(unix)]
5812                terminal_state: self.terminal_state.clone(),
5813            }
5814        } else {
5815            crate::spawn::OutputPolicy::Captured
5816        };
5817
5818        let request = crate::spawn::SpawnRequest {
5819            executable: PathBuf::from(executable),
5820            argv,
5821            cwd: real_cwd,
5822            env,
5823            stdin,
5824            output,
5825            label: name.to_string(),
5826        };
5827
5828        Ok(Some(crate::spawn::spawn_process(request, &spawn_ctx).await))
5829    }
5830
5831    // --- Variable Access ---
5832
5833    /// Get a variable value.
5834    pub async fn get_var(&self, name: &str) -> Option<Value> {
5835        let scope = self.scope.read().await;
5836        scope.get(name).cloned()
5837    }
5838
5839    /// Check if error-exit mode is enabled (for testing).
5840    #[cfg(test)]
5841    pub async fn error_exit_enabled(&self) -> bool {
5842        let scope = self.scope.read().await;
5843        scope.error_exit_enabled()
5844    }
5845
5846    /// Set a variable value.
5847    pub async fn set_var(&self, name: &str, value: Value) {
5848        let mut scope = self.scope.write().await;
5849        scope.set(name.to_string(), value);
5850    }
5851
5852    /// Set positional parameters ($0 script name and $1-$9 args).
5853    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5854        let mut scope = self.scope.write().await;
5855        scope.set_positional(script_name, args);
5856    }
5857
5858    /// List all variables.
5859    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5860        let scope = self.scope.read().await;
5861        scope.all()
5862    }
5863
5864    /// List exported variables (name, value), sorted by name. These are the
5865    /// vars a child process would see (see `dispatch`'s hermetic env build).
5866    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5867        let scope = self.scope.read().await;
5868        scope.exported_vars()
5869    }
5870
5871    // --- CWD ---
5872
5873    /// Get current working directory.
5874    pub async fn cwd(&self) -> PathBuf {
5875        self.exec_ctx.read().await.cwd.clone()
5876    }
5877
5878    /// Set current working directory.
5879    pub async fn set_cwd(&self, path: PathBuf) {
5880        let mut ctx = self.exec_ctx.write().await;
5881        ctx.set_cwd(path);
5882    }
5883
5884    /// Set the working directory only if `path` resolves to a directory in the
5885    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5886    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5887    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5888    /// disappeared. Returns whether the cwd was changed.
5889    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5890        // Clone the backend Arc out before the stat so we never hold the
5891        // exec_ctx lock across the await.
5892        let backend = self.exec_ctx.read().await.backend.clone();
5893        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5894        if is_dir {
5895            self.exec_ctx.write().await.set_cwd(path);
5896        }
5897        is_dir
5898    }
5899
5900    // --- Last Result ---
5901
5902    /// Get the last result ($?).
5903    pub async fn last_result(&self) -> ExecResult {
5904        let scope = self.scope.read().await;
5905        scope.last_result().clone()
5906    }
5907
5908    // --- Tools ---
5909
5910    /// Check if a user-defined function exists.
5911    pub async fn has_function(&self, name: &str) -> bool {
5912        self.user_tools.read().await.contains_key(name)
5913    }
5914
5915    /// Get available tool schemas.
5916    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5917        self.tools.schemas()
5918    }
5919
5920    /// Classify how the kernel will resolve a command name.
5921    ///
5922    /// This is the supported, single source of truth for command resolution that
5923    /// embedders should call instead of re-deriving the rules. Walk a parsed
5924    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5925    /// this per command name to bucket each into builtin / user-function /
5926    /// special-form / dynamic / external — for example a consent gate that blocks
5927    /// a script until external commands are approved.
5928    ///
5929    /// The classification mirrors the interpreter's real resolution order
5930    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5931    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5932    /// re-checking special-forms each step, exactly as execution does), then user
5933    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5934    /// name that is a variable or command-substitution expansion (`$cmd`,
5935    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5936    /// be resolved statically.
5937    ///
5938    /// Aliases are resolved against the kernel's current alias table, so an
5939    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5940    /// thing it would actually run. The safe direction of any residual imprecision
5941    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5942    /// `.kai`/backend-tool resolution are reported `External` even though some of
5943    /// those resolve in-process, so a consent gate over-gates rather than letting
5944    /// a `PATH` escape slip through.
5945    pub async fn classify_command(&self, name: &str) -> CommandKind {
5946        // Resolve the command head the way `execute_command_depth` does: a
5947        // special-form short-circuits before any alias lookup, otherwise expand
5948        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5949        // can't be resolved at all.
5950        let mut name = name.to_string();
5951        let mut alias_depth = 0u8;
5952        loop {
5953            if !crate::validator::is_static_command_name(&name) {
5954                return CommandKind::Dynamic;
5955            }
5956            if crate::validator::is_runtime_special_form(&name) {
5957                return CommandKind::Special;
5958            }
5959            if alias_depth >= 10 {
5960                break;
5961            }
5962            let alias_value = {
5963                let ctx = self.exec_ctx.read().await;
5964                ctx.aliases.get(&name).cloned()
5965            };
5966            // Expand to the alias's head command. An empty alias value (no head)
5967            // is ignored by execution, so resolution continues with this name.
5968            match alias_value
5969                .as_deref()
5970                .and_then(|v| v.split_whitespace().next())
5971            {
5972                Some(head) => {
5973                    name = head.to_string();
5974                    alias_depth += 1;
5975                }
5976                None => break,
5977            }
5978        }
5979
5980        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5981        let is_builtin = self.tools.contains(&name);
5982        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5983    }
5984
5985    // --- Jobs ---
5986
5987    /// Get job manager.
5988    pub fn jobs(&self) -> Arc<JobManager> {
5989        self.jobs.clone()
5990    }
5991
5992    // --- VFS ---
5993
5994    /// Get VFS router.
5995    pub fn vfs(&self) -> Arc<VfsRouter> {
5996        self.vfs.clone()
5997    }
5998
5999    // --- State ---
6000
6001    /// Reset kernel to initial state.
6002    ///
6003    /// Clears in-memory variables and resets cwd to root. History is not
6004    /// cleared (it persists across resets). The kernel's `$$` identity, the
6005    /// trash-on-delete configuration, the current errexit state, and any
6006    /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
6007    /// are re-applied to the fresh scope rather than silently reverting to
6008    /// defaults — an embedder that opted into trash or errexit must not find
6009    /// either quietly disabled after a `reset()` between requests.
6010    ///
6011    /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
6012    /// reset, not a session boundary for `&`. A job started before `reset()`
6013    /// keeps running, stays in `jobs`, and the job ID counter keeps counting
6014    /// up. An embedder treating `reset()` as "new session" (a fresh MCP
6015    /// conversation reusing one kernel, say) inherits every job the previous
6016    /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
6017    /// that inheritance is not wanted.
6018    pub async fn reset(&self) -> Result<()> {
6019        {
6020            let mut scope = self.scope.write().await;
6021            let pid = scope.pid();
6022            let trash_enabled = scope.trash_enabled();
6023            let errexit_enabled = scope.error_exit_enabled();
6024            let mut fresh = Scope::new();
6025            fresh.set_pid(pid);
6026            for (name, value) in self.initial_vars.clone() {
6027                fresh.set_exported(name, value);
6028            }
6029            // The pin travels with the policy it pins — a `reset()` between
6030            // requests that dropped it would hand the next request an
6031            // unpinned session (spec §F.3 item 3).
6032            fresh.set_trash_enabled(trash_enabled);
6033            // Same reasoning as trash: an embedder relying on errexit for a
6034            // gating decision must not find it quietly disabled after a
6035            // `reset()` between requests.
6036            fresh.set_error_exit(errexit_enabled);
6037            // `reset()` puts the session back at `/`, so `$PWD` says so.
6038            // `initial_vars` can carry an inherited `PWD` from the invoking
6039            // environment, which would otherwise survive the reset and name a
6040            // directory this session is no longer in. `$OLDPWD` goes for the
6041            // same reason a fresh kernel has none: there is no previous
6042            // directory, and `cd -` refuses.
6043            fresh.set_global("PWD", Value::String("/".to_string()));
6044            fresh.remove("OLDPWD");
6045            *scope = fresh;
6046        }
6047        {
6048            let mut ctx = self.exec_ctx.write().await;
6049            ctx.cwd = PathBuf::from("/");
6050            ctx.prev_cwd = None;
6051        }
6052        Ok(())
6053    }
6054
6055    /// Trip the cancellation token of every tracked background job (`&`) —
6056    /// whether or not `shutdown` follows.
6057    ///
6058    /// This is the same lever `kill %N` uses: a *running* job's in-process
6059    /// future exits at its next checkpoint, and any external children it
6060    /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
6061    /// status `Killed` once it unwinds. For an already-finished job the
6062    /// token trip is a no-op — its future has already resolved and the job
6063    /// keeps reporting its terminal status. This only
6064    /// *starts* cancellation, it does not wait (pair with
6065    /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
6066    /// unwind, bounded as [`Self::shutdown`] does).
6067    ///
6068    /// A job registered by an embedder via [`JobManager::register`] with no
6069    /// cancel token attached has no lever to cancel — silently skipped here,
6070    /// same as `kill %N`'s own "no cancellation token" case.
6071    ///
6072    /// Returns how many jobs a token was actually tripped for.
6073    pub async fn cancel_all_jobs(&self) -> usize {
6074        let ids = self.jobs.list_ids().await;
6075        let mut cancelled = 0;
6076        for id in ids {
6077            if self.jobs.mark_killed_and_cancel(id, false).await {
6078                cancelled += 1;
6079            }
6080        }
6081        cancelled
6082    }
6083
6084    /// Shut down the kernel.
6085    ///
6086    /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
6087    /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
6088    /// gives a single target (GH #244) — for it to actually unwind. The
6089    /// waits are sequential, so the worst case is additive: N jobs that all
6090    /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
6091    /// that unwind promptly (the normal case) cost only their own unwind
6092    /// time. Before this fix `shutdown` called `wait_all()` with no timeout
6093    /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
6094    ///
6095    /// A job that has not unwound by its deadline is abandoned: logged via
6096    /// `tracing::warn!` and left running detached until the tokio runtime
6097    /// itself goes away. There is no further lever once `shutdown()` has
6098    /// returned — this method does not hang, but it also does not guarantee
6099    /// every job actually stopped.
6100    ///
6101    /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
6102    /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
6103    /// `Arc::try_unwrap`, since the work here only touches the shared
6104    /// `Arc<JobManager>`, never kernel state that would need exclusive
6105    /// ownership.
6106    pub async fn shutdown(&self) -> Result<()> {
6107        let ids = self.jobs.list_ids().await;
6108        self.cancel_all_jobs().await;
6109
6110        let bound = self.jobs.kill_grace() + Duration::from_secs(3);
6111        for id in ids {
6112            if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
6113                tracing::warn!(
6114                    job_id = %id,
6115                    bound_secs = bound.as_secs_f64(),
6116                    "kernel shutdown: job did not exit within the grace period after \
6117                     cancellation — abandoning it"
6118                );
6119            }
6120        }
6121        Ok(())
6122    }
6123
6124    /// Run a compound statement that occupies a pipeline stage.
6125    ///
6126    /// Same ctx↔exec_ctx sync as `dispatch_command`, with one deliberate
6127    /// difference: the stage's pipe writer stays behind with the runner. The
6128    /// statement buffers — its whole output comes back in the `ExecResult` and
6129    /// the runner writes it to the pipe once. Handing the writer down instead
6130    /// would give it to whichever nested command grabbed the slot first, and
6131    /// every later iteration would write nowhere.
6132    ///
6133    /// Streaming a stage would mean threading a writer through nested
6134    /// statement execution, which is the shared-slot machinery GH #369 is
6135    /// about. Revisit once the interpreter takes a ctx parameter.
6136    async fn dispatch_statement(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
6137        if let Some(d) = self.dispatcher() {
6138            ctx.dispatcher = Some(d);
6139        }
6140
6141        // 1. Sync ctx → self internals
6142        {
6143            let mut scope = self.scope.write().await;
6144            *scope = ctx.scope.clone();
6145        }
6146        {
6147            let mut ec = self.exec_ctx.write().await;
6148            ec.cwd = ctx.cwd.clone();
6149            ec.prev_cwd = ctx.prev_cwd.clone();
6150            ec.stdin = ctx.stdin.take();
6151            ec.stdin_data = ctx.stdin_data.take();
6152            ec.stdin_data_rx = ctx.stdin_data_rx.take();
6153            ec.pipe_stdin = ctx.pipe_stdin.take();
6154            // The writer is NOT handed over — see this function's doc comment.
6155            // Clearing the slot keeps a writer left by an earlier dispatch from
6156            // catching the first command inside the loop body.
6157            ec.pipe_stdout = None;
6158            if let Some(stderr) = ctx.stderr.clone() {
6159                ec.stderr = Some(stderr);
6160            }
6161            ec.aliases = ctx.aliases.clone();
6162            ec.ignore_config = ctx.ignore_config.clone();
6163            ec.output_limit = ctx.output_limit.clone();
6164            ec.pipeline_position = ctx.pipeline_position;
6165            ec.cancel = ctx.cancel.clone();
6166            ec.watchdog = ctx.watchdog.clone();
6167        }
6168
6169        // 2. Run the statement. A stage is its own execution unit, so a
6170        // `break`, `continue`, `return`, or `exit` that reaches the top of the
6171        // statement stops here rather than escaping into the enclosing script —
6172        // the same boundary bash draws by running each stage in a subshell.
6173        // Whatever output the statement produced before the signal still comes
6174        // back and still reaches the pipe.
6175        let result = match self.execute_stmt_flow(stmt).await? {
6176            ControlFlow::Normal(result)
6177            | ControlFlow::Break { result, .. }
6178            | ControlFlow::Continue { result, .. }
6179            | ControlFlow::Return { value: result } => result,
6180            ControlFlow::Exit { code, mut result } => {
6181                result.code = code;
6182                result
6183            }
6184        };
6185
6186        // 3. Sync self → ctx
6187        {
6188            let scope = self.scope.read().await;
6189            ctx.scope = scope.clone();
6190        }
6191        {
6192            let mut ec = self.exec_ctx.write().await;
6193            ctx.cwd = ec.cwd.clone();
6194            ctx.prev_cwd = ec.prev_cwd.clone();
6195            ctx.aliases = ec.aliases.clone();
6196            ctx.ignore_config = ec.ignore_config.clone();
6197            ctx.output_limit = ec.output_limit.clone();
6198            ctx.pipe_stdin = ec.pipe_stdin.take();
6199            ctx.stdin = ec.stdin.take();
6200            ctx.stdin_data = ec.stdin_data.take();
6201            ctx.stdin_data_rx = ec.stdin_data_rx.take();
6202        }
6203
6204        Ok(result)
6205    }
6206
6207    /// Dispatch a single command using the full resolution chain.
6208    ///
6209    /// This is the core of `CommandDispatcher` — it syncs state between the
6210    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
6211    /// then delegates to `execute_command` for the actual dispatch.
6212    ///
6213    /// State flow:
6214    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
6215    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
6216    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
6217    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6218        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
6219        // its inner command via ctx.dispatcher) routes through THIS kernel,
6220        // not a stale parent. Critical for forks: the fork's builtins must
6221        // use the fork's dispatcher, not the parent's.
6222        if let Some(d) = self.dispatcher() {
6223            ctx.dispatcher = Some(d);
6224        }
6225
6226        // 1. Sync ctx → self internals
6227        {
6228            let mut scope = self.scope.write().await;
6229            *scope = ctx.scope.clone();
6230        }
6231        {
6232            let mut ec = self.exec_ctx.write().await;
6233            ec.cwd = ctx.cwd.clone();
6234            ec.prev_cwd = ctx.prev_cwd.clone();
6235            ec.stdin = ctx.stdin.take();
6236            ec.stdin_data = ctx.stdin_data.take();
6237            // The structured-data sideband receiver (set by the concurrent
6238            // pipeline runner on the stage ctx) must reach the tool's snapshot
6239            // too — same reason as the pipe endpoints below. Without this a
6240            // pipeline consumer never sees the producer's `.data`.
6241            ec.stdin_data_rx = ctx.stdin_data_rx.take();
6242            // Streaming pipe endpoints and kernel stderr must flow to the
6243            // tool via self.exec_ctx — execute_command reads that, not the
6244            // passed-in ctx. Without moving these, concurrent pipeline
6245            // stages dispatched via a fork get pipe_stdin = None and
6246            // silently read nothing.
6247            ec.pipe_stdin = ctx.pipe_stdin.take();
6248            ec.pipe_stdout = ctx.pipe_stdout.take();
6249            if let Some(stderr) = ctx.stderr.clone() {
6250                ec.stderr = Some(stderr);
6251            }
6252            ec.aliases = ctx.aliases.clone();
6253            ec.ignore_config = ctx.ignore_config.clone();
6254            ec.output_limit = ctx.output_limit.clone();
6255            ec.pipeline_position = ctx.pipeline_position;
6256            // Sync the cancel token from ctx → ec. Builtins like `timeout`
6257            // swap ctx.cancel to a derived child token before re-dispatching;
6258            // execute_command's snapshot reads ec.cancel (kept aligned by
6259            // this sync), so try_execute_external sees the right token.
6260            ec.cancel = ctx.cancel.clone();
6261            // Same alignment for the watchdog: a fork dispatching through its
6262            // own kernel must hand the shared script clock to the snapshot so
6263            // patient holds in forked stages suspend the right timer.
6264            ec.watchdog = ctx.watchdog.clone();
6265        }
6266
6267        // 2. Execute via the full dispatch chain
6268        let result = self.execute_command(&cmd.name, &cmd.args).await?;
6269
6270        // 3. Sync self → ctx
6271        {
6272            let scope = self.scope.read().await;
6273            ctx.scope = scope.clone();
6274        }
6275        {
6276            let mut ec = self.exec_ctx.write().await;
6277            ctx.cwd = ec.cwd.clone();
6278            ctx.prev_cwd = ec.prev_cwd.clone();
6279            ctx.aliases = ec.aliases.clone();
6280            ctx.ignore_config = ec.ignore_config.clone();
6281            ctx.output_limit = ec.output_limit.clone();
6282            // Return any pipe endpoints that the tool didn't consume.
6283            // `take()` here keeps the fork's exec_ctx in a clean state for
6284            // the next dispatch — these are per-command and shouldn't leak
6285            // between calls.
6286            ctx.pipe_stdin = ec.pipe_stdin.take();
6287            ctx.pipe_stdout = ec.pipe_stdout.take();
6288            // Unconsumed buffered stdin comes back the same way, and for a
6289            // sharper reason than symmetry: a partial read (`read` takes one
6290            // line) leaves its remainder in `ec`, and the caller's own
6291            // end-of-statement sync writes `ctx.stdin` back over `ec.stdin`.
6292            // Without this the caller writes its stale `None` over the
6293            // remainder and the rest of the stream is gone.
6294            ctx.stdin = ec.stdin.take();
6295            // The sideband rides home with stdin, same rule.
6296            ctx.stdin_data = ec.stdin_data.take();
6297            ctx.stdin_data_rx = ec.stdin_data_rx.take();
6298            // Same take-don't-clone discipline as stdin, and for the same
6299            // reason: these belong to exactly one dispatch, and a copy left
6300            // behind would let the next command adopt it.
6301        }
6302
6303        Ok(result)
6304    }
6305}
6306
6307/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
6308/// shared arg-binding core behind both `Kernel::build_args_async`
6309/// (production: full recursion through the async pipeline, command
6310/// substitution, real glob expansion) and the reduced sync evaluator behind
6311/// scatter/gather's own option parsing and the `#[cfg(test)]`
6312/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
6313/// `SyncEvalSource`). GH #188 closes the drift class between those two
6314/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
6315/// is now the ONLY implementation; only expression evaluation, which is
6316/// capability-bound (recursing into command substitution needs a live async
6317/// pipeline the reduced context doesn't have), still has two providers.
6318#[async_trait]
6319pub(crate) trait ArgValueSource: Send + Sync {
6320    /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
6321    /// this evaluator" — the reduced sync evaluator's bash-compatible
6322    /// "coalesce" convention for an unset bare variable, or an expression
6323    /// form it doesn't support (a binary op) — and the caller drops the
6324    /// argument the same way an unset bare variable always has. The real
6325    /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
6326    /// evaluate.
6327    async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
6328
6329    /// Expand a bare glob-pattern positional to display strings, or `None`
6330    /// if this evaluator doesn't expand globs here (disabled, or the reduced
6331    /// sync context, which never has — matching its documented "no
6332    /// filesystem walk before worker forks" limit). `bind_tool_args` falls
6333    /// back to `eval` (which hands back the pattern text as a literal
6334    /// string) when this returns `None`. An enabled expansion that matches
6335    /// nothing is a genuine error, not `Ok(None)`.
6336    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
6337
6338    /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
6339    /// — the reduced sync evaluator's existing behavior (it never expanded
6340    /// `~`).
6341    async fn home(&self) -> Option<String>;
6342}
6343
6344#[async_trait]
6345impl ArgValueSource for Kernel {
6346    async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
6347        Ok(Some(self.eval_expr_async(expr).await?))
6348    }
6349
6350    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
6351        let glob_enabled = self.scope.read().await.glob_enabled();
6352        if !glob_enabled {
6353            return Ok(None);
6354        }
6355        let (paths, cwd) = {
6356            let ctx = self.exec_ctx.read().await;
6357            let paths = ctx
6358                .expand_glob(pattern)
6359                .await
6360                .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
6361            let cwd = ctx.resolve_path(".");
6362            (paths, cwd)
6363        };
6364        if paths.is_empty() {
6365            anyhow::bail!("no matches: {}", pattern);
6366        }
6367        let display = paths
6368            .into_iter()
6369            .map(|path| {
6370                if !pattern.starts_with('/') {
6371                    path.strip_prefix(&cwd)
6372                        .unwrap_or(&path)
6373                        .to_string_lossy()
6374                        .into_owned()
6375                } else {
6376                    path.to_string_lossy().into_owned()
6377                }
6378            })
6379            .collect();
6380        Ok(Some(display))
6381    }
6382
6383    async fn home(&self) -> Option<String> {
6384        self.scope_home().await
6385    }
6386}
6387
6388/// Pull `consumes` positional args after a non-bool flag and stash them on
6389/// `tool_args.named` under the canonical param name. Shared core behind
6390/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
6391/// function's doc comment for the unification story (GH #188).
6392///
6393/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
6394///   single scalar value (last write wins on the rare duplicate).
6395/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
6396///   inside `named[canonical] = Value::Json(Array(...))`, preserving
6397///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
6398///   a repeated single-value flag must keep every value, not silently drop
6399///   all but the last (a "no silent corruption" violation).
6400/// - `consumes > 1` accumulates each occurrence as an inner
6401///   `serde_json::Value::Array` inside `named[canonical] =
6402///   Value::Json(Array(...))`, preserving invocation order. This is the
6403///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
6404///
6405/// Errors loudly if the flag is missing required positionals — matches
6406/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
6407/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
6408/// can't represent — Kernel's evaluator never returns this) falls back to a
6409/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
6410/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
6411/// error rather than a silently-partial array.
6412#[allow(clippy::too_many_arguments)]
6413async fn consume_flag_positionals(
6414    source: &dyn ArgValueSource,
6415    home: Option<&str>,
6416    args: &[Arg],
6417    flag_name: &str,
6418    canonical: &str,
6419    consumes: usize,
6420    repeatable: bool,
6421    positional_indices: &[usize],
6422    consumed: &mut std::collections::HashSet<usize>,
6423    current_idx: usize,
6424    tool_args: &mut ToolArgs,
6425) -> Result<()> {
6426    let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
6427    for _ in 0..consumes.max(1) {
6428        // A `key=value` (WordAssign) token is consumable only by a
6429        // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
6430        // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
6431        // filter` would reassemble `x=1` into the first slot and steal the
6432        // filter into the second. Multi-value flags take plain positionals.
6433        let allow_word_assign = consumes <= 1;
6434        let next_pos = positional_indices
6435            .iter()
6436            .find(|idx| {
6437                **idx > current_idx
6438                    && !consumed.contains(idx)
6439                    && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
6440            })
6441            .copied();
6442        match next_pos {
6443            Some(pos_idx) => match &args[pos_idx] {
6444                Arg::Positional(expr) => match source.eval(expr).await? {
6445                    Some(value) => {
6446                        let value = apply_tilde_expansion(value, home);
6447                        collected.push(value);
6448                        consumed.insert(pos_idx);
6449                    }
6450                    None if collected.is_empty() => {
6451                        tool_args.flags.insert(flag_name.to_string());
6452                        return Ok(());
6453                    }
6454                    None => anyhow::bail!(
6455                        "--{flag_name}: could not evaluate argument {} in this context",
6456                        collected.len() + 1
6457                    ),
6458                },
6459                // `-v a=1`: reassemble the `key=value` token as the flag's
6460                // scalar value (see `positional_indices` construction).
6461                Arg::WordAssign { key, value } => match source.eval(value).await? {
6462                    Some(val) => {
6463                        let val = apply_tilde_expansion(val, home);
6464                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
6465                        // reassemble the `[binary: N bytes]` placeholder into the
6466                        // flag's value — same text-sink boundary as the primary
6467                        // sinks fixed in #93 item 1.
6468                        let val_str = crate::interpreter::value_to_text_sink_named(
6469                            &val,
6470                            "a key=value argument",
6471                        )
6472                        .map_err(|e| anyhow::anyhow!("{e}"))?;
6473                        collected.push(Value::String(format!("{key}={val_str}")));
6474                        consumed.insert(pos_idx);
6475                    }
6476                    None if collected.is_empty() => {
6477                        tool_args.flags.insert(flag_name.to_string());
6478                        return Ok(());
6479                    }
6480                    None => anyhow::bail!(
6481                        "--{flag_name}: could not evaluate argument {} in this context",
6482                        collected.len() + 1
6483                    ),
6484                },
6485                _ => {}
6486            },
6487            None => {
6488                if consumes <= 1 && collected.is_empty() {
6489                    // Back-compat: a flag with no follow-up positional
6490                    // becomes a bare flag. `--path` with nothing after
6491                    // lands in `flags`, same as before this refactor.
6492                    tool_args.flags.insert(flag_name.to_string());
6493                    return Ok(());
6494                }
6495                anyhow::bail!(
6496                    "--{flag_name} requires {consumes} argument{}, got {}",
6497                    if consumes == 1 { "" } else { "s" },
6498                    collected.len()
6499                );
6500            }
6501        }
6502    }
6503
6504    if consumes <= 1 {
6505        if let Some(v) = collected.pop() {
6506            if repeatable {
6507                push_repeatable_value(tool_args, flag_name, canonical, v)?;
6508            } else {
6509                tool_args.named.insert(canonical.to_string(), v);
6510            }
6511        }
6512        return Ok(());
6513    }
6514
6515    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
6516    let occ: Vec<serde_json::Value> = collected
6517        .iter()
6518        .map(|v| flag_value_to_json(canonical, v))
6519        .collect::<Result<Vec<_>>>()?;
6520    let entry = tool_args
6521        .named
6522        .entry(canonical.to_string())
6523        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6524    if let Value::Json(serde_json::Value::Array(outer)) = entry {
6525        outer.push(serde_json::Value::Array(occ));
6526    } else {
6527        anyhow::bail!(
6528            "--{flag_name}: named[{canonical}] already holds a non-array value"
6529        );
6530    }
6531    Ok(())
6532}
6533
6534/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
6535/// (GH #188) shared by `Kernel::build_args_async` (production) and the
6536/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
6537/// scatter/gather's own option parsing and the `#[cfg(test)]`
6538/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
6539/// pass: Kernel's evaluates full expressions (including `$(...)` command
6540/// substitution) and expands real globs/tilde; the reduced one can't recurse
6541/// into the async pipeline this early (scatter/gather's own flags bind
6542/// before any worker forks) so it evaluates a smaller expression subset and
6543/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
6544///
6545/// If a schema is provided, uses it to determine argument types:
6546/// - For `--flag` where schema says type is non-bool: consume next
6547///   positional(s) as value(s) (`consumes`/`repeatable`-aware).
6548/// - For `--flag` where schema says type is bool (or unknown): treat as a
6549///   boolean flag.
6550///
6551/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
6552pub(crate) async fn bind_tool_args(
6553    args: &[Arg],
6554    schema: Option<&crate::tools::ToolSchema>,
6555    source: &dyn ArgValueSource,
6556) -> Result<ToolArgs> {
6557    let mut tool_args = ToolArgs::new();
6558    let home = source.home().await;
6559
6560    // A glob-passthrough tool (`glob`) consumes patterns as data: skip
6561    // argv glob expansion so the pattern reaches the tool as written —
6562    // otherwise `glob **/*.rs` binds the first *matching path* as its
6563    // pattern. The eval fallback turns `Expr::GlobPattern` into its
6564    // literal string.
6565    let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
6566
6567    // Verbatim: the tool owns its grammar, so it gets every word in source
6568    // order and nothing in `positional`/`named`. The typed split below is
6569    // set-shaped and drops the order and multiplicity a clap subcommand tree
6570    // needs, which no inversion can recover.
6571    //
6572    // `--json` is still the kernel's, so it is lifted into `flags` wherever it
6573    // sits and `apply_from_args` handles it as it does for a typed tool —
6574    // unless the tool owns its output, in which case the kernel renders
6575    // nothing and the flag has to reach the tool's own argv instead. Lifting it
6576    // there would strip it from the words AND skip rendering, so asking for
6577    // JSON would do nothing at all.
6578    if schema.is_some_and(|s| matches!(s.arg_binding, crate::tools::ArgBinding::Verbatim)) {
6579        let lift_global_flags = !schema.is_some_and(|s| s.owns_output);
6580        let mut words: Vec<Value> = Vec::new();
6581        let mut past_double_dash = false;
6582        for arg in args {
6583            match arg {
6584                Arg::Positional(expr) => {
6585                    let glob = if let Expr::GlobPattern(p) = expr {
6586                        (!glob_passthrough).then(|| p.clone())
6587                    } else {
6588                        None
6589                    };
6590                    let expanded = match &glob {
6591                        Some(pattern) => source.expand_glob(pattern).await?,
6592                        None => None,
6593                    };
6594                    match expanded {
6595                        Some(paths) => {
6596                            for path in paths {
6597                                words.push(Value::String(path));
6598                            }
6599                        }
6600                        None => {
6601                            // Nothing evaluated means no word, matching the
6602                            // typed path's `if let Some(value)`.
6603                            if let Some(value) = source.eval(expr).await? {
6604                                // Recorded at the index the push below lands
6605                                // at — see `ToolArgs::words_raw`.
6606                                if let Expr::NumericLiteral { raw, .. } = expr {
6607                                    tool_args.words_raw.insert(words.len(), raw.clone());
6608                                }
6609                                words.push(apply_tilde_expansion(value, home.as_deref()));
6610                            }
6611                        }
6612                    }
6613                }
6614                Arg::ShortFlag(name) => words.push(Value::String(format!("-{name}"))),
6615                Arg::LongFlag(name) => {
6616                    if lift_global_flags
6617                        && !past_double_dash
6618                        && crate::tools::is_global_output_flag(name)
6619                    {
6620                        tool_args.flags.insert(name.clone());
6621                        continue;
6622                    }
6623                    words.push(Value::String(format!("--{name}")));
6624                }
6625                Arg::Named { key, value } => {
6626                    let val = source.eval(value).await?.ok_or_else(|| {
6627                        anyhow::anyhow!("verbatim --key=value could not be evaluated in this context")
6628                    })?;
6629                    let val = apply_tilde_expansion(val, home.as_deref());
6630                    if lift_global_flags
6631                        && !past_double_dash
6632                        && crate::tools::is_global_output_flag(key)
6633                    {
6634                        // Removed from the words whether or not it is on: the
6635                        // tool must not meet the kernel's flag in any form.
6636                        if global_flag_value_is_truthy(&val) {
6637                            tool_args.flags.insert(key.clone());
6638                        }
6639                        continue;
6640                    }
6641                    // Loud on binary (GH #116): reassembling `--k=$BIN` as text
6642                    // hands the tool a placeholder that looks like data. A bare
6643                    // binary word is fine — it stays typed. The whole word is
6644                    // composed here, so no later render step could reach a
6645                    // `words_raw` entry.
6646                    let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6647                        raw.clone()
6648                    } else {
6649                        crate::interpreter::value_to_text_sink_named(
6650                            &val,
6651                            "a --key=value argument",
6652                        )
6653                        .map_err(|e| anyhow::anyhow!("{e}"))?
6654                    };
6655                    words.push(Value::String(format!("--{key}={val_str}")));
6656                }
6657                Arg::WordAssign { key, value } => {
6658                    let val = source.eval(value).await?.ok_or_else(|| {
6659                        anyhow::anyhow!("verbatim key=value could not be evaluated in this context")
6660                    })?;
6661                    let val = apply_tilde_expansion(val, home.as_deref());
6662                    let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6663                        raw.clone()
6664                    } else {
6665                        crate::interpreter::value_to_text_sink_named(
6666                            &val,
6667                            "a key=value argument",
6668                        )
6669                        .map_err(|e| anyhow::anyhow!("{e}"))?
6670                    };
6671                    words.push(Value::String(format!("{key}={val_str}")));
6672                }
6673                Arg::DoubleDash => {
6674                    past_double_dash = true;
6675                    words.push(Value::String("--".to_string()));
6676                }
6677            }
6678        }
6679        tool_args.words = Some(words);
6680        return Ok(tool_args);
6681    }
6682
6683    // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
6684    // in source order with types preserved — operators (`-f`, `=`, `!`) as
6685    // strings, operands keeping their `Value` — leaving `flags`/`named`
6686    // empty. A position-sensitive command needs the *true* argv: an operand
6687    // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
6688    // hoisted into the unordered flag set the normal binder splits into.
6689    // Globs still expand and `~` still resolves, matching normal positional
6690    // binding — so `test -f *.rs` errors on too many args, not a literal
6691    // pattern stat.
6692    if schema.is_some_and(|s| s.raw_argv) {
6693        for arg in args {
6694            match arg {
6695                Arg::Positional(expr) => {
6696                    let glob = if let Expr::GlobPattern(p) = expr {
6697                        (!glob_passthrough).then(|| p.clone())
6698                    } else {
6699                        None
6700                    };
6701                    if let Some(pattern) = glob {
6702                        match source.expand_glob(&pattern).await? {
6703                            Some(paths) => {
6704                                for path in paths {
6705                                    tool_args.positional.push(Value::String(path));
6706                                }
6707                            }
6708                            None => {
6709                                let value = source.eval(expr).await?.ok_or_else(|| {
6710                                    anyhow::anyhow!(
6711                                        "raw-argv positional could not be evaluated in this context"
6712                                    )
6713                                })?;
6714                                let value = apply_tilde_expansion(value, home.as_deref());
6715                                if let Expr::NumericLiteral { raw, .. } = expr {
6716                                    tool_args
6717                                        .positional_raw
6718                                        .insert(tool_args.positional.len(), raw.clone());
6719                                }
6720                                tool_args.positional.push(value);
6721                            }
6722                        }
6723                    } else {
6724                        let value = source.eval(expr).await?.ok_or_else(|| {
6725                            anyhow::anyhow!(
6726                                "raw-argv positional could not be evaluated in this context"
6727                            )
6728                        })?;
6729                        let value = apply_tilde_expansion(value, home.as_deref());
6730                        // `test`'s numeric operators still get the real
6731                        // `value`; a text consumer gets `raw`.
6732                        if let Expr::NumericLiteral { raw, .. } = expr {
6733                            tool_args
6734                                .positional_raw
6735                                .insert(tool_args.positional.len(), raw.clone());
6736                        }
6737                        tool_args.positional.push(value);
6738                    }
6739                }
6740                Arg::ShortFlag(name) => {
6741                    tool_args.positional.push(Value::String(format!("-{name}")));
6742                }
6743                Arg::LongFlag(name) => {
6744                    tool_args.positional.push(Value::String(format!("--{name}")));
6745                }
6746                Arg::Named { key, value } => {
6747                    let val = source.eval(value).await?.ok_or_else(|| {
6748                        anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
6749                    })?;
6750                    let val = apply_tilde_expansion(val, home.as_deref());
6751                    // Loud on binary (GH #116): `test --k=$BIN` must not
6752                    // silently reassemble the placeholder into the raw-argv
6753                    // positional stream `test` binds against. Source text
6754                    // wins, as in the Verbatim binder's `Arg::Named` arm.
6755                    let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6756                        raw.clone()
6757                    } else {
6758                        crate::interpreter::value_to_text_sink_named(
6759                            &val,
6760                            "a --key=value argument",
6761                        )
6762                        .map_err(|e| anyhow::anyhow!("{e}"))?
6763                    };
6764                    tool_args
6765                        .positional
6766                        .push(Value::String(format!("--{key}={val_str}")));
6767                }
6768                Arg::WordAssign { key, value } => {
6769                    let val = source.eval(value).await?.ok_or_else(|| {
6770                        anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
6771                    })?;
6772                    let val = apply_tilde_expansion(val, home.as_deref());
6773                    // Loud on binary (GH #116): same reasoning as the Named
6774                    // arm above, for the bare `key=value` raw-argv form.
6775                    let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6776                        raw.clone()
6777                    } else {
6778                        crate::interpreter::value_to_text_sink_named(
6779                            &val,
6780                            "a key=value argument",
6781                        )
6782                        .map_err(|e| anyhow::anyhow!("{e}"))?
6783                    };
6784                    tool_args
6785                        .positional
6786                        .push(Value::String(format!("{key}={val_str}")));
6787                }
6788                Arg::DoubleDash => {
6789                    tool_args.positional.push(Value::String("--".to_string()));
6790                }
6791            }
6792        }
6793        return Ok(tool_args);
6794    }
6795
6796    // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
6797    // schemas; pick the leaf the leading positionals route to and bind
6798    // flags against *its* params. Flat tools return the root. select_leaf
6799    // errors (fail loud) if a computed positional sits where a subcommand
6800    // selector is required.
6801    let leaf = match schema {
6802        Some(s) => Some(select_leaf(s, args)?),
6803        None => None,
6804    };
6805    // Bind against the leaf's params, but MERGE the root schema's params on
6806    // top as "global" flags: a value-flag declared at the tool's top level
6807    // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
6808    // it trails the subcommand path (`kj context retag a b --confirm <n>`).
6809    // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
6810    // merge is a harmless no-op.
6811    let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
6812    if let Some(l) = leaf {
6813        param_lookup.extend(schema_param_lookup(l));
6814    }
6815    // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
6816    // not the leaf — it's a property of the command, not the subcommand.
6817    let accepts_word_assign = schema
6818        .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
6819        .unwrap_or(false);
6820
6821    // Track which positional indices have been consumed as flag values
6822    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
6823    let mut past_double_dash = false;
6824
6825    // Indices a value-flag may consume as its value. Positionals always
6826    // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
6827    // itself treat `key=value` as an assignment (everything but
6828    // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
6829    // `-v`, rather than skipping it and grabbing the next positional (the
6830    // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
6831    // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
6832    let positional_indices: Vec<usize> = args
6833        .iter()
6834        .enumerate()
6835        .filter_map(|(i, a)| {
6836            let consumable = matches!(a, Arg::Positional(_))
6837                || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
6838            consumable.then_some(i)
6839        })
6840        .collect();
6841
6842    let mut i = 0;
6843    while i < args.len() {
6844        match &args[i] {
6845            Arg::DoubleDash => {
6846                past_double_dash = true;
6847            }
6848            Arg::Positional(expr) => {
6849                if !consumed.contains(&i) {
6850                    // Glob expansion: bare glob patterns expand to matching files
6851                    if let Expr::GlobPattern(pattern) = expr {
6852                        if !glob_passthrough {
6853                            if let Some(paths) = source.expand_glob(pattern).await? {
6854                                for path in paths {
6855                                    tool_args.positional.push(Value::String(path));
6856                                }
6857                                i += 1;
6858                                continue;
6859                            }
6860                        }
6861                    }
6862                    if let Some(value) = source.eval(expr).await? {
6863                        let value = apply_tilde_expansion(value, home.as_deref());
6864                        // The path `echo` reads: it takes `args.positional`
6865                        // directly, never the clap-parsed field, so a plain
6866                        // `Value::Int` here could not reproduce `-0`.
6867                        if let Expr::NumericLiteral { raw, .. } = expr {
6868                            tool_args
6869                                .positional_raw
6870                                .insert(tool_args.positional.len(), raw.clone());
6871                        }
6872                        tool_args.positional.push(value);
6873                    }
6874                }
6875            }
6876            Arg::Named { key, value } => {
6877                if let Some(val) = source.eval(value).await? {
6878                    let val = apply_tilde_expansion(val, home.as_deref());
6879                    // Past `--` this is data, not a flag: one operand spelled
6880                    // `--key=value`, the same collapse the `WordAssign` arm
6881                    // below does for `A=1` (GH #189). The value still expands.
6882                    if past_double_dash {
6883                        let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6884                            raw.clone()
6885                        } else {
6886                            crate::interpreter::value_to_text_sink_named(
6887                                &val,
6888                                "a --key=value operand after `--`",
6889                            )
6890                            .map_err(|e| anyhow::anyhow!("{e}"))?
6891                        };
6892                        tool_args
6893                            .positional
6894                            .push(Value::String(format!("--{key}={val_str}")));
6895                        i += 1;
6896                        continue;
6897                    }
6898                    // The kernel's own `--json=VALUE`, decided here and never
6899                    // placed in `named`. Left there it reaches the builtin's
6900                    // clap parser as a value on a `bool` field, whose `SetTrue`
6901                    // action rejects every spelling but `true`/`false` — so
6902                    // `seq --json=1` exited 2 while the raw-argv and verbatim
6903                    // binders were quietly accepting the same word. One rule,
6904                    // asked in one place, for all three.
6905                    if !past_double_dash && crate::tools::is_global_output_flag(key) {
6906                        if global_flag_value_is_truthy(&val) {
6907                            tool_args.flags.insert(key.clone());
6908                        }
6909                        i += 1;
6910                        continue;
6911                    }
6912                    // A repeatable flag in `--flag=value` form must accumulate too,
6913                    // not overwrite — otherwise `--expression=A --expression=B`
6914                    // would silently keep only B, and mixing with the `-e` space
6915                    // form would clobber the array. Route it through the same
6916                    // accumulator the space form uses.
6917                    let is_declared_value_flag = param_lookup
6918                        .get(key.as_str())
6919                        .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6920                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6921                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
6922                    } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6923                        // Flagify at bind time (GH #189): `--flag=true`/
6924                        // `--flag=false` binds the same way the bare
6925                        // `--flag`/its absence already do (true → flag
6926                        // presence, false → dropped) instead of landing in
6927                        // `named` as a literal `Value::Bool` that a clap
6928                        // `bool` field's `SetTrue` action rejects
6929                        // (`seq --json=true` used to exit 2 with a clap
6930                        // parse error). Covers both a schema-declared bool
6931                        // param AND an undeclared flag — `--json` itself is
6932                        // deliberately excluded from every builtin's schema
6933                        // (`clap_schema::is_skipped`), so this is what makes
6934                        // `--json=true` work universally instead of only for
6935                        // the builtins that happen to call
6936                        // `ToolArgs::flagify_bool_named` themselves. A
6937                        // declared VALUE-taking flag's own `=true` literal
6938                        // (`spawn --command=true`) is excluded by
6939                        // `is_declared_value_flag` and still falls to
6940                        // `named` below.
6941                        if let Value::Bool(true) = val {
6942                            tool_args.flags.insert(key.clone());
6943                        }
6944                        // Value::Bool(false): absent == false, nothing to insert.
6945                    } else {
6946                        // A named value is normally read off the clap-parsed
6947                        // field, built from `to_argv()` — see
6948                        // `ToolArgs::named_raw`.
6949                        if let Expr::NumericLiteral { raw, .. } = value {
6950                            tool_args.named_raw.insert(key.clone(), raw.clone());
6951                        }
6952                        tool_args.named.insert(key.clone(), val);
6953                    }
6954                }
6955            }
6956            Arg::WordAssign { key, value } => {
6957                // Already pulled in as a preceding value-flag's argument
6958                // (`awk -v a=1`); don't also emit it as a positional.
6959                if consumed.contains(&i) {
6960                    i += 1;
6961                    continue;
6962                }
6963                if let Some(val) = source.eval(value).await? {
6964                    let val = apply_tilde_expansion(val, home.as_deref());
6965                    // Past `--`, EVERY token is raw data — including for
6966                    // export/alias, whose `key=value` is normally a shell
6967                    // assignment (GH #189). `export -- A=1` must bind `A=1`
6968                    // as a literal positional, not silently re-enter the
6969                    // named-assignment path `past_double_dash` exists to
6970                    // suppress for flags right above this arm.
6971                    if accepts_word_assign && !past_double_dash {
6972                        if let Expr::NumericLiteral { raw, .. } = value {
6973                            tool_args.named_raw.insert(key.clone(), raw.clone());
6974                        }
6975                        tool_args.named.insert(key.clone(), val);
6976                    } else {
6977                        // Stringify "key=value" and pass as a positional.
6978                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6979                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6980                        // must not silently become a path/operand literally named
6981                        // `foo=[binary: N bytes]`. Source text wins, as in
6982                        // every other composed-string arm.
6983                        let val_str = if let Expr::NumericLiteral { raw, .. } = value {
6984                            raw.clone()
6985                        } else {
6986                            crate::interpreter::value_to_text_sink_named(
6987                                &val,
6988                                "a key=value argument",
6989                            )
6990                            .map_err(|e| anyhow::anyhow!("{e}"))?
6991                        };
6992                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6993                    }
6994                }
6995            }
6996            Arg::ShortFlag(name) => {
6997                if past_double_dash {
6998                    tool_args.positional.push(Value::String(format!("-{name}")));
6999                } else if name.len() == 1 {
7000                    let flag_name = name.as_str();
7001                    let lookup = param_lookup.get(flag_name);
7002
7003                    // Same ambiguity guard as the `LongFlag` arm below (GH
7004                    // #189 item 4): an undeclared short flag immediately
7005                    // followed by an unconsumed positional under a
7006                    // map_positionals (backend/MCP) schema is exactly as
7007                    // ambiguous as the long-flag case — kaish can't tell a
7008                    // space-form value (`-t explorer`) from a bool flag
7009                    // sitting before a real positional (`-f file.txt`).
7010                    // Unlike `--flag`, there is no `-f=value` escape hatch to
7011                    // suggest: a glued `-f=val` is two tokens with a dangling
7012                    // `=` that the parser's no-token-pasting guard already
7013                    // rejects — the only fix is declaring the flag.
7014                    let ambiguous_value = (lookup.is_none()
7015                        && leaf.is_some_and(|s| s.map_positionals)
7016                        && !consumed.contains(&(i + 1)))
7017                        .then(|| match args.get(i + 1) {
7018                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
7019                                Some(s.clone())
7020                            }
7021                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
7022                            _ => None,
7023                        })
7024                        .flatten();
7025                    if let Some(val) = ambiguous_value {
7026                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
7027                        anyhow::bail!(
7028                            "{tool}: -{name} is not a declared flag, so the \
7029                             space-separated value ({val:?}) would be silently \
7030                             dropped. Have {tool} declare -{name} in its schema \
7031                             (short flags have no -{name}=value form to fall \
7032                             back on)."
7033                        );
7034                    }
7035
7036                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
7037
7038                    if is_bool {
7039                        tool_args.flags.insert(flag_name.to_string());
7040                    } else {
7041                        // Non-bool: consume `consumes` positionals as value(s)
7042                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
7043                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
7044                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
7045                        consume_flag_positionals(
7046                            source,
7047                            home.as_deref(),
7048                            args,
7049                            name,
7050                            canonical,
7051                            consumes,
7052                            repeatable,
7053                            &positional_indices,
7054                            &mut consumed,
7055                            i,
7056                            &mut tool_args,
7057                        )
7058                        .await?;
7059                    }
7060                } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
7061                    // Multi-char short flag matches a schema param (POSIX style: -name value)
7062                    if is_bool_type(typ) {
7063                        tool_args.flags.insert(canonical.to_string());
7064                    } else {
7065                        consume_flag_positionals(
7066                            source,
7067                            home.as_deref(),
7068                            args,
7069                            name,
7070                            canonical,
7071                            consumes,
7072                            repeatable,
7073                            &positional_indices,
7074                            &mut consumed,
7075                            i,
7076                            &mut tool_args,
7077                        )
7078                        .await?;
7079                    }
7080                } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
7081                    .get(&name[..1])
7082                    .filter(|(_, typ, ..)| !is_bool_type(typ))
7083                {
7084                    // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
7085                    // `grep -A1`, `sed -e1d`. The first char is a declared
7086                    // value-taking short flag, so the rest of the token is its
7087                    // value — the coreutils idiom. The lexer's flag char class is
7088                    // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
7089                    // (safe to slice) and the tail is a plain literal.
7090                    bind_glued_short_value(
7091                        &mut tool_args,
7092                        &name[..1],
7093                        canonical,
7094                        consumes,
7095                        repeatable,
7096                        name[1..].to_string(),
7097                    )?;
7098                } else {
7099                    // Multi-char combined short flags. Bool flags stack
7100                    // (`-la`), but the FIRST value-taking flag reached
7101                    // consumes the rest of the token as its glued value
7102                    // (`-ivC3` → C=3) or, if it is the last char, the next
7103                    // positional (`grep -ivC 3` → C=3). Before this, a
7104                    // trailing value-flag was silently treated as a bool,
7105                    // stranding its argument as a stray positional (arity
7106                    // error). Undeclared/bool chars stay bare flags, so a
7107                    // schemaless tool keeps the old all-boolean behavior.
7108                    // The first char being value-taking is handled by the
7109                    // glued arm above, so it never reaches here. The flag
7110                    // char class is ASCII, so byte indexing is char indexing
7111                    // (no `Vec<char>` allocation needed).
7112                    let bytes = name.as_bytes();
7113                    let mut p = 0;
7114                    while p < bytes.len() {
7115                        let key = &name[p..p + 1];
7116                        match param_lookup.get(key) {
7117                            Some(&(canonical, typ, consumes, repeatable))
7118                                if !is_bool_type(typ) =>
7119                            {
7120                                let glued = name[p + 1..].to_string();
7121                                if glued.is_empty() {
7122                                    // Value flag is the last char: take the
7123                                    // next positional. `consume_flag_positionals`
7124                                    // respects `consumes`.
7125                                    consume_flag_positionals(
7126                                        source,
7127                                        home.as_deref(),
7128                                        args,
7129                                        key,
7130                                        canonical,
7131                                        consumes,
7132                                        repeatable,
7133                                        &positional_indices,
7134                                        &mut consumed,
7135                                        i,
7136                                        &mut tool_args,
7137                                    )
7138                                    .await?;
7139                                } else {
7140                                    bind_glued_short_value(
7141                                        &mut tool_args,
7142                                        key,
7143                                        canonical,
7144                                        consumes,
7145                                        repeatable,
7146                                        glued,
7147                                    )?;
7148                                }
7149                                break;
7150                            }
7151                            _ => {
7152                                tool_args.flags.insert(key.to_string());
7153                                p += 1;
7154                            }
7155                        }
7156                    }
7157                }
7158            }
7159            Arg::LongFlag(name) => {
7160                if past_double_dash {
7161                    tool_args.positional.push(Value::String(format!("--{name}")));
7162                } else {
7163                    let lookup = param_lookup.get(name.as_str());
7164                    // An *undeclared* long flag under a `map_positionals`
7165                    // (backend/MCP) schema that is immediately followed by an
7166                    // unconsumed positional is ambiguous: kaish can't tell the
7167                    // space-form value (`--type explorer`) from a bool flag
7168                    // before a real positional (`--force file.txt`). Defaulting
7169                    // to bool here silently divorces the value and misroutes it
7170                    // — a privilege-escalation-by-typo against deny-by-default
7171                    // embedders. Fail loud instead of guessing.
7172                    let ambiguous_value = (lookup.is_none()
7173                        && leaf.is_some_and(|s| s.map_positionals)
7174                        && !consumed.contains(&(i + 1)))
7175                        .then(|| match args.get(i + 1) {
7176                            // Echo a concrete value for a copy-pasteable fix
7177                            // when it's a plain literal; fall back to VALUE.
7178                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
7179                                Some(s.clone())
7180                            }
7181                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
7182                            _ => None,
7183                        })
7184                        .flatten();
7185                    if let Some(val) = ambiguous_value {
7186                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
7187                        anyhow::bail!(
7188                            "{tool}: --{name} is not a declared flag, so the \
7189                             space-separated value would be silently dropped. \
7190                             Use --{name}={val}, or have {tool} declare --{name} \
7191                             in its schema."
7192                        );
7193                    }
7194                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
7195
7196                    if is_bool {
7197                        tool_args.flags.insert(name.clone());
7198                    } else {
7199                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
7200                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
7201                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
7202                        consume_flag_positionals(
7203                            source,
7204                            home.as_deref(),
7205                            args,
7206                            name,
7207                            canonical,
7208                            consumes,
7209                            repeatable,
7210                            &positional_indices,
7211                            &mut consumed,
7212                            i,
7213                            &mut tool_args,
7214                        )
7215                        .await?;
7216                    }
7217                }
7218            }
7219        }
7220        i += 1;
7221    }
7222
7223    // Map remaining positionals to unfilled non-bool schema params (in order).
7224    // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
7225    // Positionals that appeared after `--` are never mapped (they're raw data).
7226    // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
7227    // Keyed off the routed leaf so a subcommand tool maps against the active
7228    // leaf's params (kj leaves keep map_positionals=false → block skipped).
7229    if let Some(schema) = leaf.filter(|s| s.map_positionals) {
7230        let pre_dash_count = if past_double_dash {
7231            let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
7232            positional_indices.iter()
7233                .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
7234                .count()
7235        } else {
7236            tool_args.positional.len()
7237        };
7238
7239        // This block reindexes `positional`, and `positional_raw` is keyed
7240        // by index — a value that moves must carry its raw text along or the
7241        // map mislabels some other positional's text as this one's.
7242        // `old_raw` is keyed by the pre-drain index; both destinations insert
7243        // under the index the value actually lands at.
7244        let old_raw = std::mem::take(&mut tool_args.positional_raw);
7245        let mut remaining = Vec::new();
7246        let mut remaining_raw: std::collections::BTreeMap<usize, String> =
7247            std::collections::BTreeMap::new();
7248        let mut positional_iter = tool_args.positional.drain(..).enumerate();
7249
7250        for param in &schema.params {
7251            if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
7252                continue;
7253            }
7254            if is_bool_type(&param.param_type) {
7255                continue;
7256            }
7257            loop {
7258                match positional_iter.next() {
7259                    Some((idx, val)) if idx < pre_dash_count => {
7260                        if let Some(raw) = old_raw.get(&idx) {
7261                            tool_args.named_raw.insert(param.name.clone(), raw.clone());
7262                        }
7263                        tool_args.named.insert(param.name.clone(), val);
7264                        break;
7265                    }
7266                    Some((idx, val)) => {
7267                        if let Some(raw) = old_raw.get(&idx) {
7268                            remaining_raw.insert(remaining.len(), raw.clone());
7269                        }
7270                        remaining.push(val);
7271                    }
7272                    None => break,
7273                }
7274            }
7275        }
7276
7277        for (idx, val) in positional_iter {
7278            if let Some(raw) = old_raw.get(&idx) {
7279                remaining_raw.insert(remaining.len(), raw.clone());
7280            }
7281            remaining.push(val);
7282        }
7283        tool_args.positional = remaining;
7284        tool_args.positional_raw = remaining_raw;
7285    }
7286
7287    Ok(tool_args)
7288}
7289
7290#[async_trait]
7291impl CommandDispatcher for Kernel {
7292    /// Dispatch a command through the Kernel's full resolution chain.
7293    ///
7294    /// This is the single path for all command execution when called from
7295    /// the pipeline runner. It provides the full dispatch chain:
7296    /// user tools → builtins → .kai scripts → external commands → backend tools.
7297    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
7298        self.dispatch_command(cmd, ctx).await
7299    }
7300
7301    /// Run a compound pipeline stage through the kernel's statement executor.
7302    async fn dispatch_stmt(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
7303        self.dispatch_statement(stmt, ctx).await
7304    }
7305
7306    /// Evaluate an expression through the kernel's async chain, including
7307    /// command substitution. Delegates to `eval_expr_async`, which snapshots
7308    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
7309    /// only command output escapes. The `ctx` is unused here because the
7310    /// kernel evaluates against its own session state (a fork carries the
7311    /// pipeline stage's snapshot); var refs resolve against that scope.
7312    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
7313        self.eval_expr_async(expr).await
7314    }
7315
7316    /// Produce a forked dispatcher with independent mutable state (detached).
7317    ///
7318    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
7319    /// recursing into the trait method we're defining) and coerces the
7320    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
7321    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
7322        let fork: Arc<Kernel> = Kernel::fork(self).await;
7323        fork
7324    }
7325
7326    /// Produce a forked dispatcher with cancellation cascading from this kernel.
7327    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
7328        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
7329        fork
7330    }
7331}
7332
7333/// Apply the requested output format to a builtin's result, unless the tool
7334/// owns its own output — and even then, only on success.
7335///
7336/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
7337/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
7338/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
7339/// gather never render a structured error themselves, so a failure
7340/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
7341/// never "already rendered" by the tool. Skipping `apply_output_format` on
7342/// that path just leaked the raw diagnostic under `--json` instead of the
7343/// uniform `{"error","code"}` envelope every other builtin's failure gets
7344/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
7345/// whole owns_output error-path class). Gating the skip on `result.ok()`
7346/// keeps the intentional success-path opt-out while closing that gap.
7347fn finalize_output(
7348    result: ExecResult,
7349    format: Option<crate::interpreter::OutputFormat>,
7350    owns_output: bool,
7351) -> ExecResult {
7352    match format {
7353        Some(_) if owns_output && result.ok() => result,
7354        Some(format) => apply_output_format(result, format),
7355        None => result,
7356    }
7357}
7358
7359/// Accumulate output from one result into another.
7360///
7361/// Appends stdout and stderr verbatim and updates the exit code to match the
7362/// new result. Used to preserve output from multiple statements, loop
7363/// iterations, and command chains. No separator is inserted between outputs —
7364/// each command's output concatenates raw, matching bash (`printf a; printf b`
7365/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
7366/// when a command emits its own, as `echo` does).
7367/// Append `new`'s stdout to `accumulated`'s, and nothing else.
7368///
7369/// Split out of [`accumulate_result`] because a condition carries only its
7370/// stdout up to the enclosing statement — its exit code is the `if`/`while`'s
7371/// answer, not the statement's status. Sharing the append keeps the two
7372/// callers from drifting on the byte handling below.
7373fn push_stdout_of(accumulated: &mut ExecResult, new: &ExecResult) {
7374    // Materialize lazy OutputData into .out before accumulating.
7375    // Without this, the first command's output stays in .output while
7376    // the second's text gets appended to .out, losing the first.
7377    accumulated.materialize();
7378    match new.out_bytes() {
7379        // A binary result must not be lossy-decoded by text_out(): concatenate
7380        // raw bytes so the combined output stays binary (this is the path every
7381        // top-level statement's result flows through). See docs/binary-data.md.
7382        Some(new_bytes) => {
7383            let mut combined: Vec<u8> = match accumulated.out_bytes() {
7384                Some(b) => b.to_vec(),
7385                None => accumulated.text_out().into_owned().into_bytes(),
7386            };
7387            combined.extend_from_slice(new_bytes);
7388            accumulated.set_out_bytes(combined);
7389        }
7390        None => accumulated.push_out(&new.text_out()),
7391    }
7392}
7393
7394fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
7395    push_stdout_of(accumulated, new);
7396    accumulated.err.push_str(&new.err);
7397    accumulated.code = new.code;
7398    // The marker travels WITH the data, always. Copying one without the other
7399    // is wrong in both directions: a compound statement ending in `fromjson`
7400    // lost its value (`$(if true; then fromjson '[1,2]'; fi)` bound text), and
7401    // a chain whose LEFT side was typed kept that marker over the right side's
7402    // data (`$(fromjson '[1,2]' && cut -f2 f)` bound `["b"]` typed — the very
7403    // bug this mechanism exists to fix, re-entering through a side door).
7404    accumulated.data = new.data.clone();
7405    accumulated.data_is_value = new.data_is_value;
7406    // Assign, like `code`: the combined result IS `new`'s value, so it is a
7407    // fault exactly when `new` is. This lets an outer chain see a fault that
7408    // arrived through an inner chain's right operand.
7409    accumulated.fault = new.fault;
7410    // OR, not assign. `did_spill` is a fact about the OUTPUT — this block's
7411    // text was truncated — and an ordinary statement running afterwards does
7412    // not untruncate it. Assigning let `seq …; echo after` report
7413    // `did_spill: false` with output still missing, telling an embedder asking
7414    // "did I get everything" the wrong thing. The exit CODE is a separate
7415    // question and still belongs to the last statement, as in any shell.
7416    accumulated.did_spill |= new.did_spill;
7417    // `original_code` is only meaningful alongside a spill, so it follows the
7418    // same rule: keep the first one rather than letting a later clean
7419    // statement's `None` erase the code the spill replaced.
7420    if accumulated.original_code.is_none() {
7421        accumulated.original_code = new.original_code;
7422    }
7423    accumulated.content_type = new.content_type.clone();
7424    accumulated.baggage.clone_from(&new.baggage);
7425}
7426
7427/// Fold a block's accumulated output into a signal that is leaving the block.
7428///
7429/// Any block that builds up a result — a loop body, an `if`/`case` branch, the
7430/// left side of a `&&`/`||` chain — hands that result back when it finishes.
7431/// When `break`/`continue`/`return`/`exit` leaves early instead, the signal
7432/// replaces the result on the way up, so output printed before the signal
7433/// would otherwise be discarded. Leaving early stops the block; it does not
7434/// unprint what already ran. The block's output comes first (it ran before the
7435/// signal was raised), then the signal's already-carried output.
7436fn fold_block_output_into_flow(block_output: ExecResult, flow: &mut ControlFlow) {
7437    let carried = match flow {
7438        ControlFlow::Break { result, .. }
7439        | ControlFlow::Continue { result, .. }
7440        | ControlFlow::Exit { result, .. } => result,
7441        ControlFlow::Return { value } => value,
7442        ControlFlow::Normal(_) => return,
7443    };
7444    let mut merged = block_output;
7445    accumulate_result(&mut merged, carried);
7446    *carried = merged;
7447}
7448
7449/// Accumulate the output a break/continue signal carried (from inner loops it
7450/// propagated through) into the loop that finally handles it, so it survives
7451/// into that loop's result.
7452fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
7453    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
7454        // `break`/`continue` carry no value of their own, and a signal that
7455        // produced nothing must not erase what the body already produced —
7456        // leaving a loop early stops it, it does not unmake its output. Same
7457        // reasoning as `fold_block_output_into_flow`'s, one step further:
7458        // `$(while true; do fromjson '[5]'; break; done)` bound text because
7459        // the empty `break` result overwrote the body's value.
7460        let carried = (accumulated.data.take(), accumulated.data_is_value);
7461        accumulate_result(accumulated, result);
7462        if result.data.is_none() {
7463            (accumulated.data, accumulated.data_is_value) = carried;
7464        }
7465    }
7466}
7467
7468/// Check if a value is truthy.
7469fn is_truthy(value: &Value) -> bool {
7470    match value {
7471        Value::Null => false,
7472        Value::Bool(b) => *b,
7473        Value::Int(i) => *i != 0,
7474        Value::Float(f) => *f != 0.0,
7475        Value::String(s) => !s.is_empty(),
7476        Value::Json(json) => match json {
7477            serde_json::Value::Null => false,
7478            serde_json::Value::Array(arr) => !arr.is_empty(),
7479            serde_json::Value::Object(obj) => !obj.is_empty(),
7480            serde_json::Value::Bool(b) => *b,
7481            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
7482            serde_json::Value::String(s) => !s.is_empty(),
7483        },
7484        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
7485    }
7486}
7487
7488/// Apply tilde expansion to a value.
7489///
7490/// Only string values starting with `~` are expanded. `home` is the session
7491/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
7492/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
7493fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
7494    match value {
7495        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
7496        _ => value,
7497    }
7498}
7499
7500/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
7501/// how the lexer tokenizes the equivalent minimally-quoted command string —
7502/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
7503/// (`build_args_async`) verbatim instead of carrying a parallel one that could
7504/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
7505/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
7506///
7507/// Classification matches the lexer's word classes:
7508/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
7509///   the binder's `past_double_dash` arms, exactly as for the string door).
7510/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
7511/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
7512///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
7513///   they fall through to a positional, not a flag).
7514/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
7515///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
7516///   `key=value` positional, per the command's word-assign allowlist).
7517/// - everything else → a literal [`Arg::Positional`].
7518///
7519/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
7520/// positional — it can never be a flag — and rides through as-is. That is the
7521/// typed passthrough the string-native door cannot offer.
7522pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
7523    argv.iter().map(classify_argv_token).collect()
7524}
7525
7526fn classify_argv_token(token: &Value) -> Arg {
7527    let Value::String(s) = token else {
7528        return Arg::Positional(Expr::Literal(token.clone()));
7529    };
7530
7531    if s == "--" {
7532        return Arg::DoubleDash;
7533    }
7534
7535    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
7536    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
7537    // literal word (GH #137), matching this classifier's own literal
7538    // fallback — so they fall through to a literal positional rather than a
7539    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
7540    if let Some(rest) = s.strip_prefix("--") {
7541        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
7542            return match rest.split_once('=') {
7543                Some((key, val)) => Arg::Named {
7544                    key: key.to_string(),
7545                    value: Expr::Literal(Value::String(val.to_string())),
7546                },
7547                None => Arg::LongFlag(rest.to_string()),
7548            };
7549        }
7550    } else if let Some(rest) = s.strip_prefix('-') {
7551        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
7552        // token carrying any other char — notably `=` (`-k=v` is a parse error in
7553        // the string door) — or a leading digit (`-1` lexes as a number) is not a
7554        // short-flag word, so it falls through to a literal positional instead of
7555        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
7556        if is_short_flag_body(rest) {
7557            return Arg::ShortFlag(rest.to_string());
7558        }
7559    }
7560
7561    if let Some((key, val)) = s.split_once('=') {
7562        if is_shell_identifier(key) {
7563            return Arg::WordAssign {
7564                key: key.to_string(),
7565                value: Expr::Literal(Value::String(val.to_string())),
7566            };
7567        }
7568    }
7569
7570    Arg::Positional(Expr::Literal(Value::String(s.clone())))
7571}
7572
7573/// A short-flag word: a leading ASCII letter, then only ASCII
7574/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
7575/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
7576/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
7577/// (`=` is the assignment operator — a parse error in the string door), and
7578/// any non-ASCII tail (never produced by the lexer, and not safe for the
7579/// combined-short-flag binder's byte-index slicing) do not, so they fall
7580/// through to a literal positional instead of a malformed `ShortFlag`.
7581fn is_short_flag_body(s: &str) -> bool {
7582    s.starts_with(|c: char| c.is_ascii_alphabetic())
7583        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
7584}
7585
7586/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
7587fn is_shell_identifier(s: &str) -> bool {
7588    let mut chars = s.chars();
7589    match chars.next() {
7590        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
7591        _ => return false,
7592    }
7593    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
7594}
7595
7596/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
7597/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
7598/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
7599/// must keep every value, not silently drop all but the last. Used by every flag
7600/// surface that can carry the same flag twice — the space form
7601/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
7602/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
7603/// ordered array.
7604/// Flatten a bound flag value to JSON, going LOUD on binary.
7605///
7606/// The accumulating flag forms (`jq --arg NAME VAL`, `sed -e EXPR -e EXPR`)
7607/// store their values as `serde_json::Value` rather than keeping the kaish
7608/// `Value`, and [`value_to_json`](crate::interpreter::value_to_json) renders a
7609/// `Value::Bytes` as the base64 envelope
7610/// (`{"_type":"bytes","encoding":"base64",…}`). That envelope is an internal
7611/// wire form, not the user's data: bound into `--arg x`, the tool sees the
7612/// envelope's literal JSON *text* where the bytes should be and reports
7613/// success, which is silent corruption (GH #223). Binary stops here instead,
7614/// with the same wording and exit 1 as every other text sink.
7615///
7616/// Valid-UTF-8 bytes coerce to their text, matching
7617/// [`value_to_text_sink_named`](crate::interpreter::value_to_text_sink_named);
7618/// in practice `Value::Bytes` only ever holds non-UTF-8, so this errors
7619/// whenever binary reaches a flag value. Gating on the kaish `Value` (not on
7620/// the envelope's JSON shape) is what keeps an envelope-shaped record the user
7621/// actually built — `fromjson '{"_type":"bytes",…}'` — a plain record: kaish
7622/// never sniffs JSON to decide a type.
7623fn flag_value_to_json(canonical: &str, v: &Value) -> Result<serde_json::Value> {
7624    match v {
7625        Value::Bytes(_) => crate::interpreter::value_to_text_sink_named(
7626            v,
7627            &format!("the value of the {canonical} flag"),
7628        )
7629        .map(serde_json::Value::String)
7630        .map_err(|e| anyhow::anyhow!("{e}")),
7631        other => Ok(crate::interpreter::value_to_json(other)),
7632    }
7633}
7634
7635pub(crate) fn push_repeatable_value(
7636    tool_args: &mut ToolArgs,
7637    flag_name: &str,
7638    canonical: &str,
7639    v: Value,
7640) -> anyhow::Result<()> {
7641    let occ = flag_value_to_json(canonical, &v)?;
7642    let entry = tool_args
7643        .named
7644        .entry(canonical.to_string())
7645        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
7646    if let Value::Json(serde_json::Value::Array(items)) = entry {
7647        items.push(occ);
7648        Ok(())
7649    } else {
7650        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
7651    }
7652}
7653
7654/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
7655/// is one token, so it carries a single value: a repeatable flag accumulates
7656/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
7657/// first-char glued arm and the combined-bundle arm so the two can't drift on
7658/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
7659/// is a loud error, not a silent single-value bind.
7660pub(crate) fn bind_glued_short_value(
7661    tool_args: &mut ToolArgs,
7662    flag_name: &str,
7663    canonical: &str,
7664    consumes: usize,
7665    repeatable: bool,
7666    value: String,
7667) -> anyhow::Result<()> {
7668    if consumes > 1 {
7669        anyhow::bail!(
7670            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
7671        );
7672    }
7673    if repeatable {
7674        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
7675    } else {
7676        tool_args
7677            .named
7678            .insert(canonical.to_string(), Value::String(value));
7679        Ok(())
7680    }
7681}
7682
7683/// Map a child's exit status to a shell-style exit code.
7684///
7685/// `ExitStatus::code()` is `None` when the process died from a signal rather
7686/// than exiting normally; in that case this maps to POSIX's `128 + signal`
7687/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
7688/// number. Shared by both external-command spawn sites — production
7689/// (`try_execute_external`, below) and the test-only twin
7690/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
7691/// this mapping again (GH #133 item 1).
7692#[cfg(feature = "subprocess")]
7693pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
7694    status.code().unwrap_or_else(|| {
7695        #[cfg(unix)]
7696        {
7697            use std::os::unix::process::ExitStatusExt;
7698            128 + status.signal().unwrap_or(0)
7699        }
7700        #[cfg(not(unix))]
7701        {
7702            -1
7703        }
7704    }) as i64
7705}
7706
7707/// Wait for a child to exit, killing it if `cancel` fires first.
7708///
7709/// `target` carries a Linux pidfd (when available) for race-free direct-child
7710/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
7711/// parameter is ignored and we use tokio's cross-platform `start_kill`.
7712#[cfg(all(unix, feature = "subprocess"))]
7713pub(crate) async fn wait_or_kill(
7714    child: &mut tokio::process::Child,
7715    target: Option<&crate::pidfd::KillTarget>,
7716    cancel: &tokio_util::sync::CancellationToken,
7717    grace: Duration,
7718) -> std::io::Result<std::process::ExitStatus> {
7719    tokio::select! {
7720        biased;
7721        status = child.wait() => status,
7722        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
7723    }
7724}
7725
7726#[cfg(all(not(unix), feature = "subprocess"))]
7727pub(crate) async fn wait_or_kill(
7728    child: &mut tokio::process::Child,
7729    _target: Option<&()>,
7730    cancel: &tokio_util::sync::CancellationToken,
7731    _grace: Duration,
7732) -> std::io::Result<std::process::ExitStatus> {
7733    tokio::select! {
7734        biased;
7735        status = child.wait() => status,
7736        _ = cancel.cancelled() => {
7737            let _ = child.start_kill();
7738            child.wait().await
7739        }
7740    }
7741}
7742
7743/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
7744///
7745/// Direct-child kill goes through `target.signal()`, which on Linux uses a
7746/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
7747/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
7748#[cfg(all(unix, feature = "subprocess"))]
7749pub(crate) async fn kill_with_grace(
7750    child: &mut tokio::process::Child,
7751    target: Option<&crate::pidfd::KillTarget>,
7752    grace: Duration,
7753) -> std::io::Result<std::process::ExitStatus> {
7754    use nix::sys::signal::Signal;
7755
7756    if let Some(t) = target {
7757        t.signal(Signal::SIGTERM);
7758        t.signal_pg(Signal::SIGTERM);
7759        if grace > Duration::ZERO
7760            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
7761        {
7762            return status;
7763        }
7764        t.signal(Signal::SIGKILL);
7765        t.signal_pg(Signal::SIGKILL);
7766    }
7767    child.wait().await
7768}
7769
7770#[cfg(test)]
7771#[allow(clippy::unwrap_used, clippy::expect_used)]
7772mod argv_classify_tests {
7773    use super::*;
7774
7775    /// A normalized, comparable view of one `Arg` representing its *logical
7776    /// argument* (what the command observably receives), not its exact AST shape:
7777    ///
7778    /// - Value-bearing arms compare by *stringified* value, so the parser's
7779    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
7780    ///   (`String("-1")`) count as the same argument.
7781    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
7782    ///   For every command except the `export`/`alias` allowlist, a bareword
7783    ///   `key=value` is stringified straight back to a `"key=value"` positional
7784    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
7785    ///   converge observably even when they disagree on the AST tag — e.g. the
7786    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
7787    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
7788    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
7789    ///   separately by `classifies_each_word_class`.
7790    ///
7791    /// Returns `None` for shapes we deliberately don't compare:
7792    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
7793    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
7794    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
7795    ///   string. That divergence is *intentional* — `execute_argv` preserves a
7796    ///   literal numeric string (pass `Value::Int` for a number), the string door
7797    ///   can only guess — so the property skips it rather than demanding the
7798    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
7799    ///   by `classifies_each_word_class`.
7800    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
7801        // Only a *string*-valued literal is comparable; a coerced number is not.
7802        let lit = |e: &Expr| match e {
7803            Expr::Literal(Value::String(s)) => Some(s.clone()),
7804            _ => None,
7805        };
7806        Some(match arg {
7807            Arg::DoubleDash => ("dash", String::new(), String::new()),
7808            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
7809            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
7810            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
7811            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
7812            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
7813        })
7814    }
7815
7816    /// Classify a single string token the way `execute_argv` would.
7817    fn classify(token: &str) -> Arg {
7818        classify_argv_token(&Value::String(token.to_string()))
7819    }
7820
7821    #[test]
7822    fn classifies_each_word_class() {
7823        assert_eq!(classify("--"), Arg::DoubleDash);
7824        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
7825        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
7826        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
7827        assert_eq!(
7828            classify("--key=value"),
7829            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
7830        );
7831        assert_eq!(
7832            classify("NAME=val"),
7833            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
7834        );
7835        // Digits after the first flag char are ordinary (kept verbatim).
7836        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
7837        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
7838        // Leading-digit dash is a number to the lexer, not a flag → positional.
7839        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
7840        // Numeric strings keep their literal text — `execute_argv` does NOT
7841        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
7842        // who wants a number passes `Value::Int`; a string stays the string.
7843        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
7844        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
7845        // A lone dash (stdin convention) is a positional, not a flag.
7846        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
7847        // Non-identifier LHS is not an assignment.
7848        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
7849        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
7850    }
7851
7852    #[test]
7853    fn typed_values_pass_through_as_literal_positionals() {
7854        // The whole point of the `&[Value]` signature: a non-string value is a
7855        // literal positional carrying the *exact* value, never stringified and
7856        // never flag-interpreted.
7857        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
7858        assert_eq!(
7859            classify_argv_token(&bytes),
7860            Arg::Positional(Expr::Literal(bytes.clone()))
7861        );
7862        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
7863        assert_eq!(
7864            classify_argv_token(&json),
7865            Arg::Positional(Expr::Literal(json.clone()))
7866        );
7867        // An integer token that *looks* like a flag is still a positional value
7868        // (only strings are inspected for a leading dash).
7869        assert_eq!(
7870            classify_argv_token(&Value::Int(-9)),
7871            Arg::Positional(Expr::Literal(Value::Int(-9)))
7872        );
7873    }
7874
7875    #[test]
7876    fn double_dash_only_matches_exactly() {
7877        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
7878        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
7879        // as a single argv token here it's likewise literal.
7880        assert_eq!(classify("--"), Arg::DoubleDash);
7881        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
7882        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
7883    }
7884
7885    #[test]
7886    fn malformed_flag_words_fall_back_to_literal_positionals() {
7887        // A token that isn't a well-formed flag word must NOT be silently misbound
7888        // into the arg binder (house rule: loud/visible over silent-wrong). Each
7889        // of these is a parse error or different tokenization in the string door,
7890        // so the argv door keeps them as literal positionals.
7891        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
7892        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
7893        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
7894        assert_eq!(classify("-k=v"), pos("-k=v"));
7895        assert_eq!(classify("-="), pos("-="));
7896        // Empty long-flag key.
7897        assert_eq!(classify("--=v"), pos("--=v"));
7898        // `--` followed by a non-letter is not a long flag.
7899        assert_eq!(classify("--1"), pos("--1"));
7900        // A bare dash and a number-dash are positionals (covered above too).
7901        assert_eq!(classify("-"), pos("-"));
7902        assert_eq!(classify("-9"), pos("-9"));
7903        // A non-ASCII tail is not part of the lexer's short-flag char class
7904        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
7905        // absorbs) — classifying it as `ShortFlag` would hand the combined
7906        // short-flag binder a byte string it (correctly, for real ASCII flag
7907        // words) slices by *byte* index, panicking on a multi-byte char
7908        // boundary. Fall back to a literal positional instead.
7909        assert_eq!(classify("-lé"), pos("-lé"));
7910        assert_eq!(classify("-é"), pos("-é"));
7911    }
7912
7913    #[tokio::test]
7914    async fn non_ascii_short_flag_bundle_does_not_panic() {
7915        // Regression: `execute_argv`'s combined-short-flag loop assumed the
7916        // flag body was ASCII (safe to byte-slice) because the lexer's
7917        // grammar guarantees that on the *string* door. The argv door's
7918        // classifier let a non-ASCII tail through as `ShortFlag`, so
7919        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
7920        let kernel = Kernel::transient().expect("failed to create kernel");
7921        let result = kernel
7922            .execute_argv("ls", &[Value::String("-lé".into())])
7923            .await
7924            .expect("execute_argv must not panic on a non-ASCII short-flag token");
7925        // Not a well-formed flag word, so it's a literal positional — `ls`
7926        // then reports it as a missing path rather than mangling flags.
7927        assert_ne!(result.code, 0);
7928    }
7929
7930    proptest::proptest! {
7931        /// The core correctness claim: the classifier mirrors the lexer/parser
7932        /// on metacharacter-free tokens. For any such single token, the `Arg`
7933        /// the classifier produces matches the one the real parser produces for
7934        /// the equivalent one-word command — so `execute_argv` reusing the
7935        /// string door's binder is sound. (First proptest in the workspace.)
7936        #[test]
7937        fn classifier_matches_parser_on_clean_tokens(
7938            // No digits: this property tests the *classification* boundary
7939            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
7940            // positional), not numeric coercion. The lexer coerces digit runs to
7941            // `Int`/`Float` and drops the literal text (even inside a colon-merged
7942            // word: `00:` → `0:`); the classifier intentionally preserves the raw
7943            // string. Those numeric edges are pinned exactly by the unit tests.
7944            // Non-ASCII is a word character now, so the generator has to
7945            // reach it — an ASCII-only strategy tests a shrinking slice of
7946            // what the classifier actually sees.
7947            token in "[a-zA-Z_=./@:+\\-\u{00e9}\u{540d}\u{1f600}]{1,8}"
7948        ) {
7949            let parsed = match parse(&format!("cmd {token}")) {
7950                Ok(p) => p,
7951                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
7952            };
7953            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
7954                return Ok(());
7955            };
7956            // Only compare when the token lexed as exactly one argument.
7957            let [arg] = cmd.args.as_slice() else { return Ok(()); };
7958
7959            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
7960                return Ok(()); // a non-literal parsed Expr we don't model — skip
7961            };
7962            proptest::prop_assert_eq!(
7963                ours, theirs,
7964                "classifier diverged from parser on token {:?}", token
7965            );
7966        }
7967    }
7968}
7969
7970#[cfg(all(test, feature = "subprocess"))]
7971#[allow(clippy::expect_used)]
7972mod tests {
7973    use super::*;
7974
7975    #[tokio::test]
7976    async fn test_kernel_transient() {
7977        let kernel = Kernel::transient().expect("failed to create kernel");
7978        assert_eq!(kernel.name(), "transient");
7979    }
7980
7981    #[tokio::test]
7982    async fn test_kernel_execute_echo() {
7983        let kernel = Kernel::transient().expect("failed to create kernel");
7984        let result = kernel.execute("echo hello").await.expect("execution failed");
7985        assert!(result.ok());
7986        assert_eq!(result.text_out().trim(), "hello");
7987    }
7988
7989    #[tokio::test]
7990    async fn test_multiple_statements_accumulate_output() {
7991        let kernel = Kernel::transient().expect("failed to create kernel");
7992        let result = kernel
7993            .execute("echo one\necho two\necho three")
7994            .await
7995            .expect("execution failed");
7996        assert!(result.ok());
7997        // Should have all three outputs separated by newlines
7998        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
7999        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
8000        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
8001    }
8002
8003    #[tokio::test]
8004    async fn test_and_chain_accumulates_output() {
8005        let kernel = Kernel::transient().expect("failed to create kernel");
8006        let result = kernel
8007            .execute("echo first && echo second")
8008            .await
8009            .expect("execution failed");
8010        assert!(result.ok());
8011        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
8012        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
8013    }
8014
8015    #[tokio::test]
8016    async fn test_for_loop_accumulates_output() {
8017        let kernel = Kernel::transient().expect("failed to create kernel");
8018        let result = kernel
8019            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8020            .await
8021            .expect("execution failed");
8022        assert!(result.ok());
8023        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
8024        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
8025        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
8026    }
8027
8028    #[tokio::test]
8029    async fn test_while_loop_accumulates_output() {
8030        let kernel = Kernel::transient().expect("failed to create kernel");
8031        let result = kernel
8032            .execute(r#"
8033                N=3
8034                while [[ ${N} -gt 0 ]]; do
8035                    echo "N=${N}"
8036                    N=$((N - 1))
8037                done
8038            "#)
8039            .await
8040            .expect("execution failed");
8041        assert!(result.ok());
8042        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
8043        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
8044        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
8045    }
8046
8047    #[tokio::test]
8048    async fn test_kernel_set_var() {
8049        let kernel = Kernel::transient().expect("failed to create kernel");
8050
8051        kernel.execute("X=42").await.expect("set failed");
8052
8053        let value = kernel.get_var("X").await;
8054        assert_eq!(value, Some(Value::Int(42)));
8055    }
8056
8057    #[tokio::test]
8058    async fn test_kernel_var_expansion() {
8059        let kernel = Kernel::transient().expect("failed to create kernel");
8060
8061        kernel.execute("NAME=\"world\"").await.expect("set failed");
8062        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
8063
8064        assert!(result.ok());
8065        assert_eq!(result.text_out().trim(), "hello world");
8066    }
8067
8068    #[tokio::test]
8069    async fn test_kernel_last_result() {
8070        let kernel = Kernel::transient().expect("failed to create kernel");
8071
8072        kernel.execute("echo test").await.expect("echo failed");
8073
8074        let last = kernel.last_result().await;
8075        assert!(last.ok());
8076        assert_eq!(last.text_out().trim(), "test");
8077    }
8078
8079    #[tokio::test]
8080    async fn test_kernel_tool_not_found() {
8081        let kernel = Kernel::transient().expect("failed to create kernel");
8082
8083        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
8084        assert!(!result.ok());
8085        assert_eq!(result.code, 127);
8086        assert!(result.err.contains("command not found"));
8087    }
8088
8089    #[tokio::test]
8090    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
8091        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
8092        // engine, …) returns a `ToolResult` with structured `data` — this
8093        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
8094        // and `for r in $(embedder_tool)` see the typed value, not just
8095        // stdout text.
8096        use crate::backend::testing::MockBackend;
8097        use crate::backend::ToolResult;
8098        let (mock, _calls) = MockBackend::new();
8099        let backend = mock.with_tool_result(|_name| {
8100            let mut baggage = std::collections::BTreeMap::new();
8101            baggage.insert("trace_id".to_string(), "abc123".to_string());
8102            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
8103            // construct via with_data + the with_* setters, not a struct literal.
8104            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
8105                .with_content_type("application/json")
8106                .with_baggage(baggage))
8107        });
8108        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8109        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8110            .expect("with_backend kernel");
8111
8112        let result = kernel
8113            .execute("embedder_tool")
8114            .await
8115            .expect("execution failed");
8116        assert!(result.ok(), "backend tool call should succeed: {result:?}");
8117        assert_eq!(
8118            result.data,
8119            Some(Value::Json(serde_json::json!({"key": "value"}))),
8120            "backend tool's structured data must survive into ExecResult, not be dropped"
8121        );
8122        assert_eq!(
8123            result.content_type.as_deref(),
8124            Some("application/json"),
8125            "backend tool's content_type must survive into ExecResult"
8126        );
8127        assert_eq!(
8128            result.baggage.get("trace_id").map(String::as_str),
8129            Some("abc123"),
8130            "backend tool's baggage must survive into ExecResult"
8131        );
8132    }
8133
8134    #[tokio::test]
8135    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
8136        // A backend tool that IS found but fails during execution (`Io`,
8137        // `PermissionDenied`, …) must surface its real error, not get
8138        // misreported as exit-127 "command not found" — that masks a genuine
8139        // failure as a lookup miss.
8140        use crate::backend::testing::MockBackend;
8141        let (mock, _calls) = MockBackend::new();
8142        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
8143        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8144        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8145            .expect("with_backend kernel");
8146
8147        let result = kernel
8148            .execute("embedder_tool")
8149            .await
8150            .expect("execution failed");
8151        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
8152        assert!(!result.ok());
8153        assert!(
8154            result.err.contains("disk exploded"),
8155            "the real backend error must be visible, not masked: {result:?}"
8156        );
8157    }
8158
8159    #[tokio::test]
8160    async fn disabled_external_commands_still_resolve_a_backend_tool() {
8161        // Regression guard for the kaijutsu shape: a read-only shell sets
8162        // `allow_external_commands: false` (no host subprocess exec) but
8163        // still registers its own backend tools — e.g. a sandboxed `curl`
8164        // that reads the network without touching a host binary or the VFS.
8165        // Refusing external commands must NOT short-circuit the
8166        // backend-tool lookup that runs after it. If it did, that `curl`
8167        // would stop resolving and fail with a message claiming it isn't
8168        // available — the exact wrong belief this whole fix exists to
8169        // prevent, just relocated one layer down. Nothing stops a future
8170        // "simplification" of the disabled bail into a terminal branch;
8171        // this test is what catches that.
8172        use crate::backend::testing::MockBackend;
8173        let (mock, calls) = MockBackend::new();
8174        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(mock);
8175        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8176            .expect("with_backend kernel");
8177        assert!(!kernel.allow_external_commands, "isolated() must keep external commands off for this guard to mean anything");
8178
8179        let result = kernel.execute("curl").await.expect("execution failed");
8180        assert!(
8181            result.ok(),
8182            "a backend-registered tool must still run with external commands disabled: {result:?}"
8183        );
8184        assert_eq!(
8185            calls.load(std::sync::atomic::Ordering::SeqCst),
8186            1,
8187            "the backend tool must actually have been invoked, not just assumed"
8188        );
8189    }
8190
8191    #[tokio::test]
8192    async fn test_external_command_true() {
8193        // Use REPL config for passthrough filesystem access
8194        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8195
8196        // /bin/true should be available on any Unix system
8197        let result = kernel.execute("true").await.expect("execution failed");
8198        // This should use the builtin true, which returns 0
8199        assert!(result.ok(), "true should succeed: {:?}", result);
8200    }
8201
8202    #[tokio::test]
8203    async fn test_external_command_basic() {
8204        // Use REPL config for passthrough filesystem access
8205        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8206
8207        // Test with /bin/echo which is external
8208        // Note: kaish has a builtin echo, so this will use the builtin
8209        // Let's test with a command that's not a builtin
8210        // Actually, let's just test that PATH resolution works by checking the PATH var
8211        let path_var = std::env::var("PATH").unwrap_or_default();
8212        eprintln!("System PATH: {}", path_var);
8213
8214        // Set PATH in kernel to ensure it's available
8215        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
8216
8217        // Now try an external command like /usr/bin/env
8218        // But env is also a builtin... let's try uname
8219        let result = kernel.execute("uname").await.expect("execution failed");
8220        eprintln!("uname result: {:?}", result);
8221        // uname should succeed if external commands work
8222        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
8223    }
8224
8225    #[tokio::test]
8226    async fn test_kernel_reset() {
8227        let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229        kernel.execute("X=1").await.expect("set failed");
8230        assert!(kernel.get_var("X").await.is_some());
8231
8232        kernel.reset().await.expect("reset failed");
8233        assert!(kernel.get_var("X").await.is_none());
8234    }
8235
8236    #[tokio::test]
8237    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
8238        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
8239            .expect("failed to create kernel");
8240
8241        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8242        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
8243
8244        kernel.reset().await.expect("reset failed");
8245
8246        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8247        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
8248        assert_eq!(
8249            kernel.get_var("HOME").await,
8250            Some(Value::String("/home/probe".into())),
8251            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
8252        );
8253    }
8254
8255    #[tokio::test]
8256    async fn test_kernel_cwd() {
8257        let kernel = Kernel::transient().expect("failed to create kernel");
8258
8259        // Transient kernel uses sandboxed mode with cwd=$HOME
8260        let cwd = kernel.cwd().await;
8261        let home = std::env::var("HOME")
8262            .map(PathBuf::from)
8263            .unwrap_or_else(|_| PathBuf::from("/"));
8264        assert_eq!(cwd, home);
8265
8266        kernel.set_cwd(PathBuf::from("/tmp")).await;
8267        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
8268    }
8269
8270    #[tokio::test]
8271    async fn test_kernel_list_vars() {
8272        let kernel = Kernel::transient().expect("failed to create kernel");
8273
8274        kernel.execute("A=1").await.ok();
8275        kernel.execute("B=2").await.ok();
8276
8277        let vars = kernel.list_vars().await;
8278        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
8279        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
8280    }
8281
8282    #[tokio::test]
8283    async fn test_is_truthy() {
8284        assert!(!is_truthy(&Value::Null));
8285        assert!(!is_truthy(&Value::Bool(false)));
8286        assert!(is_truthy(&Value::Bool(true)));
8287        assert!(!is_truthy(&Value::Int(0)));
8288        assert!(is_truthy(&Value::Int(1)));
8289        assert!(!is_truthy(&Value::String("".into())));
8290        assert!(is_truthy(&Value::String("x".into())));
8291    }
8292
8293    #[tokio::test]
8294    async fn test_jq_in_pipeline() {
8295        let kernel = Kernel::transient().expect("failed to create kernel");
8296        // kaish uses double quotes only; escape inner quotes
8297        let result = kernel
8298            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
8299            .await
8300            .expect("execution failed");
8301        assert!(result.ok(), "jq pipeline failed: {}", result.err);
8302        assert_eq!(result.text_out().trim(), "Alice");
8303    }
8304
8305    #[tokio::test]
8306    async fn test_user_defined_tool() {
8307        let kernel = Kernel::transient().expect("failed to create kernel");
8308
8309        // Define a function
8310        kernel
8311            .execute(r#"greet() { echo "Hello, $1!" }"#)
8312            .await
8313            .expect("function definition failed");
8314
8315        // Call the function
8316        let result = kernel
8317            .execute(r#"greet "World""#)
8318            .await
8319            .expect("function call failed");
8320
8321        assert!(result.ok(), "greet failed: {}", result.err);
8322        assert_eq!(result.text_out().trim(), "Hello, World!");
8323    }
8324
8325    #[tokio::test]
8326    async fn test_user_tool_positional_args() {
8327        let kernel = Kernel::transient().expect("failed to create kernel");
8328
8329        // Define a function with positional param
8330        kernel
8331            .execute(r#"greet() { echo "Hi $1" }"#)
8332            .await
8333            .expect("function definition failed");
8334
8335        // Call with positional argument
8336        let result = kernel
8337            .execute(r#"greet "Amy""#)
8338            .await
8339            .expect("function call failed");
8340
8341        assert!(result.ok(), "greet failed: {}", result.err);
8342        assert_eq!(result.text_out().trim(), "Hi Amy");
8343    }
8344
8345    #[tokio::test]
8346    async fn test_function_shared_scope() {
8347        let kernel = Kernel::transient().expect("failed to create kernel");
8348
8349        // Set a variable in parent scope
8350        kernel
8351            .execute(r#"SECRET="hidden""#)
8352            .await
8353            .expect("set failed");
8354
8355        // Define a function that accesses and modifies parent variable
8356        kernel
8357            .execute(r#"access_parent() {
8358                echo "${SECRET}"
8359                SECRET="modified"
8360            }"#)
8361            .await
8362            .expect("function definition failed");
8363
8364        // Call the function - it SHOULD see SECRET (shared scope like sh)
8365        let result = kernel.execute("access_parent").await.expect("function call failed");
8366
8367        // Function should have access to parent scope
8368        assert!(
8369            result.text_out().contains("hidden"),
8370            "Function should access parent scope, got: {}",
8371            result.text_out()
8372        );
8373
8374        // Function should have modified the parent variable
8375        let secret = kernel.get_var("SECRET").await;
8376        assert_eq!(
8377            secret,
8378            Some(Value::String("modified".into())),
8379            "Function should modify parent scope"
8380        );
8381    }
8382
8383    #[tokio::test]
8384    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
8385    async fn test_exec_builtin() {
8386        let kernel = Kernel::transient().expect("failed to create kernel");
8387        // argv is now a space-separated string or JSON array string
8388        let result = kernel
8389            .execute(r#"exec command="/bin/echo" argv="hello world""#)
8390            .await
8391            .expect("exec failed");
8392
8393        assert!(result.ok(), "exec failed: {}", result.err);
8394        assert_eq!(result.text_out().trim(), "hello world");
8395    }
8396
8397    #[tokio::test]
8398    async fn test_while_false_never_runs() {
8399        let kernel = Kernel::transient().expect("failed to create kernel");
8400
8401        // A while loop with false condition should never run
8402        let result = kernel
8403            .execute(r#"
8404                while false; do
8405                    echo "should not run"
8406                done
8407            "#)
8408            .await
8409            .expect("while false failed");
8410
8411        assert!(result.ok());
8412        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
8413    }
8414
8415    #[tokio::test]
8416    async fn test_while_string_comparison() {
8417        let kernel = Kernel::transient().expect("failed to create kernel");
8418
8419        // Set a flag
8420        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
8421
8422        // Use string comparison as condition (shell-compatible [[ ]] syntax)
8423        // Note: Put echo last so we can check the output
8424        let result = kernel
8425            .execute(r#"
8426                while [[ ${FLAG} == "go" ]]; do
8427                    FLAG="stop"
8428                    echo "running"
8429                done
8430            "#)
8431            .await
8432            .expect("while with string cmp failed");
8433
8434        assert!(result.ok());
8435        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
8436
8437        // Verify flag was changed
8438        let flag = kernel.get_var("FLAG").await;
8439        assert_eq!(flag, Some(Value::String("stop".into())));
8440    }
8441
8442    #[tokio::test]
8443    async fn test_while_numeric_comparison() {
8444        let kernel = Kernel::transient().expect("failed to create kernel");
8445
8446        // Test > comparison (shell-compatible [[ ]] with -gt)
8447        kernel.execute("N=5").await.expect("set failed");
8448
8449        // Note: Put echo last so we can check the output
8450        let result = kernel
8451            .execute(r#"
8452                while [[ ${N} -gt 3 ]]; do
8453                    N=3
8454                    echo "N was greater"
8455                done
8456            "#)
8457            .await
8458            .expect("while with > failed");
8459
8460        assert!(result.ok());
8461        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
8462    }
8463
8464    #[tokio::test]
8465    async fn test_break_in_while_loop() {
8466        let kernel = Kernel::transient().expect("failed to create kernel");
8467
8468        let result = kernel
8469            .execute(r#"
8470                I=0
8471                while true; do
8472                    I=1
8473                    echo "before break"
8474                    break
8475                    echo "after break"
8476                done
8477            "#)
8478            .await
8479            .expect("while with break failed");
8480
8481        assert!(result.ok());
8482        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
8483        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
8484
8485        // Verify we exited the loop
8486        let i = kernel.get_var("I").await;
8487        assert_eq!(i, Some(Value::Int(1)));
8488    }
8489
8490    #[tokio::test]
8491    async fn test_continue_in_while_loop() {
8492        let kernel = Kernel::transient().expect("failed to create kernel");
8493
8494        // Test continue in a while loop where variables persist
8495        // We use string state transition: "start" -> "middle" -> "end"
8496        // continue on "middle" should skip to next iteration
8497        // Shell-compatible: use [[ ]] for comparisons
8498        let result = kernel
8499            .execute(r#"
8500                STATE="start"
8501                AFTER_CONTINUE="no"
8502                while [[ ${STATE} != "done" ]]; do
8503                    if [[ ${STATE} == "start" ]]; then
8504                        STATE="middle"
8505                        continue
8506                        AFTER_CONTINUE="yes"
8507                    fi
8508                    if [[ ${STATE} == "middle" ]]; then
8509                        STATE="done"
8510                    fi
8511                done
8512            "#)
8513            .await
8514            .expect("while with continue failed");
8515
8516        assert!(result.ok());
8517
8518        // STATE should be "done" (we completed the loop)
8519        let state = kernel.get_var("STATE").await;
8520        assert_eq!(state, Some(Value::String("done".into())));
8521
8522        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
8523        let after = kernel.get_var("AFTER_CONTINUE").await;
8524        assert_eq!(after, Some(Value::String("no".into())));
8525    }
8526
8527    #[tokio::test]
8528    async fn test_break_with_level() {
8529        let kernel = Kernel::transient().expect("failed to create kernel");
8530
8531        // Nested loop with break 2 to exit both loops
8532        // We verify by checking OUTER value:
8533        // - If break 2 works, OUTER stays at 1 (set before for loop)
8534        // - If break 2 fails, OUTER becomes 2 (set after for loop)
8535        let result = kernel
8536            .execute(r#"
8537                OUTER=0
8538                while true; do
8539                    OUTER=1
8540                    for X in "1 2"; do
8541                        break 2
8542                    done
8543                    OUTER=2
8544                done
8545            "#)
8546            .await
8547            .expect("nested break failed");
8548
8549        assert!(result.ok());
8550
8551        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
8552        let outer = kernel.get_var("OUTER").await;
8553        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
8554    }
8555
8556    #[tokio::test]
8557    async fn test_return_from_tool() {
8558        let kernel = Kernel::transient().expect("failed to create kernel");
8559
8560        // Define a function that returns early
8561        kernel
8562            .execute(r#"early_return() {
8563                if [[ $1 == 1 ]]; then
8564                    return 42
8565                fi
8566                echo "not returned"
8567            }"#)
8568            .await
8569            .expect("function definition failed");
8570
8571        // Call with arg=1 should return with exit code 42
8572        // (POSIX shell behavior: return N sets exit code, doesn't output N)
8573        let result = kernel
8574            .execute("early_return 1")
8575            .await
8576            .expect("function call failed");
8577
8578        // Exit code should be 42 (non-zero, so not ok())
8579        assert_eq!(result.code, 42);
8580        // Output should be empty (we returned before echo)
8581        assert!(result.text_out().is_empty());
8582    }
8583
8584    #[tokio::test]
8585    async fn test_return_without_value() {
8586        let kernel = Kernel::transient().expect("failed to create kernel");
8587
8588        // Define a function that returns without a value
8589        kernel
8590            .execute(r#"early_exit() {
8591                if [[ $1 == "stop" ]]; then
8592                    return
8593                fi
8594                echo "continued"
8595            }"#)
8596            .await
8597            .expect("function definition failed");
8598
8599        // Call with arg="stop" should return early
8600        let result = kernel
8601            .execute(r#"early_exit "stop""#)
8602            .await
8603            .expect("function call failed");
8604
8605        assert!(result.ok());
8606        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
8607    }
8608
8609    #[tokio::test]
8610    async fn test_exit_stops_execution() {
8611        let kernel = Kernel::transient().expect("failed to create kernel");
8612
8613        // exit should stop further execution
8614        kernel
8615            .execute(r#"
8616                BEFORE="yes"
8617                exit 0
8618                AFTER="yes"
8619            "#)
8620            .await
8621            .expect("execution failed");
8622
8623        // BEFORE should be set, AFTER should not
8624        let before = kernel.get_var("BEFORE").await;
8625        assert_eq!(before, Some(Value::String("yes".into())));
8626
8627        let after = kernel.get_var("AFTER").await;
8628        assert!(after.is_none(), "AFTER should not be set after exit");
8629    }
8630
8631    #[tokio::test]
8632    async fn test_exit_with_code() {
8633        let kernel = Kernel::transient().expect("failed to create kernel");
8634
8635        // exit with code should propagate the exit code
8636        let result = kernel
8637            .execute("exit 42")
8638            .await
8639            .expect("exit failed");
8640
8641        assert_eq!(result.code, 42);
8642        assert!(result.text_out().is_empty(), "exit should not produce stdout");
8643    }
8644
8645    #[tokio::test]
8646    async fn test_set_e_stops_on_failure() {
8647        let kernel = Kernel::transient().expect("failed to create kernel");
8648
8649        // Enable error-exit mode
8650        kernel.execute("set -e").await.expect("set -e failed");
8651
8652        // Run a sequence where the middle command fails
8653        kernel
8654            .execute(r#"
8655                STEP1="done"
8656                false
8657                STEP2="done"
8658            "#)
8659            .await
8660            .expect("execution failed");
8661
8662        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
8663        let step1 = kernel.get_var("STEP1").await;
8664        assert_eq!(step1, Some(Value::String("done".into())));
8665
8666        let step2 = kernel.get_var("STEP2").await;
8667        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
8668    }
8669
8670    #[tokio::test]
8671    async fn test_set_plus_e_disables_error_exit() {
8672        let kernel = Kernel::transient().expect("failed to create kernel");
8673
8674        // Enable then disable error-exit mode
8675        kernel.execute("set -e").await.expect("set -e failed");
8676        kernel.execute("set +e").await.expect("set +e failed");
8677
8678        // Now failure should NOT stop execution
8679        kernel
8680            .execute(r#"
8681                STEP1="done"
8682                false
8683                STEP2="done"
8684            "#)
8685            .await
8686            .expect("execution failed");
8687
8688        // Both should be set since +e disables error exit
8689        let step1 = kernel.get_var("STEP1").await;
8690        assert_eq!(step1, Some(Value::String("done".into())));
8691
8692        let step2 = kernel.get_var("STEP2").await;
8693        assert_eq!(step2, Some(Value::String("done".into())));
8694    }
8695
8696    #[tokio::test]
8697    async fn test_set_euo_pipefail_is_a_working_prelude() {
8698        let kernel = Kernel::transient().expect("failed to create kernel");
8699
8700        // `set -euo pipefail` is muscle memory for a lot of script authors.
8701        // kaish implements -e and pipefail, and silently ignores the bare -u
8702        // (no fixed set to check it against). It used to FAIL on -o pipefail,
8703        // which mattered well beyond tidiness: an embedder whose exit status
8704        // is a policy decision — kaijutsu gates a tool call on it — read a
8705        // habitual first line as a deny.
8706        let result = kernel
8707            .execute("set -euo pipefail")
8708            .await
8709            .expect("set -euo pipefail failed");
8710        assert!(result.ok(), "the prelude must succeed, err={}", result.err);
8711
8712        // Both options really took effect, not just parsed.
8713        let result = kernel.execute("set -o").await.expect("set -o failed");
8714        let text = result.text_out();
8715        for row in text.lines() {
8716            if row.contains("pipefail") {
8717                assert!(row.contains("on"), "pipefail should read on: {row}");
8718            }
8719        }
8720
8721        // -e is live: the statement after a failure never runs.
8722        kernel
8723            .execute(r#"
8724                BEFORE="yes"
8725                false
8726                AFTER="yes"
8727            "#)
8728            .await
8729            .ok();
8730
8731        let after = kernel.get_var("AFTER").await;
8732        assert!(after.is_none(), "-e should be enabled by the prelude");
8733    }
8734
8735    #[tokio::test]
8736    async fn test_set_no_args_shows_settings() {
8737        let kernel = Kernel::transient().expect("failed to create kernel");
8738
8739        // Enable -e
8740        kernel.execute("set -e").await.expect("set -e failed");
8741
8742        // Call set with no args to see settings
8743        let result = kernel.execute("set").await.expect("set failed");
8744
8745        assert!(result.ok());
8746        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
8747    }
8748
8749    #[tokio::test]
8750    async fn test_set_e_in_pipeline() {
8751        let kernel = Kernel::transient().expect("failed to create kernel");
8752
8753        kernel.execute("set -e").await.expect("set -e failed");
8754
8755        // Pipeline failure should trigger exit
8756        kernel
8757            .execute(r#"
8758                BEFORE="yes"
8759                false | cat
8760                AFTER="yes"
8761            "#)
8762            .await
8763            .ok();
8764
8765        let before = kernel.get_var("BEFORE").await;
8766        assert_eq!(before, Some(Value::String("yes".into())));
8767
8768        // AFTER should not be set if pipeline failure triggers exit
8769        // Note: The exit code of a pipeline is the exit code of the last command
8770        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
8771        // To test pipeline failure, we need the last command to fail.
8772    }
8773
8774    #[tokio::test]
8775    async fn test_set_e_with_and_chain() {
8776        let kernel = Kernel::transient().expect("failed to create kernel");
8777
8778        kernel.execute("set -e").await.expect("set -e failed");
8779
8780        // Commands in && chain should not trigger -e on the first failure
8781        // because && explicitly handles the error
8782        kernel
8783            .execute(r#"
8784                RESULT="initial"
8785                false && RESULT="chained"
8786                RESULT="continued"
8787            "#)
8788            .await
8789            .ok();
8790
8791        // In bash, commands in && don't trigger -e. The chain handles the failure.
8792        // Our implementation may differ - let's verify current behavior.
8793        let result = kernel.get_var("RESULT").await;
8794        // If we follow bash semantics, RESULT should be "continued"
8795        // If we trigger -e on the false, RESULT stays "initial"
8796        assert!(result.is_some(), "RESULT should be set");
8797    }
8798
8799    #[tokio::test]
8800    async fn test_set_e_exits_in_for_loop() {
8801        let kernel = Kernel::transient().expect("failed to create kernel");
8802
8803        kernel.execute("set -e").await.expect("set -e failed");
8804
8805        kernel
8806            .execute(r#"
8807                REACHED="no"
8808                for x in 1 2 3; do
8809                    false
8810                    REACHED="yes"
8811                done
8812            "#)
8813            .await
8814            .ok();
8815
8816        // With set -e, false should trigger exit; REACHED should remain "no"
8817        let reached = kernel.get_var("REACHED").await;
8818        assert_eq!(reached, Some(Value::String("no".into())),
8819            "set -e should exit on failure in for loop body");
8820    }
8821
8822    #[tokio::test]
8823    async fn test_for_loop_continues_without_set_e() {
8824        let kernel = Kernel::transient().expect("failed to create kernel");
8825
8826        // Without set -e, for loop should continue normally
8827        kernel
8828            .execute(r#"
8829                COUNT=0
8830                for x in 1 2 3; do
8831                    false
8832                    COUNT=$((COUNT + 1))
8833                done
8834            "#)
8835            .await
8836            .ok();
8837
8838        let count = kernel.get_var("COUNT").await;
8839        // Arithmetic produces Int values; accept either Int or String representation
8840        let count_val = match &count {
8841            Some(Value::Int(n)) => *n,
8842            Some(Value::String(s)) => s.parse().unwrap_or(-1),
8843            _ => -1,
8844        };
8845        assert_eq!(count_val, 3,
8846            "without set -e, loop should complete all iterations (got {:?})", count);
8847    }
8848
8849    // ═══════════════════════════════════════════════════════════════════════════
8850    // Source Tests
8851    // ═══════════════════════════════════════════════════════════════════════════
8852
8853    #[tokio::test]
8854    async fn test_source_sets_variables() {
8855        let kernel = Kernel::transient().expect("failed to create kernel");
8856
8857        // Write a script to the VFS
8858        kernel
8859            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
8860            .await
8861            .expect("write failed");
8862
8863        // Source the script
8864        let result = kernel
8865            .execute(r#"source "/test.kai""#)
8866            .await
8867            .expect("source failed");
8868
8869        assert!(result.ok(), "source should succeed");
8870
8871        // Variable should be set in current scope
8872        let foo = kernel.get_var("FOO").await;
8873        assert_eq!(foo, Some(Value::String("bar".into())));
8874    }
8875
8876    #[tokio::test]
8877    async fn test_source_with_dot_alias() {
8878        let kernel = Kernel::transient().expect("failed to create kernel");
8879
8880        // Write a script to the VFS
8881        kernel
8882            .execute(r#"write "/vars.kai" 'X=42'"#)
8883            .await
8884            .expect("write failed");
8885
8886        // Source using . alias
8887        let result = kernel
8888            .execute(r#". "/vars.kai""#)
8889            .await
8890            .expect(". failed");
8891
8892        assert!(result.ok(), ". should succeed");
8893
8894        // Variable should be set in current scope
8895        let x = kernel.get_var("X").await;
8896        assert_eq!(x, Some(Value::Int(42)));
8897    }
8898
8899    #[tokio::test]
8900    async fn test_source_not_found() {
8901        let kernel = Kernel::transient().expect("failed to create kernel");
8902
8903        // Try to source a non-existent file
8904        let result = kernel
8905            .execute(r#"source "/nonexistent.kai""#)
8906            .await
8907            .expect("source should not fail with error");
8908
8909        assert!(!result.ok(), "source of non-existent file should fail");
8910        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
8911    }
8912
8913    #[tokio::test]
8914    async fn test_source_missing_filename() {
8915        let kernel = Kernel::transient().expect("failed to create kernel");
8916
8917        // Call source with no arguments
8918        let result = kernel
8919            .execute("source")
8920            .await
8921            .expect("source should not fail with error");
8922
8923        assert!(!result.ok(), "source without filename should fail");
8924        assert!(result.err.contains("missing filename"), "error should mention missing filename");
8925    }
8926
8927    #[tokio::test]
8928    async fn test_source_executes_multiple_statements() {
8929        let kernel = Kernel::transient().expect("failed to create kernel");
8930
8931        // Write a script with multiple statements
8932        kernel
8933            .execute(r#"write "/multi.kai" 'A=1
8934B=2
8935C=3'"#)
8936            .await
8937            .expect("write failed");
8938
8939        // Source it
8940        kernel
8941            .execute(r#"source "/multi.kai""#)
8942            .await
8943            .expect("source failed");
8944
8945        // All variables should be set
8946        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
8947        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
8948        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
8949    }
8950
8951    #[tokio::test]
8952    async fn test_source_can_define_functions() {
8953        let kernel = Kernel::transient().expect("failed to create kernel");
8954
8955        // Write a script that defines a function
8956        kernel
8957            .execute(r#"write "/functions.kai" 'greet() {
8958    echo "Hello, $1!"
8959}'"#)
8960            .await
8961            .expect("write failed");
8962
8963        // Source it
8964        kernel
8965            .execute(r#"source "/functions.kai""#)
8966            .await
8967            .expect("source failed");
8968
8969        // Use the defined function
8970        let result = kernel
8971            .execute(r#"greet "World""#)
8972            .await
8973            .expect("greet failed");
8974
8975        assert!(result.ok());
8976        assert!(result.text_out().contains("Hello, World!"));
8977    }
8978
8979    #[tokio::test]
8980    async fn test_source_inherits_error_exit() {
8981        let kernel = Kernel::transient().expect("failed to create kernel");
8982
8983        // Enable error exit
8984        kernel.execute("set -e").await.expect("set -e failed");
8985
8986        // Write a script that has a failure
8987        kernel
8988            .execute(r#"write "/fail.kai" 'BEFORE="yes"
8989false
8990AFTER="yes"'"#)
8991            .await
8992            .expect("write failed");
8993
8994        // Source it (should exit on false due to set -e)
8995        kernel
8996            .execute(r#"source "/fail.kai""#)
8997            .await
8998            .ok();
8999
9000        // BEFORE should be set, AFTER should NOT be set due to error exit
9001        let before = kernel.get_var("BEFORE").await;
9002        assert_eq!(before, Some(Value::String("yes".into())));
9003
9004        // Note: This test depends on whether error exit is checked within source
9005        // Currently our implementation checks per-statement in the main kernel
9006    }
9007
9008    // ═══════════════════════════════════════════════════════════════════════════
9009    // set -e with && / || chains
9010    // ═══════════════════════════════════════════════════════════════════════════
9011
9012    #[tokio::test]
9013    async fn test_set_e_and_chain_left_fails() {
9014        // set -e; false && echo hi; REACHED=1 → REACHED should be set
9015        let kernel = Kernel::transient().expect("failed to create kernel");
9016        kernel.execute("set -e").await.expect("set -e failed");
9017
9018        kernel
9019            .execute("false && echo hi; REACHED=1")
9020            .await
9021            .expect("execution failed");
9022
9023        let reached = kernel.get_var("REACHED").await;
9024        assert_eq!(
9025            reached,
9026            Some(Value::Int(1)),
9027            "set -e should not trigger on left side of &&"
9028        );
9029    }
9030
9031    #[tokio::test]
9032    async fn test_set_e_and_chain_right_fails() {
9033        // set -e; true && false; REACHED=1 → REACHED should NOT be set
9034        let kernel = Kernel::transient().expect("failed to create kernel");
9035        kernel.execute("set -e").await.expect("set -e failed");
9036
9037        kernel
9038            .execute("true && false; REACHED=1")
9039            .await
9040            .expect("execution failed");
9041
9042        let reached = kernel.get_var("REACHED").await;
9043        assert!(
9044            reached.is_none(),
9045            "set -e should trigger when right side of && fails"
9046        );
9047    }
9048
9049    #[tokio::test]
9050    async fn test_set_e_or_chain_recovers() {
9051        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
9052        let kernel = Kernel::transient().expect("failed to create kernel");
9053        kernel.execute("set -e").await.expect("set -e failed");
9054
9055        kernel
9056            .execute("false || echo recovered; REACHED=1")
9057            .await
9058            .expect("execution failed");
9059
9060        let reached = kernel.get_var("REACHED").await;
9061        assert_eq!(
9062            reached,
9063            Some(Value::Int(1)),
9064            "set -e should not trigger when || recovers the failure"
9065        );
9066    }
9067
9068    #[tokio::test]
9069    async fn test_set_e_or_chain_both_fail() {
9070        // set -e; false || false; REACHED=1 → REACHED should NOT be set
9071        let kernel = Kernel::transient().expect("failed to create kernel");
9072        kernel.execute("set -e").await.expect("set -e failed");
9073
9074        kernel
9075            .execute("false || false; REACHED=1")
9076            .await
9077            .expect("execution failed");
9078
9079        let reached = kernel.get_var("REACHED").await;
9080        assert!(
9081            reached.is_none(),
9082            "set -e should trigger when || chain ultimately fails"
9083        );
9084    }
9085
9086    // ═══════════════════════════════════════════════════════════════════════════
9087    // Cancellation Tests
9088    // ═══════════════════════════════════════════════════════════════════════════
9089
9090    /// Schedule a cancel after a delay, from an OS thread because `cancel()`
9091    /// is sync and must run while the test runtime is inside `execute()`.
9092    ///
9093    /// The delay races `execute()`: a cancel firing before `reset_cancel()` is
9094    /// discarded. Tests that need it to land use an `interrupt` tripwire
9095    /// instead, as the loop tests below do.
9096    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
9097        let k = Arc::clone(kernel);
9098        std::thread::spawn(move || {
9099            std::thread::sleep(delay);
9100            k.cancel();
9101        });
9102    }
9103
9104    #[tokio::test]
9105    async fn test_cancel_interrupts_for_loop() {
9106        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9107
9108        // A `sleep` body, per #149: a bare `X=$i` has no await point for the
9109        // per-iteration checkpoint to land on, and at this count natural
9110        // completion (~100s) stays far past the bound.
9111        //
9112        // The timer this used to use raced `execute()`: `reset_cancel()`
9113        // replaces an already-cancelled token, so a cancel firing first was
9114        // dropped and the loop ran all 2000 iterations. The `interrupt` slot is
9115        // installed after that line, so it cannot be dropped.
9116        // `Kernel::cancel()` still does the cancelling.
9117        const ITERATIONS: u32 = 2000;
9118        const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
9119        let bound = std::time::Duration::from_secs(10);
9120
9121        // Counted, not latched: the checkpoint polls before the body, so a
9122        // cancel released on the first poll can land before the body runs at
9123        // all. The second poll puts one completed iteration between them.
9124        let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
9125        let tripwire = Arc::clone(&polls);
9126        let opts = ExecuteOptions::new().with_interrupt(Arc::new(move || {
9127            tripwire.fetch_add(1, Ordering::SeqCst);
9128            false
9129        }));
9130
9131        // A background OS thread, not `tokio::spawn`: the cancel has to land
9132        // while the current-thread test runtime is busy inside the loop.
9133        {
9134            let k = Arc::clone(&kernel);
9135            let tripped = Arc::clone(&polls);
9136            std::thread::spawn(move || {
9137                let deadline = std::time::Instant::now() + bound;
9138                while tripped.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
9139                    std::thread::sleep(std::time::Duration::from_millis(1));
9140                }
9141                // Cancel even if the tripwire never tripped, so a loop that
9142                // never started fails on an assertion below instead of just
9143                // running out the bound.
9144                k.cancel();
9145            });
9146        }
9147
9148        let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
9149
9150        let result = tokio::time::timeout(bound, kernel.execute_with_options(&script, opts))
9151            .await
9152            .unwrap_or_else(|_| {
9153                panic!(
9154                    "for-loop did not return within {bound:?} — cancellation support looks \
9155                     broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
9156                     longer than this bound)",
9157                    ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
9158                )
9159            })
9160            .expect("execute failed");
9161
9162        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
9163
9164        // How far the loop got — the half of this test that reads no clock. A
9165        // loop ignoring cancellation reports 2000. Parsed from text because
9166        // `$(seq …)` binds the loop variable as a string; the `Value::Int` arm
9167        // this replaces never matched, so it asserted nothing.
9168        const MAX_REACHED: i64 = (ITERATIONS / 10) as i64;
9169        let reached = match kernel.get_var("X").await {
9170            Some(Value::Int(n)) => n,
9171            Some(Value::String(s)) => s
9172                .parse::<i64>()
9173                .unwrap_or_else(|e| panic!("loop variable X should be numeric, got {s:?}: {e}")),
9174            other => {
9175                panic!("loop variable X should record the last iteration reached, got {other:?}")
9176            }
9177        };
9178        assert!(
9179            reached <= MAX_REACHED,
9180            "cancellation should have stopped the loop within {MAX_REACHED} of {ITERATIONS} \
9181             iterations, but it reached {reached} — the per-iteration checkpoint is not \
9182             honoring the cancellation token"
9183        );
9184    }
9185
9186    #[tokio::test]
9187    async fn test_cancel_interrupts_while_loop() {
9188        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9189        kernel.execute("COUNT=0").await.expect("init failed");
9190
9191        // Same swallowed cancel as the for-loop test, but this one HUNG
9192        // rather than failing: `while true` has no iteration count to run out
9193        // and there was no bound, so a dropped cancel burned a core until CI's
9194        // job timeout. Fixed the same way, plus a bound — which does fire here,
9195        // so each iteration yields to the runtime somewhere.
9196        let bound = std::time::Duration::from_secs(10);
9197
9198        // Counted, not latched: the checkpoint polls before the body, so a
9199        // cancel released on the first poll can land before the body runs at
9200        // all. The second poll puts one completed iteration between them.
9201        let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
9202        let tripwire = Arc::clone(&polls);
9203        let opts = ExecuteOptions::new().with_interrupt(Arc::new(move || {
9204            tripwire.fetch_add(1, Ordering::SeqCst);
9205            false
9206        }));
9207
9208        {
9209            let k = Arc::clone(&kernel);
9210            let tripped = Arc::clone(&polls);
9211            std::thread::spawn(move || {
9212                let deadline = std::time::Instant::now() + bound;
9213                while tripped.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
9214                    std::thread::sleep(std::time::Duration::from_millis(1));
9215                }
9216                k.cancel();
9217            });
9218        }
9219
9220        let result = tokio::time::timeout(
9221            bound,
9222            kernel.execute_with_options("while true; do COUNT=$((COUNT + 1)); done", opts),
9223        )
9224        .await
9225        .unwrap_or_else(|_| {
9226            panic!(
9227                "while-loop did not return within {bound:?} — `while true` never ends on \
9228                 its own, so the per-iteration cancellation checkpoint is not honoring the \
9229                 token and this loop would have spun forever"
9230            )
9231        })
9232        .expect("execute failed");
9233
9234        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
9235
9236        // A count above zero proves a *running* loop was interrupted, and it
9237        // holds by construction: the cancel waits for the second poll. No upper
9238        // bound — how far bare arithmetic gets is a function of host speed.
9239        match kernel.get_var("COUNT").await {
9240            Some(Value::Int(n)) => assert!(n > 0, "loop should have run at least once, got {n}"),
9241            other => panic!("COUNT should be an integer set by the loop body, got {other:?}"),
9242        }
9243    }
9244
9245    #[tokio::test]
9246    async fn test_reset_after_cancel() {
9247        // After cancellation, the next execute() should work normally
9248        let kernel = Kernel::transient().expect("failed to create kernel");
9249        kernel.cancel(); // cancel with nothing running
9250
9251        let result = kernel.execute("echo hello").await.expect("execute failed");
9252        assert!(result.ok(), "execute after cancel should succeed");
9253        assert_eq!(result.text_out().trim(), "hello");
9254    }
9255
9256    #[tokio::test]
9257    async fn test_cancel_interrupts_statement_sequence() {
9258        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9259
9260        // Schedule cancel after the first statement runs but before sleep finishes
9261        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
9262
9263        let result = kernel
9264            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
9265            .await
9266            .expect("execute failed");
9267
9268        assert_eq!(result.code, 130);
9269
9270        // STEP should be 1 (set before sleep), not 2 or 3
9271        let step = kernel.get_var("STEP").await;
9272        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
9273    }
9274
9275    // ═══════════════════════════════════════════════════════════════════════════
9276    // Case Statement Tests
9277    // ═══════════════════════════════════════════════════════════════════════════
9278
9279    #[tokio::test]
9280    async fn test_case_simple_match() {
9281        let kernel = Kernel::transient().expect("failed to create kernel");
9282
9283        let result = kernel
9284            .execute(r#"
9285                case "hello" in
9286                    hello) echo "matched hello" ;;
9287                    world) echo "matched world" ;;
9288                esac
9289            "#)
9290            .await
9291            .expect("case failed");
9292
9293        assert!(result.ok());
9294        assert_eq!(result.text_out().trim(), "matched hello");
9295    }
9296
9297    #[tokio::test]
9298    async fn test_case_wildcard_match() {
9299        let kernel = Kernel::transient().expect("failed to create kernel");
9300
9301        let result = kernel
9302            .execute(r#"
9303                case "main.rs" in
9304                    *.py) echo "Python" ;;
9305                    *.rs) echo "Rust" ;;
9306                    *) echo "Unknown" ;;
9307                esac
9308            "#)
9309            .await
9310            .expect("case failed");
9311
9312        assert!(result.ok());
9313        assert_eq!(result.text_out().trim(), "Rust");
9314    }
9315
9316    #[tokio::test]
9317    async fn test_case_default_match() {
9318        let kernel = Kernel::transient().expect("failed to create kernel");
9319
9320        let result = kernel
9321            .execute(r#"
9322                case "unknown.xyz" in
9323                    *.py) echo "Python" ;;
9324                    *.rs) echo "Rust" ;;
9325                    *) echo "Default" ;;
9326                esac
9327            "#)
9328            .await
9329            .expect("case failed");
9330
9331        assert!(result.ok());
9332        assert_eq!(result.text_out().trim(), "Default");
9333    }
9334
9335    #[tokio::test]
9336    async fn test_case_no_match() {
9337        let kernel = Kernel::transient().expect("failed to create kernel");
9338
9339        // Case with no default branch and no match
9340        let result = kernel
9341            .execute(r#"
9342                case "nope" in
9343                    "yes") echo "yes" ;;
9344                    "no") echo "no" ;;
9345                esac
9346            "#)
9347            .await
9348            .expect("case failed");
9349
9350        assert!(result.ok());
9351        assert!(result.text_out().is_empty(), "no match should produce empty output");
9352    }
9353
9354    #[tokio::test]
9355    async fn test_case_with_variable() {
9356        let kernel = Kernel::transient().expect("failed to create kernel");
9357
9358        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
9359
9360        let result = kernel
9361            .execute(r#"
9362                case ${LANG} in
9363                    python) echo "snake" ;;
9364                    rust) echo "crab" ;;
9365                    go) echo "gopher" ;;
9366                esac
9367            "#)
9368            .await
9369            .expect("case failed");
9370
9371        assert!(result.ok());
9372        assert_eq!(result.text_out().trim(), "crab");
9373    }
9374
9375    #[tokio::test]
9376    async fn test_case_multiple_patterns() {
9377        let kernel = Kernel::transient().expect("failed to create kernel");
9378
9379        let result = kernel
9380            .execute(r#"
9381                case "yes" in
9382                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
9383                    "n"|"no"|"N"|"NO") echo "negative" ;;
9384                esac
9385            "#)
9386            .await
9387            .expect("case failed");
9388
9389        assert!(result.ok());
9390        assert_eq!(result.text_out().trim(), "affirmative");
9391    }
9392
9393    #[tokio::test]
9394    async fn test_case_glob_question_mark() {
9395        let kernel = Kernel::transient().expect("failed to create kernel");
9396
9397        let result = kernel
9398            .execute(r#"
9399                case "test1" in
9400                    test?) echo "matched test?" ;;
9401                    *) echo "default" ;;
9402                esac
9403            "#)
9404            .await
9405            .expect("case failed");
9406
9407        assert!(result.ok());
9408        assert_eq!(result.text_out().trim(), "matched test?");
9409    }
9410
9411    #[tokio::test]
9412    async fn test_case_char_class() {
9413        let kernel = Kernel::transient().expect("failed to create kernel");
9414
9415        let result = kernel
9416            .execute(r#"
9417                case "Yes" in
9418                    [Yy]*) echo "yes-like" ;;
9419                    [Nn]*) echo "no-like" ;;
9420                esac
9421            "#)
9422            .await
9423            .expect("case failed");
9424
9425        assert!(result.ok());
9426        assert_eq!(result.text_out().trim(), "yes-like");
9427    }
9428
9429    // ═══════════════════════════════════════════════════════════════════════════
9430    // Cat Stdin Tests
9431    // ═══════════════════════════════════════════════════════════════════════════
9432
9433    #[tokio::test]
9434    async fn test_cat_from_pipeline() {
9435        let kernel = Kernel::transient().expect("failed to create kernel");
9436
9437        let result = kernel
9438            .execute(r#"echo "piped text" | cat"#)
9439            .await
9440            .expect("cat pipeline failed");
9441
9442        assert!(result.ok(), "cat failed: {}", result.err);
9443        assert_eq!(result.text_out().trim(), "piped text");
9444    }
9445
9446    #[tokio::test]
9447    async fn test_cat_from_pipeline_multiline() {
9448        let kernel = Kernel::transient().expect("failed to create kernel");
9449
9450        let result = kernel
9451            .execute(r#"echo "line1\nline2" | cat -n"#)
9452            .await
9453            .expect("cat pipeline failed");
9454
9455        assert!(result.ok(), "cat failed: {}", result.err);
9456        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
9457    }
9458
9459    // ═══════════════════════════════════════════════════════════════════════════
9460    // Heredoc Tests
9461    // ═══════════════════════════════════════════════════════════════════════════
9462
9463    #[tokio::test]
9464    async fn test_heredoc_basic() {
9465        let kernel = Kernel::transient().expect("failed to create kernel");
9466
9467        let result = kernel
9468            .execute("cat <<EOF\nhello\nEOF")
9469            .await
9470            .expect("heredoc failed");
9471
9472        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9473        assert_eq!(result.text_out().trim(), "hello");
9474    }
9475
9476    #[tokio::test]
9477    async fn test_arithmetic_in_string() {
9478        let kernel = Kernel::transient().expect("failed to create kernel");
9479
9480        let result = kernel
9481            .execute(r#"echo "result: $((1 + 2))""#)
9482            .await
9483            .expect("arithmetic in string failed");
9484
9485        assert!(result.ok(), "echo failed: {}", result.err);
9486        assert_eq!(result.text_out().trim(), "result: 3");
9487    }
9488
9489    #[tokio::test]
9490    async fn test_heredoc_multiline() {
9491        let kernel = Kernel::transient().expect("failed to create kernel");
9492
9493        let result = kernel
9494            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
9495            .await
9496            .expect("heredoc failed");
9497
9498        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9499        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
9500        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
9501        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
9502    }
9503
9504    #[tokio::test]
9505    async fn test_heredoc_variable_expansion() {
9506        // Bug N: unquoted heredoc should expand variables
9507        let kernel = Kernel::transient().expect("failed to create kernel");
9508
9509        kernel.execute("GREETING=hello").await.expect("set var");
9510
9511        let result = kernel
9512            .execute("cat <<EOF\n$GREETING world\nEOF")
9513            .await
9514            .expect("heredoc expansion failed");
9515
9516        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
9517        assert_eq!(result.text_out().trim(), "hello world");
9518    }
9519
9520    #[tokio::test]
9521    async fn test_heredoc_quoted_no_expansion() {
9522        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
9523        let kernel = Kernel::transient().expect("failed to create kernel");
9524
9525        kernel.execute("GREETING=hello").await.expect("set var");
9526
9527        let result = kernel
9528            .execute("cat <<'EOF'\n$GREETING world\nEOF")
9529            .await
9530            .expect("quoted heredoc failed");
9531
9532        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
9533        assert_eq!(result.text_out().trim(), "$GREETING world");
9534    }
9535
9536    #[tokio::test]
9537    async fn test_heredoc_default_value_expansion() {
9538        // Bug N: ${VAR:-default} should expand in unquoted heredocs
9539        let kernel = Kernel::transient().expect("failed to create kernel");
9540
9541        let result = kernel
9542            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
9543            .await
9544            .expect("heredoc default expansion failed");
9545
9546        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
9547        assert_eq!(result.text_out().trim(), "fallback");
9548    }
9549
9550    // ═══════════════════════════════════════════════════════════════════════════
9551    // Read Builtin Tests
9552    // ═══════════════════════════════════════════════════════════════════════════
9553
9554    #[tokio::test]
9555    async fn test_read_from_pipeline() {
9556        let kernel = Kernel::transient().expect("failed to create kernel");
9557
9558        // Pipe input to read
9559        let result = kernel
9560            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
9561            .await
9562            .expect("read pipeline failed");
9563
9564        assert!(result.ok(), "read failed: {}", result.err);
9565        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
9566    }
9567
9568    #[tokio::test]
9569    async fn test_read_multiple_vars_from_pipeline() {
9570        let kernel = Kernel::transient().expect("failed to create kernel");
9571
9572        let result = kernel
9573            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
9574            .await
9575            .expect("read pipeline failed");
9576
9577        assert!(result.ok(), "read failed: {}", result.err);
9578        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
9579    }
9580
9581    // ═══════════════════════════════════════════════════════════════════════════
9582    // Shell-Style Function Tests
9583    // ═══════════════════════════════════════════════════════════════════════════
9584
9585    #[tokio::test]
9586    async fn test_posix_function_with_positional_params() {
9587        let kernel = Kernel::transient().expect("failed to create kernel");
9588
9589        // Define POSIX-style function
9590        kernel
9591            .execute(r#"greet() { echo "Hello, $1!" }"#)
9592            .await
9593            .expect("function definition failed");
9594
9595        // Call the function
9596        let result = kernel
9597            .execute(r#"greet "Amy""#)
9598            .await
9599            .expect("function call failed");
9600
9601        assert!(result.ok(), "greet failed: {}", result.err);
9602        assert_eq!(result.text_out().trim(), "Hello, Amy!");
9603    }
9604
9605    #[tokio::test]
9606    async fn test_posix_function_multiple_args() {
9607        let kernel = Kernel::transient().expect("failed to create kernel");
9608
9609        // Define function using $1 and $2
9610        kernel
9611            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
9612            .await
9613            .expect("function definition failed");
9614
9615        // Call the function
9616        let result = kernel
9617            .execute(r#"add_greeting "Hello" "World""#)
9618            .await
9619            .expect("function call failed");
9620
9621        assert!(result.ok(), "function failed: {}", result.err);
9622        assert_eq!(result.text_out().trim(), "Hello World!");
9623    }
9624
9625    #[tokio::test]
9626    async fn test_bash_function_with_positional_params() {
9627        let kernel = Kernel::transient().expect("failed to create kernel");
9628
9629        // Define bash-style function (function keyword, no parens)
9630        kernel
9631            .execute(r#"function greet { echo "Hi $1" }"#)
9632            .await
9633            .expect("function definition failed");
9634
9635        // Call the function
9636        let result = kernel
9637            .execute(r#"greet "Bob""#)
9638            .await
9639            .expect("function call failed");
9640
9641        assert!(result.ok(), "greet failed: {}", result.err);
9642        assert_eq!(result.text_out().trim(), "Hi Bob");
9643    }
9644
9645    #[tokio::test]
9646    async fn test_shell_function_with_all_args() {
9647        let kernel = Kernel::transient().expect("failed to create kernel");
9648
9649        // Define function using $@ (all args)
9650        kernel
9651            .execute(r#"echo_all() { echo "args: $@" }"#)
9652            .await
9653            .expect("function definition failed");
9654
9655        // Call with multiple args
9656        let result = kernel
9657            .execute(r#"echo_all "a" "b" "c""#)
9658            .await
9659            .expect("function call failed");
9660
9661        assert!(result.ok(), "function failed: {}", result.err);
9662        assert_eq!(result.text_out().trim(), "args: a b c");
9663    }
9664
9665    #[tokio::test]
9666    async fn test_shell_function_with_arg_count() {
9667        let kernel = Kernel::transient().expect("failed to create kernel");
9668
9669        // Define function using $# (arg count)
9670        kernel
9671            .execute(r#"count_args() { echo "count: $#" }"#)
9672            .await
9673            .expect("function definition failed");
9674
9675        // Call with three args
9676        let result = kernel
9677            .execute(r#"count_args "x" "y" "z""#)
9678            .await
9679            .expect("function call failed");
9680
9681        assert!(result.ok(), "function failed: {}", result.err);
9682        assert_eq!(result.text_out().trim(), "count: 3");
9683    }
9684
9685    #[tokio::test]
9686    async fn test_shell_function_shared_scope() {
9687        let kernel = Kernel::transient().expect("failed to create kernel");
9688
9689        // Set a variable in parent scope
9690        kernel
9691            .execute(r#"PARENT_VAR="visible""#)
9692            .await
9693            .expect("set failed");
9694
9695        // Define shell function that reads and writes parent variable
9696        kernel
9697            .execute(r#"modify_parent() {
9698                echo "saw: ${PARENT_VAR}"
9699                PARENT_VAR="changed by function"
9700            }"#)
9701            .await
9702            .expect("function definition failed");
9703
9704        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
9705        let result = kernel.execute("modify_parent").await.expect("function failed");
9706
9707        assert!(
9708            result.text_out().contains("visible"),
9709            "Shell function should access parent scope, got: {}",
9710            result.text_out()
9711        );
9712
9713        // Parent variable should be modified
9714        let var = kernel.get_var("PARENT_VAR").await;
9715        assert_eq!(
9716            var,
9717            Some(Value::String("changed by function".into())),
9718            "Shell function should modify parent scope"
9719        );
9720    }
9721
9722    // ═══════════════════════════════════════════════════════════════════════════
9723    // Script Execution via PATH Tests
9724    // ═══════════════════════════════════════════════════════════════════════════
9725
9726    #[tokio::test]
9727    async fn test_script_execution_from_path() {
9728        let kernel = Kernel::transient().expect("failed to create kernel");
9729
9730        // Create /bin directory and script
9731        kernel.execute(r#"mkdir "/bin""#).await.ok();
9732        kernel
9733            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
9734            .await
9735            .expect("write script failed");
9736
9737        // Set PATH to /bin
9738        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9739
9740        // Call script by name (without .kai extension)
9741        let result = kernel
9742            .execute("hello")
9743            .await
9744            .expect("script execution failed");
9745
9746        assert!(result.ok(), "script failed: {}", result.err);
9747        assert_eq!(result.text_out().trim(), "Hello from script!");
9748    }
9749
9750    #[tokio::test]
9751    async fn test_script_with_args() {
9752        let kernel = Kernel::transient().expect("failed to create kernel");
9753
9754        // Create script that uses positional params
9755        kernel.execute(r#"mkdir "/bin""#).await.ok();
9756        kernel
9757            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
9758            .await
9759            .expect("write script failed");
9760
9761        // Set PATH
9762        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9763
9764        // Call script with arg
9765        let result = kernel
9766            .execute(r#"greet "World""#)
9767            .await
9768            .expect("script execution failed");
9769
9770        assert!(result.ok(), "script failed: {}", result.err);
9771        assert_eq!(result.text_out().trim(), "Hello, World!");
9772    }
9773
9774    #[tokio::test]
9775    async fn test_script_not_found() {
9776        let kernel = Kernel::transient().expect("failed to create kernel");
9777
9778        // Set empty PATH
9779        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
9780
9781        // Call non-existent script
9782        let result = kernel
9783            .execute("noscript")
9784            .await
9785            .expect("execution failed");
9786
9787        assert!(!result.ok(), "should fail with command not found");
9788        assert_eq!(result.code, 127);
9789        assert!(result.err.contains("command not found"));
9790    }
9791
9792    #[tokio::test]
9793    async fn test_script_path_search_order() {
9794        let kernel = Kernel::transient().expect("failed to create kernel");
9795
9796        // Create two directories with same-named script
9797        // Note: using "myscript" not "test" to avoid conflict with test builtin
9798        kernel.execute(r#"mkdir "/first""#).await.ok();
9799        kernel.execute(r#"mkdir "/second""#).await.ok();
9800        kernel
9801            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
9802            .await
9803            .expect("write failed");
9804        kernel
9805            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
9806            .await
9807            .expect("write failed");
9808
9809        // Set PATH with first before second
9810        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
9811
9812        // Should find first one
9813        let result = kernel
9814            .execute("myscript")
9815            .await
9816            .expect("script execution failed");
9817
9818        assert!(result.ok(), "script failed: {}", result.err);
9819        assert_eq!(result.text_out().trim(), "from first");
9820    }
9821
9822    // ═══════════════════════════════════════════════════════════════════════════
9823    // Special Variable Tests ($?, $$, unset vars)
9824    // ═══════════════════════════════════════════════════════════════════════════
9825
9826    #[tokio::test]
9827    async fn test_last_exit_code_success() {
9828        let kernel = Kernel::transient().expect("failed to create kernel");
9829
9830        // true exits with 0
9831        let result = kernel.execute("true; echo $?").await.expect("execution failed");
9832        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
9833    }
9834
9835    #[tokio::test]
9836    async fn test_last_exit_code_failure() {
9837        let kernel = Kernel::transient().expect("failed to create kernel");
9838
9839        // false exits with 1
9840        let result = kernel.execute("false; echo $?").await.expect("execution failed");
9841        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
9842    }
9843
9844    #[tokio::test]
9845    async fn test_current_pid() {
9846        let kernel = Kernel::transient().expect("failed to create kernel");
9847
9848        let result = kernel.execute("echo $$").await.expect("execution failed");
9849        // PID should be a positive number
9850        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
9851        assert!(pid > 0, "PID should be positive");
9852    }
9853
9854    #[tokio::test]
9855    async fn test_unset_variable_expands_to_empty() {
9856        let kernel = Kernel::transient().expect("failed to create kernel");
9857
9858        // Unset variable in interpolation should be empty
9859        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
9860        assert_eq!(result.text_out().trim(), "prefix::suffix");
9861    }
9862
9863    #[tokio::test]
9864    async fn test_eq_ne_operators() {
9865        let kernel = Kernel::transient().expect("failed to create kernel");
9866
9867        // Test -eq operator
9868        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
9869        assert_eq!(result.text_out().trim(), "eq works");
9870
9871        // Test -ne operator
9872        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
9873        assert_eq!(result.text_out().trim(), "ne works");
9874
9875        // Test -eq with different values
9876        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
9877        assert_eq!(result.text_out().trim(), "correct");
9878    }
9879
9880    #[tokio::test]
9881    async fn test_escaped_dollar_in_string() {
9882        let kernel = Kernel::transient().expect("failed to create kernel");
9883
9884        // \$ should produce literal $
9885        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
9886        assert_eq!(result.text_out().trim(), "$100");
9887    }
9888
9889    #[tokio::test]
9890    async fn test_special_vars_in_interpolation() {
9891        let kernel = Kernel::transient().expect("failed to create kernel");
9892
9893        // Test $? in string interpolation
9894        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
9895        assert_eq!(result.text_out().trim(), "exit: 0");
9896
9897        // Test $$ in string interpolation
9898        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
9899        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
9900        let text = result.text_out();
9901        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
9902        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
9903    }
9904
9905    // ═══════════════════════════════════════════════════════════════════════════
9906    // Command Substitution Tests
9907    // ═══════════════════════════════════════════════════════════════════════════
9908
9909    #[tokio::test]
9910    async fn test_command_subst_assignment() {
9911        let kernel = Kernel::transient().expect("failed to create kernel");
9912
9913        // Command substitution in assignment
9914        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
9915        assert_eq!(result.text_out().trim(), "hello");
9916    }
9917
9918    #[tokio::test]
9919    async fn test_command_subst_with_args() {
9920        let kernel = Kernel::transient().expect("failed to create kernel");
9921
9922        // Command substitution with string argument
9923        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
9924        assert_eq!(result.text_out().trim(), "a b c");
9925    }
9926
9927    #[tokio::test]
9928    async fn test_command_subst_nested_vars() {
9929        let kernel = Kernel::transient().expect("failed to create kernel");
9930
9931        // Variables inside command substitution
9932        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
9933        assert_eq!(result.text_out().trim(), "hello world");
9934    }
9935
9936    #[tokio::test]
9937    async fn test_background_job_basic() {
9938        use std::time::Duration;
9939
9940        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
9941
9942        // Run a simple background command, redirecting its output to a
9943        // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
9944        // live); the redirect is what this test asserts on.
9945        let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
9946        assert!(result.ok(), "background command should succeed: {}", result.err);
9947        assert!(result.err.contains("[1]"), "announcement rides stderr: {:?}", result.err);
9948
9949        // Give the job time to complete
9950        tokio::time::sleep(Duration::from_millis(100)).await;
9951
9952        // Check job status
9953        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
9954        assert!(status.ok(), "status should succeed: {}", status.err);
9955        assert!(
9956            status.text_out().contains("done:") || status.text_out().contains("running"),
9957            "should have valid status: {}",
9958            status.text_out()
9959        );
9960
9961        // Check the redirected output
9962        let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
9963        assert!(stdout.ok());
9964        assert!(stdout.text_out().contains("hello"));
9965    }
9966
9967    #[tokio::test]
9968    async fn test_heredoc_piped_to_command() {
9969        // Bug 4: heredoc content should pipe through to next command
9970        let kernel = Kernel::transient().expect("kernel");
9971        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
9972        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
9973        assert_eq!(result.text_out().trim(), "hello world");
9974    }
9975
9976    /// A transient kernel paired with a real, auto-cleaning tempdir. The
9977    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
9978    /// tests need actual files on disk. Hold the returned `TempDir` for the
9979    /// test's lifetime: it removes the directory tree on drop — including on
9980    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
9981    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
9982    /// as a string for interpolation into scripts.
9983    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
9984        let kernel = Kernel::transient().expect("kernel");
9985        let tmp = tempfile::tempdir().expect("tempdir");
9986        let dir = tmp.path().display().to_string();
9987        (kernel, tmp, dir)
9988    }
9989
9990    #[tokio::test]
9991    async fn test_for_loop_glob_iterates() {
9992        // Bug 1: for F in $(glob ...) should iterate per file, not once
9993        let (kernel, _tmp, dir) = transient_with_tempdir();
9994        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9995        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9996        let result = kernel.execute(&format!(r#"
9997            N=0
9998            for F in $(glob "{dir}/*.txt"); do
9999                N=$((N + 1))
10000            done
10001            echo $N
10002        "#)).await.unwrap();
10003        assert!(result.ok(), "for glob failed: {}", result.err);
10004        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
10005    }
10006
10007    #[tokio::test]
10008    async fn test_bare_glob_expansion_echo() {
10009        let (kernel, _tmp, dir) = transient_with_tempdir();
10010        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
10011        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
10012        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
10013        kernel.execute(&format!("cd {dir}")).await.unwrap();
10014        let result = kernel.execute("echo *.txt").await.unwrap();
10015        assert!(result.ok(), "echo *.txt failed: {}", result.err);
10016        let out = result.text_out();
10017        let out = out.trim();
10018        // Should contain both .txt files (order may vary)
10019        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
10020        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
10021        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
10022    }
10023
10024    #[tokio::test]
10025    async fn test_bare_glob_no_matches_errors() {
10026        let (kernel, _tmp, dir) = transient_with_tempdir();
10027        kernel.execute(&format!("cd {dir}")).await.unwrap();
10028        let result = kernel.execute("echo *.nonexistent").await;
10029        match &result {
10030            Ok(exec) => {
10031                // No-match glob should produce a non-zero exit code
10032                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
10033                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
10034            }
10035            Err(e) => {
10036                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
10037            }
10038        }
10039    }
10040
10041    #[tokio::test]
10042    async fn test_bare_glob_disabled_with_set() {
10043        let (kernel, _tmp, dir) = transient_with_tempdir();
10044        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
10045        kernel.execute(&format!("cd {dir}")).await.unwrap();
10046        // Disable glob expansion
10047        kernel.execute("set +o glob").await.unwrap();
10048        let result = kernel.execute("echo *.txt").await.unwrap();
10049        // With glob disabled, *.txt should be passed as literal string
10050        assert!(result.ok(), "echo should succeed: {}", result.err);
10051        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
10052    }
10053
10054    #[tokio::test]
10055    async fn test_bare_glob_quoted_not_expanded() {
10056        let (kernel, _tmp, dir) = transient_with_tempdir();
10057        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
10058        kernel.execute(&format!("cd {dir}")).await.unwrap();
10059        // Quoted globs should NOT expand
10060        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
10061        assert!(result.ok(), "echo should succeed: {}", result.err);
10062        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
10063    }
10064
10065    #[tokio::test]
10066    async fn test_bare_glob_for_loop() {
10067        let (kernel, _tmp, dir) = transient_with_tempdir();
10068        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
10069        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
10070        kernel.execute(&format!("cd {dir}")).await.unwrap();
10071        let result = kernel.execute(r#"
10072            N=0
10073            for f in *.txt; do
10074                N=$((N + 1))
10075            done
10076            echo $N
10077        "#).await.unwrap();
10078        assert!(result.ok(), "for loop failed: {}", result.err);
10079        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
10080    }
10081
10082    #[tokio::test]
10083    async fn test_glob_in_assignment_is_literal() {
10084        let kernel = Kernel::transient().expect("kernel");
10085        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
10086        assert!(result.ok());
10087        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
10088    }
10089
10090    #[tokio::test]
10091    async fn test_glob_in_test_expr_is_literal() {
10092        let kernel = Kernel::transient().expect("kernel");
10093        let result = kernel.execute(r#"
10094            if [[ *.txt == "*.txt" ]]; then
10095                echo "match"
10096            else
10097                echo "no"
10098            fi
10099        "#).await.unwrap();
10100        assert!(result.ok());
10101        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
10102    }
10103
10104    #[tokio::test]
10105    async fn test_command_subst_echo_not_iterable() {
10106        // Regression guard: $(echo "a b c") must remain a single string
10107        let kernel = Kernel::transient().expect("kernel");
10108        let result = kernel.execute(r#"
10109            N=0
10110            for X in $(echo "a b c"); do N=$((N + 1)); done
10111            echo $N
10112        "#).await.unwrap();
10113        assert!(result.ok());
10114        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
10115    }
10116
10117    // -- accumulate_result / newline tests --
10118
10119    #[test]
10120    fn test_accumulate_preserves_own_newlines() {
10121        // Outputs concatenate verbatim — a command's own trailing newline is
10122        // kept, none is invented.
10123        let mut acc = ExecResult::success("line1\n");
10124        let new = ExecResult::success("line2\n");
10125        accumulate_result(&mut acc, &new);
10126        assert_eq!(&*acc.text_out(), "line1\nline2\n");
10127        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
10128    }
10129
10130    #[test]
10131    fn test_accumulate_inserts_no_separator() {
10132        // No artificial separator: `printf a; printf b` style concatenates to
10133        // `ab`, matching bash (regression for the 2026-06-09 finding).
10134        let mut acc = ExecResult::success("line1");
10135        let new = ExecResult::success("line2");
10136        accumulate_result(&mut acc, &new);
10137        assert_eq!(&*acc.text_out(), "line1line2");
10138    }
10139
10140    #[test]
10141    fn test_accumulate_empty_into_nonempty() {
10142        let mut acc = ExecResult::success("");
10143        let new = ExecResult::success("hello\n");
10144        accumulate_result(&mut acc, &new);
10145        assert_eq!(&*acc.text_out(), "hello\n");
10146    }
10147
10148    #[test]
10149    fn test_accumulate_nonempty_into_empty() {
10150        let mut acc = ExecResult::success("hello\n");
10151        let new = ExecResult::success("");
10152        accumulate_result(&mut acc, &new);
10153        assert_eq!(&*acc.text_out(), "hello\n");
10154    }
10155
10156    #[test]
10157    fn test_accumulate_stderr_no_double_newlines() {
10158        let mut acc = ExecResult::failure(1, "err1\n");
10159        let new = ExecResult::failure(1, "err2\n");
10160        accumulate_result(&mut acc, &new);
10161        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
10162    }
10163
10164    #[tokio::test]
10165    async fn test_multiple_echo_no_blank_lines() {
10166        let kernel = Kernel::transient().expect("kernel");
10167        let result = kernel
10168            .execute("echo one\necho two\necho three")
10169            .await
10170            .expect("execution failed");
10171        assert!(result.ok());
10172        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
10173    }
10174
10175    #[tokio::test]
10176    async fn test_for_loop_no_blank_lines() {
10177        let kernel = Kernel::transient().expect("kernel");
10178        let result = kernel
10179            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
10180            .await
10181            .expect("execution failed");
10182        assert!(result.ok());
10183        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
10184    }
10185
10186    #[tokio::test]
10187    async fn test_for_command_subst_no_blank_lines() {
10188        let kernel = Kernel::transient().expect("kernel");
10189        let result = kernel
10190            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
10191            .await
10192            .expect("execution failed");
10193        assert!(result.ok());
10194        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
10195    }
10196
10197    // ------------------------------------------------------------------
10198    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
10199    // ------------------------------------------------------------------
10200
10201    /// Helper: a throwaway schema with one `--pair` param declared as
10202    /// consuming two positionals per occurrence. Modelled after what
10203    /// jq_native will declare for `--arg` / `--argjson`.
10204    fn multi_consume_schema() -> crate::tools::ToolSchema {
10205        use crate::tools::{ParamSchema, ToolSchema};
10206        ToolSchema::new("test", "multi-consume smoke")
10207            .param(
10208                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
10209                    .consumes(2),
10210            )
10211    }
10212
10213    fn pos(s: &str) -> Arg {
10214        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
10215    }
10216
10217    #[tokio::test]
10218    async fn build_args_multi_consume_single_occurrence() {
10219        let kernel = Kernel::transient().expect("kernel");
10220        let schema = multi_consume_schema();
10221        // Simulates:  test --pair NAME VALUE filter
10222        let args = vec![
10223            Arg::LongFlag("pair".into()),
10224            pos("NAME"),
10225            pos("VALUE"),
10226            pos("filter"),
10227        ];
10228        let built = kernel
10229            .build_args_async(&args, Some(&schema))
10230            .await
10231            .expect("build_args should succeed");
10232
10233        // `--pair` + its two positionals are consumed into named["pair"],
10234        // which becomes an outer array of one inner 2-element array.
10235        let pair = built.named.get("pair").expect("named[pair] missing");
10236        match pair {
10237            Value::Json(serde_json::Value::Array(occurrences)) => {
10238                assert_eq!(occurrences.len(), 1, "expected one occurrence");
10239                match &occurrences[0] {
10240                    serde_json::Value::Array(values) => {
10241                        assert_eq!(values.len(), 2, "pair must have 2 values");
10242                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
10243                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
10244                    }
10245                    other => panic!("expected inner array, got {other:?}"),
10246                }
10247            }
10248            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
10249        }
10250
10251        // The un-consumed positional ("filter") remains in `positional`.
10252        assert_eq!(built.positional.len(), 1);
10253        assert_eq!(built.positional[0], Value::String("filter".into()));
10254    }
10255    #[tokio::test]
10256    async fn build_args_multi_consume_two_occurrences_accumulate() {
10257        let kernel = Kernel::transient().expect("kernel");
10258        let schema = multi_consume_schema();
10259        // Simulates:  test --pair A 1 --pair B 2 filter
10260        let args = vec![
10261            Arg::LongFlag("pair".into()),
10262            pos("A"),
10263            pos("1"),
10264            Arg::LongFlag("pair".into()),
10265            pos("B"),
10266            pos("2"),
10267            pos("filter"),
10268        ];
10269        let built = kernel
10270            .build_args_async(&args, Some(&schema))
10271            .await
10272            .expect("build_args should succeed");
10273
10274        let pair = built.named.get("pair").expect("named[pair] missing");
10275        match pair {
10276            Value::Json(serde_json::Value::Array(occurrences)) => {
10277                assert_eq!(occurrences.len(), 2, "expected two occurrences");
10278                // Preserved in invocation order.
10279                match &occurrences[0] {
10280                    serde_json::Value::Array(values) => {
10281                        assert_eq!(values[0], serde_json::Value::String("A".into()));
10282                        assert_eq!(values[1], serde_json::Value::String("1".into()));
10283                    }
10284                    other => panic!("expected inner array, got {other:?}"),
10285                }
10286                match &occurrences[1] {
10287                    serde_json::Value::Array(values) => {
10288                        assert_eq!(values[0], serde_json::Value::String("B".into()));
10289                        assert_eq!(values[1], serde_json::Value::String("2".into()));
10290                    }
10291                    other => panic!("expected inner array, got {other:?}"),
10292                }
10293            }
10294            other => panic!("expected Json(Array(...)), got {other:?}"),
10295        }
10296    }
10297
10298    // ── undeclared space-form flag under map_positionals (kj --type val) ──
10299    //
10300    // A backend/MCP tool whose schema does NOT declare a flag must not let
10301    // `--flag value` (space form) silently divorce the value: that was a
10302    // privilege-escalation-by-typo against kaijutsu.
10303    // kaish fails loud rather than guessing.
10304
10305    use crate::tools::{ParamSchema, ToolSchema};
10306
10307    /// Backend-style schema (map_positionals) declaring only a `name`
10308    /// positional — `--type` is intentionally undeclared.
10309    fn kj_like_schema() -> ToolSchema {
10310        ToolSchema::new("kj", "incomplete backend schema")
10311            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10312            .with_positional_mapping()
10313    }
10314
10315    #[tokio::test]
10316    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
10317        let kernel = Kernel::transient().expect("kernel");
10318        let schema = kj_like_schema();
10319        // kj context create exp --type explorer
10320        let args = vec![
10321            pos("context"),
10322            pos("create"),
10323            pos("exp"),
10324            Arg::LongFlag("type".into()),
10325            pos("explorer"),
10326        ];
10327        let err = kernel
10328            .build_args_async(&args, Some(&schema))
10329            .await
10330            .expect_err("undeclared --type with a space value must fail loud");
10331        let msg = err.to_string();
10332        assert!(msg.contains("--type"), "message should name the flag: {msg}");
10333        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
10334        assert!(msg.contains("kj"), "message should name the tool: {msg}");
10335    }
10336
10337    #[tokio::test]
10338    async fn build_args_declared_space_flag_still_binds() {
10339        let kernel = Kernel::transient().expect("kernel");
10340        // Same tool, but now the schema DECLARES --type as a string param.
10341        let schema = ToolSchema::new("kj", "complete schema")
10342            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10343            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
10344            .with_positional_mapping();
10345        let args = vec![
10346            pos("exp"),
10347            Arg::LongFlag("type".into()),
10348            pos("explorer"),
10349        ];
10350        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10351        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10352    }
10353
10354    #[tokio::test]
10355    async fn build_args_equals_form_binds_for_undeclared_flag() {
10356        let kernel = Kernel::transient().expect("kernel");
10357        let schema = kj_like_schema();
10358        // The unambiguous `=` form must keep working even when undeclared.
10359        let args = vec![
10360            pos("exp"),
10361            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
10362        ];
10363        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10364        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10365    }
10366
10367    #[tokio::test]
10368    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
10369        let kernel = Kernel::transient().expect("kernel");
10370        let schema = kj_like_schema();
10371        // No positional follows --force → unambiguously a bare flag.
10372        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
10373        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10374        assert!(built.flags.contains("force"));
10375    }
10376
10377    #[tokio::test]
10378    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
10379        let kernel = Kernel::transient().expect("kernel");
10380        let schema = kj_like_schema();
10381        // --verbose is followed by a flag, not a positional → not ambiguous.
10382        let args = vec![
10383            Arg::LongFlag("verbose".into()),
10384            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
10385        ];
10386        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10387        assert!(built.flags.contains("verbose"));
10388    }
10389
10390    #[tokio::test]
10391    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
10392        let kernel = Kernel::transient().expect("kernel");
10393        // Builtins set map_positionals=false; the ambiguity guard must not
10394        // fire there (clap validates their flags separately).
10395        let schema = ToolSchema::new("frobnicate", "builtin-style")
10396            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10397        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
10398        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10399        assert!(built.flags.contains("frob"));
10400    }
10401
10402    // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
10403    //
10404    // The long-flag guard above was closed by GH #188; an undeclared SHORT
10405    // flag under a map_positionals schema was still silently defaulting to
10406    // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
10407    // same way the long-flag case used to.
10408
10409    #[tokio::test]
10410    async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
10411        let kernel = Kernel::transient().expect("kernel");
10412        let schema = kj_like_schema();
10413        // kj exp -t explorer
10414        let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
10415        let err = kernel
10416            .build_args_async(&args, Some(&schema))
10417            .await
10418            .expect_err("undeclared -t with a space value must fail loud");
10419        let msg = err.to_string();
10420        assert!(msg.contains("-t"), "message should name the flag: {msg}");
10421        assert!(msg.contains("kj"), "message should name the tool: {msg}");
10422    }
10423
10424    #[tokio::test]
10425    async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
10426        let kernel = Kernel::transient().expect("kernel");
10427        // Builtins set map_positionals=false; the ambiguity guard must not
10428        // fire there.
10429        let schema = ToolSchema::new("frobnicate", "builtin-style")
10430            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10431        let args = vec![Arg::ShortFlag("t".into()), pos("value")];
10432        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10433        assert!(built.flags.contains("t"));
10434    }
10435
10436    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
10437    //
10438    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
10439    // params, not the root's. The subcommand-path positionals stay positional
10440    // (kj re-parses them with its own clap), and a value flag declared only on
10441    // a deep leaf still binds in space form.
10442
10443    /// kj → context (alias ctx) → create{--type value, --force bool}.
10444    /// map_positionals defaults false on every node (builtin/kj style).
10445    fn kj_tree_schema() -> ToolSchema {
10446        ToolSchema::new("kj", "subcommand tool").subcommand(
10447            ToolSchema::new("context", "context ops")
10448                .with_command_aliases(["ctx"])
10449                .subcommand(
10450                    ToolSchema::new("create", "create context")
10451                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
10452                        .param(ParamSchema::new("force", "bool")),
10453                ),
10454        )
10455    }
10456
10457    #[tokio::test]
10458    async fn build_args_binds_deep_leaf_value_flag_space_form() {
10459        let kernel = Kernel::transient().expect("kernel");
10460        let schema = kj_tree_schema();
10461        // kj context create --type explorer
10462        let args = vec![
10463            pos("context"),
10464            pos("create"),
10465            Arg::LongFlag("type".into()),
10466            pos("explorer"),
10467        ];
10468        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10469        // --type (declared only on the create leaf) binds in space form.
10470        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10471        // The subcommand path survives as positionals for kj to re-parse.
10472        let positionals: Vec<&str> = built
10473            .positional
10474            .iter()
10475            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10476            .collect();
10477        assert_eq!(positionals, vec!["context", "create"]);
10478    }
10479
10480    #[tokio::test]
10481    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
10482        let kernel = Kernel::transient().expect("kernel");
10483        let schema = kj_tree_schema();
10484        // kj context create --force somearg  → --force is a leaf bool flag,
10485        // it must NOT consume `somearg`.
10486        let args = vec![
10487            pos("context"),
10488            pos("create"),
10489            Arg::LongFlag("force".into()),
10490            pos("somearg"),
10491        ];
10492        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10493        assert!(built.flags.contains("force"), "force should be a bare flag");
10494        let positionals: Vec<&str> = built
10495            .positional
10496            .iter()
10497            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10498            .collect();
10499        assert_eq!(positionals, vec!["context", "create", "somearg"]);
10500    }
10501
10502    #[tokio::test]
10503    async fn build_args_alias_routed_leaf_binds_value_flag() {
10504        let kernel = Kernel::transient().expect("kernel");
10505        let schema = kj_tree_schema();
10506        // kj ctx create -t explorer  → command alias + short flag alias.
10507        let args = vec![
10508            pos("ctx"),
10509            pos("create"),
10510            Arg::ShortFlag("t".into()),
10511            pos("explorer"),
10512        ];
10513        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10514        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10515    }
10516
10517    #[tokio::test]
10518    async fn build_args_computed_subcommand_selector_fails_loud() {
10519        let kernel = Kernel::transient().expect("kernel");
10520        let schema = kj_tree_schema();
10521        // kj $(echo context) — routing can't see the value; fail loud.
10522        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
10523            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
10524        )]))];
10525        let err = kernel
10526            .build_args_async(&args, Some(&schema))
10527            .await
10528            .expect_err("computed subcommand selector must error");
10529        assert!(
10530            err.to_string().contains("subcommand name is required"),
10531            "got: {err}"
10532        );
10533    }
10534
10535    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
10536
10537    #[test]
10538    fn finalize_output_renders_when_kernel_owns_it() {
10539        use crate::interpreter::{OutputData, OutputFormat};
10540        let r = ExecResult::with_output(OutputData::text("RAW"));
10541        let out = finalize_output(r, Some(OutputFormat::Json), false);
10542        // Kernel renders the typed OutputData → JSON; text is no longer bare.
10543        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
10544    }
10545
10546    #[test]
10547    fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
10548        use crate::interpreter::{OutputData, OutputFormat};
10549        let r = ExecResult::with_output(OutputData::text("RAW"));
10550        let out = finalize_output(r, Some(OutputFormat::Json), true);
10551        // owns_output + success: the tool already rendered; kernel leaves bytes
10552        // untouched.
10553        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
10554    }
10555
10556    #[test]
10557    fn finalize_output_renders_owns_output_failure() {
10558        // scatter/gather (the only owns_output tools) never render their own
10559        // JSONL/array on a FAILURE path — their error returns are plain-text
10560        // `ExecResult::failure(code, msg)`, identical in shape to any other
10561        // builtin's. owns_output means "the tool already rendered its own
10562        // SUCCESS output", not "never touch this tool's bytes" — a failure
10563        // must still get the uniform --json error envelope like every other
10564        // builtin (kaibo review finding on merged PR #215; confirmed
10565        // pre-existing for scatter/gather's whole error-path class, including
10566        // the clap-parse-failure path).
10567        use crate::interpreter::OutputFormat;
10568        let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
10569        let out = finalize_output(r, Some(OutputFormat::Json), true);
10570        let parsed: serde_json::Value =
10571            serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
10572        assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
10573        assert_eq!(parsed["code"], 2);
10574    }
10575
10576    #[test]
10577    fn finalize_output_no_format_is_noop() {
10578        use crate::interpreter::OutputData;
10579        let r = ExecResult::with_output(OutputData::text("RAW"));
10580        let out = finalize_output(r, None, false);
10581        assert_eq!(out.text_out(), "RAW");
10582    }
10583
10584    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
10585
10586    #[tokio::test]
10587    async fn test_initial_vars_set_and_exported() {
10588        let config = KernelConfig::transient()
10589            .with_var("INIT_FOO", Value::String("bar".into()));
10590        let kernel = Kernel::new(config).expect("failed to create kernel");
10591
10592        assert_eq!(
10593            kernel.get_var("INIT_FOO").await,
10594            Some(Value::String("bar".into()))
10595        );
10596        assert!(
10597            kernel.scope.read().await.is_exported("INIT_FOO"),
10598            "initial_vars entries must be marked exported"
10599        );
10600    }
10601
10602    #[tokio::test]
10603    async fn test_execute_with_vars_overlay_visible() {
10604        let kernel = Kernel::transient().expect("failed to create kernel");
10605        let mut overlay = HashMap::new();
10606        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
10607
10608        let result = kernel
10609            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
10610            .await
10611            .expect("execute failed");
10612
10613        assert!(result.ok());
10614        assert_eq!(result.text_out().trim(), "yes");
10615    }
10616
10617    #[tokio::test]
10618    async fn test_execute_with_vars_overlay_cleanup() {
10619        let kernel = Kernel::transient().expect("failed to create kernel");
10620        let mut overlay = HashMap::new();
10621        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
10622
10623        kernel
10624            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
10625            .await
10626            .expect("execute failed");
10627
10628        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
10629        assert!(
10630            !kernel.scope.read().await.is_exported("EPHEMERAL"),
10631            "overlay-only export must be cleared on return"
10632        );
10633    }
10634
10635    #[tokio::test]
10636    async fn test_execute_with_vars_does_not_clobber_existing_export() {
10637        let kernel = Kernel::transient().expect("failed to create kernel");
10638        kernel
10639            .execute("export OUTER=outer")
10640            .await
10641            .expect("export failed");
10642
10643        let mut overlay = HashMap::new();
10644        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
10645        let result = kernel
10646            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
10647            .await
10648            .expect("execute failed");
10649        assert_eq!(result.text_out().trim(), "inner");
10650
10651        assert_eq!(
10652            kernel.get_var("OUTER").await,
10653            Some(Value::String("outer".into())),
10654            "outer value must reappear after pop"
10655        );
10656        assert!(
10657            kernel.scope.read().await.is_exported("OUTER"),
10658            "outer export must survive overlay"
10659        );
10660    }
10661
10662    #[tokio::test]
10663    async fn test_execute_with_vars_inner_assignment_is_local() {
10664        let kernel = Kernel::transient().expect("failed to create kernel");
10665        let mut overlay = HashMap::new();
10666        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
10667
10668        // Variable assignment inside a single statement uses set() (innermost
10669        // frame), not set_global() — this matches bash function-local semantics.
10670        // We explicitly use `local FOO=...` style by relying on the pushed
10671        // frame; the assignment in the script body modifies the same frame.
10672        let result = kernel
10673            .execute_with_options(
10674                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
10675                ExecuteOptions::new().with_vars(overlay),
10676            )
10677            .await
10678            .expect("execute failed");
10679        assert!(result.ok());
10680
10681        // After the call the frame is popped, so LOCAL_FOO is gone regardless
10682        // of how the script reassigned it.
10683        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
10684    }
10685
10686    #[tokio::test]
10687    async fn test_external_command_sees_exported_var() {
10688        let kernel = Kernel::transient().expect("failed to create kernel");
10689        // PATH must be in scope to resolve the external `printenv` — the kernel
10690        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
10691        // what a frontend does through initial_vars.
10692        let path = std::env::var("PATH").unwrap_or_default();
10693        let result = kernel
10694            .execute(&format!(
10695                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
10696            ))
10697            .await
10698            .expect("execute failed");
10699
10700        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
10701        assert_eq!(result.text_out().trim(), "bar");
10702    }
10703
10704    #[tokio::test]
10705    async fn test_external_command_does_not_see_unexported_var() {
10706        let kernel = Kernel::transient().expect("failed to create kernel");
10707
10708        // Set without exporting; printenv must not see it (exit code != 0,
10709        // empty stdout per printenv semantics).
10710        let result = kernel
10711            .execute("EXT_BAR=hidden; printenv EXT_BAR")
10712            .await
10713            .expect("execute failed");
10714
10715        assert!(!result.ok(), "printenv should fail when var is unexported");
10716        assert!(
10717            result.text_out().trim().is_empty(),
10718            "no stdout when var is missing, got: {}",
10719            result.text_out()
10720        );
10721    }
10722
10723    #[tokio::test]
10724    async fn test_external_command_does_not_see_os_env() {
10725        // The kernel is hermetic: it never reads std::env::vars() and only
10726        // exports what it has been told to export. Cargo always sets PATH for
10727        // tests, so PATH is reliably present in the OS env — but a transient
10728        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
10729        // inside the kernel must fail.
10730        assert!(
10731            std::env::var_os("PATH").is_some(),
10732            "test precondition: cargo should set PATH"
10733        );
10734
10735        let kernel = Kernel::transient().expect("failed to create kernel");
10736        let result = kernel
10737            .execute("printenv PATH")
10738            .await
10739            .expect("execute failed");
10740
10741        assert!(
10742            !result.ok(),
10743            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
10744            result.text_out()
10745        );
10746        assert!(
10747            result.text_out().trim().is_empty(),
10748            "no PATH in subprocess env, got stdout={:?}",
10749            result.text_out()
10750        );
10751    }
10752
10753    #[tokio::test]
10754    async fn test_execute_with_vars_overlay_reaches_subprocess() {
10755        let kernel = Kernel::transient().expect("failed to create kernel");
10756        let mut overlay = HashMap::new();
10757        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
10758        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
10759        overlay.insert(
10760            "PATH".to_string(),
10761            Value::String(std::env::var("PATH").unwrap_or_default()),
10762        );
10763
10764        let result = kernel
10765            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
10766            .await
10767            .expect("execute failed");
10768
10769        assert!(
10770            result.ok(),
10771            "printenv should succeed: code={} stdout={:?} stderr={:?}",
10772            result.code,
10773            result.text_out(),
10774            result.err
10775        );
10776        assert_eq!(result.text_out().trim(), "subproc");
10777    }
10778
10779    #[tokio::test]
10780    async fn test_classify_command_builtin() {
10781        let kernel = Kernel::transient().expect("failed to create kernel");
10782        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
10783        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
10784    }
10785
10786    #[tokio::test]
10787    async fn test_classify_command_special_forms() {
10788        let kernel = Kernel::transient().expect("failed to create kernel");
10789        for name in ["true", "false", "source", "."] {
10790            assert_eq!(
10791                kernel.classify_command(name).await,
10792                CommandKind::Special,
10793                "{name} should be a special-form",
10794            );
10795        }
10796    }
10797
10798    #[tokio::test]
10799    async fn test_classify_command_dynamic() {
10800        let kernel = Kernel::transient().expect("failed to create kernel");
10801        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
10802        assert_eq!(
10803            kernel.classify_command("$(pick)").await,
10804            CommandKind::Dynamic
10805        );
10806    }
10807
10808    #[tokio::test]
10809    async fn test_classify_command_external() {
10810        let kernel = Kernel::transient().expect("failed to create kernel");
10811        // Not a builtin, user function, or special-form → escapes to PATH.
10812        assert_eq!(
10813            kernel.classify_command("definitely_not_a_kaish_builtin").await,
10814            CommandKind::External
10815        );
10816        // `readonly` is *not* a kaish special-form despite the validator's
10817        // warning heuristic — at runtime it resolves to an external command, so
10818        // a consent gate must see it as External (regression guard against the
10819        // validator/runtime divergence).
10820        assert_eq!(
10821            kernel.classify_command("readonly").await,
10822            CommandKind::External
10823        );
10824        assert!(kernel.classify_command("readonly").await.escapes_kernel());
10825    }
10826
10827    #[tokio::test]
10828    async fn test_classify_command_user_tool_shadows_builtin() {
10829        let kernel = Kernel::transient().expect("failed to create kernel");
10830        kernel
10831            .execute(r#"greet() { echo "hi" }"#)
10832            .await
10833            .expect("function definition failed");
10834        assert_eq!(
10835            kernel.classify_command("greet").await,
10836            CommandKind::UserTool
10837        );
10838
10839        // A user function named after a builtin classifies as UserTool, matching
10840        // the interpreter's user-tools-first resolution.
10841        kernel
10842            .execute(r#"cat() { echo "shadowed" }"#)
10843            .await
10844            .expect("function definition failed");
10845        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10846    }
10847
10848    #[tokio::test]
10849    async fn test_classify_command_alias_to_external_is_external() {
10850        let kernel = Kernel::transient().expect("failed to create kernel");
10851        // An alias whose head is an external binary must NOT report as the
10852        // builtin it shadows — execution expands the alias, so a consent gate
10853        // would otherwise be told an external command is internal.
10854        kernel
10855            .execute("alias cat='/usr/bin/whatever'")
10856            .await
10857            .expect("alias failed");
10858        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
10859        assert!(kernel.classify_command("cat").await.escapes_kernel());
10860    }
10861
10862    #[tokio::test]
10863    async fn test_classify_command_alias_to_builtin() {
10864        let kernel = Kernel::transient().expect("failed to create kernel");
10865        kernel.execute("alias g=grep").await.expect("alias failed");
10866        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
10867    }
10868
10869    #[tokio::test]
10870    async fn test_classify_command_alias_to_special_form() {
10871        let kernel = Kernel::transient().expect("failed to create kernel");
10872        kernel.execute("alias t=true").await.expect("alias failed");
10873        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
10874    }
10875
10876    #[tokio::test]
10877    async fn test_classify_command_braced_var_is_dynamic() {
10878        let kernel = Kernel::transient().expect("failed to create kernel");
10879        // The string API can be handed a `${VAR}` head; it must not be mistaken
10880        // for an external named literally "${VAR}".
10881        assert_eq!(
10882            kernel.classify_command("${CMD}").await,
10883            CommandKind::Dynamic
10884        );
10885    }
10886
10887    /// Drift guard: `classify_command` must agree with what the executor
10888    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
10889    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
10890    /// precedence, alias expansion); without this test those copies could diverge
10891    /// silently — the exact failure class `classify_command` exists to prevent,
10892    /// just moved inside the kernel. Each case asserts the classification AND
10893    /// observes the real resolution, so a future change to one side without the
10894    /// other fails here.
10895    #[tokio::test]
10896    async fn classify_command_matches_executor() {
10897        let kernel = Kernel::transient().expect("failed to create kernel");
10898
10899        // (1) Special-forms. `SpecialForm::from_name` is the single source of
10900        // truth: classify reports Special via it, and the executor matches the
10901        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
10902        // form won't build until both sides handle it). This test pins the other
10903        // half — that each form classifies Special AND actually short-circuits at
10904        // runtime rather than escaping to `PATH`. Every form is executed (not just
10905        // `true`/`false`): an external miss in this PATH-less kernel would be exit
10906        // 127, so a non-127 result that matches the form's own behavior proves the
10907        // short-circuit fired.
10908        for name in ["true", "false", "source", "."] {
10909            assert_eq!(
10910                kernel.classify_command(name).await,
10911                CommandKind::Special,
10912                "{name} should classify Special",
10913            );
10914        }
10915        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
10916        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
10917        // `source`/`.` short-circuit to execute_source, which (no filename) fails
10918        // with its own message — exit 1, never the 127 of an unresolved external.
10919        for name in ["source", "."] {
10920            let r = kernel.execute(name).await.expect("run source form");
10921            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
10922            assert!(
10923                r.err.contains("source: missing filename"),
10924                "{name} did not route to execute_source: {:?}",
10925                r.err,
10926            );
10927        }
10928
10929        // (2) Builtin: classify Builtin AND the executor runs the builtin.
10930        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
10931        let r = kernel.execute("echo hi").await.expect("run echo");
10932        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
10933
10934        // (3) User function shadows a builtin: classify UserTool AND the executor
10935        // runs the function body, not the `cat` builtin.
10936        kernel
10937            .execute(r#"cat() { echo SHADOWED }"#)
10938            .await
10939            .expect("define cat()");
10940        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10941        let r = kernel.execute("cat").await.expect("run shadowed cat");
10942        assert_eq!(
10943            r.text_out().trim(),
10944            "SHADOWED",
10945            "executor ran the builtin instead of the shadowing function",
10946        );
10947
10948        // (4) Alias whose head is external: classify External AND the executor
10949        // resolves through the alias to a missing external (not a builtin).
10950        kernel
10951            .execute("alias x='/nonexistent/binary'")
10952            .await
10953            .expect("define alias x");
10954        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
10955        let r = kernel.execute("x").await.expect("run alias x");
10956        assert!(
10957            !r.ok(),
10958            "alias to a missing external should fail, not resolve internally",
10959        );
10960    }
10961
10962    /// `kill_grace` is the number the SIGTERM-to-SIGKILL cascade waits on, and
10963    /// the spawn path reads it off the execution context. Seeding it only on
10964    /// the `Kernel` would silently give every child the 2s default instead of
10965    /// the embedder's setting.
10966    #[tokio::test]
10967    async fn kill_grace_reaches_the_execution_context_and_its_forks() {
10968        let grace = Duration::from_millis(250);
10969        let kernel = Kernel::new(KernelConfig::transient().with_kill_grace(grace))
10970            .expect("build kernel");
10971        assert_eq!(
10972            kernel.exec_ctx.read().await.kill_grace,
10973            grace,
10974            "the configured kill grace never reached the execution context",
10975        );
10976
10977        let fork = kernel.fork().await;
10978        assert_eq!(
10979            fork.exec_ctx.read().await.kill_grace,
10980            grace,
10981            "a fork's children would be killed on a different clock than the parent's",
10982        );
10983    }
10984
10985    /// A child spawned under a background job records its process group on
10986    /// that job (for `kill -<sig> %N`) and tees its output into the job's
10987    /// streams. Both reads go through the execution context, so the job id has
10988    /// to survive `fork_for_background` and every sub-fork beneath it.
10989    #[tokio::test]
10990    async fn a_background_fork_stamps_its_job_on_the_execution_context() {
10991        let kernel = Kernel::transient().expect("build kernel").into_arc();
10992        assert_eq!(
10993            kernel.exec_ctx.read().await.background_job,
10994            None,
10995            "foreground execution must not claim a job",
10996        );
10997
10998        let (_tx, rx) = tokio::sync::oneshot::channel();
10999        let job_id = kernel.jobs.register("sleep 60".to_string(), rx).await;
11000        let cancel = tokio_util::sync::CancellationToken::new();
11001        let background = kernel.fork_for_background(cancel, job_id).await;
11002        assert_eq!(
11003            background.exec_ctx.read().await.background_job,
11004            Some(job_id),
11005            "a background fork's children would never be registered on the job",
11006        );
11007
11008        let stage = background.fork().await;
11009        assert_eq!(
11010            stage.exec_ctx.read().await.background_job,
11011            Some(job_id),
11012            "a pipeline stage under a background job lost the job id",
11013        );
11014    }
11015}