Skip to main content

kaish_kernel/
kernel.rs

1//! The Kernel (核) — the heart of kaish.
2//!
3//! The Kernel owns and coordinates all core components:
4//! - Interpreter state (scope, $?)
5//! - Tool registry (builtins, user tools)
6//! - VFS router (mount points)
7//! - Job manager (background jobs)
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌────────────────────────────────────────────────────────────┐
13//! │                         Kernel (核)                         │
14//! │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
15//! │  │   Scope      │  │ ToolRegistry │  │  VfsRouter       │  │
16//! │  │  (variables) │  │  (builtins,  │  │  (mount points)  │  │
17//! │  │              │  │   user tools)│  │                  │  │
18//! │  └──────────────┘  └──────────────┘  └──────────────────┘  │
19//! │  ┌──────────────────────────────┐  ┌──────────────────┐    │
20//! │  │  JobManager (background)     │  │  ExecResult ($?) │    │
21//! │  └──────────────────────────────┘  └──────────────────┘    │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
29use std::time::Duration;
30
31use anyhow::{Context, Result};
32use tokio::sync::RwLock;
33
34/// Monotonic counter assigned to each Kernel at construction time, exposed
35/// via `$$` / `${$}`. Starts at 1; each new Kernel gets the next value.
36/// `Kernel::fork()` inherits the parent's value (matching bash's "subshell
37/// keeps parent's $$" semantics) because forks clone the parent's Scope
38/// rather than calling `set_pid` again.
39///
40/// Deliberately *not* the OS PID — kaish runs as a long-lived MCP server
41/// or embedded inside other binaries (kaijutsu), where the host PID is
42/// meaningless to the script. See
43/// `~/.claude/projects/-home-atobey-src-kaish/memory/lang_dollar_dollar_identifier.md`
44/// for the design rationale.
45static KERNEL_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47/// Maximum depth of dynamic statement-engine re-entry — command substitution
48/// (`$(…)`), shell-function calls, and `.kai` script sourcing — before a
49/// **loud** error is returned instead of letting the native call stack
50/// overflow (a `SIGSEGV`/abort with no diagnostic). Mirrors the intent of the
51/// alias re-entry cap (10) and the lexer's `MAX_PAREN_DEPTH` (256): a runaway
52/// or mutually recursive script hits a catchable ceiling, not a signal.
53///
54/// Each level stacks the dispatch chain between re-entries. After the GH #48
55/// allocation pass this measures ~50 KB (release) / ~57 KB (debug at the default
56/// `opt-level = 1` dev profile — see the root `Cargo.toml`) / ~193 KB (a fully
57/// unoptimized debug build) of native stack per level, down from ~80 / ~380 KB
58/// before; the `recursion_stack_cost_tests` probe reports the live figure.
59///
60/// This cap and [`RECOMMENDED_STACK_SIZE`] are a **matched pair**: the cap must
61/// trip *before* `cap × (worst-case per-level stack)` can exceed the floor, so a
62/// runaway is caught, not a `SIGSEGV`. The worst case is the ~193 KB unoptimized
63/// figure — the `opt-level = 1` dev profile above is local to this workspace and
64/// does **not** propagate to embedders, whose own debug builds of the kernel pay
65/// the full unoptimized cost. `48 × 193 KB ≈ 9.3 MB` under the 12 MiB floor
66/// keeps the same ~1.3× margin the pre-#48 pair had (`32 × 380 KB ≈ 12 MB` under
67/// 16 MiB); #48's smaller frames are what let the cap rise 32→48 and the floor
68/// drop 16→12 MiB together. **The guard only fires *before* the stack overflows
69/// on a thread that meets that floor** — this is why the REPL sizes its threads
70/// to it and embedders must too (see `docs/EMBEDDING.md`). Forks (background
71/// jobs, scatter workers, pipeline stages) run on fresh stacks and get a fresh
72/// counter, bounding each chain independently. GH #46 / #47 / #48.
73pub const MAX_RECURSION_DEPTH: usize = 48;
74
75/// Recommended native stack size (12 MiB) for any thread that drives kaish
76/// execution — the REPL sizes its `block_on` thread and tokio worker threads
77/// to this, and embedders that call `Kernel::execute` (directly or via a tokio
78/// runtime) should do the same (`runtime::Builder::thread_stack_size`, and a
79/// `std::thread` stack for a non-worker driver).
80///
81/// The kernel recurses on the native stack (command substitution, shell
82/// functions, `.kai` scripts). [`MAX_RECURSION_DEPTH`] converts a runaway into
83/// a loud error, but only *if the stack is at least this large* — on the
84/// default ~2 MB tokio worker stack the recursion overflows (SIGSEGV) before
85/// reaching the cap. This floor is the companion to that cap (see its docs for
86/// the `cap × per-level < floor` relationship): 12 MiB holds the depth-48 cap
87/// with margin even for an unoptimized embedder build (~193 KB/level), and #48
88/// shrank the per-level cost enough to drop it from 16 MiB. kaish can't set this
89/// itself (it doesn't own the runtime), so it exposes the floor for owners to
90/// apply. See GH #47 / #48.
91pub const RECOMMENDED_STACK_SIZE: usize = 12 * 1024 * 1024;
92
93use async_trait::async_trait;
94
95use crate::ast::{
96    spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
97    StringPart, TestExpr, ToolDef, Value,
98};
99pub use kaish_types::{CommandKind, ExecuteOptions};
100use crate::backend::{BackendError, KernelBackend};
101use kaish_glob::glob_match;
102use crate::dispatch::{CommandDispatcher, PipelinePosition};
103use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value_no_envelope, value_to_bool, value_to_string, value_to_text_sink, ControlFlow, ExecResult, LatchRequest, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, BoundedStream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream, DEFAULT_STREAM_MAX_SIZE};
108use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
109#[cfg(feature = "subprocess")]
110use crate::tools::resolve_in_path;
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127    /// LocalFs at "/" — native paths work directly.
128    ///
129    /// Full filesystem access. Use for human-operated REPL sessions where
130    /// native paths like `/home/user/project` should just work.
131    ///
132    /// Mounts:
133    /// - `/` → LocalFs("/")
134    /// - `/v` → MemoryFs (blob storage)
135    #[cfg(feature = "localfs")]
136    Passthrough,
137
138    /// Transparent sandbox — paths look native but access is restricted.
139    ///
140    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141    /// so `/home/user/src/project` just works. But paths outside the sandbox
142    /// root are not accessible.
143    ///
144    /// **Note:** This only restricts VFS (builtin) operations. External commands
145    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146    ///
147    /// Mounts:
148    /// - `/` → MemoryFs (catches paths outside sandbox)
149    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
150    /// - `/tmp` → LocalFs("/tmp")
151    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152    /// - `/v` → MemoryFs (blob storage)
153    #[cfg(feature = "localfs")]
154    Sandboxed {
155        /// Root path for local filesystem. Defaults to `$HOME`.
156        /// Can be restricted further, e.g., `~/src`.
157        root: Option<PathBuf>,
158    },
159
160    /// No local filesystem. Memory only.
161    ///
162    /// Complete isolation — no access to the host filesystem.
163    /// Useful for tests or pure sandboxed execution.
164    ///
165    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166    /// for this mode at kernel construction: with no host filesystem mounted,
167    /// large output must not write a host spill file (`paths::spill_dir()`
168    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169    ///
170    /// Mounts:
171    /// - `/` → MemoryFs
172    /// - `/tmp` → MemoryFs
173    /// - `/v` → MemoryFs
174    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175    NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180    fn default() -> Self {
181        #[cfg(feature = "localfs")]
182        { VfsMountMode::Sandboxed { root: None } }
183        #[cfg(not(feature = "localfs"))]
184        { VfsMountMode::NoLocal }
185    }
186}
187
188/// Configuration for kernel initialization.
189#[derive(Debug, Clone)]
190pub struct KernelConfig {
191    /// Name of this kernel (for identification).
192    pub name: String,
193
194    /// VFS mount mode — controls how local filesystem is exposed.
195    pub vfs_mode: VfsMountMode,
196
197    /// Initial working directory (VFS path).
198    pub cwd: PathBuf,
199
200    /// Whether to skip pre-execution validation.
201    ///
202    /// When false (default), scripts are validated before execution to catch
203    /// errors early. Set to true to skip validation for performance or to
204    /// allow dynamic/external commands.
205    pub skip_validation: bool,
206
207    /// When true, standalone external commands inherit stdio for real-time output.
208    ///
209    /// Set by script runner and REPL for human-visible output.
210    /// Not set by MCP server (output must be captured for structured responses).
211    pub interactive: bool,
212
213    /// Ignore file configuration for file-walking tools.
214    pub ignore_config: crate::ignore_config::IgnoreConfig,
215
216    /// Output size limit configuration for agent safety.
217    pub output_limit: crate::output_limit::OutputLimitConfig,
218
219    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
220    ///
221    /// When `true` (default), commands not found as builtins are resolved via PATH
222    /// and executed as child processes. When `false`, only kaish builtins and
223    /// backend-registered tools are available.
224    ///
225    /// **Security:** External commands bypass the VFS sandbox entirely — they see
226    /// the real filesystem, network, and environment. Set to `false` when running
227    /// untrusted input.
228    pub allow_external_commands: bool,
229
230    /// Enable confirmation latch for dangerous operations (set -o latch).
231    ///
232    /// When enabled, destructive operations like `rm` require nonce confirmation.
233    /// Can also be enabled at runtime with `set -o latch` or via `KAISH_LATCH=1`.
234    pub latch_enabled: bool,
235
236    /// Enable trash-on-delete for rm (set -o trash).
237    ///
238    /// When enabled, small files are moved to freedesktop.org Trash instead of
239    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
240    /// or via `KAISH_TRASH=1`.
241    pub trash_enabled: bool,
242
243    /// Shared nonce store for cross-request confirmation latch.
244    ///
245    /// When `Some`, the kernel uses this store instead of creating a fresh one.
246    /// This allows nonces issued in one MCP `execute()` call to be validated
247    /// in a subsequent call. When `None` (default), a fresh store is created.
248    pub nonce_store: Option<crate::nonce::NonceStore>,
249
250    /// Variables to populate the root scope with at construction, all marked
251    /// for export to child processes.
252    ///
253    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
254    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
255    /// from `std::env::vars()`. Embedders that want isolation pass nothing
256    /// (or only the keys they curate).
257    pub initial_vars: HashMap<String, Value>,
258
259    /// Default per-request timeout. When `Some`, every `execute_with_options`
260    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
261    /// When elapsed, the kernel cancels the request, kills any external
262    /// children with the configured grace, and returns exit code 124.
263    ///
264    /// `None` means no default timeout — only explicit per-call timeouts apply.
265    pub request_timeout: Option<Duration>,
266
267    /// Grace period between SIGTERM and SIGKILL when killing an external
268    /// child on cancellation or timeout.
269    ///
270    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
271    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
272    pub kill_grace: Duration,
273
274    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
275    ///
276    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
277    /// construction and handed to every `MemoryFs` the kernel builds in
278    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
279    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
280    /// `StorageFull` — an in-band error a model reads and adapts to; fail
281    /// loud over quietly eating RAM.
282    ///
283    /// **Why the agent preset is bounded by default:** an agent embedder
284    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
285    /// is per-call, not per-session. Embedders that know their workload needs
286    /// more opt out with `without_vfs_budget()` or raise the cap with
287    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
288    /// All other profiles default to `None` (unbounded).
289    ///
290    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
291    pub vfs_budget_bytes: Option<u64>,
292
293    /// Enable copy-on-write overlay mode (opt-in).
294    ///
295    /// When `true`, the primary local filesystem mount is wrapped in an
296    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
297    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
298    /// overlay transaction.
299    ///
300    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
301    /// **Sandboxed{root}:** the `{root}` mount becomes
302    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
303    /// mounts stay as real `LocalFs` (real writes escape the transaction —
304    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
305    /// **NoLocal:** incompatible — construction fails loudly (everything is
306    /// already virtual; an overlay adds no value and no lower layer to wrap).
307    /// **with_backend:** incompatible — the embedder controls the VFS; the
308    /// kernel cannot wrap it without bypassing the embedder's semantics.
309    ///
310    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
311    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
312    /// in the same call as the writes, or the transaction is discarded on drop.
313    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
314    pub overlay: bool,
315}
316
317/// Get the default sandbox root ($HOME).
318#[cfg(feature = "localfs")]
319fn default_sandbox_root() -> PathBuf {
320    std::env::var("HOME")
321        .map(PathBuf::from)
322        .unwrap_or_else(|_| PathBuf::from("/"))
323}
324
325impl Default for KernelConfig {
326    fn default() -> Self {
327        #[cfg(feature = "localfs")]
328        {
329            let home = default_sandbox_root();
330            Self {
331                name: "default".to_string(),
332                vfs_mode: VfsMountMode::Sandboxed { root: None },
333                cwd: home,
334                skip_validation: false,
335                interactive: false,
336                ignore_config: crate::ignore_config::IgnoreConfig::none(),
337                output_limit: crate::output_limit::OutputLimitConfig::none(),
338                allow_external_commands: cfg!(feature = "subprocess"),
339                latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
340                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
341                nonce_store: None,
342                initial_vars: HashMap::new(),
343                request_timeout: None,
344                kill_grace: Duration::from_secs(2),
345                vfs_budget_bytes: None,
346                overlay: false,
347            }
348        }
349        #[cfg(not(feature = "localfs"))]
350        {
351            Self {
352                name: "default".to_string(),
353                vfs_mode: VfsMountMode::NoLocal,
354                cwd: PathBuf::from("/"),
355                skip_validation: false,
356                interactive: false,
357                ignore_config: crate::ignore_config::IgnoreConfig::none(),
358                output_limit: crate::output_limit::OutputLimitConfig::none(),
359                allow_external_commands: false,
360                latch_enabled: false,
361                trash_enabled: false,
362                nonce_store: None,
363                initial_vars: HashMap::new(),
364                request_timeout: None,
365                kill_grace: Duration::from_secs(2),
366                vfs_budget_bytes: None,
367                overlay: false,
368            }
369        }
370    }
371}
372
373impl KernelConfig {
374    /// Create a transient kernel config (sandboxed, for temporary use).
375    #[cfg(feature = "localfs")]
376    pub fn transient() -> Self {
377        let home = default_sandbox_root();
378        Self {
379            name: "transient".to_string(),
380            vfs_mode: VfsMountMode::Sandboxed { root: None },
381            cwd: home,
382            skip_validation: false,
383            interactive: false,
384            ignore_config: crate::ignore_config::IgnoreConfig::none(),
385            output_limit: crate::output_limit::OutputLimitConfig::none(),
386            allow_external_commands: cfg!(feature = "subprocess"),
387            latch_enabled: false,
388            trash_enabled: false,
389            nonce_store: None,
390            initial_vars: HashMap::new(),
391            request_timeout: None,
392            kill_grace: Duration::from_secs(2),
393            vfs_budget_bytes: None,
394            overlay: false,
395        }
396    }
397
398    /// Create a transient kernel config (isolated, no-default-features).
399    #[cfg(not(feature = "localfs"))]
400    pub fn transient() -> Self {
401        Self::isolated()
402    }
403
404    /// Create a kernel config with the given name (sandboxed by default).
405    #[cfg(feature = "localfs")]
406    pub fn named(name: &str) -> Self {
407        let home = default_sandbox_root();
408        Self {
409            name: name.to_string(),
410            vfs_mode: VfsMountMode::Sandboxed { root: None },
411            cwd: home,
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: cfg!(feature = "subprocess"),
417            latch_enabled: false,
418            trash_enabled: false,
419            nonce_store: None,
420            initial_vars: HashMap::new(),
421            request_timeout: None,
422            kill_grace: Duration::from_secs(2),
423            vfs_budget_bytes: None,
424            overlay: false,
425        }
426    }
427
428    /// Create a kernel config with the given name (isolated, no-default-features).
429    #[cfg(not(feature = "localfs"))]
430    pub fn named(name: &str) -> Self {
431        Self {
432            name: name.to_string(),
433            ..Self::isolated()
434        }
435    }
436
437    /// Create a REPL config with passthrough filesystem access.
438    ///
439    /// Native paths like `/home/user/project` work directly.
440    /// The cwd is set to the actual current working directory.
441    #[cfg(feature = "localfs")]
442    pub fn repl() -> Self {
443        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
444        Self {
445            name: "repl".to_string(),
446            vfs_mode: VfsMountMode::Passthrough,
447            cwd,
448            skip_validation: false,
449            interactive: false,
450            // Ignore-aware by default (GH #134): .gitignore + default ignores
451            // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
452            ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
453            output_limit: crate::output_limit::OutputLimitConfig::none(),
454            allow_external_commands: cfg!(feature = "subprocess"),
455            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
456            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
457            nonce_store: None,
458            initial_vars: HashMap::new(),
459            request_timeout: None,
460            kill_grace: Duration::from_secs(2),
461            vfs_budget_bytes: None,
462            overlay: false,
463        }
464    }
465
466    /// Create a sandboxed-agent config with sandboxed filesystem access.
467    ///
468    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
469    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
470    /// memory and output. Local filesystem is accessible at its real path (e.g.,
471    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
472    /// accessible through builtins. External commands still access the real
473    /// filesystem — use `.with_allow_external_commands(false)` to block them.
474    ///
475    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
476    /// embedder typically creates a fresh kernel per call). Raise or remove with
477    /// `with_vfs_budget` / `without_vfs_budget`.
478    #[cfg(feature = "localfs")]
479    pub fn agent() -> Self {
480        let home = default_sandbox_root();
481        Self {
482            name: "agent".to_string(),
483            vfs_mode: VfsMountMode::Sandboxed { root: None },
484            cwd: home,
485            skip_validation: false,
486            interactive: false,
487            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
488            output_limit: crate::output_limit::OutputLimitConfig::agent(),
489            allow_external_commands: cfg!(feature = "subprocess"),
490            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
491            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
492            nonce_store: None,
493            initial_vars: HashMap::new(),
494            request_timeout: None,
495            kill_grace: Duration::from_secs(2),
496            vfs_budget_bytes: Some(64 * 1024 * 1024),
497            overlay: false,
498        }
499    }
500
501    /// Create a sandboxed-agent config with a custom sandbox root.
502    ///
503    /// Use this to restrict access to a subdirectory like `~/src`.
504    ///
505    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
506    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
507    #[cfg(feature = "localfs")]
508    pub fn agent_with_root(root: PathBuf) -> Self {
509        Self {
510            name: "agent".to_string(),
511            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
512            cwd: root,
513            skip_validation: false,
514            interactive: false,
515            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
516            output_limit: crate::output_limit::OutputLimitConfig::agent(),
517            allow_external_commands: cfg!(feature = "subprocess"),
518            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
519            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
520            nonce_store: None,
521            initial_vars: HashMap::new(),
522            request_timeout: None,
523            kill_grace: Duration::from_secs(2),
524            vfs_budget_bytes: Some(64 * 1024 * 1024),
525            overlay: false,
526        }
527    }
528
529    /// Create a config with no local filesystem (memory only).
530    ///
531    /// Complete isolation: no local filesystem and external commands are disabled.
532    /// Useful for tests or pure sandboxed execution.
533    pub fn isolated() -> Self {
534        Self {
535            name: "isolated".to_string(),
536            vfs_mode: VfsMountMode::NoLocal,
537            cwd: PathBuf::from("/"),
538            skip_validation: false,
539            interactive: false,
540            ignore_config: crate::ignore_config::IgnoreConfig::none(),
541            output_limit: crate::output_limit::OutputLimitConfig::none(),
542            allow_external_commands: false,
543            latch_enabled: false,
544            trash_enabled: false,
545            nonce_store: None,
546            initial_vars: HashMap::new(),
547            request_timeout: None,
548            kill_grace: Duration::from_secs(2),
549            vfs_budget_bytes: None,
550            overlay: false,
551        }
552    }
553
554    /// Set the VFS mount mode.
555    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
556        self.vfs_mode = mode;
557        self
558    }
559
560    /// Set the initial working directory.
561    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
562        self.cwd = cwd;
563        self
564    }
565
566    /// Skip pre-execution validation.
567    pub fn with_skip_validation(mut self, skip: bool) -> Self {
568        self.skip_validation = skip;
569        self
570    }
571
572    /// Enable interactive mode (external commands inherit stdio).
573    pub fn with_interactive(mut self, interactive: bool) -> Self {
574        self.interactive = interactive;
575        self
576    }
577
578    /// Set the ignore file configuration.
579    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
580        self.ignore_config = config;
581        self
582    }
583
584    /// Set the output limit configuration.
585    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
586        self.output_limit = config;
587        self
588    }
589
590    /// Set whether external command execution is allowed.
591    ///
592    /// When `false`, commands not found as builtins produce "command not found"
593    /// instead of searching PATH. The `exec` and `spawn` builtins also return
594    /// errors. Use this to prevent VFS sandbox bypass via external binaries.
595    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
596        self.allow_external_commands = allow;
597        self
598    }
599
600    /// Enable or disable confirmation latch at startup.
601    pub fn with_latch(mut self, enabled: bool) -> Self {
602        self.latch_enabled = enabled;
603        self
604    }
605
606    /// Enable or disable trash-on-delete at startup.
607    pub fn with_trash(mut self, enabled: bool) -> Self {
608        self.trash_enabled = enabled;
609        self
610    }
611
612    /// Use a shared nonce store for cross-request confirmation latch.
613    ///
614    /// Pass a `NonceStore` that outlives individual kernel instances so nonces
615    /// issued in one MCP `execute()` call can be validated in subsequent calls.
616    pub fn with_nonce_store(mut self, store: crate::nonce::NonceStore) -> Self {
617        self.nonce_store = Some(store);
618        self
619    }
620
621    /// Add a single initial variable; marked exported when the kernel boots.
622    ///
623    /// Repeated calls add (last write wins on key collision).
624    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
625        self.initial_vars.insert(name.into(), value);
626        self
627    }
628
629    /// Replace the entire initial-vars map. All entries are marked exported.
630    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
631        self.initial_vars = vars;
632        self
633    }
634
635    /// Extend the initial-vars map with the given entries (last write wins).
636    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
637        self.initial_vars.extend(vars);
638        self
639    }
640
641    /// Set the default per-request timeout (kernel-wide).
642    ///
643    /// Each `execute_with_options` call without an explicit timeout uses
644    /// this. On elapsed, the kernel cancels and returns exit code 124.
645    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
646        self.request_timeout = Some(timeout);
647        self
648    }
649
650    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
651    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
652        self.kill_grace = grace;
653        self
654    }
655
656    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
657    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
658    /// created at kernel construction and passed to every `MemoryFs` the
659    /// kernel builds (see `setup_vfs` and `with_backend`).
660    ///
661    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
662    /// in-band error a model reads and adapts to; fail loud over quietly eating
663    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
664    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
665        self.vfs_budget_bytes = Some(bytes);
666        self
667    }
668
669    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
670    ///
671    /// Use when the caller knows the workload and the default 64 MiB cap
672    /// (set by `KernelConfig::agent`) is too conservative.
673    pub fn without_vfs_budget(mut self) -> Self {
674        self.vfs_budget_bytes = None;
675        self
676    }
677
678    /// Enable or disable copy-on-write overlay mode.
679    ///
680    /// When `true`, the primary local filesystem mount is wrapped in an
681    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
682    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
683    /// and `with_backend` kernels (same — the embedder controls the VFS).
684    pub fn with_overlay(mut self, overlay: bool) -> Self {
685        self.overlay = overlay;
686        self
687    }
688}
689
690/// Handle to an active overlay session, kept on the kernel and shared to
691/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
692///
693/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
694/// `/home/user`); `commit_root` is the real filesystem path the overlay's
695/// lower is backed by (used as the target for `kaish-vfs commit`).
696#[cfg(all(feature = "localfs", feature = "overlay"))]
697#[derive(Clone)]
698pub struct OverlayHandle {
699    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
700    /// methods without holding a VfsRouter lock.
701    pub fs: Arc<OverlayFs>,
702    /// VFS path this overlay is mounted at (e.g. `/home/user`).
703    pub mount_path: PathBuf,
704    /// Real filesystem root to commit into. Same as the lower's root.
705    pub commit_root: PathBuf,
706}
707
708/// The Kernel (核) — executes kaish code.
709///
710/// This is the primary interface for running kaish commands. It owns all
711/// the runtime state: variables, tools, VFS, jobs, and persistence.
712pub struct Kernel {
713    /// Kernel name.
714    name: String,
715    /// Variable scope.
716    scope: RwLock<Scope>,
717    /// Tool registry.
718    tools: Arc<ToolRegistry>,
719    /// User-defined tools (from `tool name { body }` statements).
720    user_tools: RwLock<HashMap<String, ToolDef>>,
721    /// Virtual filesystem router.
722    vfs: Arc<VfsRouter>,
723    /// Background job manager.
724    jobs: Arc<JobManager>,
725    /// Pipeline runner.
726    runner: PipelineRunner,
727    /// Execution context (cwd, stdin, etc.).
728    exec_ctx: RwLock<ExecContext>,
729    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
730    /// retained past construction so `reset()` can re-seed them into the fresh
731    /// scope instead of silently dropping them.
732    initial_vars: HashMap<String, Value>,
733    /// Whether to skip pre-execution validation.
734    skip_validation: bool,
735    /// When true, standalone external commands inherit stdio for real-time output.
736    interactive: bool,
737    /// Whether external command execution is allowed.
738    allow_external_commands: bool,
739    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
740    ///
741    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
742    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
743    /// the same pool — a background job's writes reduce the same cap as
744    /// foreground writes, which is the correct behaviour.
745    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
746    /// Active overlay session handle, if this kernel was constructed with
747    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
748    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
749    /// holding a kernel write lock. Propagated to forks via `fork_inner`
750    /// and `child_for_pipeline` so `kaish-vfs` works inside background
751    /// jobs, scatter workers, and pipeline stages.
752    #[cfg(all(feature = "localfs", feature = "overlay"))]
753    overlay_handle: Option<Arc<OverlayHandle>>,
754    /// Default per-request timeout (None = no default).
755    request_timeout: Option<Duration>,
756    /// SIGTERM-to-SIGKILL grace period for child kills.
757    kill_grace: Duration,
758    /// Receiver for the kernel stderr stream.
759    ///
760    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
761    /// The kernel drains this after each statement in `execute_streaming`.
762    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
763    /// Cancellation token for interrupting execution (Ctrl-C).
764    ///
765    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
766    /// needs sync access. Each `execute()` call gets a fresh child token;
767    /// `cancel()` cancels the current token and replaces it.
768    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
769    /// Terminal state for job control (interactive mode only, Unix only).
770    #[cfg(all(unix, feature = "subprocess"))]
771    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
772    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
773    ///
774    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
775    /// through the full Kernel resolution chain.
776    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
777    /// Background job this kernel (a fork) is executing on behalf of, if any.
778    /// Set on the fork created by `execute_background` and inherited by all its
779    /// sub-forks (pipeline stages, scatter workers), so an external command
780    /// spawned anywhere under a background job can record its process group on
781    /// that job for `kill -<sig> %N`. `None` for foreground execution.
782    bg_job_id: Option<crate::scheduler::JobId>,
783    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
784    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
785    /// queue. Background jobs, scatter workers, and concurrent pipeline
786    /// stages do NOT take this lock — they run against a *forked* Kernel
787    /// (see [`Kernel::fork`]) so they never contend with the foreground.
788    execute_lock: tokio::sync::Mutex<()>,
789    /// Current dynamic statement-engine re-entry depth — incremented on entry
790    /// to command substitution, a shell-function call, or a `.kai` source, and
791    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
792    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
793    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
794    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
795    /// the recursion chain is single-threaded (top-level `execute` is
796    /// serialized by `execute_lock`; concurrency happens on forks).
797    recursion_depth: AtomicUsize,
798}
799
800/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
801/// (in `enter_recursion`) and decrements on drop, so a cancelled or
802/// error-unwound re-entry can never leave the counter inflated (which would
803/// spuriously trip later, unrelated recursions).
804struct RecursionGuard<'a> {
805    counter: &'a AtomicUsize,
806}
807
808impl Drop for RecursionGuard<'_> {
809    fn drop(&mut self) {
810        self.counter.fetch_sub(1, Ordering::Relaxed);
811    }
812}
813
814/// Internal result of [`Kernel::setup_vfs`].
815struct VfsSetupResult {
816    vfs: VfsRouter,
817    budget: Option<Arc<ByteBudget>>,
818    #[cfg(all(feature = "localfs", feature = "overlay"))]
819    overlay_handle: Option<Arc<OverlayHandle>>,
820}
821
822impl Kernel {
823    /// Create a new kernel with the given configuration.
824    pub fn new(config: KernelConfig) -> Result<Self> {
825        let mut setup = Self::setup_vfs(&config)?;
826        let jobs = Arc::new(JobManager::new());
827
828        // Mount JobFs for job observability at /v/jobs
829        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
830
831        #[cfg(all(feature = "localfs", feature = "overlay"))]
832        let overlay_handle = setup.overlay_handle.take();
833
834        // Mode-based construction: the kernel owns its host mounts, so whether
835        // host side channels are allowed is decided by the VFS mode inside
836        // `assemble` (NoLocal forbids them).
837        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
838            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
839        })?;
840
841        #[cfg(all(feature = "localfs", feature = "overlay"))]
842        {
843            let mut kernel = kernel;
844            kernel.overlay_handle = overlay_handle;
845            // Also set it on the ExecContext so builtins can access it.
846            if let Some(ref handle) = kernel.overlay_handle {
847                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
848            }
849            return Ok(kernel);
850        }
851
852        #[allow(unreachable_code)]
853        Ok(kernel)
854    }
855
856    /// Set up VFS based on mount mode.
857    ///
858    /// Returns the router, the budget handle (if bounded), and an optional
859    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
860    /// every `MemoryFs` the kernel creates here holds a clone of the same
861    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
862    /// in-memory content across all kernel-owned memory mounts.
863    ///
864    /// # Errors
865    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
866    /// (overlay is meaningless when everything is already virtual — there is
867    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
868    /// this as an `anyhow::Error`.
869    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
870        let mut vfs = VfsRouter::new();
871
872        // One budget for all memory mounts this kernel owns — labeled so the
873        // error message tells the user exactly which knob to raise.
874        let budget: Option<Arc<ByteBudget>> = config
875            .vfs_budget_bytes
876            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
877
878        /// Helper: construct a `MemoryFs` wired to `budget` if present.
879        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
880            match budget {
881                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
882                None => MemoryFs::new(),
883            }
884        }
885
886        // Overlay handle — populated below if config.overlay is true.
887        #[cfg(all(feature = "localfs", feature = "overlay"))]
888        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
889
890        match &config.vfs_mode {
891            #[cfg(feature = "localfs")]
892            VfsMountMode::Passthrough => {
893                #[cfg(feature = "overlay")]
894                if config.overlay {
895                    // Wrap "/" in an OverlayFs so writes are virtual.
896                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
897                    let overlay_fs = Arc::new(match &budget {
898                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
899                        None => OverlayFs::over(lower),
900                    });
901                    let handle = Arc::new(OverlayHandle {
902                        fs: Arc::clone(&overlay_fs),
903                        mount_path: PathBuf::from("/"),
904                        commit_root: PathBuf::from("/"),
905                    });
906                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
907                    overlay_handle = Some(handle);
908                } else {
909                    // LocalFs at "/" — native paths work directly
910                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
911                }
912                #[cfg(not(feature = "overlay"))]
913                {
914                    if config.overlay {
915                        return Err(anyhow::anyhow!(
916                            "overlay=true requires the `overlay` feature, but this build \
917                             was compiled without it. Recompile with --features overlay \
918                             (or the default feature set) to enable overlay mode."
919                        ));
920                    }
921                    // LocalFs at "/" — native paths work directly
922                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
923                }
924                // Memory for blobs
925                vfs.mount("/v", mem(&budget));
926            }
927            #[cfg(feature = "localfs")]
928            VfsMountMode::Sandboxed { root } => {
929                // Memory at root for safety (catches paths outside sandbox).
930                // Note: /tmp and the XDG runtime dir are LocalFs — writes
931                // there escape the VFS budget and are NOT virtual. This is
932                // intentional: /tmp interop with other processes matters more
933                // than accounting for scratch files there.
934                vfs.mount("/", mem(&budget));
935                vfs.mount("/v", mem(&budget));
936
937                // Synthetic /dev: the host's real /dev isn't reachable here, so
938                // /dev/null and /dev/zero are software-backed (see DevFs).
939                vfs.mount("/dev", DevFs::new());
940
941                // Real /tmp for interop with other processes
942                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
943
944                // Mount XDG runtime dir for spill files and socket access
945                let runtime = crate::paths::xdg_runtime_dir();
946                if runtime.exists() {
947                    let runtime_str = runtime.to_string_lossy().to_string();
948                    vfs.mount(&runtime_str, LocalFs::new(runtime));
949                }
950
951                // Resolve the sandbox root (defaults to $HOME)
952                let local_root = root.clone().unwrap_or_else(|| {
953                    std::env::var("HOME")
954                        .map(PathBuf::from)
955                        .unwrap_or_else(|_| PathBuf::from("/"))
956                });
957
958                let mount_point = local_root.to_string_lossy().to_string();
959
960                #[cfg(feature = "overlay")]
961                if config.overlay {
962                    // Wrap the sandbox root in an OverlayFs.
963                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
964                    let overlay_fs = Arc::new(match &budget {
965                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
966                        None => OverlayFs::over(lower),
967                    });
968                    let handle = Arc::new(OverlayHandle {
969                        fs: Arc::clone(&overlay_fs),
970                        mount_path: PathBuf::from(&mount_point),
971                        commit_root: local_root,
972                    });
973                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
974                    overlay_handle = Some(handle);
975                } else {
976                    // Mount at the real path for transparent access
977                    // e.g., /home/atobey → LocalFs("/home/atobey")
978                    // so /home/atobey/src/kaish just works
979                    vfs.mount(&mount_point, LocalFs::new(local_root));
980                }
981                #[cfg(not(feature = "overlay"))]
982                {
983                    if config.overlay {
984                        return Err(anyhow::anyhow!(
985                            "overlay=true requires the `overlay` feature, but this build \
986                             was compiled without it. Recompile with --features overlay \
987                             (or the default feature set) to enable overlay mode."
988                        ));
989                    }
990                    // Mount at the real path for transparent access
991                    vfs.mount(&mount_point, LocalFs::new(local_root));
992                }
993            }
994            VfsMountMode::NoLocal => {
995                if config.overlay {
996                    return Err(anyhow::anyhow!(
997                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
998                         everything is already virtual, there is no real lower layer \
999                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1000                         or Sandboxed VFS mode."
1001                    ));
1002                }
1003                // Pure memory mode — no local filesystem
1004                vfs.mount("/", mem(&budget));
1005                vfs.mount("/tmp", mem(&budget));
1006                vfs.mount("/v", mem(&budget));
1007                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1008                vfs.mount("/dev", DevFs::new());
1009            }
1010        }
1011
1012        Ok(VfsSetupResult {
1013            vfs,
1014            budget,
1015            #[cfg(all(feature = "localfs", feature = "overlay"))]
1016            overlay_handle,
1017        })
1018    }
1019
1020    /// Create a transient kernel (no persistence).
1021    pub fn transient() -> Result<Self> {
1022        Self::new(KernelConfig::transient())
1023    }
1024
1025    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1026    ///
1027    /// This is the constructor for embedding kaish in other systems that provide
1028    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1029    ///
1030    /// A `VirtualOverlayBackend` routes paths automatically:
1031    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1032    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1033    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1034    ///   read-only
1035    /// - Everything else → Your custom backend
1036    ///
1037    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1038    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1039    ///
1040    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1041    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1042    /// `skip_validation`, and `interactive`.
1043    ///
1044    /// # Example
1045    ///
1046    /// ```ignore
1047    /// // Simple: default /v/* mounts only
1048    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1049    ///
1050    /// // With custom mounts
1051    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1052    ///     vfs.mount_arc("/v/docs", docs_fs);
1053    ///     vfs.mount_arc("/v/g", git_fs);
1054    /// }, |_| {})?;
1055    ///
1056    /// // With custom tools
1057    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1058    ///     tools.register(MyCustomTool::new());
1059    /// })?;
1060    /// ```
1061    pub fn with_backend(
1062        backend: Arc<dyn KernelBackend>,
1063        config: KernelConfig,
1064        configure_vfs: impl FnOnce(&mut VfsRouter),
1065        configure_tools: impl FnOnce(&mut ToolRegistry),
1066    ) -> Result<Self> {
1067        use crate::backend::VirtualOverlayBackend;
1068
1069        // overlay=true is incompatible with with_backend: the embedder controls
1070        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1071        // semantics. Fail loudly rather than silently ignoring the flag.
1072        if config.overlay {
1073            return Err(anyhow::anyhow!(
1074                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1075                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1076                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1077            ));
1078        }
1079
1080        let mut vfs = VfsRouter::new();
1081        let jobs = Arc::new(JobManager::new());
1082
1083        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1084        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1085        // kernel-owned memory mount here — embedders own the rest of the VFS.
1086        let vfs_budget: Option<Arc<ByteBudget>> = config
1087            .vfs_budget_bytes
1088            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1089
1090        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1091        let blobs_fs = match &vfs_budget {
1092            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1093            None => MemoryFs::new(),
1094        };
1095        vfs.mount("/v/blobs", blobs_fs);
1096
1097        // /dev/null and friends are software-backed (see DevFs) and must not
1098        // depend on the embedder's backend — a read-only embedder backend
1099        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1100        // to /dev/null as a filesystem error instead of discarding them.
1101        vfs.mount("/dev", DevFs::new());
1102
1103        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1104        configure_vfs(&mut vfs);
1105
1106        // A custom-backend kernel owns no host mounts — the embedder supplies
1107        // the entire VFS — so any kernel write to a host filesystem via
1108        // `std::fs` (output spill, job output files) bypasses that VFS and its
1109        // read-only guarantees. Forbid host side channels unconditionally.
1110        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1111            let overlay: Arc<dyn KernelBackend> =
1112                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1113            ExecContext::with_backend(overlay)
1114        })
1115    }
1116
1117    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1118    ///
1119    /// The `make_ctx` closure receives the VFS and tools so backends that need
1120    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1121    /// that already have their own storage can ignore these parameters.
1122    fn assemble(
1123        config: KernelConfig,
1124        mut vfs: VfsRouter,
1125        jobs: Arc<JobManager>,
1126        no_host_filesystem: bool,
1127        vfs_budget: Option<Arc<ByteBudget>>,
1128        configure_tools: impl FnOnce(&mut ToolRegistry),
1129        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1130    ) -> Result<Self> {
1131        // A kernel with no host filesystem of its own must never write to one
1132        // through a side channel. Two paths bypass the VFS by going straight to
1133        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1134        // background-job output files (`Job::write_output_file` → host temp).
1135        // Both would punch through the isolation, so force them off:
1136        // in-memory truncation for spill, no host file for job output.
1137        //
1138        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1139        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1140        // VFS, so the kernel controls no host mounts and any host write is a
1141        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1142        // when there is no kernel-owned host filesystem to spill to.
1143        let no_host_side_channel =
1144            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1145
1146        let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, latch_enabled, trash_enabled, nonce_store, initial_vars, request_timeout, kill_grace, .. } = config;
1147
1148        if no_host_side_channel {
1149            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1150            jobs.set_persist_output_files(false);
1151        }
1152
1153        let mut tools = ToolRegistry::new();
1154        register_builtins(&mut tools);
1155        configure_tools(&mut tools);
1156        let tools = Arc::new(tools);
1157
1158        // Mount BuiltinFs so `ls /v/bin` lists builtins
1159        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1160
1161        let vfs = Arc::new(vfs);
1162
1163        let runner = PipelineRunner::new(tools.clone());
1164
1165        let (stderr_writer, stderr_receiver) = stderr_stream();
1166
1167        let mut exec_ctx = make_ctx(&vfs, &tools);
1168        exec_ctx.set_cwd(cwd);
1169        exec_ctx.set_job_manager(jobs.clone());
1170        exec_ctx.set_tool_schemas(tools.schemas());
1171        exec_ctx.set_tools(tools.clone());
1172        #[cfg(feature = "os-integration")]
1173        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1174        exec_ctx.stderr = Some(stderr_writer);
1175        exec_ctx.ignore_config = ignore_config;
1176        exec_ctx.output_limit = output_limit;
1177        exec_ctx.allow_external_commands = allow_external_commands;
1178        exec_ctx.vfs_budget = vfs_budget.clone();
1179        if let Some(store) = nonce_store {
1180            exec_ctx.nonce_store = store;
1181        }
1182
1183        Ok(Self {
1184            name,
1185            scope: RwLock::new({
1186                let mut scope = Scope::new();
1187                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1188                // HOME is NOT read from the host env here — the kernel is
1189                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1190                // below (from `std::env::vars()`); a hermetic embedder leaves
1191                // `initial_vars` empty and gets no HOME (tilde stays literal).
1192                // Apply caller-supplied initial variables, all marked exported.
1193                // Frontends (REPL, MCP) populate this from std::env::vars()
1194                // for shell-like UX; embedders that want hermetic behavior
1195                // simply leave it empty.
1196                for (name, value) in initial_vars.clone() {
1197                    scope.set_exported(name, value);
1198                }
1199                scope.set_latch_enabled(latch_enabled);
1200                scope.set_trash_enabled(trash_enabled);
1201                scope
1202            }),
1203            initial_vars,
1204            tools,
1205            user_tools: RwLock::new(HashMap::new()),
1206            vfs,
1207            jobs,
1208            runner,
1209            exec_ctx: RwLock::new(exec_ctx),
1210            skip_validation,
1211            interactive,
1212            allow_external_commands,
1213            vfs_budget,
1214            request_timeout,
1215            kill_grace,
1216            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1217            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1218            #[cfg(all(unix, feature = "subprocess"))]
1219            terminal_state: None,
1220            self_weak: std::sync::OnceLock::new(),
1221            execute_lock: tokio::sync::Mutex::new(()),
1222            recursion_depth: AtomicUsize::new(0),
1223            bg_job_id: None,
1224            // Overlay handle is set by Kernel::new after assemble returns;
1225            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1226            // with_backend always has None (overlay=true is rejected above).
1227            #[cfg(all(feature = "localfs", feature = "overlay"))]
1228            overlay_handle: None,
1229        })
1230    }
1231
1232    /// Get the kernel name.
1233    pub fn name(&self) -> &str {
1234        &self.name
1235    }
1236
1237    /// Wrap this Kernel in an Arc and initialize its self-reference.
1238    ///
1239    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1240    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1241    /// commands through the full resolution chain (user tools → builtins →
1242    /// .kai scripts → external commands).
1243    pub fn into_arc(self) -> Arc<Self> {
1244        let arc = Arc::new(self);
1245        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1246        arc
1247    }
1248
1249    /// Fork a subsidiary kernel for concurrent execution.
1250    ///
1251    /// The fork is a fully-functional `Kernel` that:
1252    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1253    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1254    ///   the fork do NOT propagate back to the parent — matching bash
1255    ///   subshell / background-job semantics.
1256    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1257    ///   registry, the VFS router, and the job manager. A job registered by
1258    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1259    ///   sees the same VFS mounts.
1260    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1261    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1262    ///   `false` and `terminal_state` is `None`.
1263    ///
1264    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1265    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1266    /// routes through the fork itself, not the parent — which is essential
1267    /// for concurrency safety.
1268    ///
1269    /// Use this for **detached** background concurrency where the fork should
1270    /// survive parent cancellation: the `&` background-job operator and any
1271    /// other "fire and forget" worker. The fork gets a fresh, independent
1272    /// cancellation token.
1273    ///
1274    /// For foreground concurrency (scatter workers, concurrent pipeline
1275    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1276    /// into the fork's external children, use [`Self::fork_attached`].
1277    pub async fn fork(&self) -> Arc<Self> {
1278        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1279            .await
1280    }
1281
1282    /// Fork attached to the parent's cancellation.
1283    ///
1284    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1285    /// the parent's. When the parent cancels (request timeout, embedder
1286    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1287    /// turn kills any external children spawned in the fork via the
1288    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1289    pub async fn fork_attached(&self) -> Arc<Self> {
1290        let child_token = {
1291            #[allow(clippy::expect_used)]
1292            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1293            parent.child_token()
1294        };
1295        self.fork_inner(child_token, self.bg_job_id).await
1296    }
1297
1298    /// Fork for a background job, stamping the job id so external commands
1299    /// spawned anywhere beneath it record their process groups on that job
1300    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1301    /// `JobManager::cancel`.
1302    pub async fn fork_for_background(
1303        &self,
1304        cancel: tokio_util::sync::CancellationToken,
1305        job_id: crate::scheduler::JobId,
1306    ) -> Arc<Self> {
1307        self.fork_inner(cancel, Some(job_id)).await
1308    }
1309
1310    /// Shared fork implementation. Caller decides the cancellation token and
1311    /// which background job (if any) this fork runs on behalf of.
1312    async fn fork_inner(
1313        &self,
1314        cancel: tokio_util::sync::CancellationToken,
1315        bg_job_id: Option<crate::scheduler::JobId>,
1316    ) -> Arc<Self> {
1317        let scope_snapshot = self.scope.read().await.clone();
1318        let user_tools_snapshot = self.user_tools.read().await.clone();
1319
1320        // Snapshot exec_ctx by cloning the cloneable fields, then override
1321        // the ones that should not carry over (stderr channel, dispatcher,
1322        // interactive flag, terminal state, cancel — set from `cancel` arg).
1323        let mut fork_ctx = {
1324            let parent_ctx = self.exec_ctx.read().await;
1325            parent_ctx.child_for_pipeline()
1326        };
1327        let (stderr_writer, stderr_receiver) = stderr_stream();
1328        fork_ctx.stderr = Some(stderr_writer);
1329        // Clear dispatcher; dispatch_command will repopulate it to point at
1330        // the fork on the first dispatch call.
1331        fork_ctx.dispatcher = None;
1332        fork_ctx.interactive = false;
1333        fork_ctx.cancel = cancel.clone();
1334        #[cfg(all(unix, feature = "subprocess"))]
1335        {
1336            fork_ctx.terminal_state = None;
1337        }
1338
1339        let fork = Self {
1340            name: format!("{}:fork", self.name),
1341            scope: RwLock::new(scope_snapshot),
1342            initial_vars: self.initial_vars.clone(),
1343            tools: Arc::clone(&self.tools),
1344            user_tools: RwLock::new(user_tools_snapshot),
1345            vfs: Arc::clone(&self.vfs),
1346            jobs: Arc::clone(&self.jobs),
1347            runner: self.runner.clone(),
1348            exec_ctx: RwLock::new(fork_ctx),
1349            skip_validation: self.skip_validation,
1350            // Forks are never the TTY owner — they run in the background.
1351            interactive: false,
1352            allow_external_commands: self.allow_external_commands,
1353            // Arc-clone the budget so the fork draws from the same pool as the
1354            // parent — background jobs and scatter workers count against the same
1355            // cap as foreground writes.
1356            vfs_budget: self.vfs_budget.clone(),
1357            request_timeout: self.request_timeout,
1358            kill_grace: self.kill_grace,
1359            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1360            cancel_token: std::sync::Mutex::new(cancel),
1361            #[cfg(all(unix, feature = "subprocess"))]
1362            terminal_state: None,
1363            self_weak: std::sync::OnceLock::new(),
1364            execute_lock: tokio::sync::Mutex::new(()),
1365            // A fork runs on a fresh stack (spawned task) — its recursion
1366            // budget is independent of the parent's current depth (GH #46).
1367            recursion_depth: AtomicUsize::new(0),
1368            bg_job_id,
1369            // Arc-clone the overlay handle so forks (background jobs, scatter
1370            // workers, pipeline stages) can reach the same overlay transaction
1371            // via `kaish-vfs status/diff/commit/reset`.
1372            #[cfg(all(feature = "localfs", feature = "overlay"))]
1373            overlay_handle: self.overlay_handle.clone(),
1374        };
1375
1376        fork.into_arc()
1377    }
1378
1379    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1380    ///
1381    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1382    /// have been dropped (the `Weak` can no longer upgrade).
1383    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1384        self.self_weak
1385            .get()
1386            .and_then(|weak| weak.upgrade())
1387            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1388    }
1389
1390    /// Initialize terminal state for interactive job control.
1391    ///
1392    /// Call this after kernel creation when running as an interactive REPL
1393    /// and stdin is a TTY. Sets up process groups and signal handling.
1394    #[cfg(all(unix, feature = "subprocess"))]
1395    pub fn init_terminal(&mut self) {
1396        if !self.interactive {
1397            return;
1398        }
1399        match crate::terminal::TerminalState::init() {
1400            Ok(state) => {
1401                let state = Arc::new(state);
1402                self.terminal_state = Some(state.clone());
1403                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1404                self.exec_ctx.get_mut().terminal_state = Some(state);
1405                tracing::debug!("terminal job control initialized");
1406            }
1407            Err(e) => {
1408                tracing::warn!("failed to initialize terminal job control: {}", e);
1409            }
1410        }
1411    }
1412
1413    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1414    ///
1415    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1416    /// built with the `os-integration` feature. Embedders and tests can swap
1417    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1418    /// it — with trash enabled but no backend present, `rm` fails loud
1419    /// rather than falling through to permanent delete.
1420    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1421        self.exec_ctx.get_mut().trash_backend = backend;
1422    }
1423
1424    /// Cancel the current execution.
1425    ///
1426    /// This cancels the current cancellation token, causing any execution
1427    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1428    /// A fresh token is installed for the next `execute()` call.
1429    pub fn cancel(&self) {
1430        #[allow(clippy::expect_used)]
1431        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1432        token.cancel();
1433    }
1434
1435    /// Check if the current execution has been cancelled.
1436    pub fn is_cancelled(&self) -> bool {
1437        #[allow(clippy::expect_used)]
1438        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1439        token.is_cancelled()
1440    }
1441
1442    /// Reset the cancellation token (called at the start of each execute).
1443    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1444        #[allow(clippy::expect_used)]
1445        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1446        if token.is_cancelled() {
1447            *token = tokio_util::sync::CancellationToken::new();
1448        }
1449        token.clone()
1450    }
1451
1452    /// Acquire the per-Kernel execute lock, warning on contention.
1453    ///
1454    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1455    /// the lock is already held, emit a warning so the silent serialization
1456    /// is observable in logs — if you need real parallelism, fork the kernel.
1457    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1458        match self.execute_lock.try_lock() {
1459            Ok(guard) => guard,
1460            Err(_) => {
1461                tracing::warn!(
1462                    target: "kaish::kernel::concurrency",
1463                    kernel = %self.name,
1464                    "execute() contended — serializing concurrent caller; \
1465                     use Kernel::fork() for parallelism instead of sharing"
1466                );
1467                self.execute_lock.lock().await
1468            }
1469        }
1470    }
1471
1472    /// Execute kaish source code with default options.
1473    ///
1474    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1475    /// Returns the result of the last statement executed.
1476    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1477        self.run_inner(input, ExecuteOptions::default(), None, None).await
1478    }
1479
1480    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1481    /// are **already tokenized**.
1482    ///
1483    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1484    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1485    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1486    /// into a string just to have the lexer split it apart again — a round-trip
1487    /// that is lossy for typed values, since `to_argv()` stringifies
1488    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1489    ///
1490    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1491    /// command substitution, no word splitting — the "single-quoted word"
1492    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1493    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1494    /// does still apply, for consistency with the string door: a leading `~` is
1495    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1496    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1497    /// non-string `Value`
1498    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1499    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1500    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1501    /// sees a stringified value; the typed-passthrough win fully lands only for
1502    /// builtins that read `args.positional` directly — the documented pattern.)
1503    ///
1504    /// This is a *peer*, not a subset: a command string can carry pipelines,
1505    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1506    /// doors converge **late** (at the shared dispatch chain) rather than one
1507    /// wrapping the other. From argv classification onward `execute_argv` reuses
1508    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1509    /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1510    /// transform, and the confirmation latch — so a latched `rm` still emits a
1511    /// nonce and an `ls --json` still applies output formatting. The kernel's
1512    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1513    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1514    ///
1515    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1516    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1517    /// external is interrupted at the deadline with exit code 124, the same as the
1518    /// string door). There is no per-call options surface yet — a future
1519    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1520    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1521    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1522        let _guard = self.acquire_execute_lock().await;
1523        // Fresh cancel surface for this call: `execute_pipeline` reads
1524        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1525        // replaced first. The returned clone is the token the watchdog cancels on
1526        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1527        // cascading SIGTERM/SIGKILL to any external child.
1528        let cancel = self.reset_cancel();
1529
1530        // Honor the kernel-configured request timeout for parity with `execute`.
1531        let timeout = self.request_timeout;
1532        if timeout == Some(Duration::ZERO) {
1533            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1534        }
1535
1536        let pipeline = crate::ast::Pipeline {
1537            commands: vec![crate::ast::Command {
1538                name: name.to_string(),
1539                args: argv_to_args(argv),
1540                redirects: Vec::new(),
1541            }],
1542            background: false,
1543        };
1544        let result = self
1545            .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1546            .await?;
1547        self.update_last_result(&result).await;
1548        Ok(result)
1549    }
1550
1551    /// Fulfill a confirmation latch by replaying its exact captured invocation
1552    /// with the nonce — the highest-fidelity approval path.
1553    ///
1554    /// Inspect a gated result with [`ExecResult::latch_request`]; apply whatever
1555    /// policy (allowlist, model review) over `req.command`/`req.paths`; then call
1556    /// this to approve. It replays `execute_argv(req.tool, req.argv)` with
1557    /// `--confirm=<nonce>` prepended — no re-parsing of the human `hint`, so a
1558    /// path with spaces or glob characters round-trips exactly. Share the nonce
1559    /// store ([`KernelConfig::with_nonce_store`]) to confirm from a *later*
1560    /// kernel call than the one that produced the latch.
1561    ///
1562    /// Errors (exit 2) if the latch carries no captured invocation — a latch
1563    /// produced outside a dispatch seam (a direct `tool.execute` in a unit
1564    /// test). Those are confirmable only by re-running with `--confirm=<nonce>`.
1565    ///
1566    /// If `latch.job_id` is set (the gate came from a *backgrounded* job —
1567    /// `rm x &` reaching its gate), a successful replay also retires that job
1568    /// from the `JobManager` (GH #124 part 4) — mirroring the existing manual
1569    /// discard path (`kill --discard %N`), automated. A failed replay leaves
1570    /// the job in place for inspection/retry. Guarded by `is_latched` so a
1571    /// stale/foreign `job_id` can never remove an unrelated running job;
1572    /// idempotent on a repeat confirm (nonces are reusable within TTL, and
1573    /// removing an already-absent job is a no-op).
1574    pub async fn confirm(&self, latch: &LatchRequest) -> Result<ExecResult> {
1575        if latch.tool.is_empty() {
1576            return Ok(ExecResult::failure(
1577                2,
1578                "confirm: latch carries no captured invocation to replay — \
1579                 re-run the command with --confirm=<nonce> instead",
1580            ));
1581        }
1582        // Prepend the nonce as a `--confirm=` flag: `to_argv()` trails a `--`
1583        // positional terminator, so appending would let it swallow the flag.
1584        let mut argv: Vec<Value> = Vec::with_capacity(latch.argv.len() + 1);
1585        argv.push(Value::String(format!("--confirm={}", latch.nonce)));
1586        argv.extend(latch.argv.iter().map(|a| Value::String(a.clone())));
1587        let result = self.execute_argv(&latch.tool, &argv).await?;
1588
1589        if result.ok()
1590            && let Some(id) = latch.job_id
1591        {
1592            let job_id = crate::scheduler::JobId(id);
1593            if self.jobs.is_latched(job_id).await {
1594                self.jobs.remove(job_id).await;
1595            }
1596        }
1597
1598        Ok(result)
1599    }
1600
1601    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1602    /// string door ([`Self::execute_with_options`]) and the argv door
1603    /// ([`Self::execute_argv`]).
1604    ///
1605    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1606    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1607    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1608    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1609    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1610    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1611    /// stale handle would silently suspend nothing). Callers must short-circuit a
1612    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1613    async fn run_under_watchdog<F>(
1614        &self,
1615        timeout: Option<Duration>,
1616        cancel: &tokio_util::sync::CancellationToken,
1617        work: F,
1618    ) -> Result<ExecResult>
1619    where
1620        F: std::future::Future<Output = Result<ExecResult>>,
1621    {
1622        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1623        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1624        {
1625            let mut ec = self.exec_ctx.write().await;
1626            ec.watchdog = watchdog.clone();
1627        }
1628
1629        let result = if let Some(d) = timeout {
1630            #[allow(clippy::expect_used)]
1631            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1632            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1633            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1634            let r = work.await;
1635            timer.abort();
1636            match r {
1637                Ok(mut res) => {
1638                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1639                        res.code = 124;
1640                        if res.err.is_empty() {
1641                            res.err = format!("timeout: timed out after {:?}", d);
1642                        }
1643                    }
1644                    Ok(res)
1645                }
1646                Err(e) => Err(e),
1647            }
1648        } else {
1649            work.await
1650        };
1651
1652        // The timer task is gone (fired or aborted); drop the stale handle.
1653        {
1654            let mut ec = self.exec_ctx.write().await;
1655            ec.watchdog = None;
1656        }
1657        result
1658    }
1659
1660    /// Execute with per-call options. The primary entry point for embedders
1661    /// that don't need per-statement output streaming.
1662    ///
1663    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1664    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1665    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1666    ///
1667    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1668    /// against the kernel's internal token. Either firing cancels and kills
1669    /// external children. The embedder's token is read-only — kernel
1670    /// timeouts do NOT propagate into it. Distinguish via the returned
1671    /// `code`: 124 = timeout, 130 = cancellation.
1672    ///
1673    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1674    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1675    ///
1676    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1677    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1678    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1679    pub async fn execute_with_options(
1680        &self,
1681        input: &str,
1682        opts: ExecuteOptions,
1683    ) -> Result<ExecResult> {
1684        self.run_inner(input, opts, None, None).await
1685    }
1686
1687    /// Same as [`Self::execute_with_options`] but with a per-statement output
1688    /// callback. The callback fires after each top-level statement so the
1689    /// embedder (REPL, MCP streaming) can flush output incrementally.
1690    pub async fn execute_with_options_streaming(
1691        &self,
1692        input: &str,
1693        opts: ExecuteOptions,
1694        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1695    ) -> Result<ExecResult> {
1696        self.run_inner(input, opts, None, Some(on_output)).await
1697    }
1698
1699    /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1700    ///
1701    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read `String`), this never
1702    /// forces the input to be drained before execution: the reader seeds the
1703    /// first top-level command's `pipe_stdin`, and a command that does not read
1704    /// stdin (`echo`) returns without touching it. This is the seam a
1705    /// non-interactive frontend uses to forward an *open* process stdin without
1706    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1707    ///
1708    /// Embedders that already hold a complete buffer should prefer the simpler
1709    /// [`ExecuteOptions::with_stdin`] String path.
1710    pub async fn execute_with_pipe_stdin(
1711        &self,
1712        input: &str,
1713        opts: ExecuteOptions,
1714        pipe_stdin: crate::scheduler::PipeReader,
1715    ) -> Result<ExecResult> {
1716        self.run_inner(input, opts, Some(pipe_stdin), None).await
1717    }
1718
1719    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1720    /// `-c`/script frontend uses this to print output incrementally while
1721    /// feeding a lazy process-stdin pipe.
1722    pub async fn execute_with_pipe_stdin_streaming(
1723        &self,
1724        input: &str,
1725        opts: ExecuteOptions,
1726        pipe_stdin: crate::scheduler::PipeReader,
1727        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1728    ) -> Result<ExecResult> {
1729        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1730    }
1731
1732    /// Execute kaish source code with a transient overlay of exported variables.
1733    ///
1734    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1735    /// should use that method directly:
1736    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1737    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1738    pub async fn execute_with_vars(
1739        &self,
1740        input: &str,
1741        vars: HashMap<String, Value>,
1742    ) -> Result<ExecResult> {
1743        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1744    }
1745
1746    /// Execute kaish source code with a per-statement callback.
1747    ///
1748    /// Deprecated thin wrapper. New code should use
1749    /// [`Self::execute_with_options_streaming`].
1750    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1751    pub async fn execute_streaming(
1752        &self,
1753        input: &str,
1754        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1755    ) -> Result<ExecResult> {
1756        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1757    }
1758
1759    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1760    ///
1761    /// The `#[instrument]` execution span resolves its parent from the *current*
1762    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1763    /// captured when the span is first entered — not when the future is
1764    /// constructed. So a thread-local `attach()` scoped to construction is too
1765    /// early to be seen (the integration test confirms this). `with_context`
1766    /// re-attaches the embedder's context on *every* poll of the inner future,
1767    /// so the context is current at first-enter and survives runtime thread
1768    /// hops. With no embedder trace context, the future runs unwrapped.
1769    async fn run_inner(
1770        &self,
1771        input: &str,
1772        opts: ExecuteOptions,
1773        pipe_stdin: Option<crate::scheduler::PipeReader>,
1774        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1775    ) -> Result<ExecResult> {
1776        use opentelemetry::context::FutureExt;
1777
1778        // Capture the embedder's baggage before `opts` is consumed so it can be
1779        // echoed back onto the result on egress (see `merge_egress_baggage`).
1780        let embedder_baggage = opts.baggage.clone();
1781
1782        let result = match crate::telemetry::extract_parent(&opts) {
1783            Some(parent) => self
1784                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1785                .with_context(parent)
1786                .await,
1787            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1788        };
1789
1790        result.map(|mut r| {
1791            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1792            r
1793        })
1794    }
1795
1796    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1797    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1798    /// cwd override, and timeout race.
1799    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1800    async fn execute_with_options_inner(
1801        &self,
1802        input: &str,
1803        opts: ExecuteOptions,
1804        pipe_stdin: Option<crate::scheduler::PipeReader>,
1805        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1806    ) -> Result<ExecResult> {
1807        let _guard = self.acquire_execute_lock().await;
1808
1809        // Always reset to a fresh internal token; this is the kernel's own
1810        // cancel surface for embedders calling `Kernel::cancel()`. The
1811        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1812        // is NOT written into `self.cancel_token`, because doing so would
1813        // (a) leak the embedder's token past this call's lifetime,
1814        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1815        // (c) extend the token's lifetime via the kernel's strong clone.
1816        let internal = self.reset_cancel();
1817        // Race the embedder token against the kernel's internal token via a
1818        // tracked watcher task. We hold the JoinHandle so we can abort the
1819        // task at function exit — otherwise it would wait forever for either
1820        // token to fire and leak per call.
1821        let (effective_cancel, watcher_handle): (
1822            tokio_util::sync::CancellationToken,
1823            Option<tokio::task::JoinHandle<()>>,
1824        ) = if let Some(ext) = opts.cancel_token {
1825            let combined = tokio_util::sync::CancellationToken::new();
1826            let combined_writer = combined.clone();
1827            let i = internal.clone();
1828            let handle = tokio::spawn(async move {
1829                tokio::select! {
1830                    _ = i.cancelled() => combined_writer.cancel(),
1831                    _ = ext.cancelled() => combined_writer.cancel(),
1832                }
1833            });
1834            (combined, Some(handle))
1835        } else {
1836            (internal, None)
1837        };
1838
1839        // Effective timeout: per-call wins over kernel-config default.
1840        let timeout = opts.timeout.or(self.request_timeout);
1841
1842        // ZERO timeout: return 124 immediately without spawning anything.
1843        if timeout == Some(Duration::ZERO) {
1844            if let Some(h) = watcher_handle {
1845                h.abort();
1846            }
1847            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1848        }
1849
1850        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1851        // an RAII guard so a panic inside `execute_streaming_inner` still
1852        // pops the frame and unexports the temporarily-exported names.
1853        struct VarsFrameGuard<'a> {
1854            kernel: &'a Kernel,
1855            newly_exported: Vec<String>,
1856        }
1857        impl Drop for VarsFrameGuard<'_> {
1858            fn drop(&mut self) {
1859                // Best-effort cleanup using try_write. The execute_lock held
1860                // throughout execute_with_options means there is no concurrent
1861                // foreground caller; forks have their own scope and won't
1862                // block this. blocking_write would deadlock the runtime when
1863                // called from a tokio worker thread, so we explicitly do NOT
1864                // fall back to it — if try_write fails (which we've never
1865                // seen in practice), log loudly and accept the leak rather
1866                // than deadlock the entire kernel.
1867                let Ok(mut scope) = self.kernel.scope.try_write() else {
1868                    tracing::error!(
1869                        "vars frame guard: scope lock unexpectedly busy; \
1870                         skipping pop_frame to avoid runtime deadlock — \
1871                         transient vars may leak"
1872                    );
1873                    return;
1874                };
1875                scope.pop_frame();
1876                for name in self.newly_exported.drain(..) {
1877                    scope.unexport(&name);
1878                }
1879            }
1880        }
1881
1882        // Per-call cwd override: save current cwd, set the new one, restore
1883        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1884        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1885        struct CwdGuard<'a> {
1886            kernel: &'a Kernel,
1887            saved: PathBuf,
1888        }
1889        impl Drop for CwdGuard<'_> {
1890            fn drop(&mut self) {
1891                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1892                    tracing::error!(
1893                        "cwd guard: exec_ctx lock unexpectedly busy; \
1894                         skipping cwd restore — kernel cwd may be wrong for next call"
1895                    );
1896                    return;
1897                };
1898                ec.cwd = std::mem::take(&mut self.saved);
1899            }
1900        }
1901        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1902            let mut ec = self.exec_ctx.write().await;
1903            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1904            drop(ec);
1905            Some(CwdGuard { kernel: self, saved })
1906        } else {
1907            None
1908        };
1909
1910        // Per-call stdin: seed the persistent exec_ctx so the first top-level
1911        // command that reads stdin consumes it (it's `take()`n at dispatch).
1912        // Restore the prior value on Drop — normally `None`, so this also drops
1913        // any residual seed an stdin-less program never consumed, keeping it
1914        // from bleeding into the next call. Same RAII pattern as CwdGuard.
1915        struct StdinGuard<'a> {
1916            kernel: &'a Kernel,
1917            saved: Option<String>,
1918        }
1919        impl Drop for StdinGuard<'_> {
1920            fn drop(&mut self) {
1921                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1922                    tracing::error!(
1923                        "stdin guard: exec_ctx lock unexpectedly busy; \
1924                         skipping stdin restore — stale stdin may leak to next call"
1925                    );
1926                    return;
1927                };
1928                ec.stdin = self.saved.take();
1929            }
1930        }
1931        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1932            let mut ec = self.exec_ctx.write().await;
1933            let saved = ec.stdin.replace(stdin);
1934            drop(ec);
1935            Some(StdinGuard { kernel: self, saved })
1936        } else {
1937            None
1938        };
1939
1940        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1941        // persistent exec_ctx so the first stdin-reading command drains it (it's
1942        // `take()`n at pipeline build). The RAII guard restores the prior value
1943        // on Drop (normally `None`), so an unread reader doesn't bleed into the
1944        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1945        struct PipeStdinGuard<'a> {
1946            kernel: &'a Kernel,
1947            saved: Option<crate::scheduler::PipeReader>,
1948        }
1949        impl Drop for PipeStdinGuard<'_> {
1950            fn drop(&mut self) {
1951                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1952                    tracing::error!(
1953                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1954                         skipping restore — stale pipe stdin may leak to next call"
1955                    );
1956                    return;
1957                };
1958                ec.pipe_stdin = self.saved.take();
1959            }
1960        }
1961        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
1962            let mut ec = self.exec_ctx.write().await;
1963            let saved = ec.pipe_stdin.replace(reader);
1964            drop(ec);
1965            Some(PipeStdinGuard { kernel: self, saved })
1966        } else {
1967            None
1968        };
1969
1970        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
1971            let mut scope = self.scope.write().await;
1972            scope.push_frame();
1973            let mut newly = Vec::with_capacity(opts.vars.len());
1974            for (name, value) in opts.vars {
1975                if !scope.is_exported(&name) {
1976                    newly.push(name.clone());
1977                }
1978                scope.set_exported(name, value);
1979            }
1980            drop(scope);
1981            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
1982        } else {
1983            None
1984        };
1985
1986        // Sync the effective cancel into self.exec_ctx so try_execute_external
1987        // (which reads via self.cancel_token) sees cancellation. We also need
1988        // builtins to see it via ctx.cancel — handled in execute_command.
1989        // For simplicity here we mirror effective_cancel into self.cancel_token
1990        // for the duration of this call, then restore the internal token at
1991        // the end (so a later Kernel::cancel still hits our internal surface).
1992        {
1993            #[allow(clippy::expect_used)]
1994            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1995            *cur = effective_cancel.clone();
1996        }
1997
1998        // Run the script under the movable-deadline watchdog (shared with the
1999        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2000        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2001        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2002        // already handled by the early return above.
2003        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2004        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2005            Some(cb) => cb,
2006            None => &mut *noop_cb,
2007        };
2008
2009        let result = self
2010            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2011            .await;
2012
2013        // Restore self.cancel_token to a fresh, uncancelled token so the
2014        // embedder's view of `Kernel::cancel()` stays predictable on the
2015        // next call (it cancels the kernel's own token, not whatever was
2016        // left over from this call's combined token).
2017        {
2018            #[allow(clippy::expect_used)]
2019            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2020            *cur = tokio_util::sync::CancellationToken::new();
2021        }
2022
2023        // Tear down the embedder-token race watcher (if any). Leaving it
2024        // alive would idle forever waiting for tokens that may never fire.
2025        if let Some(h) = watcher_handle {
2026            h.abort();
2027        }
2028
2029        // VarsFrameGuard drops here on the success path and on early-return
2030        // paths above (error path included). Panic safety preserved.
2031        result
2032    }
2033
2034    /// The actual body of `execute_streaming`, run while holding the execute lock.
2035    ///
2036    /// Split out so internal kernel paths that are already under the lock can
2037    /// call this without deadlocking on re-entry. External callers must go
2038    /// through [`Self::execute_streaming`] so they acquire the lock.
2039    async fn execute_streaming_inner(
2040        &self,
2041        input: &str,
2042        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2043    ) -> Result<ExecResult> {
2044        let program = parse(input).map_err(|errors| {
2045            let msg = errors
2046                .iter()
2047                .map(|e| e.format(input))
2048                .collect::<Vec<_>>()
2049                .join("\n");
2050            anyhow::anyhow!("parse error:\n{}", msg)
2051        })?;
2052
2053        // AST display mode: show AST instead of executing
2054        {
2055            let scope = self.scope.read().await;
2056            if scope.show_ast() {
2057                let output = format!("{:#?}\n", program);
2058                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2059            }
2060        }
2061
2062        // Pre-execution validation. Most warnings stay trace-only (every
2063        // external command fires an `UndefinedCommand` warning), but a warning
2064        // whose code opts into agent surfacing is collected here and prepended
2065        // to the result's stderr at each return point below.
2066        let mut surfaced_warnings = String::new();
2067        if !self.skip_validation {
2068            let user_tools = self.user_tools.read().await;
2069            let validator = Validator::new(&self.tools, &user_tools);
2070            let issues = validator.validate(&program);
2071
2072            // Collect errors (warnings are logged but don't prevent execution)
2073            let errors: Vec<_> = issues
2074                .iter()
2075                .filter(|i| i.severity == Severity::Error)
2076                .collect();
2077
2078            if !errors.is_empty() {
2079                let error_msg = errors
2080                    .iter()
2081                    .map(|e| e.format(input))
2082                    .collect::<Vec<_>>()
2083                    .join("\n");
2084                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2085            }
2086
2087            // Log warnings via tracing (trace level to avoid noise); surface the
2088            // opted-in ones to the agent so the guidance is actually seen.
2089            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2090                tracing::trace!("validation: {}", warning.format(input));
2091                if warning.code.surfaces_to_agent() {
2092                    surfaced_warnings.push_str(&warning.format(input));
2093                    surfaced_warnings.push('\n');
2094                }
2095            }
2096        }
2097
2098        // Surface opted-in validation warnings to the streaming frontend once,
2099        // before any command output. The streaming consumer (`-c`, REPL) prints
2100        // per `on_output` and ignores the returned aggregate err; non-streaming
2101        // callers (`kernel.execute`) use a noop callback and read the aggregate
2102        // `result.err` (prepended at each return below). The two paths are
2103        // disjoint, so this prints the advisory exactly once on each.
2104        if !surfaced_warnings.is_empty() {
2105            let mut advisory = ExecResult::success("");
2106            advisory.err = surfaced_warnings.clone();
2107            on_output(&advisory);
2108        }
2109
2110        let mut result = ExecResult::success("");
2111
2112        // Reset cancellation token for this execution.
2113        let cancel = self.reset_cancel();
2114
2115        for stmt in program.statements {
2116            if matches!(stmt, Stmt::Empty) {
2117                continue;
2118            }
2119
2120            // Cancellation checkpoint
2121            if cancel.is_cancelled() {
2122                result.code = 130;
2123                return Ok(result);
2124            }
2125
2126            let flow = self.execute_stmt_flow(&stmt).await?;
2127
2128            // Drain any stderr written by pipeline stages during this statement.
2129            // This captures stderr from intermediate pipeline stages that would
2130            // otherwise be lost (only the last stage's result is returned).
2131            let drained_stderr = {
2132                let mut receiver = self.stderr_receiver.lock().await;
2133                receiver.drain_lossy()
2134            };
2135
2136            match flow {
2137                ControlFlow::Normal(mut r) => {
2138                    if !drained_stderr.is_empty() {
2139                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2140                            r.err.push('\n');
2141                        }
2142                        // Prepend pipeline stderr before the last stage's stderr
2143                        let combined = format!("{}{}", drained_stderr, r.err);
2144                        r.err = combined;
2145                    }
2146                    on_output(&r);
2147                    // Carry the last statement's structured output for MCP TOON encoding.
2148                    // Must be done here (not in accumulate_result) because accumulate_result
2149                    // is also used in loops where per-iteration output would be wrong.
2150                    let last_output = r.output().cloned();
2151                    accumulate_result(&mut result, &r);
2152                    result.set_output(last_output);
2153                }
2154                ControlFlow::Exit { code } => {
2155                    if !drained_stderr.is_empty() {
2156                        result.err.push_str(&drained_stderr);
2157                    }
2158                    result.code = code;
2159                    if !surfaced_warnings.is_empty() {
2160                        result.err = format!("{surfaced_warnings}{}", result.err);
2161                    }
2162                    return Ok(result);
2163                }
2164                ControlFlow::Return { mut value } => {
2165                    if !drained_stderr.is_empty() {
2166                        value.err = format!("{}{}", drained_stderr, value.err);
2167                    }
2168                    on_output(&value);
2169                    // A top-level `return` stops the script, like `exit` —
2170                    // it must not discard prior statements' accumulated
2171                    // output nor let execution continue past it.
2172                    accumulate_result(&mut result, &value);
2173                    if !surfaced_warnings.is_empty() {
2174                        result.err = format!("{surfaced_warnings}{}", result.err);
2175                    }
2176                    return Ok(result);
2177                }
2178                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2179                    if !drained_stderr.is_empty() {
2180                        r.err = format!("{}{}", drained_stderr, r.err);
2181                    }
2182                    on_output(&r);
2183                    accumulate_result(&mut result, &r);
2184                }
2185            }
2186        }
2187
2188        if !surfaced_warnings.is_empty() {
2189            result.err = format!("{surfaced_warnings}{}", result.err);
2190        }
2191        Ok(result)
2192    }
2193
2194    /// Execute a single statement, returning control flow information.
2195    fn execute_stmt_flow<'a>(
2196        &'a self,
2197        stmt: &'a Stmt,
2198    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2199        // No per-statement span here: `execute_stmt_flow` is the largest future
2200        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2201        // the span's state through every `.await` at every level, costing native
2202        // stack per level (GH #48). Coarser spans on the outer execute entries
2203        // remain. See item 3 of the #48 burndown.
2204        Box::pin(async move {
2205        match stmt {
2206            Stmt::Assignment(assign) => {
2207                // Use async evaluator to support command substitution
2208                let value = self.eval_expr_async(&assign.value).await
2209                    .context("failed to evaluate assignment")?;
2210                let mut scope = self.scope.write().await;
2211                if assign.path.segments.len() == 1 {
2212                    // Plain `NAME=value` — no subscript, so `local` applies.
2213                    if assign.local {
2214                        // local: set in innermost (current function) frame
2215                        scope.set(assign.name(), value.clone());
2216                    } else {
2217                        // non-local: update existing or create in root frame
2218                        scope.set_global(assign.name(), value.clone());
2219                    }
2220                } else {
2221                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2222                    // mutates the existing root wherever it lives — `local` is
2223                    // meaningless here (see docs/arrays-and-hashes.md, "Assignment
2224                    // lvalues").
2225                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2226                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2227                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2228                        ),
2229                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2230                    })?;
2231                }
2232                drop(scope);
2233
2234                // Assignments don't produce output (like sh)
2235                Ok(ControlFlow::ok(ExecResult::success("")))
2236            }
2237            Stmt::Command(cmd) => {
2238                // Route single commands through execute_pipeline for a unified path.
2239                // This ensures all commands go through the dispatcher chain.
2240                let pipeline = crate::ast::Pipeline {
2241                    commands: vec![cmd.clone()],
2242                    background: false,
2243                };
2244                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2245                self.update_last_result(&result).await;
2246
2247                // Check for error exit mode (set -e)
2248                if !result.ok() {
2249                    let scope = self.scope.read().await;
2250                    if scope.error_exit_enabled() {
2251                        return Ok(ControlFlow::exit_code(result.code));
2252                    }
2253                }
2254
2255                Ok(ControlFlow::ok(result))
2256            }
2257            Stmt::Pipeline(pipeline) => {
2258                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2259                self.update_last_result(&result).await;
2260
2261                // Check for error exit mode (set -e)
2262                if !result.ok() {
2263                    let scope = self.scope.read().await;
2264                    if scope.error_exit_enabled() {
2265                        return Ok(ControlFlow::exit_code(result.code));
2266                    }
2267                }
2268
2269                Ok(ControlFlow::ok(result))
2270            }
2271            Stmt::If(if_stmt) => {
2272                // Use async evaluator to support command substitution in conditions
2273                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2274
2275                let branch = if is_truthy(&cond_value) {
2276                    &if_stmt.then_branch
2277                } else {
2278                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2279                };
2280
2281                let mut result = ExecResult::success("");
2282                for stmt in branch {
2283                    let flow = self.execute_stmt_flow(stmt).await?;
2284                    match flow {
2285                        ControlFlow::Normal(r) => {
2286                            accumulate_result(&mut result, &r);
2287                            self.drain_stderr_into(&mut result).await;
2288                        }
2289                        other => {
2290                            self.drain_stderr_into(&mut result).await;
2291                            return Ok(other);
2292                        }
2293                    }
2294                }
2295                Ok(ControlFlow::ok(result))
2296            }
2297            Stmt::For(for_loop) => {
2298                // Evaluate all items and collect values for iteration
2299                // Use async evaluator to support command substitution like $(seq 1 5)
2300                let mut items: Vec<Value> = Vec::new();
2301                for item_expr in &for_loop.items {
2302                    // Glob expansion in for-loop items: `for f in *.txt`
2303                    if let Expr::GlobPattern(pattern) = item_expr {
2304                        let glob_enabled = {
2305                            let scope = self.scope.read().await;
2306                            scope.glob_enabled()
2307                        };
2308                        if glob_enabled {
2309                            let (paths, cwd) = {
2310                                let ctx = self.exec_ctx.read().await;
2311                                let paths = ctx.expand_glob(pattern).await
2312                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2313                                let cwd = ctx.resolve_path(".");
2314                                (paths, cwd)
2315                            };
2316                            if paths.is_empty() {
2317                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2318                            }
2319                            for path in paths {
2320                                let display = if !pattern.starts_with('/') {
2321                                    path.strip_prefix(&cwd)
2322                                        .unwrap_or(&path)
2323                                        .to_string_lossy().into_owned()
2324                                } else {
2325                                    path.to_string_lossy().into_owned()
2326                                };
2327                                items.push(Value::String(display));
2328                            }
2329                            continue;
2330                        }
2331                    }
2332                    // Track whether this item came from $(cmd); that's the
2333                    // only position where multi-line stdout auto-splits per
2334                    // line. Arrays still spread element-by-element; bare
2335                    // $VAR is rejected upstream by validator E012. See
2336                    // docs/LANGUAGE.md.
2337                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2338                    let item = self.eval_expr_async(item_expr).await?;
2339                    match item {
2340                        // JSON arrays iterate over elements (preferred path
2341                        // when builtins emit .data — seq, jq, cut, find, …)
2342                        Value::Json(serde_json::Value::Array(arr)) => {
2343                            for elem in arr {
2344                                // Envelope-free: an element that happens to be
2345                                // envelope-shaped (e.g. from `fromjson`) is
2346                                // external data, not an internal bytes round-trip,
2347                                // so it must NOT be re-decoded to Value::Bytes.
2348                                items.push(json_to_value_no_envelope(elem));
2349                            }
2350                        }
2351                        // Strings from $(cmd): empty → 0 iterations,
2352                        // multi-line → split per line (trimming trailing
2353                        // newlines and per-line trailing \r), single-line
2354                        // → one iteration. Whitespace within a line is
2355                        // NOT split — the "$VAR with spaces just works"
2356                        // promise is preserved because this only fires
2357                        // in CommandSubst position.
2358                        Value::String(s) if from_command_subst => {
2359                            let trimmed = s.trim_end_matches(['\n', '\r']);
2360                            if trimmed.is_empty() {
2361                                continue;
2362                            }
2363                            if trimmed.contains('\n') {
2364                                for line in trimmed.split('\n') {
2365                                    let line = line.trim_end_matches('\r');
2366                                    items.push(Value::String(line.to_string()));
2367                                }
2368                            } else {
2369                                items.push(Value::String(trimmed.to_string()));
2370                            }
2371                        }
2372                        // Binary isn't iterable — fail loud rather than loop
2373                        // once over an opaque byte blob.
2374                        Value::Bytes(_) => {
2375                            anyhow::bail!(
2376                                "for: cannot iterate over binary data — decode it \
2377                                 (base64/xxd) first"
2378                            );
2379                        }
2380                        // Strings not from $(cmd) stay as one value.
2381                        other => items.push(other),
2382                    }
2383                }
2384
2385                let mut result = ExecResult::success("");
2386                {
2387                    let mut scope = self.scope.write().await;
2388                    scope.push_frame();
2389                }
2390
2391                'outer: for item in items {
2392                    // Cancellation checkpoint per iteration
2393                    if self.is_cancelled() {
2394                        let mut scope = self.scope.write().await;
2395                        scope.pop_frame();
2396                        result.code = 130;
2397                        return Ok(ControlFlow::ok(result));
2398                    }
2399                    {
2400                        let mut scope = self.scope.write().await;
2401                        scope.set(&for_loop.variable, item);
2402                    }
2403                    for stmt in &for_loop.body {
2404                        let mut flow = match self.execute_stmt_flow(stmt).await {
2405                            Ok(f) => f,
2406                            Err(e) => {
2407                                let mut scope = self.scope.write().await;
2408                                scope.pop_frame();
2409                                return Err(e);
2410                            }
2411                        };
2412                        self.drain_stderr_into(&mut result).await;
2413                        match &mut flow {
2414                            ControlFlow::Normal(r) => {
2415                                accumulate_result(&mut result, r);
2416                                if !r.ok() {
2417                                    let scope = self.scope.read().await;
2418                                    if scope.error_exit_enabled() {
2419                                        drop(scope);
2420                                        let mut scope = self.scope.write().await;
2421                                        scope.pop_frame();
2422                                        return Ok(ControlFlow::exit_code(r.code));
2423                                    }
2424                                }
2425                            }
2426                            ControlFlow::Break { .. } => {
2427                                if flow.decrement_level() {
2428                                    accumulate_flow_output(&mut result, &flow);
2429                                    break 'outer;
2430                                }
2431                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2432                                let mut scope = self.scope.write().await;
2433                                scope.pop_frame();
2434                                return Ok(flow);
2435                            }
2436                            ControlFlow::Continue { .. } => {
2437                                if flow.decrement_level() {
2438                                    accumulate_flow_output(&mut result, &flow);
2439                                    continue 'outer;
2440                                }
2441                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2442                                let mut scope = self.scope.write().await;
2443                                scope.pop_frame();
2444                                return Ok(flow);
2445                            }
2446                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2447                                let mut scope = self.scope.write().await;
2448                                scope.pop_frame();
2449                                return Ok(flow);
2450                            }
2451                        }
2452                    }
2453                }
2454
2455                {
2456                    let mut scope = self.scope.write().await;
2457                    scope.pop_frame();
2458                }
2459                Ok(ControlFlow::ok(result))
2460            }
2461            Stmt::While(while_loop) => {
2462                let mut result = ExecResult::success("");
2463
2464                'outer: loop {
2465                    // Evaluate condition - use async to support command substitution
2466                    // Cancellation checkpoint per iteration
2467                    if self.is_cancelled() {
2468                        result.code = 130;
2469                        return Ok(ControlFlow::ok(result));
2470                    }
2471
2472                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2473
2474                    if !is_truthy(&cond_value) {
2475                        break;
2476                    }
2477
2478                    // Execute body
2479                    for stmt in &while_loop.body {
2480                        let mut flow = self.execute_stmt_flow(stmt).await?;
2481                        self.drain_stderr_into(&mut result).await;
2482                        match &mut flow {
2483                            ControlFlow::Normal(r) => {
2484                                accumulate_result(&mut result, r);
2485                                if !r.ok() {
2486                                    let scope = self.scope.read().await;
2487                                    if scope.error_exit_enabled() {
2488                                        return Ok(ControlFlow::exit_code(r.code));
2489                                    }
2490                                }
2491                            }
2492                            ControlFlow::Break { .. } => {
2493                                if flow.decrement_level() {
2494                                    accumulate_flow_output(&mut result, &flow);
2495                                    break 'outer;
2496                                }
2497                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2498                                return Ok(flow);
2499                            }
2500                            ControlFlow::Continue { .. } => {
2501                                if flow.decrement_level() {
2502                                    accumulate_flow_output(&mut result, &flow);
2503                                    continue 'outer;
2504                                }
2505                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2506                                return Ok(flow);
2507                            }
2508                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2509                                return Ok(flow);
2510                            }
2511                        }
2512                    }
2513                }
2514
2515                Ok(ControlFlow::ok(result))
2516            }
2517            Stmt::Case(case_stmt) => {
2518                // Evaluate the expression to match against. Text sink: a
2519                // `case $bin in ...)` pattern match on binary goes loud
2520                // rather than glob-matching against the `[binary: N bytes]`
2521                // placeholder (Decision E — same class as `==`/`in`).
2522                let match_value = {
2523                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2524                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2525                };
2526
2527                // Try each branch until we find a match
2528                for branch in &case_stmt.branches {
2529                    let matched = branch.patterns.iter().any(|pattern| {
2530                        glob_match(pattern, &match_value)
2531                    });
2532
2533                    if matched {
2534                        // Execute the branch body
2535                        let mut result = ExecResult::success("");
2536                        for stmt in &branch.body {
2537                            let flow = self.execute_stmt_flow(stmt).await?;
2538                            match flow {
2539                                ControlFlow::Normal(r) => {
2540                                    accumulate_result(&mut result, &r);
2541                                    self.drain_stderr_into(&mut result).await;
2542                                }
2543                                other => {
2544                                    self.drain_stderr_into(&mut result).await;
2545                                    return Ok(other);
2546                                }
2547                            }
2548                        }
2549                        return Ok(ControlFlow::ok(result));
2550                    }
2551                }
2552
2553                // No match - return success with empty output (like sh)
2554                Ok(ControlFlow::ok(ExecResult::success("")))
2555            }
2556            Stmt::Break(levels) => {
2557                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2558            }
2559            Stmt::Continue(levels) => {
2560                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2561            }
2562            Stmt::Return(expr) => {
2563                // return [N] - N becomes the exit code, NOT stdout
2564                // Shell semantics: return sets exit code, doesn't produce output
2565                let result = if let Some(e) = expr {
2566                    let val = self.eval_expr_async(e).await?;
2567                    let code = crate::interpreter::value_to_exit_code(&val)
2568                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2569                    ExecResult::from_parts(code, String::new(), String::new(), None)
2570                } else {
2571                    ExecResult::success("")
2572                };
2573                Ok(ControlFlow::return_value(result))
2574            }
2575            Stmt::Exit(expr) => {
2576                let code = if let Some(e) = expr {
2577                    let val = self.eval_expr_async(e).await?;
2578                    crate::interpreter::value_to_exit_code(&val)
2579                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2580                } else {
2581                    0
2582                };
2583                Ok(ControlFlow::exit_code(code))
2584            }
2585            Stmt::ToolDef(tool_def) => {
2586                let mut user_tools = self.user_tools.write().await;
2587                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2588                Ok(ControlFlow::ok(ExecResult::success("")))
2589            }
2590            Stmt::AndChain { left, right } => {
2591                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2592                // Suppress errexit for the left side — && handles failure itself.
2593                {
2594                    let mut scope = self.scope.write().await;
2595                    scope.suppress_errexit();
2596                }
2597                let left_flow = match self.execute_stmt_flow(left).await {
2598                    Ok(f) => f,
2599                    Err(e) => {
2600                        let mut scope = self.scope.write().await;
2601                        scope.unsuppress_errexit();
2602                        return Err(e);
2603                    }
2604                };
2605                {
2606                    let mut scope = self.scope.write().await;
2607                    scope.unsuppress_errexit();
2608                }
2609                match left_flow {
2610                    ControlFlow::Normal(mut left_result) => {
2611                        self.drain_stderr_into(&mut left_result).await;
2612                        self.update_last_result(&left_result).await;
2613                        if left_result.ok() {
2614                            let right_flow = self.execute_stmt_flow(right).await?;
2615                            match right_flow {
2616                                ControlFlow::Normal(mut right_result) => {
2617                                    self.drain_stderr_into(&mut right_result).await;
2618                                    self.update_last_result(&right_result).await;
2619                                    let mut combined = left_result;
2620                                    accumulate_result(&mut combined, &right_result);
2621                                    Ok(ControlFlow::ok(combined))
2622                                }
2623                                other => Ok(other),
2624                            }
2625                        } else {
2626                            Ok(ControlFlow::ok(left_result))
2627                        }
2628                    }
2629                    _ => Ok(left_flow),
2630                }
2631            }
2632            Stmt::OrChain { left, right } => {
2633                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2634                // Suppress errexit for the left side — || handles failure itself.
2635                {
2636                    let mut scope = self.scope.write().await;
2637                    scope.suppress_errexit();
2638                }
2639                let left_flow = match self.execute_stmt_flow(left).await {
2640                    Ok(f) => f,
2641                    Err(e) => {
2642                        let mut scope = self.scope.write().await;
2643                        scope.unsuppress_errexit();
2644                        return Err(e);
2645                    }
2646                };
2647                {
2648                    let mut scope = self.scope.write().await;
2649                    scope.unsuppress_errexit();
2650                }
2651                match left_flow {
2652                    ControlFlow::Normal(mut left_result) => {
2653                        self.drain_stderr_into(&mut left_result).await;
2654                        self.update_last_result(&left_result).await;
2655                        if !left_result.ok() {
2656                            let right_flow = self.execute_stmt_flow(right).await?;
2657                            match right_flow {
2658                                ControlFlow::Normal(mut right_result) => {
2659                                    self.drain_stderr_into(&mut right_result).await;
2660                                    self.update_last_result(&right_result).await;
2661                                    let mut combined = left_result;
2662                                    accumulate_result(&mut combined, &right_result);
2663                                    Ok(ControlFlow::ok(combined))
2664                                }
2665                                other => Ok(other),
2666                            }
2667                        } else {
2668                            Ok(ControlFlow::ok(left_result))
2669                        }
2670                    }
2671                    _ => Ok(left_flow), // Propagate non-normal flow
2672                }
2673            }
2674            Stmt::Test(test_expr) => {
2675                let is_true = self.eval_test_async(test_expr).await?;
2676                let result = if is_true {
2677                    ExecResult::success("")
2678                } else {
2679                    ExecResult::failure(1, "")
2680                };
2681                // A bare test writes `$?` and honors `set -e` like any command
2682                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2683                // safe: the chain arms suppress errexit around their left side,
2684                // and `if`/`while` conditions evaluate as expressions, never
2685                // through this statement arm.
2686                self.update_last_result(&result).await;
2687                if !result.ok() {
2688                    let scope = self.scope.read().await;
2689                    if scope.error_exit_enabled() {
2690                        return Ok(ControlFlow::exit_code(result.code));
2691                    }
2692                }
2693                Ok(ControlFlow::ok(result))
2694            }
2695            Stmt::EnvScoped { assignments, body } => {
2696                // Inline env prefix (`NAME=value ... command`): apply the
2697                // assignments as EXPORTED vars in a fresh frame so the command
2698                // — and its subprocess environment — sees them, then unwind so
2699                // they do NOT persist (bash-style command-scoped env). Values
2700                // evaluate left-to-right with earlier ones already in scope, so
2701                // `A=1 B=$A cmd` works.
2702                {
2703                    let mut scope = self.scope.write().await;
2704                    scope.push_frame();
2705                }
2706                let mut prior_export: Vec<(String, bool)> =
2707                    Vec::with_capacity(assignments.len());
2708                let mut setup_err: Option<anyhow::Error> = None;
2709                for assign in assignments {
2710                    match self.eval_expr_async(&assign.value).await {
2711                        Ok(value) => {
2712                            let mut scope = self.scope.write().await;
2713                            prior_export
2714                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
2715                            scope.set_exported(assign.name(), value);
2716                        }
2717                        Err(e) => {
2718                            setup_err = Some(e);
2719                            break;
2720                        }
2721                    }
2722                }
2723
2724                let flow = if setup_err.is_none() {
2725                    self.execute_stmt_flow(body).await
2726                } else {
2727                    Ok(ControlFlow::ok(ExecResult::success("")))
2728                };
2729
2730                // Unwind the env frame and restore export marks unconditionally
2731                // (names that were not exported before must not stay exported).
2732                {
2733                    let mut scope = self.scope.write().await;
2734                    scope.pop_frame();
2735                    for (name, was_exported) in &prior_export {
2736                        if !*was_exported {
2737                            scope.unexport(name);
2738                        }
2739                    }
2740                }
2741
2742                match setup_err {
2743                    Some(e) => Err(e),
2744                    None => flow,
2745                }
2746            }
2747            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2748        }
2749        })
2750    }
2751
2752    /// Build a boxed per-command `ExecContext` snapshot from the persistent
2753    /// kernel state (`ec`/`scope`, both already locked by the caller).
2754    ///
2755    /// Sync on purpose: the ~30 field clones live in this transient frame rather
2756    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2757    /// — not the 960-byte struct — rides the dispatch await at every recursion
2758    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2759    /// per-site differences (the pipeline runner uses the kernel's own cancel
2760    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2761    /// they're parameters; every other field is snapshotted identically.
2762    fn snapshot_exec_ctx(
2763        &self,
2764        ec: &ExecContext,
2765        scope: &Scope,
2766        pipeline_position: PipelinePosition,
2767        cancel: tokio_util::sync::CancellationToken,
2768    ) -> Box<ExecContext> {
2769        Box::new(ExecContext {
2770            backend: ec.backend.clone(),
2771            scope: scope.clone(),
2772            cwd: ec.cwd.clone(),
2773            prev_cwd: ec.prev_cwd.clone(),
2774            stdin: ec.stdin.clone(),
2775            stdin_data: ec.stdin_data.clone(),
2776            stdin_data_rx: None,
2777            pipe_stdin: None,
2778            pipe_stdout: None,
2779            stderr: ec.stderr.clone(),
2780            tool_schemas: ec.tool_schemas.clone(),
2781            tools: ec.tools.clone(),
2782            job_manager: ec.job_manager.clone(),
2783            pipeline_position,
2784            interactive: self.interactive,
2785            aliases: ec.aliases.clone(),
2786            ignore_config: ec.ignore_config.clone(),
2787            output_limit: ec.output_limit.clone(),
2788            allow_external_commands: self.allow_external_commands,
2789            nonce_store: ec.nonce_store.clone(),
2790            trash_backend: ec.trash_backend.clone(),
2791            #[cfg(all(unix, feature = "subprocess"))]
2792            terminal_state: ec.terminal_state.clone(),
2793            dispatcher: self.dispatcher(),
2794            cancel,
2795            output_format: None,
2796            current_invocation: None,
2797            vfs_budget: self.vfs_budget.clone(),
2798            watchdog: ec.watchdog.clone(),
2799            #[cfg(all(feature = "localfs", feature = "overlay"))]
2800            overlay_handle: self.overlay_handle.clone(),
2801        })
2802    }
2803
2804    /// Execute a pipeline.
2805    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2806        if pipeline.commands.is_empty() {
2807            return Ok(ExecResult::success(""));
2808        }
2809
2810        // Handle background execution (`&` operator)
2811        if pipeline.background {
2812            return self.execute_background(pipeline).await;
2813        }
2814
2815        // All commands go through the runner with the Kernel as dispatcher.
2816        // This is the single execution path — no fast path for single commands.
2817        //
2818        // IMPORTANT: We snapshot exec_ctx into a local context and release the
2819        // lock before running. This prevents deadlocks when dispatch_command
2820        // is called from within the pipeline and recursively triggers another
2821        // pipeline (e.g., via user-defined tools).
2822        let (mut ctx, has_pipe_stdin) = {
2823            let ec = self.exec_ctx.read().await;
2824            let scope = self.scope.read().await;
2825            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2826            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2827            // the consume-once block below, so note its presence here.
2828            let has_pipe_stdin = ec.pipe_stdin.is_some();
2829            // The pipeline runner drives stage 0 with the first stage's stdin
2830            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2831            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2832            // and uses the kernel's own cancel token so a `cancel()` reaches the
2833            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2834            let cancel = {
2835                #[allow(clippy::expect_used)]
2836                let token = self.cancel_token.lock().expect("cancel_token poisoned");
2837                token.clone()
2838            };
2839            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2840        }; // locks released
2841
2842        // Consume-once: move/clear the seeded stdin sources from the persistent
2843        // exec_ctx now that this pipeline's ctx owns them, so a later statement
2844        // in the same call (`cat ; cat`) does not re-receive them — matching
2845        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2846        // (the ctx above was built with `pipe_stdin: None`).
2847        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2848            let mut ec = self.exec_ctx.write().await;
2849            ctx.pipe_stdin = ec.pipe_stdin.take();
2850            ec.stdin = None;
2851            ec.stdin_data = None;
2852        }
2853
2854        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2855
2856        // Post-hoc spill check (catches builtins and fast external commands)
2857        if ctx.output_limit.is_enabled() {
2858            let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2859        }
2860
2861        // Signal spill with exit 3; agent reads the spill file directly
2862        // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2863        if result.did_spill {
2864            result.original_code = Some(result.code);
2865            result.code = 3;
2866        }
2867
2868        // Sync changes back from context
2869        {
2870            let mut ec = self.exec_ctx.write().await;
2871            ec.cwd = ctx.cwd.clone();
2872            ec.prev_cwd = ctx.prev_cwd.clone();
2873            ec.aliases = ctx.aliases.clone();
2874            ec.ignore_config = ctx.ignore_config.clone();
2875            ec.output_limit = ctx.output_limit.clone();
2876        }
2877        {
2878            let mut scope = self.scope.write().await;
2879            *scope = ctx.scope.clone();
2880        }
2881
2882        Ok(result)
2883    }
2884
2885    /// Execute a pipeline in the background.
2886    ///
2887    /// The command is spawned as a tokio task, registered with the JobManager,
2888    /// and its output is captured via BoundedStreams. The job is observable via
2889    /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2890    ///
2891    /// Returns immediately with a job ID like "[1]".
2892    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2893    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2894        use tokio::sync::oneshot;
2895
2896        // Format the command for display in /v/jobs/{id}/command
2897        let command_str = self.format_pipeline(pipeline);
2898
2899        // Create bounded streams for output capture
2900        let stdout = Arc::new(BoundedStream::default_size());
2901        let stderr = Arc::new(BoundedStream::default_size());
2902
2903        // Create channel for result notification
2904        let (tx, rx) = oneshot::channel();
2905
2906        // Register with JobManager to get job ID and create VFS entries
2907        let job_id = self.jobs.register_with_streams(
2908            command_str.clone(),
2909            rx,
2910            stdout.clone(),
2911            stderr.clone(),
2912        ).await;
2913
2914        // Fork the kernel for this background job. The fork snapshots the
2915        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2916        // while sharing the job manager, VFS, and tool registry. The fork's
2917        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2918        // is available here — something BackendDispatcher couldn't provide.
2919        //
2920        // The fork gets its own cancellation token (recorded on the job so
2921        // `kill %N` can stop the job — including a pure-builtin job with no OS
2922        // process group) and is stamped with the job id so any external
2923        // command it spawns records its process group for `kill -<sig> %N`.
2924        let cancel = tokio_util::sync::CancellationToken::new();
2925        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2926        let fork = self.fork_for_background(cancel, job_id).await;
2927        let runner = self.runner.clone();
2928        let commands = pipeline.commands.clone();
2929
2930        // Snapshot the fork's exec_ctx for the spawned task. We have to do
2931        // this before tokio::spawn because the fork's exec_ctx is behind a
2932        // tokio RwLock and we want the spawned task to own its ctx.
2933        let mut bg_ctx = {
2934            let ec = fork.exec_ctx.read().await;
2935            ec.child_for_pipeline()
2936        };
2937        bg_ctx.scope = fork.scope.read().await.clone();
2938        // The fork's dispatcher points at the fork itself; set it here so
2939        // builtins inside the background task (e.g. timeout) re-dispatch
2940        // through the fork, not the parent.
2941        bg_ctx.dispatcher = fork.dispatcher();
2942
2943        // Spawn the background task. Propagate the embedder's trace context
2944        // across the spawn boundary so the job's spans stay in the same trace.
2945        tokio::spawn(crate::telemetry::bind_current_context(async move {
2946            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2947            // gives us that (Kernel implements CommandDispatcher).
2948            let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2949
2950            // Write output to streams
2951            let text = result.text_out();
2952            if !text.is_empty() {
2953                stdout.write(text.as_bytes()).await;
2954            }
2955            if !result.err.is_empty() {
2956                stderr.write(result.err.as_bytes()).await;
2957            }
2958
2959            // Close streams
2960            stdout.close().await;
2961            stderr.close().await;
2962
2963            // Send result to JobManager (ignore error if receiver dropped)
2964            let _ = tx.send(result);
2965        }));
2966
2967        Ok(ExecResult::success(format!("[{}]", job_id)))
2968    }
2969
2970    /// Format a pipeline as a command string for display.
2971    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
2972        pipeline.commands
2973            .iter()
2974            .map(|cmd| {
2975                let mut parts = vec![cmd.name.clone()];
2976                for arg in &cmd.args {
2977                    match arg {
2978                        Arg::Positional(expr) => {
2979                            parts.push(self.format_expr(expr));
2980                        }
2981                        Arg::Named { key, value } => {
2982                            parts.push(format!("--{}={}", key, self.format_expr(value)));
2983                        }
2984                        Arg::WordAssign { key, value } => {
2985                            parts.push(format!("{}={}", key, self.format_expr(value)));
2986                        }
2987                        Arg::ShortFlag(name) => {
2988                            parts.push(format!("-{}", name));
2989                        }
2990                        Arg::LongFlag(name) => {
2991                            parts.push(format!("--{}", name));
2992                        }
2993                        Arg::DoubleDash => {
2994                            parts.push("--".to_string());
2995                        }
2996                    }
2997                }
2998                parts.join(" ")
2999            })
3000            .collect::<Vec<_>>()
3001            .join(" | ")
3002    }
3003
3004    /// Format an expression as a string for display.
3005    fn format_expr(&self, expr: &Expr) -> String {
3006        match expr {
3007            Expr::Literal(Value::String(s)) => {
3008                if s.contains(' ') || s.contains('"') {
3009                    format!("'{}'", s.replace('\'', "\\'"))
3010                } else {
3011                    s.clone()
3012                }
3013            }
3014            Expr::Literal(Value::Int(i)) => i.to_string(),
3015            Expr::Literal(Value::Float(f)) => f.to_string(),
3016            Expr::Literal(Value::Bool(b)) => b.to_string(),
3017            Expr::Literal(Value::Null) => "null".to_string(),
3018            Expr::VarRef(path) => {
3019                let mut name = String::new();
3020                for (i, seg) in path.segments.iter().enumerate() {
3021                    match seg {
3022                        crate::ast::VarSegment::Field(f) => {
3023                            if i > 0 {
3024                                name.push('.');
3025                            }
3026                            name.push_str(f);
3027                        }
3028                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3029                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3030                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3031                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3032                            "[{}:{}]",
3033                            a.map(|n| n.to_string()).unwrap_or_default(),
3034                            b.map(|n| n.to_string()).unwrap_or_default()
3035                        )),
3036                    }
3037                }
3038                format!("${{{}}}", name)
3039            }
3040            Expr::Interpolated(_) => "\"...\"".to_string(),
3041            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3042            _ => "...".to_string(),
3043        }
3044    }
3045
3046    /// Execute a single command.
3047    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3048        self.execute_command_depth(name, args, 0).await
3049    }
3050
3051    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3052        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3053        // most-recursed function on the ring, so wrapping its future in
3054        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3055        // every level (GH #48, item 3). A `trace!` event records the command name
3056        // without living in the future.
3057        tracing::trace!(command = %name, alias_depth, "dispatch");
3058        // Special built-ins. `SpecialForm::from_name` is the single source of
3059        // truth (shared with `classify_command` via `is_runtime_special_form`),
3060        // and this match on the enum is *exhaustive* — adding a special-form is a
3061        // compile error until both the name mapping and the behavior here are
3062        // updated. A name that is not a special-form falls through to alias /
3063        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3064        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3065            return match form {
3066                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3067                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3068                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3069            };
3070        }
3071
3072        // Alias expansion (with recursion limit)
3073        if alias_depth < 10 {
3074            let alias_value = {
3075                let ctx = self.exec_ctx.read().await;
3076                ctx.aliases.get(name).cloned()
3077            };
3078            if let Some(alias_val) = alias_value {
3079                // Split alias value into command + args
3080                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3081                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3082                    let mut new_args: Vec<Arg> = alias_args
3083                        .iter()
3084                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3085                        .collect();
3086                    new_args.extend_from_slice(args);
3087                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3088                }
3089            }
3090        }
3091
3092        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3093        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3094            return match self.tools.get(builtin_name) {
3095                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3096                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3097            };
3098        }
3099
3100        // Check user-defined tools first
3101        {
3102            let user_tools = self.user_tools.read().await;
3103            if let Some(tool_def) = user_tools.get(name) {
3104                let tool_def = tool_def.clone();
3105                drop(user_tools);
3106                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3107            }
3108        }
3109
3110        // Look up builtin tool
3111        let tool = match self.tools.get(name) {
3112            Some(t) => t,
3113            None => {
3114                // Try executing as .kai script from PATH
3115                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3116                    return Ok(result);
3117                }
3118                // Try executing as external command from PATH — boxed because its
3119                // future is the heaviest branch here (holds a `tokio::process::Command`,
3120                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3121                // it inline fattens every `execute_command_depth` frame on the recursion
3122                // ring even when the command is a builtin.
3123                if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3124                    return Ok(result);
3125                }
3126
3127                // Try backend-registered tools (embedder engines, etc.)
3128                // Look up tool schema for positional→named mapping.
3129                // Clone backend and drop read lock before awaiting (may involve network I/O).
3130                // Backend tools expect named JSON params, so enable positional mapping.
3131                let backend = self.exec_ctx.read().await.backend.clone();
3132                let tool_schema = backend
3133                    .get_tool(name)
3134                    .await
3135                    .unwrap_or_else(|e| {
3136                        // Schema lookup failing just means positionals won't
3137                        // get name-mapped below — `call_tool` is still
3138                        // attempted. Trace it so the degradation is visible
3139                        // rather than silently swallowed.
3140                        tracing::debug!("backend get_tool error for {name}: {e}");
3141                        None
3142                    })
3143                    .map(|t| {
3144                    let mut s = t.schema;
3145                    // Flat backend/MCP tools expect named JSON params, so map
3146                    // bare positionals onto named params. Subcommand-aware tools
3147                    // route positionals through the subcommand path and declare
3148                    // map_positionals per leaf (kj keeps it false so it re-parses
3149                    // the argv with its own clap) — don't blanket-override them.
3150                    if s.subcommands.is_empty() {
3151                        s.map_positionals = true;
3152                    }
3153                    s
3154                });
3155                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3156                let mut ctx = self.exec_ctx.write().await;
3157                {
3158                    let scope = self.scope.read().await;
3159                    ctx.scope = scope.clone();
3160                }
3161                let backend = ctx.backend.clone();
3162                match backend.call_tool(name, tool_args, &mut *ctx).await {
3163                    Ok(tool_result) => {
3164                        let mut scope = self.scope.write().await;
3165                        *scope = ctx.scope.clone();
3166                        // Preserve every field (data/content_type/baggage/latch,
3167                        // not just stdout text) — this is the embedder seam:
3168                        // `x=$(embedder_tool)` and structured iteration over
3169                        // its result depend on `.data` surviving the crossing
3170                        // back into the kernel.
3171                        return Ok(ExecResult::from(tool_result));
3172                    }
3173                    Err(BackendError::ToolNotFound(_)) => {
3174                        // The backend confirms no such tool exists — fall
3175                        // through to "command not found" below.
3176                    }
3177                    Err(e) => {
3178                        // The tool was found (dispatch reached real
3179                        // execution) but running it failed — a genuine
3180                        // execution error, not "command not found". Surface
3181                        // it loudly instead of masking it as exit-127.
3182                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3183                    }
3184                }
3185
3186                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3187            }
3188        };
3189
3190        // Build arguments (async to support command substitution, schema-aware for flag values)
3191        let schema = tool.schema();
3192        let tool_args = self.build_args_async(args, Some(&schema)).await?;
3193
3194        // --help / -h: show the generic whole-tool help, unless either the tool's
3195        // root schema claims that flag OR the tool owns its output. Owned-output
3196        // tools re-parse their own argv and route their own `--help` — including
3197        // leaf/subcommand help — through their internal (clap) parser, so the root
3198        // schema can't express "this leaf claims help" and intercepting here would
3199        // render top-level help and return before `execute()` ever sees the
3200        // request (#51). Pass it through and let the tool render its own help.
3201        let schema_claims = |flag: &str| -> bool {
3202            let bare = flag.trim_start_matches('-');
3203            schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3204        };
3205        let wants_help = !schema.owns_output
3206            && ((tool_args.flags.contains("help") && !schema_claims("help"))
3207                || (tool_args.flags.contains("h") && !schema_claims("-h")));
3208        if wants_help {
3209            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3210            let ctx = self.exec_ctx.read().await;
3211            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3212            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3213        }
3214
3215        // `owns_output` is the only thing read from `schema` after the recursive
3216        // `tool.execute` await below; capture the bool and drop the (heap-backed)
3217        // `ToolSchema` now so it doesn't ride that await in every command's frame
3218        // (GH #48, item 7).
3219        let owns_output = schema.owns_output;
3220        drop(schema);
3221
3222        // Snapshot exec_ctx into a local context and release the write lock
3223        // before calling tool.execute. Holding the write across tool execution
3224        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3225        // (timeout, scatter) — the inner dispatch_command needs its own
3226        // exec_ctx.write() and would block forever.
3227        let mut ctx = {
3228            let ec = self.exec_ctx.write().await;
3229            let scope = self.scope.read().await;
3230            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3231            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3232            // child token — e.g. timeout's — reaches the spawned external via
3233            // wait_or_kill; it falls back to the kernel's own token on a
3234            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3235            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3236        }; // both locks released — tool.execute can re-dispatch safely
3237
3238        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3239        // semantics): take() so a later dispatch doesn't see stale stdin.
3240        // Done after the snapshot above so we hold the write briefly.
3241        {
3242            let mut ec = self.exec_ctx.write().await;
3243            ctx.stdin = ec.stdin.take();
3244            ctx.stdin_data = ec.stdin_data.take();
3245            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3246            ctx.pipe_stdin = ec.pipe_stdin.take();
3247            ctx.pipe_stdout = ec.pipe_stdout.take();
3248        }
3249
3250        // Honor --json before the builtin runs so its setting survives a clap
3251        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3252        // --json on the floor when `try_parse_from` returns Err early).
3253        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3254        GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3255
3256        // Capture the exact invocation at the dispatch seam so a latch producer
3257        // (`latch_result`/`gate_overwrites`) can stamp it into the LatchRequest
3258        // for a precise `Kernel::confirm` replay — no re-parsing of the human
3259        // `hint`. `to_argv()` is computed before `tool_args` moves into execute.
3260        //
3261        // Captured unconditionally, NOT gated on `latch_enabled`: `kaish-trash
3262        // empty` gates every time (it's inherently destructive, independent of
3263        // `set -o latch`), so a `latch_enabled`-only gate would leave its
3264        // `tool`/`argv` empty and break `confirm`. The cost is a small argv
3265        // clone per command — marginal beside the per-command ExecContext
3266        // snapshot above — and it does NOT reintroduce the deep-`$()` stack
3267        // overflow (that was the inline `LatchRequest` in `ExecResult`, now
3268        // boxed; the capture's temporaries don't survive into the recursive
3269        // `tool.execute` below).
3270        ctx.current_invocation = Some(Box::new((name.to_string(), tool_args.to_argv())));
3271
3272        let result = tool.execute(tool_args, &mut *ctx).await;
3273
3274        // Sync mutations back. Tools may have changed scope (set/cd),
3275        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3276        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3277        // hands them back to the pipeline runner — the runner uses
3278        // stage_ctx.pipe_stdout to write the result to the next stage when
3279        // the tool itself didn't take and write to it.
3280        {
3281            let mut scope = self.scope.write().await;
3282            *scope = ctx.scope.clone();
3283        }
3284        {
3285            let mut ec = self.exec_ctx.write().await;
3286            ec.cwd = ctx.cwd;
3287            ec.prev_cwd = ctx.prev_cwd;
3288            ec.aliases = ctx.aliases;
3289            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3290            // mutate the runtime output limit; without this sync the change is
3291            // dropped here and never reaches dispatch_command's read-back, so
3292            // it would not survive past the current statement.
3293            ec.output_limit = ctx.output_limit.clone();
3294            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3295            // was missing from this sync, so every runtime ignore mutation
3296            // silently died at the end of its own statement — including the
3297            // documented `kaish-ignore add .gitignore` rc-file recipe.
3298            ec.ignore_config = ctx.ignore_config.clone();
3299            ec.pipe_stdin = ctx.pipe_stdin.take();
3300            ec.pipe_stdout = ctx.pipe_stdout.take();
3301        }
3302
3303        // Builtins parse --json via the GlobalFlags flatten in their clap
3304        // struct and write ctx.output_format. The kernel applies it — unless the
3305        // tool owns its own output (renders --json itself), in which case we
3306        // leave its bytes untouched.
3307        let result = finalize_output(result, ctx.output_format, owns_output);
3308
3309        Ok(result)
3310    }
3311
3312    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3313    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3314    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3315    /// unexpanded rather than leaking the host home directory.
3316    async fn scope_home(&self) -> Option<String> {
3317        match self.scope.read().await.get("HOME") {
3318            Some(Value::String(s)) => Some(s.clone()),
3319            _ => None,
3320        }
3321    }
3322
3323    // (see `push_repeatable_value` below for the repeatable-flag accumulation.)
3324
3325    /// Pull `consumes` positional args after a non-bool flag and stash them
3326    /// on `tool_args.named` under the canonical param name.
3327    ///
3328    /// - `consumes == 1` (non-repeatable) keeps the historical contract: a
3329    ///   single scalar value (last write wins on the rare duplicate).
3330    /// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
3331    ///   inside `named[canonical] = Value::Json(Array(...))`, preserving
3332    ///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
3333    ///   a repeated single-value flag must keep every value, not silently drop
3334    ///   all but the last (a "no silent corruption" violation).
3335    /// - `consumes > 1` accumulates each occurrence as an inner
3336    ///   `serde_json::Value::Array` inside `named[canonical] =
3337    ///   Value::Json(Array(...))`, preserving invocation order. This is the
3338    ///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
3339    ///
3340    /// Errors loudly if the flag is missing required positionals — matches
3341    /// kaish's "no silent fallback" posture and mirrors real jq, which
3342    /// errors on `--arg NAME` with no value.
3343    #[allow(clippy::too_many_arguments)]
3344    async fn consume_flag_positionals(
3345        &self,
3346        args: &[Arg],
3347        flag_name: &str,
3348        canonical: &str,
3349        consumes: usize,
3350        repeatable: bool,
3351        positional_indices: &[usize],
3352        consumed: &mut std::collections::HashSet<usize>,
3353        current_idx: usize,
3354        tool_args: &mut ToolArgs,
3355    ) -> Result<()> {
3356        let home = self.scope_home().await;
3357        let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
3358        for _ in 0..consumes.max(1) {
3359            // A `key=value` (WordAssign) token is consumable only by a
3360            // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
3361            // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
3362            // filter` would reassemble `x=1` into the first slot and steal the
3363            // filter into the second. Multi-value flags take plain positionals.
3364            let allow_word_assign = consumes <= 1;
3365            let next_pos = positional_indices
3366                .iter()
3367                .find(|idx| {
3368                    **idx > current_idx
3369                        && !consumed.contains(idx)
3370                        && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
3371                })
3372                .copied();
3373            match next_pos {
3374                Some(pos_idx) => match &args[pos_idx] {
3375                    Arg::Positional(expr) => {
3376                        let value = self.eval_expr_async(expr).await?;
3377                        let value = apply_tilde_expansion(value, home.as_deref());
3378                        collected.push(value);
3379                        consumed.insert(pos_idx);
3380                    }
3381                    // `-v a=1`: reassemble the `key=value` token as the flag's
3382                    // scalar value (see `positional_indices` construction).
3383                    Arg::WordAssign { key, value } => {
3384                        let val = self.eval_expr_async(value).await?;
3385                        let val = apply_tilde_expansion(val, home.as_deref());
3386                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
3387                        // reassemble the `[binary: N bytes]` placeholder into the
3388                        // flag's value — same text-sink boundary as the primary
3389                        // sinks fixed in #93 item 1.
3390                        let val_str = crate::interpreter::value_to_text_sink_named(
3391                            &val,
3392                            "a key=value argument",
3393                        )
3394                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3395                        collected.push(Value::String(format!("{key}={val_str}")));
3396                        consumed.insert(pos_idx);
3397                    }
3398                    _ => {}
3399                },
3400                None => {
3401                    if consumes <= 1 && collected.is_empty() {
3402                        // Back-compat: a flag with no follow-up positional
3403                        // becomes a bare flag. `--path` with nothing after
3404                        // lands in `flags`, same as before this refactor.
3405                        tool_args.flags.insert(flag_name.to_string());
3406                        return Ok(());
3407                    }
3408                    anyhow::bail!(
3409                        "--{flag_name} requires {consumes} argument{}, got {}",
3410                        if consumes == 1 { "" } else { "s" },
3411                        collected.len()
3412                    );
3413                }
3414            }
3415        }
3416
3417        if consumes <= 1 {
3418            if let Some(v) = collected.pop() {
3419                if repeatable {
3420                    push_repeatable_value(tool_args, flag_name, canonical, v)?;
3421                } else {
3422                    tool_args.named.insert(canonical.to_string(), v);
3423                }
3424            }
3425            return Ok(());
3426        }
3427
3428        // Multi-consume: accumulate under named[canonical] as array-of-arrays.
3429        let occ: Vec<serde_json::Value> = collected
3430            .into_iter()
3431            .map(|v| crate::interpreter::value_to_json(&v))
3432            .collect();
3433        let entry = tool_args
3434            .named
3435            .entry(canonical.to_string())
3436            .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
3437        if let Value::Json(serde_json::Value::Array(outer)) = entry {
3438            outer.push(serde_json::Value::Array(occ));
3439        } else {
3440            anyhow::bail!(
3441                "--{flag_name}: named[{canonical}] already holds a non-array value"
3442            );
3443        }
3444        Ok(())
3445    }
3446
3447    /// Build tool arguments from AST args.
3448    ///
3449    /// Uses async evaluation to support command substitution in arguments.
3450    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3451        let mut tool_args = ToolArgs::new();
3452        let home = self.scope_home().await;
3453
3454        // A glob-passthrough tool (`glob`) consumes patterns as data: skip
3455        // argv glob expansion so the pattern reaches the tool as written —
3456        // otherwise `glob **/*.rs` binds the first *matching path* as its
3457        // pattern. The eval fallback turns `Expr::GlobPattern` into its
3458        // literal string.
3459        let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
3460
3461        // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
3462        // in source order with types preserved — operators (`-f`, `=`, `!`) as
3463        // strings, operands keeping their `Value` — leaving `flags`/`named`
3464        // empty. A position-sensitive command needs the *true* argv: an operand
3465        // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
3466        // hoisted into the unordered flag set the normal binder splits into.
3467        // Globs still expand and `~` still resolves, matching normal positional
3468        // binding — so `test -f *.rs` errors on too many args, not a literal
3469        // pattern stat.
3470        if schema.is_some_and(|s| s.raw_argv) {
3471            for arg in args {
3472                match arg {
3473                    Arg::Positional(expr) => {
3474                        let glob = if let Expr::GlobPattern(p) = expr {
3475                            (!glob_passthrough && self.scope.read().await.glob_enabled())
3476                                .then(|| p.clone())
3477                        } else {
3478                            None
3479                        };
3480                        if let Some(pattern) = glob {
3481                            let (paths, cwd) = {
3482                                let ctx = self.exec_ctx.read().await;
3483                                let paths = ctx
3484                                    .expand_glob(&pattern)
3485                                    .await
3486                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3487                                let cwd = ctx.resolve_path(".");
3488                                (paths, cwd)
3489                            };
3490                            if paths.is_empty() {
3491                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3492                            }
3493                            for path in paths {
3494                                let display = if !pattern.starts_with('/') {
3495                                    path.strip_prefix(&cwd)
3496                                        .unwrap_or(&path)
3497                                        .to_string_lossy()
3498                                        .into_owned()
3499                                } else {
3500                                    path.to_string_lossy().into_owned()
3501                                };
3502                                tool_args.positional.push(Value::String(display));
3503                            }
3504                        } else {
3505                            let value = self.eval_expr_async(expr).await?;
3506                            let value = apply_tilde_expansion(value, home.as_deref());
3507                            tool_args.positional.push(value);
3508                        }
3509                    }
3510                    Arg::ShortFlag(name) => {
3511                        tool_args.positional.push(Value::String(format!("-{name}")));
3512                    }
3513                    Arg::LongFlag(name) => {
3514                        tool_args.positional.push(Value::String(format!("--{name}")));
3515                    }
3516                    Arg::Named { key, value } => {
3517                        let val = self.eval_expr_async(value).await?;
3518                        let val = apply_tilde_expansion(val, home.as_deref());
3519                        // Loud on binary (GH #116): `test --k=$BIN` must not
3520                        // silently reassemble the placeholder into the raw-argv
3521                        // positional stream `test` binds against.
3522                        let val_str = crate::interpreter::value_to_text_sink_named(
3523                            &val,
3524                            "a --key=value argument",
3525                        )
3526                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3527                        tool_args
3528                            .positional
3529                            .push(Value::String(format!("--{key}={val_str}")));
3530                    }
3531                    Arg::WordAssign { key, value } => {
3532                        let val = self.eval_expr_async(value).await?;
3533                        let val = apply_tilde_expansion(val, home.as_deref());
3534                        // Loud on binary (GH #116): same reasoning as the Named
3535                        // arm above, for the bare `key=value` raw-argv form.
3536                        let val_str = crate::interpreter::value_to_text_sink_named(
3537                            &val,
3538                            "a key=value argument",
3539                        )
3540                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3541                        tool_args
3542                            .positional
3543                            .push(Value::String(format!("{key}={val_str}")));
3544                    }
3545                    Arg::DoubleDash => {
3546                        tool_args.positional.push(Value::String("--".to_string()));
3547                    }
3548                }
3549            }
3550            return Ok(tool_args);
3551        }
3552
3553        // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
3554        // schemas; pick the leaf the leading positionals route to and bind
3555        // flags against *its* params. Flat tools return the root. select_leaf
3556        // errors (fail loud) if a computed positional sits where a subcommand
3557        // selector is required.
3558        let leaf = match schema {
3559            Some(s) => Some(select_leaf(s, args)?),
3560            None => None,
3561        };
3562        // Bind against the leaf's params, but MERGE the root schema's params on
3563        // top as "global" flags: a value-flag declared at the tool's top level
3564        // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
3565        // it trails the subcommand path (`kj context retag a b --confirm <n>`).
3566        // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
3567        // merge is a harmless no-op.
3568        let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
3569        if let Some(l) = leaf {
3570            param_lookup.extend(schema_param_lookup(l));
3571        }
3572        // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
3573        // not the leaf — it's a property of the command, not the subcommand.
3574        let accepts_word_assign = schema
3575            .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
3576            .unwrap_or(false);
3577
3578        // Track which positional indices have been consumed as flag values
3579        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
3580        let mut past_double_dash = false;
3581
3582        // Indices a value-flag may consume as its value. Positionals always
3583        // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
3584        // itself treat `key=value` as an assignment (everything but
3585        // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
3586        // `-v`, rather than skipping it and grabbing the next positional (the
3587        // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
3588        // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
3589        let positional_indices: Vec<usize> = args.iter().enumerate()
3590            .filter_map(|(i, a)| {
3591                let consumable = matches!(a, Arg::Positional(_))
3592                    || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
3593                consumable.then_some(i)
3594            })
3595            .collect();
3596
3597        let mut i = 0;
3598        while i < args.len() {
3599            match &args[i] {
3600                Arg::DoubleDash => {
3601                    past_double_dash = true;
3602                }
3603                Arg::Positional(expr) => {
3604                    if !consumed.contains(&i) {
3605                        // Glob expansion: bare glob patterns expand to matching files
3606                        if let Expr::GlobPattern(pattern) = expr {
3607                            let glob_enabled = {
3608                                let scope = self.scope.read().await;
3609                                scope.glob_enabled()
3610                            };
3611                            if glob_enabled && !glob_passthrough {
3612                                let (paths, cwd) = {
3613                                    let ctx = self.exec_ctx.read().await;
3614                                    let paths = ctx.expand_glob(pattern).await
3615                                        .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3616                                    let cwd = ctx.resolve_path(".");
3617                                    (paths, cwd)
3618                                };
3619                                if paths.is_empty() {
3620                                    return Err(anyhow::anyhow!("no matches: {}", pattern));
3621                                }
3622                                for path in paths {
3623                                    let display = if !pattern.starts_with('/') {
3624                                        path.strip_prefix(&cwd)
3625                                            .unwrap_or(&path)
3626                                            .to_string_lossy().into_owned()
3627                                    } else {
3628                                        path.to_string_lossy().into_owned()
3629                                    };
3630                                    tool_args.positional.push(Value::String(display));
3631                                }
3632                                i += 1;
3633                                continue;
3634                            }
3635                        }
3636                        let value = self.eval_expr_async(expr).await?;
3637                        let value = apply_tilde_expansion(value, home.as_deref());
3638                        tool_args.positional.push(value);
3639                    }
3640                }
3641                Arg::Named { key, value } => {
3642                    let val = self.eval_expr_async(value).await?;
3643                    let val = apply_tilde_expansion(val, home.as_deref());
3644                    // A repeatable flag in `--flag=value` form must accumulate too,
3645                    // not overwrite — otherwise `--expression=A --expression=B`
3646                    // would silently keep only B, and mixing with the `-e` space
3647                    // form would clobber the array. Route it through the same
3648                    // accumulator the space form uses.
3649                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
3650                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
3651                    } else {
3652                        tool_args.named.insert(key.clone(), val);
3653                    }
3654                }
3655                Arg::WordAssign { key, value } => {
3656                    // Already pulled in as a preceding value-flag's argument
3657                    // (`awk -v a=1`); don't also emit it as a positional.
3658                    if consumed.contains(&i) {
3659                        i += 1;
3660                        continue;
3661                    }
3662                    let val = self.eval_expr_async(value).await?;
3663                    let val = apply_tilde_expansion(val, home.as_deref());
3664                    if accepts_word_assign {
3665                        tool_args.named.insert(key.clone(), val);
3666                    } else {
3667                        // Stringify "key=value" and pass as a positional.
3668                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
3669                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
3670                        // must not silently become a path/operand literally named
3671                        // `foo=[binary: N bytes]`.
3672                        let val_str = crate::interpreter::value_to_text_sink_named(
3673                            &val,
3674                            "a key=value argument",
3675                        )
3676                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3677                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
3678                    }
3679                }
3680                Arg::ShortFlag(name) => {
3681                    if past_double_dash {
3682                        tool_args.positional.push(Value::String(format!("-{name}")));
3683                    } else if name.len() == 1 {
3684                        let flag_name = name.as_str();
3685                        let lookup = param_lookup.get(flag_name);
3686                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3687
3688                        if is_bool {
3689                            tool_args.flags.insert(flag_name.to_string());
3690                        } else {
3691                            // Non-bool: consume `consumes` positionals as value(s)
3692                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
3693                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3694                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3695                            self.consume_flag_positionals(
3696                                args,
3697                                name,
3698                                canonical,
3699                                consumes,
3700                                repeatable,
3701                                &positional_indices,
3702                                &mut consumed,
3703                                i,
3704                                &mut tool_args,
3705                            )
3706                            .await?;
3707                        }
3708                    } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
3709                        // Multi-char short flag matches a schema param (POSIX style: -name value)
3710                        if is_bool_type(typ) {
3711                            tool_args.flags.insert(canonical.to_string());
3712                        } else {
3713                            self.consume_flag_positionals(
3714                                args,
3715                                name,
3716                                canonical,
3717                                consumes,
3718                                repeatable,
3719                                &positional_indices,
3720                                &mut consumed,
3721                                i,
3722                                &mut tool_args,
3723                            )
3724                            .await?;
3725                        }
3726                    } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
3727                        .get(&name[..1])
3728                        .filter(|(_, typ, ..)| !is_bool_type(typ))
3729                    {
3730                        // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
3731                        // `grep -A1`, `sed -e1d`. The first char is a declared
3732                        // value-taking short flag, so the rest of the token is its
3733                        // value — the coreutils idiom. The lexer's flag char class is
3734                        // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
3735                        // (safe to slice) and the tail is a plain literal.
3736                        bind_glued_short_value(
3737                            &mut tool_args,
3738                            &name[..1],
3739                            canonical,
3740                            consumes,
3741                            repeatable,
3742                            name[1..].to_string(),
3743                        )?;
3744                    } else {
3745                        // Multi-char combined short flags. Bool flags stack
3746                        // (`-la`), but the FIRST value-taking flag reached
3747                        // consumes the rest of the token as its glued value
3748                        // (`-ivC3` → C=3) or, if it is the last char, the next
3749                        // positional (`grep -ivC 3` → C=3). Before this, a
3750                        // trailing value-flag was silently treated as a bool,
3751                        // stranding its argument as a stray positional (arity
3752                        // error). Undeclared/bool chars stay bare flags, so a
3753                        // schemaless tool keeps the old all-boolean behavior.
3754                        // The first char being value-taking is handled by the
3755                        // glued arm above, so it never reaches here. The flag
3756                        // char class is ASCII, so byte indexing is char indexing
3757                        // (no `Vec<char>` allocation needed).
3758                        let bytes = name.as_bytes();
3759                        let mut p = 0;
3760                        while p < bytes.len() {
3761                            let key = &name[p..p + 1];
3762                            match param_lookup.get(key) {
3763                                Some(&(canonical, typ, consumes, repeatable))
3764                                    if !is_bool_type(typ) =>
3765                                {
3766                                    let glued = name[p + 1..].to_string();
3767                                    if glued.is_empty() {
3768                                        // Value flag is the last char: take the
3769                                        // next positional. `consume_flag_positionals`
3770                                        // respects `consumes`.
3771                                        self.consume_flag_positionals(
3772                                            args,
3773                                            key,
3774                                            canonical,
3775                                            consumes,
3776                                            repeatable,
3777                                            &positional_indices,
3778                                            &mut consumed,
3779                                            i,
3780                                            &mut tool_args,
3781                                        )
3782                                        .await?;
3783                                    } else {
3784                                        bind_glued_short_value(
3785                                            &mut tool_args,
3786                                            key,
3787                                            canonical,
3788                                            consumes,
3789                                            repeatable,
3790                                            glued,
3791                                        )?;
3792                                    }
3793                                    break;
3794                                }
3795                                _ => {
3796                                    tool_args.flags.insert(key.to_string());
3797                                    p += 1;
3798                                }
3799                            }
3800                        }
3801                    }
3802                }
3803                Arg::LongFlag(name) => {
3804                    if past_double_dash {
3805                        tool_args.positional.push(Value::String(format!("--{name}")));
3806                    } else {
3807                        let lookup = param_lookup.get(name.as_str());
3808                        // An *undeclared* long flag under a `map_positionals`
3809                        // (backend/MCP) schema that is immediately followed by an
3810                        // unconsumed positional is ambiguous: kaish can't tell the
3811                        // space-form value (`--type explorer`) from a bool flag
3812                        // before a real positional (`--force file.txt`). Defaulting
3813                        // to bool here silently divorces the value and misroutes it
3814                        // — a privilege-escalation-by-typo against deny-by-default
3815                        // embedders (docs/issues.md). Fail loud instead of guessing.
3816                        let ambiguous_value = (lookup.is_none()
3817                            && leaf.is_some_and(|s| s.map_positionals)
3818                            && !consumed.contains(&(i + 1)))
3819                            .then(|| match args.get(i + 1) {
3820                                // Echo a concrete value for a copy-pasteable fix
3821                                // when it's a plain literal; fall back to VALUE.
3822                                Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
3823                                    Some(s.clone())
3824                                }
3825                                Some(Arg::Positional(_)) => Some("VALUE".to_string()),
3826                                _ => None,
3827                            })
3828                            .flatten();
3829                        if let Some(val) = ambiguous_value {
3830                            let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
3831                            anyhow::bail!(
3832                                "{tool}: --{name} is not a declared flag, so the \
3833                                 space-separated value would be silently dropped. \
3834                                 Use --{name}={val}, or have {tool} declare --{name} \
3835                                 in its schema."
3836                            );
3837                        }
3838                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3839
3840                        if is_bool {
3841                            tool_args.flags.insert(name.clone());
3842                        } else {
3843                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
3844                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3845                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3846                            self.consume_flag_positionals(
3847                                args,
3848                                name,
3849                                canonical,
3850                                consumes,
3851                                repeatable,
3852                                &positional_indices,
3853                                &mut consumed,
3854                                i,
3855                                &mut tool_args,
3856                            )
3857                            .await?;
3858                        }
3859                    }
3860                }
3861            }
3862            i += 1;
3863        }
3864
3865        // Map remaining positionals to unfilled non-bool schema params (in order).
3866        // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
3867        // Positionals that appeared after `--` are never mapped (they're raw data).
3868        // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
3869        // Keyed off the routed leaf so a subcommand tool maps against the active
3870        // leaf's params (kj leaves keep map_positionals=false → block skipped).
3871        if let Some(schema) = leaf.filter(|s| s.map_positionals) {
3872            let pre_dash_count = if past_double_dash {
3873                let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
3874                positional_indices.iter()
3875                    .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
3876                    .count()
3877            } else {
3878                tool_args.positional.len()
3879            };
3880
3881            let mut remaining = Vec::new();
3882            let mut positional_iter = tool_args.positional.drain(..).enumerate();
3883
3884            for param in &schema.params {
3885                if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
3886                    continue;
3887                }
3888                if is_bool_type(&param.param_type) {
3889                    continue;
3890                }
3891                loop {
3892                    match positional_iter.next() {
3893                        Some((idx, val)) if idx < pre_dash_count => {
3894                            tool_args.named.insert(param.name.clone(), val);
3895                            break;
3896                        }
3897                        Some((_, val)) => {
3898                            remaining.push(val);
3899                        }
3900                        None => break,
3901                    }
3902                }
3903            }
3904
3905            remaining.extend(positional_iter.map(|(_, v)| v));
3906            tool_args.positional = remaining;
3907        }
3908
3909        Ok(tool_args)
3910    }
3911
3912    /// Build arguments as flat string list for external commands.
3913    ///
3914    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3915    /// this preserves the original flag format as strings for external commands:
3916    /// - `-l` stays as `-l`
3917    /// - `--verbose` stays as `--verbose`
3918    /// - `key=value` stays as `key=value`
3919    ///
3920    /// This is what external commands expect in their argv.
3921    #[cfg(feature = "subprocess")]
3922    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3923        let mut argv = Vec::new();
3924        let home = self.scope_home().await;
3925        for arg in args {
3926            match arg {
3927                Arg::Positional(expr) => {
3928                    // Glob expansion for external commands
3929                    if let Expr::GlobPattern(pattern) = expr {
3930                        let glob_enabled = {
3931                            let scope = self.scope.read().await;
3932                            scope.glob_enabled()
3933                        };
3934                        if glob_enabled {
3935                            let (paths, cwd) = {
3936                                let ctx = self.exec_ctx.read().await;
3937                                let paths = ctx.expand_glob(pattern).await
3938                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3939                                let cwd = ctx.resolve_path(".");
3940                                (paths, cwd)
3941                            };
3942                            if paths.is_empty() {
3943                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3944                            }
3945                            for path in paths {
3946                                let display = if !pattern.starts_with('/') {
3947                                    path.strip_prefix(&cwd)
3948                                        .unwrap_or(&path)
3949                                        .to_string_lossy().into_owned()
3950                                } else {
3951                                    path.to_string_lossy().into_owned()
3952                                };
3953                                argv.push(display);
3954                            }
3955                            continue;
3956                        }
3957                    }
3958                    let value = self.eval_expr_async(expr).await?;
3959                    // Decision D: a bare collection can't cross the external
3960                    // process boundary as an argv element — refuse rather than
3961                    // silently JSON-serializing it. A quoted `"$x"` already
3962                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3963                    // so only a live, un-interpolated `$x` trips this.
3964                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3965                        return Err(anyhow::anyhow!(msg));
3966                    }
3967                    let value = apply_tilde_expansion(value, home.as_deref());
3968                    // External-command argv is a text sink: a bare `$BIN` binary
3969                    // word goes loud, never the `[binary: N bytes]` placeholder.
3970                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3971                }
3972                Arg::Named { key, value } => {
3973                    let val = self.eval_expr_async(value).await?;
3974                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3975                        return Err(anyhow::anyhow!(msg));
3976                    }
3977                    let val = apply_tilde_expansion(val, home.as_deref());
3978                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3979                    argv.push(format!("--{key}={val_str}"));
3980                }
3981                Arg::WordAssign { key, value } => {
3982                    let val = self.eval_expr_async(value).await?;
3983                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3984                        return Err(anyhow::anyhow!(msg));
3985                    }
3986                    let val = apply_tilde_expansion(val, home.as_deref());
3987                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3988                    argv.push(format!("{key}={val_str}"));
3989                }
3990                Arg::ShortFlag(name) => {
3991                    // Preserve original format: -l, -la (combined flags)
3992                    argv.push(format!("-{}", name));
3993                }
3994                Arg::LongFlag(name) => {
3995                    // Preserve original format: --verbose
3996                    argv.push(format!("--{}", name));
3997                }
3998                Arg::DoubleDash => {
3999                    // Preserve the -- marker
4000                    argv.push("--".to_string());
4001                }
4002            }
4003        }
4004        Ok(argv)
4005    }
4006
4007    /// Async expression evaluator that supports command substitution.
4008    ///
4009    /// This is used for contexts where expressions may contain `$(...)` command
4010    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
4011    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4012        Box::pin(async move {
4013        match expr {
4014            Expr::Literal(value) => Ok(value.clone()),
4015            Expr::VarRef(path) => {
4016                let scope = self.scope.read().await;
4017                match scope.resolve_path(path) {
4018                    Ok(v) => Ok(v),
4019                    Err(PathError::UndefinedRoot(_)) => {
4020                        Err(anyhow::anyhow!("undefined variable"))
4021                    }
4022                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4023                        Err(anyhow::anyhow!(msg))
4024                    }
4025                }
4026            }
4027            Expr::Interpolated(parts) => {
4028                let mut result = String::new();
4029                for part in parts {
4030                    result.push_str(&self.eval_string_part_async(part).await?);
4031                }
4032                Ok(Value::String(result))
4033            }
4034            Expr::HereDocBody { parts, strip_tabs } => {
4035                // Assemble part-by-part so `<<-` tab stripping applies to the
4036                // literal source, not to tabs from a `$var` value (bash strips
4037                // source-line tabs before parameter expansion).
4038                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
4039                for sp in parts {
4040                    match &sp.part {
4041                        StringPart::Literal(s) => asm.push_literal(s),
4042                        other => {
4043                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
4044                        }
4045                    }
4046                }
4047                Ok(Value::String(asm.into_string()))
4048            }
4049            Expr::BinaryOp { left, op, right } => match op {
4050                BinaryOp::And => {
4051                    let left_val = self.eval_expr_async(left).await?;
4052                    if !is_truthy(&left_val) {
4053                        return Ok(left_val);
4054                    }
4055                    self.eval_expr_async(right).await
4056                }
4057                BinaryOp::Or => {
4058                    let left_val = self.eval_expr_async(left).await?;
4059                    if is_truthy(&left_val) {
4060                        return Ok(left_val);
4061                    }
4062                    self.eval_expr_async(right).await
4063                }
4064            },
4065            Expr::CommandSubst(stmts) => {
4066                // Snapshot scope, cwd, and session config before running —
4067                // only output escapes, not side effects like `cd`, variable
4068                // assignments, or config mutations (`kaish-ignore`,
4069                // `kaish-output-limit`, `alias`/`unalias`) — matching how
4070                // every other execution context (background forks, scatter
4071                // workers) already isolates mutations (GH #139).
4072                // Boxed: this ~470 B scope snapshot is held across the nested
4073                // `$(…)` recursion await below, so inlining it grows every
4074                // command-substitution level's future (GH #48, item 4).
4075                let saved_scope = Box::new(self.scope.read().await.clone());
4076                let saved_ec = {
4077                    let ec = self.exec_ctx.read().await;
4078                    (
4079                        ec.cwd.clone(),
4080                        ec.prev_cwd.clone(),
4081                        ec.aliases.clone(),
4082                        ec.ignore_config.clone(),
4083                        ec.output_limit.clone(),
4084                    )
4085                };
4086
4087                // Capture result without `?` — restore state unconditionally
4088                let run_result = self.execute_block_capturing(stmts).await;
4089
4090                // Restore scope and cwd regardless of success/failure
4091                {
4092                    let mut scope = self.scope.write().await;
4093                    *scope = *saved_scope;
4094                    if let Ok(ref r) = run_result {
4095                        scope.set_last_result(r.clone());
4096                    }
4097                }
4098                {
4099                    let mut ec = self.exec_ctx.write().await;
4100                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4101                    ec.cwd = cwd;
4102                    ec.prev_cwd = prev_cwd;
4103                    ec.aliases = aliases;
4104                    ec.ignore_config = ignore_config;
4105                    ec.output_limit = output_limit;
4106                }
4107
4108                // Now propagate the error
4109                let result = run_result?;
4110
4111                // A binary result is preserved as bytes — never lossy-decoded to
4112                // a string. No trailing-newline trim (every byte is significant).
4113                if let Some(bytes) = result.out_bytes() {
4114                    Ok(Value::Bytes(bytes.to_vec()))
4115                // Prefer structured data (enables `for i in $(cmd)` iteration)
4116                } else if let Some(data) = &result.data {
4117                    Ok(data.clone())
4118                } else if let Some(output) = result.output() {
4119                    // Flat non-text node lists (glob, ls, tree) → iterable array
4120                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
4121                        let items: Vec<serde_json::Value> = output.root.iter()
4122                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
4123                            .collect();
4124                        Ok(Value::Json(serde_json::Value::Array(items)))
4125                    } else {
4126                        // Strip trailing newlines only (POSIX command-subst),
4127                        // not all trailing whitespace — spaces/tabs are
4128                        // significant. Use the exact same trim as the quoted
4129                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
4130                        // `trim_end_matches('\n')`) so bare and quoted command
4131                        // substitution agree.
4132                        Ok(Value::String(
4133                            result.text_out().trim_end_matches('\n').to_string(),
4134                        ))
4135                    }
4136                } else {
4137                    // Otherwise return stdout as single string (NO implicit splitting)
4138                    Ok(Value::String(
4139                        result.text_out().trim_end_matches('\n').to_string(),
4140                    ))
4141                }
4142            }
4143            Expr::Test(test_expr) => {
4144                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
4145            }
4146            Expr::Positional(n) => {
4147                let scope = self.scope.read().await;
4148                match scope.get_positional(*n) {
4149                    Some(s) => Ok(Value::String(s.to_string())),
4150                    None => Ok(Value::String(String::new())),
4151                }
4152            }
4153            Expr::AllArgs => {
4154                let scope = self.scope.read().await;
4155                Ok(Value::String(scope.all_args().join(" ")))
4156            }
4157            Expr::ArgCount => {
4158                let scope = self.scope.read().await;
4159                Ok(Value::Int(scope.arg_count() as i64))
4160            }
4161            Expr::VarLength(path) => {
4162                let scope = self.scope.read().await;
4163                crate::interpreter::resolve_length(&scope, path)
4164                    .map(Value::Int)
4165                    .map_err(|msg| anyhow::anyhow!(msg))
4166            }
4167            Expr::VarWithDefault { path, default } => {
4168                // Resolve inside a scoped guard so the lock is released before the
4169                // recursive default evaluation.
4170                let resolved = {
4171                    let scope = self.scope.read().await;
4172                    crate::interpreter::resolve_default(&scope, path)
4173                        .map_err(|msg| anyhow::anyhow!(msg))?
4174                };
4175                match resolved {
4176                    Some(value) => Ok(value),
4177                    None => self.eval_string_parts_async(default).await.map(Value::String),
4178                }
4179            }
4180            Expr::Arithmetic(expr_str) => {
4181                let scope = self.scope.read().await;
4182                crate::arithmetic::eval_arithmetic(expr_str, &scope)
4183                    .map(Value::Int)
4184                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
4185            }
4186            Expr::Command(cmd) => {
4187                // Execute command and return boolean based on exit code
4188                let result = self.execute_command(&cmd.name, &cmd.args).await?;
4189                Ok(Value::Bool(result.code == 0))
4190            }
4191            Expr::LastExitCode => {
4192                let scope = self.scope.read().await;
4193                Ok(Value::Int(scope.last_result().code))
4194            }
4195            Expr::CurrentPid => {
4196                let scope = self.scope.read().await;
4197                Ok(Value::Int(scope.pid() as i64))
4198            }
4199            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4200            Expr::ListLiteral(elems) => {
4201                // Spread must itself be a list — a scalar/record spread is a
4202                // loud error, never silently coerced or dropped (mirrors the
4203                // sync `Evaluator::eval_list_literal`; wording shared via
4204                // `spread_non_list_message` so the two paths can't diverge).
4205                let mut out = Vec::with_capacity(elems.len());
4206                for elem in elems {
4207                    match elem {
4208                        ListElem::Item(e) => {
4209                            let value = self.eval_expr_async(e).await?;
4210                            out.push(crate::interpreter::value_to_json(&value));
4211                        }
4212                        ListElem::Spread(e) => {
4213                            let value = self.eval_expr_async(e).await?;
4214                            match value {
4215                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4216                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4217                            }
4218                        }
4219                    }
4220                }
4221                Ok(Value::Json(serde_json::Value::Array(out)))
4222            }
4223            Expr::RecordLiteral(entries) => {
4224                // Insertion order preserved (workspace serde_json has
4225                // `preserve_order`); a duplicate key keeps the last value
4226                // written, matching plain map-insert semantics.
4227                let mut map = serde_json::Map::new();
4228                for entry in entries {
4229                    let key = match &entry.key {
4230                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4231                        // `{"$k": v}` resolves like any double-quoted string
4232                        // (used to silently create a literal "$k" key).
4233                        RecordKey::Interpolated(parts) => {
4234                            self.eval_string_parts_async(parts).await?
4235                        }
4236                    };
4237                    let value = self.eval_expr_async(&entry.value).await?;
4238                    map.insert(key, crate::interpreter::value_to_json(&value));
4239                }
4240                Ok(Value::Json(serde_json::Value::Object(map)))
4241            }
4242        }
4243        })
4244    }
4245
4246    /// Async helper to evaluate multiple StringParts into a single string.
4247    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4248        Box::pin(async move {
4249            let mut result = String::new();
4250            for part in parts {
4251                result.push_str(&self.eval_string_part_async(part).await?);
4252            }
4253            Ok(result)
4254        })
4255    }
4256
4257    /// Async helper to evaluate a StringPart.
4258    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4259    /// through the VFS backend instead of using raw `std::path`.
4260    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4261        Box::pin(async move {
4262            match test_expr {
4263                TestExpr::FileTest { op, path } => {
4264                    let path_value = self.eval_expr_async(path).await?;
4265                    // Expand `~` against the session HOME before stat'ing, the
4266                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4267                    // stats the literal `~/x` and is always false.
4268                    let home = self.scope_home().await;
4269                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
4270                    // A binary `[[ -f $bin ]]` operand goes loud rather than
4271                    // silently stat'ing a file literally named
4272                    // `[binary: N bytes]` (the same path-positional guard
4273                    // builtins like `stat`/`cp` use).
4274                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4275                        .map_err(|e| anyhow::anyhow!("{e}"))?;
4276                    // Resolve against the *session* cwd, not the process cwd, so a
4277                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
4278                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
4279                    // relative path against the process cwd otherwise.
4280                    let (resolved, backend) = {
4281                        let ctx = self.exec_ctx.read().await;
4282                        (ctx.resolve_path(&path_str), ctx.backend.clone())
4283                    };
4284                    let entry = backend.stat(&resolved).await.ok();
4285                    Ok(match op {
4286                        FileTestOp::Exists => entry.is_some(),
4287                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4288                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4289                        FileTestOp::Readable => entry.is_some(),
4290                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4291                            e.permissions.is_none_or(|p| p & 0o222 != 0)
4292                        }),
4293                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4294                            e.permissions.is_some_and(|p| p & 0o111 != 0)
4295                        }),
4296                    })
4297                }
4298                TestExpr::StringTest { op, value } => match op {
4299                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4300                        let val = self.eval_expr_async(value).await?;
4301                        // Decision E: a collection operand is a loud Shape error
4302                        // here too — must not diverge from the sync path in
4303                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
4304                        let symbol = match op {
4305                            crate::ast::StringTestOp::IsEmpty => "-z",
4306                            crate::ast::StringTestOp::IsNonEmpty => "-n",
4307                            crate::ast::StringTestOp::IsList
4308                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4309                        };
4310                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4311                            anyhow::bail!(msg);
4312                        }
4313                        let s = value_to_string(&val);
4314                        Ok(match op {
4315                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4316                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4317                            crate::ast::StringTestOp::IsList
4318                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4319                        })
4320                    }
4321                    // Shape guard: propagates eval errors like -z/-n (a bare
4322                    // `$unset` is an undefined-variable error, not a silent
4323                    // false). A defined-but-wrong-shaped value is false. Must
4324                    // not diverge from the sync path in interpreter/eval.rs.
4325                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4326                        let val = self.eval_expr_async(value).await?;
4327                        Ok(op.matches_shape(&val))
4328                    }
4329                },
4330                TestExpr::Comparison { left, op, right } => {
4331                    // Evaluate operands async (handles $(cmd)), then compare sync
4332                    let left_val = self.eval_expr_async(left).await?;
4333                    let right_val = self.eval_expr_async(right).await?;
4334                    let resolved = TestExpr::Comparison {
4335                        left: Box::new(Expr::Literal(left_val)),
4336                        op: *op,
4337                        right: Box::new(Expr::Literal(right_val)),
4338                    };
4339                    let expr = Expr::Test(Box::new(resolved));
4340                    let mut scope = self.scope.write().await;
4341                    let value = eval_expr(&expr, &mut scope)
4342                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4343                    Ok(value_to_bool(&value))
4344                }
4345                TestExpr::And { left, right } => {
4346                    if !self.eval_test_async(left).await? {
4347                        Ok(false)
4348                    } else {
4349                        self.eval_test_async(right).await
4350                    }
4351                }
4352                TestExpr::Or { left, right } => {
4353                    if self.eval_test_async(left).await? {
4354                        Ok(true)
4355                    } else {
4356                        self.eval_test_async(right).await
4357                    }
4358                }
4359                TestExpr::Not { expr } => {
4360                    Ok(!self.eval_test_async(expr).await?)
4361                }
4362                TestExpr::In { left, right } => {
4363                    let left_val = self.eval_expr_async(left).await?;
4364                    let right_val = self.eval_expr_async(right).await?;
4365                    let resolved = TestExpr::In {
4366                        left: Box::new(Expr::Literal(left_val)),
4367                        right: Box::new(Expr::Literal(right_val)),
4368                    };
4369                    let expr = Expr::Test(Box::new(resolved));
4370                    let mut scope = self.scope.write().await;
4371                    let value = eval_expr(&expr, &mut scope)
4372                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4373                    Ok(value_to_bool(&value))
4374                }
4375                TestExpr::NotIn { left, right } => {
4376                    let left_val = self.eval_expr_async(left).await?;
4377                    let right_val = self.eval_expr_async(right).await?;
4378                    let resolved = TestExpr::NotIn {
4379                        left: Box::new(Expr::Literal(left_val)),
4380                        right: Box::new(Expr::Literal(right_val)),
4381                    };
4382                    let expr = Expr::Test(Box::new(resolved));
4383                    let mut scope = self.scope.write().await;
4384                    let value = eval_expr(&expr, &mut scope)
4385                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4386                    Ok(value_to_bool(&value))
4387                }
4388            }
4389        })
4390    }
4391
4392    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4393        Box::pin(async move {
4394            match part {
4395                StringPart::Literal(s) => Ok(s.clone()),
4396                StringPart::Var(path) => {
4397                    let scope = self.scope.read().await;
4398                    match scope.resolve_path(path) {
4399                        // Text sink: binary goes loud, never the placeholder —
4400                        // a `b=$(cat blob)` capture holds real bytes; splicing
4401                        // `[binary: N bytes]` into "$b" would be silent loss.
4402                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4403                        // Unset vars expand to empty; loud path errors surface.
4404                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4405                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4406                            Err(anyhow::anyhow!(msg))
4407                        }
4408                    }
4409                }
4410                StringPart::VarWithDefault { path, default } => {
4411                    let resolved = {
4412                        let scope = self.scope.read().await;
4413                        crate::interpreter::resolve_default(&scope, path)
4414                            .map_err(|msg| anyhow::anyhow!(msg))?
4415                    };
4416                    match resolved {
4417                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4418                        None => self.eval_string_parts_async(default).await,
4419                    }
4420                }
4421            StringPart::VarLength(path) => {
4422                let scope = self.scope.read().await;
4423                crate::interpreter::resolve_length(&scope, path)
4424                    .map(|n| n.to_string())
4425                    .map_err(|msg| anyhow::anyhow!(msg))
4426            }
4427            StringPart::Positional(n) => {
4428                let scope = self.scope.read().await;
4429                match scope.get_positional(*n) {
4430                    Some(s) => Ok(s.to_string()),
4431                    None => Ok(String::new()),
4432                }
4433            }
4434            StringPart::AllArgs => {
4435                let scope = self.scope.read().await;
4436                Ok(scope.all_args().join(" "))
4437            }
4438            StringPart::ArgCount => {
4439                let scope = self.scope.read().await;
4440                Ok(scope.arg_count().to_string())
4441            }
4442            StringPart::Arithmetic(expr) => {
4443                let scope = self.scope.read().await;
4444                match crate::arithmetic::eval_arithmetic(expr, &scope) {
4445                    Ok(value) => Ok(value.to_string()),
4446                    Err(_) => Ok(String::new()),
4447                }
4448            }
4449            StringPart::CommandSubst(stmts) => {
4450                // Snapshot scope, cwd, and session config — command
4451                // substitution in strings must not leak side effects (e.g.,
4452                // `"dir: $(cd /; pwd)"` must not change cwd, and
4453                // `"$(kaish-ignore clear)"` must not change the session's
4454                // ignore config) — matching how every other execution
4455                // context (background forks, scatter workers) already
4456                // isolates mutations (GH #139).
4457                // Boxed: this ~470 B scope snapshot is held across the nested
4458                // `$(…)` recursion await below, so inlining it grows every
4459                // command-substitution level's future (GH #48, item 4).
4460                let saved_scope = Box::new(self.scope.read().await.clone());
4461                let saved_ec = {
4462                    let ec = self.exec_ctx.read().await;
4463                    (
4464                        ec.cwd.clone(),
4465                        ec.prev_cwd.clone(),
4466                        ec.aliases.clone(),
4467                        ec.ignore_config.clone(),
4468                        ec.output_limit.clone(),
4469                    )
4470                };
4471
4472                // Capture result without `?` — restore state unconditionally
4473                let run_result = self.execute_block_capturing(stmts).await;
4474
4475                // Restore scope and cwd regardless of success/failure
4476                {
4477                    let mut scope = self.scope.write().await;
4478                    *scope = *saved_scope;
4479                    if let Ok(ref r) = run_result {
4480                        scope.set_last_result(r.clone());
4481                    }
4482                }
4483                {
4484                    let mut ec = self.exec_ctx.write().await;
4485                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4486                    ec.cwd = cwd;
4487                    ec.prev_cwd = prev_cwd;
4488                    ec.aliases = aliases;
4489                    ec.ignore_config = ignore_config;
4490                    ec.output_limit = output_limit;
4491                }
4492
4493                // Now propagate the error
4494                let result = run_result?;
4495
4496                // Embedding binary into a string is a text context: fail loud
4497                // rather than splice in U+FFFD garbage.
4498                match result.try_text_out() {
4499                    // Text wins when present — unchanged behavior.
4500                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4501                    // `.out` is empty: a builtin/tool that set only structured
4502                    // `.data` must not silently evaporate to "" (SILENT DATA
4503                    // LOSS). Render it the same way a bare `"$x"`
4504                    // collection-valued variable renders — compact JSON for
4505                    // lists/records, plain form for scalars — by reusing
4506                    // `value_to_string` (the exact `StringPart::Var` helper
4507                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4508                    // identically. No trailing-newline trim here: that's a
4509                    // text-path artifact, not applicable to a freshly
4510                    // rendered JSON/scalar string.
4511                    Ok(_) => match &result.data {
4512                        Some(data) => Ok(value_to_string(data)),
4513                        None => Ok(String::new()),
4514                    },
4515                    Err(e) => anyhow::bail!(
4516                        "command substitution in a string produced binary data ({e}) — \
4517                         pipe through base64/xxd"
4518                    ),
4519                }
4520            }
4521            StringPart::LastExitCode => {
4522                let scope = self.scope.read().await;
4523                Ok(scope.last_result().code.to_string())
4524            }
4525            StringPart::CurrentPid => {
4526                let scope = self.scope.read().await;
4527                Ok(scope.pid().to_string())
4528            }
4529        }
4530        })
4531    }
4532
4533    /// Update the last result in scope.
4534    async fn update_last_result(&self, result: &ExecResult) {
4535        let mut scope = self.scope.write().await;
4536        scope.set_last_result(result.clone());
4537    }
4538
4539    /// Drain accumulated pipeline stderr into a result.
4540    ///
4541    /// Called after each sub-statement inside control structures (`if`, `for`,
4542    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4543    /// than batching until the entire structure finishes.
4544    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4545        let drained = {
4546            let mut receiver = self.stderr_receiver.lock().await;
4547            receiver.drain_lossy()
4548        };
4549        if !drained.is_empty() {
4550            if !result.err.is_empty() && !result.err.ends_with('\n') {
4551                result.err.push('\n');
4552            }
4553            result.err.push_str(&drained);
4554        }
4555    }
4556
4557    /// Execute a user-defined function with local variable scoping.
4558    ///
4559    /// Functions push a new scope frame for local variables. Variables declared
4560    /// with `local` are scoped to the function; other assignments modify outer
4561    /// scopes (or create in root if new).
4562    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4563        let _depth = self.enter_recursion("a shell function")?;
4564
4565        // 1. Build function args from AST args (async to support command substitution)
4566        let tool_args = self.build_args_async(args, None).await?;
4567
4568        // 2. Push a new scope frame for local variables
4569        {
4570            let mut scope = self.scope.write().await;
4571            scope.push_frame();
4572        }
4573
4574        // 3. Save current positional parameters and set new ones for this function
4575        let saved_positional = {
4576            let mut scope = self.scope.write().await;
4577            let saved = scope.save_positional();
4578
4579            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4580            let positional_args: Vec<String> = tool_args.positional
4581                .iter()
4582                .map(value_to_string)
4583                .collect();
4584            scope.set_positional(&def.name, positional_args);
4585
4586            saved
4587        };
4588
4589        // 3. Execute body statements with control flow handling
4590        // Accumulate output across statements (like sh)
4591        // Accumulate stdout as raw bytes so a binary-producing statement in a
4592        // function body survives instead of being lossy-decoded here.
4593        let mut accumulated_out: Vec<u8> = Vec::new();
4594        let mut accumulated_err = String::new();
4595        let mut last_code = 0i64;
4596        let mut last_data: Option<Value> = None;
4597
4598        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4599            match r.out_bytes() {
4600                Some(b) => buf.extend_from_slice(b),
4601                None => buf.extend_from_slice(r.text_out().as_bytes()),
4602            }
4603        }
4604
4605        // Track execution error for propagation after cleanup
4606        let mut exec_error: Option<anyhow::Error> = None;
4607        let mut exit_code: Option<i64> = None;
4608
4609        for stmt in &def.body {
4610            match self.execute_stmt_flow(stmt).await {
4611                Ok(flow) => {
4612                    // Drain pipeline stderr after each sub-statement.
4613                    let drained = {
4614                        let mut receiver = self.stderr_receiver.lock().await;
4615                        receiver.drain_lossy()
4616                    };
4617                    if !drained.is_empty() {
4618                        accumulated_err.push_str(&drained);
4619                    }
4620
4621                    match flow {
4622                        ControlFlow::Normal(r) => {
4623                            push_out(&mut accumulated_out, &r);
4624                            accumulated_err.push_str(&r.err);
4625                            last_code = r.code;
4626                            last_data = r.data;
4627                        }
4628                        ControlFlow::Return { value } => {
4629                            push_out(&mut accumulated_out, &value);
4630                            accumulated_err.push_str(&value.err);
4631                            last_code = value.code;
4632                            last_data = value.data;
4633                            break;
4634                        }
4635                        ControlFlow::Exit { code } => {
4636                            exit_code = Some(code);
4637                            break;
4638                        }
4639                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4640                            push_out(&mut accumulated_out, &r);
4641                            accumulated_err.push_str(&r.err);
4642                            last_code = r.code;
4643                            last_data = r.data;
4644                        }
4645                    }
4646                }
4647                Err(e) => {
4648                    exec_error = Some(e);
4649                    break;
4650                }
4651            }
4652        }
4653
4654        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4655        {
4656            let mut scope = self.scope.write().await;
4657            scope.pop_frame();
4658            scope.set_positional(saved_positional.0, saved_positional.1);
4659        }
4660
4661        // 5. Propagate error or exit after cleanup
4662        if let Some(e) = exec_error {
4663            return Err(e);
4664        }
4665        let code = exit_code.unwrap_or(last_code);
4666        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4667        result.err = accumulated_err;
4668        result.data = last_data;
4669        Ok(result)
4670    }
4671
4672    /// Execute a command-substitution body — a block of statements — and return
4673    /// the combined result. Stdout/stderr accumulate across statements with **no
4674    /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4675    /// and the last statement's exit code and structured `.data` ride through,
4676    /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4677    /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4678    /// cwd) is the caller's responsibility.
4679    /// Enter one level of dynamic statement-engine re-entry (command
4680    /// substitution / function call / script source), returning an RAII guard
4681    /// that releases the level on drop. Past [`MAX_RECURSION_DEPTH`] it returns
4682    /// a loud, catchable error instead of letting the native stack overflow
4683    /// (GH #46). `what` names the re-entry kind for the message.
4684    ///
4685    /// The guard is constructed *before* the ceiling check so the error path
4686    /// unwinds it too — the counter is always balanced, even when we reject.
4687    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4688        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4689        let guard = RecursionGuard { counter: &self.recursion_depth };
4690        if depth > MAX_RECURSION_DEPTH {
4691            return Err(anyhow::anyhow!(
4692                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4693                 a runaway or mutually recursive script (deeply nested $(…), or \
4694                 functions/scripts that call each other without a base case) was \
4695                 stopped before it could overflow the stack"
4696            ));
4697        }
4698        Ok(guard)
4699    }
4700
4701    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4702        let _depth = self.enter_recursion("command substitution")?;
4703        // Accumulate stdout as raw bytes so a binary-producing statement
4704        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4705        // caller can preserve it. The final result is text iff valid UTF-8.
4706        let mut accumulated_out: Vec<u8> = Vec::new();
4707        let mut accumulated_err = String::new();
4708        let mut last_code = 0i64;
4709        let mut last_data: Option<Value> = None;
4710
4711        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4712        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4713            match r.out_bytes() {
4714                Some(b) => buf.extend_from_slice(b),
4715                None => buf.extend_from_slice(r.text_out().as_bytes()),
4716            }
4717        }
4718
4719        for stmt in stmts {
4720            let flow = self.execute_stmt_flow(stmt).await?;
4721
4722            // Drain pipeline stderr after each sub-statement (incremental, like
4723            // the control-structure and function-body executors).
4724            let drained = {
4725                let mut receiver = self.stderr_receiver.lock().await;
4726                receiver.drain_lossy()
4727            };
4728            if !drained.is_empty() {
4729                accumulated_err.push_str(&drained);
4730            }
4731
4732            match flow {
4733                ControlFlow::Normal(r)
4734                | ControlFlow::Break { result: r, .. }
4735                | ControlFlow::Continue { result: r, .. } => {
4736                    push_out(&mut accumulated_out, &r);
4737                    accumulated_err.push_str(&r.err);
4738                    last_code = r.code;
4739                    last_data = r.data;
4740                }
4741                ControlFlow::Return { value } => {
4742                    push_out(&mut accumulated_out, &value);
4743                    accumulated_err.push_str(&value.err);
4744                    last_code = value.code;
4745                    last_data = value.data;
4746                    break;
4747                }
4748                ControlFlow::Exit { code } => {
4749                    last_code = code;
4750                    break;
4751                }
4752            }
4753        }
4754
4755        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4756        result.err = accumulated_err;
4757        result.data = last_data;
4758        Ok(result)
4759    }
4760
4761    /// Execute the `source` / `.` command to include and run a script.
4762    ///
4763    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4764    /// allowing the sourced script to set variables and modify shell state.
4765    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4766        // `source`/`.` is the fourth dynamic re-entry point: it runs the
4767        // sourced file's statements inline via `execute_stmt_flow`, so a file
4768        // that sources itself recurses unbounded just like a runaway function
4769        // (GH #46). It's intercepted as a special form *before* the other
4770        // guarded paths, so it needs its own guard.
4771        let _depth = self.enter_recursion("source")?;
4772
4773        // Get the file path from the first positional argument
4774        let tool_args = self.build_args_async(args, None).await?;
4775        let path = match tool_args.positional.first() {
4776            Some(Value::String(s)) => s.clone(),
4777            Some(v) => value_to_string(v),
4778            None => {
4779                return Ok(ExecResult::failure(1, "source: missing filename"));
4780            }
4781        };
4782
4783        // Resolve path relative to cwd
4784        let full_path = {
4785            let ctx = self.exec_ctx.read().await;
4786            if path.starts_with('/') {
4787                std::path::PathBuf::from(&path)
4788            } else {
4789                ctx.cwd.join(&path)
4790            }
4791        };
4792
4793        // Read file content via backend
4794        let content = {
4795            let ctx = self.exec_ctx.read().await;
4796            match ctx.backend.read(&full_path, None).await {
4797                Ok(bytes) => {
4798                    String::from_utf8(bytes).map_err(|e| {
4799                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4800                    })?
4801                }
4802                Err(e) => {
4803                    return Ok(ExecResult::failure(
4804                        1,
4805                        format!("source: {}: {}", path, e),
4806                    ));
4807                }
4808            }
4809        };
4810
4811        // Parse the content
4812        let program = match crate::parser::parse(&content) {
4813            Ok(p) => p,
4814            Err(errors) => {
4815                let msg = errors
4816                    .iter()
4817                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4818                    .collect::<Vec<_>>()
4819                    .join("\n");
4820                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4821            }
4822        };
4823
4824        // Execute each statement in the CURRENT scope (not isolated), accumulating
4825        // stdout/stderr across statements like `execute_user_tool` — a sourced
4826        // script's earlier statements must not be silently dropped in favor of
4827        // just the last one.
4828        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4829            match r.out_bytes() {
4830                Some(b) => buf.extend_from_slice(b),
4831                None => buf.extend_from_slice(r.text_out().as_bytes()),
4832            }
4833        }
4834
4835        let mut accumulated_out: Vec<u8> = Vec::new();
4836        let mut accumulated_err = String::new();
4837        let mut last_code = 0i64;
4838        let mut last_data: Option<Value> = None;
4839
4840        for stmt in program.statements {
4841            if matches!(stmt, crate::ast::Stmt::Empty) {
4842                continue;
4843            }
4844
4845            match self.execute_stmt_flow(&stmt).await {
4846                Ok(flow) => {
4847                    let drained = {
4848                        let mut receiver = self.stderr_receiver.lock().await;
4849                        receiver.drain_lossy()
4850                    };
4851                    if !drained.is_empty() {
4852                        accumulated_err.push_str(&drained);
4853                    }
4854                    match flow {
4855                        ControlFlow::Normal(r) => {
4856                            push_out(&mut accumulated_out, &r);
4857                            accumulated_err.push_str(&r.err);
4858                            last_code = r.code;
4859                            last_data = r.data.clone();
4860                            self.update_last_result(&r).await;
4861                        }
4862                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4863                            return Err(anyhow::anyhow!(
4864                                "source: {}: unexpected break/continue outside loop",
4865                                path
4866                            ));
4867                        }
4868                        ControlFlow::Return { value } => {
4869                            push_out(&mut accumulated_out, &value);
4870                            accumulated_err.push_str(&value.err);
4871                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4872                                .with_code(value.code);
4873                            result.err = accumulated_err;
4874                            result.data = value.data;
4875                            return Ok(result);
4876                        }
4877                        ControlFlow::Exit { code } => {
4878                            let mut result =
4879                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4880                            result.err = accumulated_err;
4881                            result.data = last_data;
4882                            return Ok(result);
4883                        }
4884                    }
4885                }
4886                Err(e) => {
4887                    return Err(e.context(format!("source: {}", path)));
4888                }
4889            }
4890        }
4891
4892        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4893        result.err = accumulated_err;
4894        result.data = last_data;
4895        Ok(result)
4896    }
4897
4898    /// Try to execute a script from PATH directories.
4899    ///
4900    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4901    /// (like user-defined tools). Returns None if no script is found.
4902    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4903        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4904        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4905        // A non-script command pays only a transient, balanced increment during
4906        // the probe before falling through to the external path.
4907        let _depth = self.enter_recursion("a .kai script")?;
4908
4909        // Get PATH from scope (default to "/bin")
4910        let path_value = {
4911            let scope = self.scope.read().await;
4912            scope
4913                .get("PATH")
4914                .map(value_to_string)
4915                .unwrap_or_else(|| "/bin".to_string())
4916        };
4917
4918        // Search PATH directories for script
4919        for dir in path_value.split(':') {
4920            if dir.is_empty() {
4921                continue;
4922            }
4923
4924            // Build script path: {dir}/{name}.kai
4925            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4926
4927            // Check if script exists
4928            let exists = {
4929                let ctx = self.exec_ctx.read().await;
4930                ctx.backend.exists(&script_path).await
4931            };
4932
4933            if !exists {
4934                continue;
4935            }
4936
4937            // Read script content
4938            let content = {
4939                let ctx = self.exec_ctx.read().await;
4940                match ctx.backend.read(&script_path, None).await {
4941                    Ok(bytes) => match String::from_utf8(bytes) {
4942                        Ok(s) => s,
4943                        Err(e) => {
4944                            return Ok(Some(ExecResult::failure(
4945                                1,
4946                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4947                            )));
4948                        }
4949                    },
4950                    Err(e) => {
4951                        return Ok(Some(ExecResult::failure(
4952                            1,
4953                            format!("{}: {}", script_path.display(), e),
4954                        )));
4955                    }
4956                }
4957            };
4958
4959            // Parse the script
4960            let program = match crate::parser::parse(&content) {
4961                Ok(p) => p,
4962                Err(errors) => {
4963                    let msg = errors
4964                        .iter()
4965                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4966                        .collect::<Vec<_>>()
4967                        .join("\n");
4968                    return Ok(Some(ExecResult::failure(1, msg)));
4969                }
4970            };
4971
4972            // Build tool_args from args (async for command substitution support)
4973            let tool_args = self.build_args_async(args, None).await?;
4974
4975            // Create isolated scope (like user tools)
4976            let mut isolated_scope = Scope::new();
4977
4978            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4979            let positional_args: Vec<String> = tool_args.positional
4980                .iter()
4981                .map(value_to_string)
4982                .collect();
4983            isolated_scope.set_positional(name, positional_args);
4984
4985            // Save current scope and swap with isolated scope
4986            let original_scope = {
4987                let mut scope = self.scope.write().await;
4988                std::mem::replace(&mut *scope, isolated_scope)
4989            };
4990
4991            // Execute script statements — accumulate stdout/stderr across
4992            // statements like `execute_user_tool`, rather than keeping only the
4993            // last one's result.
4994            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4995                match r.out_bytes() {
4996                    Some(b) => buf.extend_from_slice(b),
4997                    None => buf.extend_from_slice(r.text_out().as_bytes()),
4998                }
4999            }
5000
5001            let mut accumulated_out: Vec<u8> = Vec::new();
5002            let mut accumulated_err = String::new();
5003            let mut last_code = 0i64;
5004            let mut last_data: Option<Value> = None;
5005            let mut exec_error: Option<anyhow::Error> = None;
5006            let mut exit_code: Option<i64> = None;
5007
5008            for stmt in program.statements {
5009                if matches!(stmt, crate::ast::Stmt::Empty) {
5010                    continue;
5011                }
5012
5013                match self.execute_stmt_flow(&stmt).await {
5014                    Ok(flow) => {
5015                        let drained = {
5016                            let mut receiver = self.stderr_receiver.lock().await;
5017                            receiver.drain_lossy()
5018                        };
5019                        if !drained.is_empty() {
5020                            accumulated_err.push_str(&drained);
5021                        }
5022                        match flow {
5023                            ControlFlow::Normal(r) => {
5024                                push_out(&mut accumulated_out, &r);
5025                                accumulated_err.push_str(&r.err);
5026                                last_code = r.code;
5027                                last_data = r.data;
5028                            }
5029                            ControlFlow::Return { value } => {
5030                                push_out(&mut accumulated_out, &value);
5031                                accumulated_err.push_str(&value.err);
5032                                last_code = value.code;
5033                                last_data = value.data;
5034                                break;
5035                            }
5036                            ControlFlow::Exit { code } => {
5037                                exit_code = Some(code);
5038                                break;
5039                            }
5040                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
5041                                push_out(&mut accumulated_out, &r);
5042                                accumulated_err.push_str(&r.err);
5043                                last_code = r.code;
5044                                last_data = r.data;
5045                            }
5046                        }
5047                    }
5048                    Err(e) => {
5049                        exec_error = Some(e);
5050                        break;
5051                    }
5052                }
5053            }
5054
5055            // Restore original scope unconditionally
5056            {
5057                let mut scope = self.scope.write().await;
5058                *scope = original_scope;
5059            }
5060
5061            // Propagate error or exit after cleanup
5062            if let Some(e) = exec_error {
5063                return Err(e.context(format!("script: {}", script_path.display())));
5064            }
5065            let code = exit_code.unwrap_or(last_code);
5066            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5067            result.err = accumulated_err;
5068            result.data = last_data;
5069            return Ok(Some(result));
5070        }
5071
5072        // No script found
5073        Ok(None)
5074    }
5075
5076    /// Try to execute an external command from PATH.
5077    ///
5078    /// This is the fallback when no builtin or user-defined tool matches.
5079    /// External commands receive a clean argv (flags preserved in their original format).
5080    ///
5081    /// # Requirements
5082    /// - Command must be found in PATH
5083    /// - Current working directory must be on a real filesystem (not virtual like /v)
5084    ///
5085    /// # Returns
5086    /// - `Ok(Some(result))` if command was found and executed
5087    /// - `Ok(None)` if command was not found in PATH
5088    /// - `Err` on execution errors
5089    #[cfg(not(feature = "subprocess"))]
5090    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
5091        Ok(None)
5092    }
5093
5094    /// Try to execute an external command from PATH.
5095    #[cfg(feature = "subprocess")]
5096    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
5097    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5098        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
5099        // populates from the inbound ctx.cancel on every dispatch. This is
5100        // what makes the `timeout` builtin's swapped child token reach the
5101        // wait_or_kill discipline below — reading `self.cancel_token` would
5102        // give the kernel-wide token and miss the timeout's child cascade.
5103        let cancel = {
5104            let ec = self.exec_ctx.read().await;
5105            ec.cancel.clone()
5106        };
5107        let kill_grace = self.kill_grace;
5108        if !self.allow_external_commands {
5109            return Ok(None);
5110        }
5111
5112        // Get real working directory for relative path resolution and child cwd.
5113        // If the CWD is virtual (no real filesystem path), skip external command
5114        // execution entirely — return None so the dispatch can fall through to
5115        // backend-registered tools.
5116        let real_cwd = {
5117            let ctx = self.exec_ctx.read().await;
5118            match ctx.backend.resolve_real_path(&ctx.cwd) {
5119                Some(p) => p,
5120                None => return Ok(None),
5121            }
5122        };
5123
5124        let executable = if name.contains('/') {
5125            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
5126            let resolved = if std::path::Path::new(name).is_absolute() {
5127                std::path::PathBuf::from(name)
5128            } else {
5129                real_cwd.join(name)
5130            };
5131            if !resolved.exists() {
5132                return Ok(Some(ExecResult::failure(
5133                    127,
5134                    format!("{}: No such file or directory", name),
5135                )));
5136            }
5137            if !resolved.is_file() {
5138                return Ok(Some(ExecResult::failure(
5139                    126,
5140                    format!("{}: Is a directory", name),
5141                )));
5142            }
5143            #[cfg(unix)]
5144            {
5145                use std::os::unix::fs::PermissionsExt;
5146                let mode = std::fs::metadata(&resolved)
5147                    .map(|m| m.permissions().mode())
5148                    .unwrap_or(0);
5149                if mode & 0o111 == 0 {
5150                    return Ok(Some(ExecResult::failure(
5151                        126,
5152                        format!("{}: Permission denied", name),
5153                    )));
5154                }
5155            }
5156            resolved.to_string_lossy().into_owned()
5157        } else {
5158            // Get PATH from scope only. The kernel never reads OS env: a
5159            // frontend that wants host PATH seeds it via initial_vars (the REPL
5160            // does, with os_env_vars()). No PATH in scope → nothing resolves.
5161            let path_var = {
5162                let scope = self.scope.read().await;
5163                scope.get("PATH").map(value_to_string).unwrap_or_default()
5164            };
5165
5166            // Resolve command in PATH
5167            match resolve_in_path(name, &path_var) {
5168                Some(path) => path,
5169                None => return Ok(None), // Not found - let caller handle error
5170            }
5171        };
5172
5173        tracing::debug!(executable = %executable, "resolved external command");
5174
5175        // Build flat argv (preserves flag format)
5176        let argv = self.build_args_flat(args).await?;
5177
5178        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5179        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5180        // `String`. Take both out under the lock but do NOT drain here — a pipe
5181        // read can block on its producer (a still-running upstream stage), so
5182        // draining before spawn would serialize the pipeline (deadlocking
5183        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5184        // `set_stdin` clears `pipe_stdin`, so a redirect-set String and a pipe
5185        // are mutually exclusive in practice; prefer the pipe.
5186        let (pipe_stdin, stdin_string) = {
5187            let mut ctx = self.exec_ctx.write().await;
5188            (ctx.pipe_stdin.take(), ctx.take_stdin())
5189        };
5190        let has_stdin = pipe_stdin.is_some() || stdin_string.is_some();
5191
5192        // Build and spawn the command
5193        use tokio::process::Command;
5194
5195        let mut cmd = Command::new(&executable);
5196        cmd.args(&argv);
5197        cmd.current_dir(&real_cwd);
5198
5199        // Hermetic env: child sees only kaish's exported vars, not the kaish
5200        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
5201        // populate it via KernelConfig::initial_vars at construction.
5202        cmd.env_clear();
5203        {
5204            let scope = self.scope.read().await;
5205            let exported = scope.exported_vars();
5206            // A structured value can't cross the process boundary; refuse rather
5207            // than silently JSON-serialize it into the child's environment.
5208            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
5209                return Err(anyhow::anyhow!(msg));
5210            }
5211            for (var_name, value) in exported {
5212                // Binary can't cross the process boundary as an env var value
5213                // either — loud, not the `[binary: N bytes]` placeholder
5214                // silently exported in its place (kept in sync with
5215                // dispatch.rs::try_external and env.rs::execute_with_env).
5216                let value_str = crate::interpreter::value_to_text_sink_named(
5217                    &value,
5218                    "an exported environment variable value",
5219                )
5220                .map_err(|e| anyhow::anyhow!("{e}"))?;
5221                cmd.env(var_name, value_str);
5222            }
5223        }
5224
5225        // Handle stdin
5226        cmd.stdin(if has_stdin {
5227            std::process::Stdio::piped()
5228        } else if self.interactive {
5229            std::process::Stdio::inherit()
5230        } else {
5231            std::process::Stdio::null()
5232        });
5233
5234        // In interactive mode, standalone or last-in-pipeline commands inherit
5235        // the terminal's stdout/stderr so output streams in real-time.
5236        // First/middle commands must capture stdout for the pipe — same as bash.
5237        let pipeline_position = {
5238            let ctx = self.exec_ctx.read().await;
5239            ctx.pipeline_position
5240        };
5241        let inherit_output = self.interactive
5242            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5243
5244        if inherit_output {
5245            cmd.stdout(std::process::Stdio::inherit());
5246            cmd.stderr(std::process::Stdio::inherit());
5247        } else {
5248            cmd.stdout(std::process::Stdio::piped());
5249            cmd.stderr(std::process::Stdio::piped());
5250        }
5251
5252        // On Unix, always put the child in its own process group so cancellation
5253        // can `killpg` the whole tree (the child plus any grandchildren).
5254        // Restoring default tty-related signal handlers stays gated on
5255        // job-control mode — those only matter when the child has a controlling
5256        // terminal.
5257        #[cfg(unix)]
5258        {
5259            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5260            // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
5261            #[allow(unsafe_code)]
5262            unsafe {
5263                cmd.pre_exec(move || {
5264                    // Own process group — for kill scope.
5265                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5266                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5267                    if restore_jc_signals {
5268                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5269                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
5270                        sa.sa_sigaction = SIG_DFL;
5271                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5272                            return Err(std::io::Error::last_os_error());
5273                        }
5274                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5275                            return Err(std::io::Error::last_os_error());
5276                        }
5277                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5278                            return Err(std::io::Error::last_os_error());
5279                        }
5280                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5281                            return Err(std::io::Error::last_os_error());
5282                        }
5283                    }
5284                    Ok(())
5285                });
5286            }
5287        }
5288
5289        // Backstop for kill on drop in case our explicit kill path is bypassed
5290        // (panic, early return, etc) on the **capture** wait path. We do NOT
5291        // set this on the JC inherit path: that uses sync `waitpid` outside
5292        // tokio's view of the child, so on drop tokio would try to kill an
5293        // already-reaped (possibly-reused) PID. The JC path has its own
5294        // cancel handling via the side-task watcher.
5295        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5296        if !in_jc_inherit_path {
5297            cmd.kill_on_drop(true);
5298        }
5299
5300        // Spawn the process. Capture a `KillTarget` immediately so cancel/
5301        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5302        // to this process's generation, immune to PID reuse if the OS reaps
5303        // the child before our kill syscalls fire.
5304        let mut child = match cmd.spawn() {
5305            Ok(child) => child,
5306            Err(e) => {
5307                return Ok(Some(ExecResult::failure(
5308                    127,
5309                    format!("{}: {}", name, e),
5310                )));
5311            }
5312        };
5313        let kill_target = crate::pidfd::KillTarget::from_child(&child);
5314
5315        // If this external runs on behalf of a background job, record its
5316        // process group on the job so `kill -<sig> %N` can signal the real
5317        // process directly (STOP/CONT/USR1/…, not just terminate). The child
5318        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5319        if let Some(job_id) = self.bg_job_id
5320            && let Some(pid) = child.id()
5321        {
5322            self.jobs.add_pgid(job_id, pid).await;
5323        }
5324
5325        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5326        // detached task (bounded memory, no pre-drain) so an upstream stage and
5327        // this child run concurrently — and a child that never reads stdin (or
5328        // is killed) just breaks the copy, which stops. A buffered `String` is
5329        // written inline and stdin dropped to signal EOF. Bytes are copied
5330        // verbatim, so binary stdin survives.
5331        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5332            child.stdin.take().map(|mut child_stdin| {
5333                tokio::spawn(async move {
5334                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5335                    let mut buf = [0u8; 8192];
5336                    loop {
5337                        match pipe_in.read(&mut buf).await {
5338                            Ok(0) => break, // EOF
5339                            Ok(n) => {
5340                                if child_stdin.write_all(&buf[..n]).await.is_err() {
5341                                    break; // child closed stdin
5342                                }
5343                            }
5344                            Err(_) => break,
5345                        }
5346                    }
5347                    // Dropping child_stdin signals EOF to the child.
5348                })
5349            })
5350        } else if let Some(data) = stdin_string {
5351            // Write the buffered String from a detached task too — NOT inline.
5352            // An inline write blocks once the stdin pipe fills, and the output
5353            // drain hasn't spawned yet, so a child that emits a lot before
5354            // consuming all its input (every pipe buffer full) deadlocks. A
5355            // write error here is normal, not a failure: a child that closes
5356            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5357            // signals EOF.
5358            child.stdin.take().map(|mut child_stdin| {
5359                tokio::spawn(async move {
5360                    use tokio::io::AsyncWriteExt;
5361                    let _ = child_stdin.write_all(data.as_bytes()).await;
5362                })
5363            })
5364        } else {
5365            None
5366        };
5367
5368        // Abort the stdin-copy task on EVERY exit path (the capture path, both
5369        // interactive `inherit_output` returns, and any early error return).
5370        // Once the child is reaped the copy has nothing left to deliver; if it
5371        // were left parked on `pipe_in.read()` it would leak and hold the
5372        // upstream pipe reader open. A drop guard is the single place that
5373        // covers all returns — explicit per-return aborts were error-prone (an
5374        // earlier version missed the two inherit_output returns).
5375        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5376        impl Drop for AbortStdinCopyOnDrop {
5377            fn drop(&mut self) {
5378                if let Some(t) = self.0.take() {
5379                    t.abort();
5380                }
5381            }
5382        }
5383        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5384
5385        if inherit_output {
5386            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5387            #[cfg(unix)]
5388            if let Some(ref term) = self.terminal_state {
5389                let child_id = child.id().unwrap_or(0);
5390                let pid = nix::unistd::Pid::from_raw(child_id as i32);
5391                let pgid = pid; // child is its own pgid leader
5392
5393                // Give the terminal to the child's process group
5394                if let Err(e) = term.give_terminal_to(pgid) {
5395                    tracing::warn!("failed to give terminal to child: {}", e);
5396                }
5397
5398                let term_clone = term.clone();
5399                let cmd_name = name.to_string();
5400                let cmd_display = format!("{} {}", name, argv.join(" "));
5401                let jobs = self.jobs.clone();
5402
5403                // Side task that watches for cancellation while the blocking
5404                // waitpid runs. On cancel, it SIGTERMs the process group, waits
5405                // the grace period, then SIGKILLs. The blocking waitpid returns
5406                // when the child dies. AbortOnDrop guard cancels the watcher
5407                // on the success path so it doesn't keep running after wait
5408                // returns naturally.
5409                //
5410                // `wait_complete` shrinks the PID-reuse race: the watcher
5411                // checks it before each kill syscall and bails out if
5412                // wait_for_foreground has already reaped the child. This
5413                // doesn't fully eliminate the race (atomic load + kill is
5414                // not atomic with the OS reap+reuse), but narrows the window
5415                // to nanoseconds — enough to be ignorable in practice.
5416                let wait_complete = std::sync::Arc::new(
5417                    std::sync::atomic::AtomicBool::new(false)
5418                );
5419                let cancel_watcher = {
5420                    let cancel = cancel.clone();
5421                    let wc = wait_complete.clone();
5422                    // Ownership transfer: the JC path's sync wait inside
5423                    // block_in_place owns the child's reaping, so the
5424                    // cancel_watcher drives the kill side via KillTarget
5425                    // (pidfd-bound on Linux). When kill_target is None
5426                    // (older kernel + open failure, or non-Linux), falls
5427                    // through to the older PID-based path the closure
5428                    // captures from `pid`.
5429                    let target = kill_target.as_ref().map(|t| {
5430                        // Re-borrow the components we need into Owned-ish form
5431                        // so the spawned task is 'static. We can't move
5432                        // KillTarget directly because try_execute_external
5433                        // still uses it after the spawn — but on the JC path
5434                        // there is no further use after the watcher spawn,
5435                        // so a clone-of-pid + owned None pidfd is safe.
5436                        // Simpler: signal via the existing target by cloning
5437                        // a fresh pidfd; the original keeps its handle.
5438                        // Pidfd is just an OwnedFd — not Clone — so do it
5439                        // by re-opening from the pid. Fall back if reopen
5440                        // fails (race already reaped → best-effort kill).
5441                        crate::pidfd::KillTarget::from_pid(t.pid())
5442                    });
5443                    tokio::spawn(async move {
5444                        cancel.cancelled().await;
5445                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5446                        use nix::sys::signal::Signal;
5447                        if let Some(t) = &target {
5448                            t.signal(Signal::SIGTERM);
5449                            t.signal_pg(Signal::SIGTERM);
5450                        } else {
5451                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5452                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5453                        }
5454                        if kill_grace > Duration::ZERO {
5455                            tokio::time::sleep(kill_grace).await;
5456                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5457                        }
5458                        if let Some(t) = &target {
5459                            t.signal(Signal::SIGKILL);
5460                            t.signal_pg(Signal::SIGKILL);
5461                        } else {
5462                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5463                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5464                        }
5465                    })
5466                };
5467                struct AbortOnDrop(tokio::task::JoinHandle<()>);
5468                impl Drop for AbortOnDrop {
5469                    fn drop(&mut self) {
5470                        self.0.abort();
5471                    }
5472                }
5473                let _watcher_guard = AbortOnDrop(cancel_watcher);
5474
5475                let wait_complete_setter = wait_complete.clone();
5476                let code = tokio::task::block_in_place(move || {
5477                    let result = term_clone.wait_for_foreground(pid);
5478                    // Mark wait done before the watcher might fire.
5479                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5480
5481                    // Always reclaim the terminal
5482                    if let Err(e) = term_clone.reclaim_terminal() {
5483                        tracing::warn!("failed to reclaim terminal: {}", e);
5484                    }
5485
5486                    match result {
5487                        crate::terminal::WaitResult::Exited(code) => code as i64,
5488                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5489                        crate::terminal::WaitResult::Stopped(_sig) => {
5490                            // Register as a stopped job
5491                            let rt = tokio::runtime::Handle::current();
5492                            let job_id = rt.block_on(jobs.register_stopped(
5493                                cmd_display,
5494                                child_id,
5495                                child_id, // pgid = pid for group leader
5496                            ));
5497                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5498                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5499                        }
5500                    }
5501                });
5502
5503                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5504            }
5505
5506            // Non-job-control path with inherited stdio.
5507            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5508                Ok(s) => s,
5509                Err(e) => {
5510                    return Ok(Some(ExecResult::failure(
5511                        1,
5512                        format!("{}: failed to wait: {}", name, e),
5513                    )));
5514                }
5515            };
5516
5517            let code = exit_code_from_status(&status);
5518
5519            // stdout/stderr already went to the terminal
5520            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5521        } else {
5522            // Capture output via bounded streams
5523            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5524            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5525
5526            let stdout_pipe = child.stdout.take();
5527            let stderr_pipe = child.stderr.take();
5528
5529            let stdout_clone = stdout_stream.clone();
5530            let stderr_clone = stderr_stream.clone();
5531
5532            let stdout_task = stdout_pipe.map(|pipe| {
5533                tokio::spawn(async move {
5534                    drain_to_stream(pipe, stdout_clone).await;
5535                })
5536            });
5537
5538            let stderr_task = stderr_pipe.map(|pipe| {
5539                tokio::spawn(async move {
5540                    drain_to_stream(pipe, stderr_clone).await;
5541                })
5542            });
5543
5544            let cancelled_before_wait = cancel.is_cancelled();
5545            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5546                Ok(s) => s,
5547                Err(e) => {
5548                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5549                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5550                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5551                    return Ok(Some(ExecResult::failure(
5552                        1,
5553                        format!("{}: failed to wait: {}", name, e),
5554                    )));
5555                }
5556            };
5557
5558            // On cancel, abort the drain tasks (the child's pipes are gone;
5559            // late output is lost but predictable death beats partial capture).
5560            // On normal exit, await drains so we don't lose buffered output.
5561            if cancelled_before_wait || cancel.is_cancelled() {
5562                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5563                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5564            } else {
5565                if let Some(task) = stdout_task {
5566                    // Ignore join error — the drain task logs its own errors
5567                    let _ = task.await;
5568                }
5569                if let Some(task) = stderr_task {
5570                    let _ = task.await;
5571                }
5572            }
5573
5574            let code = exit_code_from_status(&status);
5575
5576            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5577            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5578            // intact. stderr stays text. See docs/binary-data.md.
5579            let stdout = stdout_stream.read().await;
5580            let stderr = stderr_stream.read_string().await;
5581            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5582            result.err = stderr;
5583            Ok(Some(result))
5584        }
5585    }
5586
5587    // --- Variable Access ---
5588
5589    /// Get a variable value.
5590    pub async fn get_var(&self, name: &str) -> Option<Value> {
5591        let scope = self.scope.read().await;
5592        scope.get(name).cloned()
5593    }
5594
5595    /// Check if error-exit mode is enabled (for testing).
5596    #[cfg(test)]
5597    pub async fn error_exit_enabled(&self) -> bool {
5598        let scope = self.scope.read().await;
5599        scope.error_exit_enabled()
5600    }
5601
5602    /// Set a variable value.
5603    pub async fn set_var(&self, name: &str, value: Value) {
5604        let mut scope = self.scope.write().await;
5605        scope.set(name.to_string(), value);
5606    }
5607
5608    /// Set positional parameters ($0 script name and $1-$9 args).
5609    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5610        let mut scope = self.scope.write().await;
5611        scope.set_positional(script_name, args);
5612    }
5613
5614    /// List all variables.
5615    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5616        let scope = self.scope.read().await;
5617        scope.all()
5618    }
5619
5620    /// List exported variables (name, value), sorted by name. These are the
5621    /// vars a child process would see (see `dispatch`'s hermetic env build).
5622    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5623        let scope = self.scope.read().await;
5624        scope.exported_vars()
5625    }
5626
5627    // --- CWD ---
5628
5629    /// Get current working directory.
5630    pub async fn cwd(&self) -> PathBuf {
5631        self.exec_ctx.read().await.cwd.clone()
5632    }
5633
5634    /// Set current working directory.
5635    pub async fn set_cwd(&self, path: PathBuf) {
5636        let mut ctx = self.exec_ctx.write().await;
5637        ctx.set_cwd(path);
5638    }
5639
5640    /// Set the working directory only if `path` resolves to a directory in the
5641    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5642    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5643    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5644    /// disappeared. Returns whether the cwd was changed.
5645    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5646        // Clone the backend Arc out before the stat so we never hold the
5647        // exec_ctx lock across the await.
5648        let backend = self.exec_ctx.read().await.backend.clone();
5649        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5650        if is_dir {
5651            self.exec_ctx.write().await.set_cwd(path);
5652        }
5653        is_dir
5654    }
5655
5656    // --- Last Result ---
5657
5658    /// Get the last result ($?).
5659    pub async fn last_result(&self) -> ExecResult {
5660        let scope = self.scope.read().await;
5661        scope.last_result().clone()
5662    }
5663
5664    // --- Tools ---
5665
5666    /// Check if a user-defined function exists.
5667    pub async fn has_function(&self, name: &str) -> bool {
5668        self.user_tools.read().await.contains_key(name)
5669    }
5670
5671    /// Get available tool schemas.
5672    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5673        self.tools.schemas()
5674    }
5675
5676    /// Classify how the kernel will resolve a command name.
5677    ///
5678    /// This is the supported, single source of truth for command resolution that
5679    /// embedders should call instead of re-deriving the rules. Walk a parsed
5680    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5681    /// this per command name to bucket each into builtin / user-function /
5682    /// special-form / dynamic / external — for example a consent gate that blocks
5683    /// a script until external commands are approved.
5684    ///
5685    /// The classification mirrors the interpreter's real resolution order
5686    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5687    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5688    /// re-checking special-forms each step, exactly as execution does), then user
5689    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5690    /// name that is a variable or command-substitution expansion (`$cmd`,
5691    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5692    /// be resolved statically.
5693    ///
5694    /// Aliases are resolved against the kernel's current alias table, so an
5695    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5696    /// thing it would actually run. The safe direction of any residual imprecision
5697    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5698    /// `.kai`/backend-tool resolution are reported `External` even though some of
5699    /// those resolve in-process, so a consent gate over-gates rather than letting
5700    /// a `PATH` escape slip through.
5701    pub async fn classify_command(&self, name: &str) -> CommandKind {
5702        // Resolve the command head the way `execute_command_depth` does: a
5703        // special-form short-circuits before any alias lookup, otherwise expand
5704        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5705        // can't be resolved at all.
5706        let mut name = name.to_string();
5707        let mut alias_depth = 0u8;
5708        loop {
5709            if !crate::validator::is_static_command_name(&name) {
5710                return CommandKind::Dynamic;
5711            }
5712            if crate::validator::is_runtime_special_form(&name) {
5713                return CommandKind::Special;
5714            }
5715            if alias_depth >= 10 {
5716                break;
5717            }
5718            let alias_value = {
5719                let ctx = self.exec_ctx.read().await;
5720                ctx.aliases.get(&name).cloned()
5721            };
5722            // Expand to the alias's head command. An empty alias value (no head)
5723            // is ignored by execution, so resolution continues with this name.
5724            match alias_value
5725                .as_deref()
5726                .and_then(|v| v.split_whitespace().next())
5727            {
5728                Some(head) => {
5729                    name = head.to_string();
5730                    alias_depth += 1;
5731                }
5732                None => break,
5733            }
5734        }
5735
5736        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5737        let is_builtin = self.tools.contains(&name);
5738        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5739    }
5740
5741    // --- Jobs ---
5742
5743    /// Get job manager.
5744    pub fn jobs(&self) -> Arc<JobManager> {
5745        self.jobs.clone()
5746    }
5747
5748    // --- VFS ---
5749
5750    /// Get VFS router.
5751    pub fn vfs(&self) -> Arc<VfsRouter> {
5752        self.vfs.clone()
5753    }
5754
5755    // --- State ---
5756
5757    /// Reset kernel to initial state.
5758    ///
5759    /// Clears in-memory variables and resets cwd to root. History is not
5760    /// cleared (it persists across resets). The kernel's `$$` identity, the
5761    /// confirmation latch / trash-on-delete configuration, and any
5762    /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
5763    /// are re-applied to the fresh scope rather than silently reverting to
5764    /// defaults — an embedder that opted into `with_latch(true)` must not
5765    /// find the gate quietly disabled after a `reset()` between requests.
5766    pub async fn reset(&self) -> Result<()> {
5767        {
5768            let mut scope = self.scope.write().await;
5769            let pid = scope.pid();
5770            let latch_enabled = scope.latch_enabled();
5771            let trash_enabled = scope.trash_enabled();
5772            let mut fresh = Scope::new();
5773            fresh.set_pid(pid);
5774            for (name, value) in self.initial_vars.clone() {
5775                fresh.set_exported(name, value);
5776            }
5777            fresh.set_latch_enabled(latch_enabled);
5778            fresh.set_trash_enabled(trash_enabled);
5779            *scope = fresh;
5780        }
5781        {
5782            let mut ctx = self.exec_ctx.write().await;
5783            ctx.cwd = PathBuf::from("/");
5784        }
5785        Ok(())
5786    }
5787
5788    /// Shutdown the kernel.
5789    pub async fn shutdown(self) -> Result<()> {
5790        // Wait for all background jobs
5791        self.jobs.wait_all().await;
5792        Ok(())
5793    }
5794
5795    /// Dispatch a single command using the full resolution chain.
5796    ///
5797    /// This is the core of `CommandDispatcher` — it syncs state between the
5798    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5799    /// then delegates to `execute_command` for the actual dispatch.
5800    ///
5801    /// State flow:
5802    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5803    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5804    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5805    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5806        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5807        // its inner command via ctx.dispatcher) routes through THIS kernel,
5808        // not a stale parent. Critical for forks: the fork's builtins must
5809        // use the fork's dispatcher, not the parent's.
5810        if let Some(d) = self.dispatcher() {
5811            ctx.dispatcher = Some(d);
5812        }
5813
5814        // 1. Sync ctx → self internals
5815        {
5816            let mut scope = self.scope.write().await;
5817            *scope = ctx.scope.clone();
5818        }
5819        {
5820            let mut ec = self.exec_ctx.write().await;
5821            ec.cwd = ctx.cwd.clone();
5822            ec.prev_cwd = ctx.prev_cwd.clone();
5823            ec.stdin = ctx.stdin.take();
5824            ec.stdin_data = ctx.stdin_data.take();
5825            // The structured-data sideband receiver (set by the concurrent
5826            // pipeline runner on the stage ctx) must reach the tool's snapshot
5827            // too — same reason as the pipe endpoints below. Without this a
5828            // pipeline consumer never sees the producer's `.data`.
5829            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5830            // Streaming pipe endpoints and kernel stderr must flow to the
5831            // tool via self.exec_ctx — execute_command reads that, not the
5832            // passed-in ctx. Without moving these, concurrent pipeline
5833            // stages dispatched via a fork get pipe_stdin = None and
5834            // silently read nothing.
5835            ec.pipe_stdin = ctx.pipe_stdin.take();
5836            ec.pipe_stdout = ctx.pipe_stdout.take();
5837            if let Some(stderr) = ctx.stderr.clone() {
5838                ec.stderr = Some(stderr);
5839            }
5840            ec.aliases = ctx.aliases.clone();
5841            ec.ignore_config = ctx.ignore_config.clone();
5842            ec.output_limit = ctx.output_limit.clone();
5843            ec.pipeline_position = ctx.pipeline_position;
5844            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5845            // swap ctx.cancel to a derived child token before re-dispatching;
5846            // execute_command's snapshot reads ec.cancel (kept aligned by
5847            // this sync), so try_execute_external sees the right token.
5848            ec.cancel = ctx.cancel.clone();
5849            // Same alignment for the watchdog: a fork dispatching through its
5850            // own kernel must hand the shared script clock to the snapshot so
5851            // patient holds in forked stages suspend the right timer.
5852            ec.watchdog = ctx.watchdog.clone();
5853        }
5854
5855        // 2. Execute via the full dispatch chain
5856        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5857
5858        // 3. Sync self → ctx
5859        {
5860            let scope = self.scope.read().await;
5861            ctx.scope = scope.clone();
5862        }
5863        {
5864            let mut ec = self.exec_ctx.write().await;
5865            ctx.cwd = ec.cwd.clone();
5866            ctx.prev_cwd = ec.prev_cwd.clone();
5867            ctx.aliases = ec.aliases.clone();
5868            ctx.ignore_config = ec.ignore_config.clone();
5869            ctx.output_limit = ec.output_limit.clone();
5870            // Return any pipe endpoints that the tool didn't consume.
5871            // `take()` here keeps the fork's exec_ctx in a clean state for
5872            // the next dispatch — these are per-command and shouldn't leak
5873            // between calls.
5874            ctx.pipe_stdin = ec.pipe_stdin.take();
5875            ctx.pipe_stdout = ec.pipe_stdout.take();
5876        }
5877
5878        Ok(result)
5879    }
5880}
5881
5882#[async_trait]
5883impl CommandDispatcher for Kernel {
5884    /// Dispatch a command through the Kernel's full resolution chain.
5885    ///
5886    /// This is the single path for all command execution when called from
5887    /// the pipeline runner. It provides the full dispatch chain:
5888    /// user tools → builtins → .kai scripts → external commands → backend tools.
5889    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5890        self.dispatch_command(cmd, ctx).await
5891    }
5892
5893    /// Evaluate an expression through the kernel's async chain, including
5894    /// command substitution. Delegates to `eval_expr_async`, which snapshots
5895    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
5896    /// only command output escapes. The `ctx` is unused here because the
5897    /// kernel evaluates against its own session state (a fork carries the
5898    /// pipeline stage's snapshot); var refs resolve against that scope.
5899    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
5900        self.eval_expr_async(expr).await
5901    }
5902
5903    /// Produce a forked dispatcher with independent mutable state (detached).
5904    ///
5905    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
5906    /// recursing into the trait method we're defining) and coerces the
5907    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
5908    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
5909        let fork: Arc<Kernel> = Kernel::fork(self).await;
5910        fork
5911    }
5912
5913    /// Produce a forked dispatcher with cancellation cascading from this kernel.
5914    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
5915        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
5916        fork
5917    }
5918}
5919
5920/// Apply the requested output format to a builtin's result, unless the tool
5921/// owns its own output.
5922///
5923/// `format` is `ctx.output_format` (set from `--json`). When `owns_output` is
5924/// true the tool already rendered its bytes (bespoke JSON envelope), so the
5925/// kernel leaves the result untouched rather than re-formatting its
5926/// `OutputData`. Otherwise the kernel renders the typed `OutputData` uniformly.
5927fn finalize_output(
5928    result: ExecResult,
5929    format: Option<crate::interpreter::OutputFormat>,
5930    owns_output: bool,
5931) -> ExecResult {
5932    match format {
5933        Some(_) if owns_output => result,
5934        Some(format) => apply_output_format(result, format),
5935        None => result,
5936    }
5937}
5938
5939/// Accumulate output from one result into another.
5940///
5941/// Appends stdout and stderr verbatim and updates the exit code to match the
5942/// new result. Used to preserve output from multiple statements, loop
5943/// iterations, and command chains. No separator is inserted between outputs —
5944/// each command's output concatenates raw, matching bash (`printf a; printf b`
5945/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
5946/// when a command emits its own, as `echo` does).
5947fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
5948    // Materialize lazy OutputData into .out before accumulating.
5949    // Without this, the first command's output stays in .output while
5950    // the second's text gets appended to .out, losing the first.
5951    accumulated.materialize();
5952    match new.out_bytes() {
5953        // A binary result must not be lossy-decoded by text_out(): concatenate
5954        // raw bytes so the combined output stays binary (this is the path every
5955        // top-level statement's result flows through). See docs/binary-data.md.
5956        Some(new_bytes) => {
5957            let mut combined: Vec<u8> = match accumulated.out_bytes() {
5958                Some(b) => b.to_vec(),
5959                None => accumulated.text_out().into_owned().into_bytes(),
5960            };
5961            combined.extend_from_slice(new_bytes);
5962            accumulated.set_out_bytes(combined);
5963        }
5964        None => accumulated.push_out(&new.text_out()),
5965    }
5966    accumulated.err.push_str(&new.err);
5967    accumulated.code = new.code;
5968    accumulated.data = new.data.clone();
5969    accumulated.did_spill = new.did_spill;
5970    accumulated.original_code = new.original_code;
5971    accumulated.content_type = new.content_type.clone();
5972    accumulated.baggage.clone_from(&new.baggage);
5973    // A latch gate (exit-2 + nonce) is the last statement's result; carry its
5974    // control-plane field through accumulation or the confirmation is lost.
5975    accumulated.latch = new.latch.clone();
5976}
5977
5978/// Fold a loop's accumulated output into a break/continue signal that is
5979/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
5980/// (with `N > 1`) would otherwise be discarded when the signal replaces the
5981/// loop's result on its way up. The loop's output comes first (it ran before
5982/// the signal was raised), then the signal's already-carried output.
5983fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
5984    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5985        let mut merged = loop_output;
5986        accumulate_result(&mut merged, result);
5987        *result = merged;
5988    }
5989}
5990
5991/// Accumulate the output a break/continue signal carried (from inner loops it
5992/// propagated through) into the loop that finally handles it, so it survives
5993/// into that loop's result.
5994fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
5995    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5996        accumulate_result(accumulated, result);
5997    }
5998}
5999
6000/// Check if a value is truthy.
6001fn is_truthy(value: &Value) -> bool {
6002    match value {
6003        Value::Null => false,
6004        Value::Bool(b) => *b,
6005        Value::Int(i) => *i != 0,
6006        Value::Float(f) => *f != 0.0,
6007        Value::String(s) => !s.is_empty(),
6008        Value::Json(json) => match json {
6009            serde_json::Value::Null => false,
6010            serde_json::Value::Array(arr) => !arr.is_empty(),
6011            serde_json::Value::Object(obj) => !obj.is_empty(),
6012            serde_json::Value::Bool(b) => *b,
6013            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6014            serde_json::Value::String(s) => !s.is_empty(),
6015        },
6016        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6017    }
6018}
6019
6020/// Apply tilde expansion to a value.
6021///
6022/// Only string values starting with `~` are expanded. `home` is the session
6023/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6024/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6025fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6026    match value {
6027        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6028        _ => value,
6029    }
6030}
6031
6032/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6033/// how the lexer tokenizes the equivalent minimally-quoted command string —
6034/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6035/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6036/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6037/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6038///
6039/// Classification matches the lexer's word classes:
6040/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6041///   the binder's `past_double_dash` arms, exactly as for the string door).
6042/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6043/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6044///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6045///   they fall through to a positional, not a flag).
6046/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6047///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6048///   `key=value` positional, per the command's word-assign allowlist).
6049/// - everything else → a literal [`Arg::Positional`].
6050///
6051/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6052/// positional — it can never be a flag — and rides through as-is. That is the
6053/// typed passthrough the string-native door cannot offer.
6054pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6055    argv.iter().map(classify_argv_token).collect()
6056}
6057
6058fn classify_argv_token(token: &Value) -> Arg {
6059    let Value::String(s) = token else {
6060        return Arg::Positional(Expr::Literal(token.clone()));
6061    };
6062
6063    if s == "--" {
6064        return Arg::DoubleDash;
6065    }
6066
6067    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6068    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6069    // literal word (GH #137), matching this classifier's own literal
6070    // fallback — so they fall through to a literal positional rather than a
6071    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6072    if let Some(rest) = s.strip_prefix("--") {
6073        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6074            return match rest.split_once('=') {
6075                Some((key, val)) => Arg::Named {
6076                    key: key.to_string(),
6077                    value: Expr::Literal(Value::String(val.to_string())),
6078                },
6079                None => Arg::LongFlag(rest.to_string()),
6080            };
6081        }
6082    } else if let Some(rest) = s.strip_prefix('-') {
6083        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6084        // token carrying any other char — notably `=` (`-k=v` is a parse error in
6085        // the string door) — or a leading digit (`-1` lexes as a number) is not a
6086        // short-flag word, so it falls through to a literal positional instead of
6087        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6088        if is_short_flag_body(rest) {
6089            return Arg::ShortFlag(rest.to_string());
6090        }
6091    }
6092
6093    if let Some((key, val)) = s.split_once('=') {
6094        if is_shell_identifier(key) {
6095            return Arg::WordAssign {
6096                key: key.to_string(),
6097                value: Expr::Literal(Value::String(val.to_string())),
6098            };
6099        }
6100    }
6101
6102    Arg::Positional(Expr::Literal(Value::String(s.clone())))
6103}
6104
6105/// A short-flag word: a leading ASCII letter, then only ASCII
6106/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6107/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6108/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6109/// (`=` is the assignment operator — a parse error in the string door), and
6110/// any non-ASCII tail (never produced by the lexer, and not safe for the
6111/// combined-short-flag binder's byte-index slicing) do not, so they fall
6112/// through to a literal positional instead of a malformed `ShortFlag`.
6113fn is_short_flag_body(s: &str) -> bool {
6114    s.starts_with(|c: char| c.is_ascii_alphabetic())
6115        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6116}
6117
6118/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6119fn is_shell_identifier(s: &str) -> bool {
6120    let mut chars = s.chars();
6121    match chars.next() {
6122        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6123        _ => return false,
6124    }
6125    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6126}
6127
6128/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6129/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6130/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6131/// must keep every value, not silently drop all but the last. Used by every flag
6132/// surface that can carry the same flag twice — the space form
6133/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6134/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6135/// ordered array.
6136pub(crate) fn push_repeatable_value(
6137    tool_args: &mut ToolArgs,
6138    flag_name: &str,
6139    canonical: &str,
6140    v: Value,
6141) -> anyhow::Result<()> {
6142    let occ = crate::interpreter::value_to_json(&v);
6143    let entry = tool_args
6144        .named
6145        .entry(canonical.to_string())
6146        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6147    if let Value::Json(serde_json::Value::Array(items)) = entry {
6148        items.push(occ);
6149        Ok(())
6150    } else {
6151        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6152    }
6153}
6154
6155/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6156/// is one token, so it carries a single value: a repeatable flag accumulates
6157/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6158/// first-char glued arm and the combined-bundle arm so the two can't drift on
6159/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6160/// is a loud error, not a silent single-value bind.
6161pub(crate) fn bind_glued_short_value(
6162    tool_args: &mut ToolArgs,
6163    flag_name: &str,
6164    canonical: &str,
6165    consumes: usize,
6166    repeatable: bool,
6167    value: String,
6168) -> anyhow::Result<()> {
6169    if consumes > 1 {
6170        anyhow::bail!(
6171            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6172        );
6173    }
6174    if repeatable {
6175        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6176    } else {
6177        tool_args
6178            .named
6179            .insert(canonical.to_string(), Value::String(value));
6180        Ok(())
6181    }
6182}
6183
6184/// Map a child's exit status to a shell-style exit code.
6185///
6186/// `ExitStatus::code()` is `None` when the process died from a signal rather
6187/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6188/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6189/// number. Shared by both external-command spawn sites — production
6190/// (`try_execute_external`, below) and the test-only twin
6191/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6192/// this mapping again (GH #133 item 1).
6193#[cfg(feature = "subprocess")]
6194pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6195    status.code().unwrap_or_else(|| {
6196        #[cfg(unix)]
6197        {
6198            use std::os::unix::process::ExitStatusExt;
6199            128 + status.signal().unwrap_or(0)
6200        }
6201        #[cfg(not(unix))]
6202        {
6203            -1
6204        }
6205    }) as i64
6206}
6207
6208/// Wait for a child to exit, killing it if `cancel` fires first.
6209///
6210/// `target` carries a Linux pidfd (when available) for race-free direct-child
6211/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6212/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6213#[cfg(all(unix, feature = "subprocess"))]
6214pub(crate) async fn wait_or_kill(
6215    child: &mut tokio::process::Child,
6216    target: Option<&crate::pidfd::KillTarget>,
6217    cancel: &tokio_util::sync::CancellationToken,
6218    grace: Duration,
6219) -> std::io::Result<std::process::ExitStatus> {
6220    tokio::select! {
6221        biased;
6222        status = child.wait() => status,
6223        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6224    }
6225}
6226
6227#[cfg(all(not(unix), feature = "subprocess"))]
6228pub(crate) async fn wait_or_kill(
6229    child: &mut tokio::process::Child,
6230    _target: Option<&()>,
6231    cancel: &tokio_util::sync::CancellationToken,
6232    _grace: Duration,
6233) -> std::io::Result<std::process::ExitStatus> {
6234    tokio::select! {
6235        biased;
6236        status = child.wait() => status,
6237        _ = cancel.cancelled() => {
6238            let _ = child.start_kill();
6239            child.wait().await
6240        }
6241    }
6242}
6243
6244/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6245///
6246/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6247/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6248/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6249#[cfg(all(unix, feature = "subprocess"))]
6250pub(crate) async fn kill_with_grace(
6251    child: &mut tokio::process::Child,
6252    target: Option<&crate::pidfd::KillTarget>,
6253    grace: Duration,
6254) -> std::io::Result<std::process::ExitStatus> {
6255    use nix::sys::signal::Signal;
6256
6257    if let Some(t) = target {
6258        t.signal(Signal::SIGTERM);
6259        t.signal_pg(Signal::SIGTERM);
6260        if grace > Duration::ZERO
6261            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6262        {
6263            return status;
6264        }
6265        t.signal(Signal::SIGKILL);
6266        t.signal_pg(Signal::SIGKILL);
6267    }
6268    child.wait().await
6269}
6270
6271#[cfg(test)]
6272#[allow(clippy::unwrap_used, clippy::expect_used)]
6273mod argv_classify_tests {
6274    use super::*;
6275
6276    /// A normalized, comparable view of one `Arg` representing its *logical
6277    /// argument* (what the command observably receives), not its exact AST shape:
6278    ///
6279    /// - Value-bearing arms compare by *stringified* value, so the parser's
6280    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6281    ///   (`String("-1")`) count as the same argument.
6282    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6283    ///   For every command except the `export`/`alias` allowlist, a bareword
6284    ///   `key=value` is stringified straight back to a `"key=value"` positional
6285    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6286    ///   converge observably even when they disagree on the AST tag — e.g. the
6287    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6288    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
6289    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6290    ///   separately by `classifies_each_word_class`.
6291    ///
6292    /// Returns `None` for shapes we deliberately don't compare:
6293    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6294    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6295    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
6296    ///   string. That divergence is *intentional* — `execute_argv` preserves a
6297    ///   literal numeric string (pass `Value::Int` for a number), the string door
6298    ///   can only guess — so the property skips it rather than demanding the
6299    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
6300    ///   by `classifies_each_word_class`.
6301    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6302        // Only a *string*-valued literal is comparable; a coerced number is not.
6303        let lit = |e: &Expr| match e {
6304            Expr::Literal(Value::String(s)) => Some(s.clone()),
6305            _ => None,
6306        };
6307        Some(match arg {
6308            Arg::DoubleDash => ("dash", String::new(), String::new()),
6309            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6310            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6311            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6312            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6313            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6314        })
6315    }
6316
6317    /// Classify a single string token the way `execute_argv` would.
6318    fn classify(token: &str) -> Arg {
6319        classify_argv_token(&Value::String(token.to_string()))
6320    }
6321
6322    #[test]
6323    fn classifies_each_word_class() {
6324        assert_eq!(classify("--"), Arg::DoubleDash);
6325        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6326        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6327        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6328        assert_eq!(
6329            classify("--key=value"),
6330            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6331        );
6332        assert_eq!(
6333            classify("NAME=val"),
6334            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6335        );
6336        // Digits after the first flag char are ordinary (kept verbatim).
6337        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6338        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6339        // Leading-digit dash is a number to the lexer, not a flag → positional.
6340        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6341        // Numeric strings keep their literal text — `execute_argv` does NOT
6342        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6343        // who wants a number passes `Value::Int`; a string stays the string.
6344        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6345        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6346        // A lone dash (stdin convention) is a positional, not a flag.
6347        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6348        // Non-identifier LHS is not an assignment.
6349        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6350        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6351    }
6352
6353    #[test]
6354    fn typed_values_pass_through_as_literal_positionals() {
6355        // The whole point of the `&[Value]` signature: a non-string value is a
6356        // literal positional carrying the *exact* value, never stringified and
6357        // never flag-interpreted.
6358        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6359        assert_eq!(
6360            classify_argv_token(&bytes),
6361            Arg::Positional(Expr::Literal(bytes.clone()))
6362        );
6363        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6364        assert_eq!(
6365            classify_argv_token(&json),
6366            Arg::Positional(Expr::Literal(json.clone()))
6367        );
6368        // An integer token that *looks* like a flag is still a positional value
6369        // (only strings are inspected for a leading dash).
6370        assert_eq!(
6371            classify_argv_token(&Value::Int(-9)),
6372            Arg::Positional(Expr::Literal(Value::Int(-9)))
6373        );
6374    }
6375
6376    #[test]
6377    fn double_dash_only_matches_exactly() {
6378        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6379        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6380        // as a single argv token here it's likewise literal.
6381        assert_eq!(classify("--"), Arg::DoubleDash);
6382        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6383        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6384    }
6385
6386    #[test]
6387    fn malformed_flag_words_fall_back_to_literal_positionals() {
6388        // A token that isn't a well-formed flag word must NOT be silently misbound
6389        // into the arg binder (house rule: loud/visible over silent-wrong). Each
6390        // of these is a parse error or different tokenization in the string door,
6391        // so the argv door keeps them as literal positionals.
6392        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6393        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6394        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6395        assert_eq!(classify("-k=v"), pos("-k=v"));
6396        assert_eq!(classify("-="), pos("-="));
6397        // Empty long-flag key.
6398        assert_eq!(classify("--=v"), pos("--=v"));
6399        // `--` followed by a non-letter is not a long flag.
6400        assert_eq!(classify("--1"), pos("--1"));
6401        // A bare dash and a number-dash are positionals (covered above too).
6402        assert_eq!(classify("-"), pos("-"));
6403        assert_eq!(classify("-9"), pos("-9"));
6404        // A non-ASCII tail is not part of the lexer's short-flag char class
6405        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6406        // absorbs) — classifying it as `ShortFlag` would hand the combined
6407        // short-flag binder a byte string it (correctly, for real ASCII flag
6408        // words) slices by *byte* index, panicking on a multi-byte char
6409        // boundary. Fall back to a literal positional instead.
6410        assert_eq!(classify("-lé"), pos("-lé"));
6411        assert_eq!(classify("-é"), pos("-é"));
6412    }
6413
6414    #[tokio::test]
6415    async fn non_ascii_short_flag_bundle_does_not_panic() {
6416        // Regression: `execute_argv`'s combined-short-flag loop assumed the
6417        // flag body was ASCII (safe to byte-slice) because the lexer's
6418        // grammar guarantees that on the *string* door. The argv door's
6419        // classifier let a non-ASCII tail through as `ShortFlag`, so
6420        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6421        let kernel = Kernel::transient().expect("failed to create kernel");
6422        let result = kernel
6423            .execute_argv("ls", &[Value::String("-lé".into())])
6424            .await
6425            .expect("execute_argv must not panic on a non-ASCII short-flag token");
6426        // Not a well-formed flag word, so it's a literal positional — `ls`
6427        // then reports it as a missing path rather than mangling flags.
6428        assert_ne!(result.code, 0);
6429    }
6430
6431    proptest::proptest! {
6432        /// The core correctness claim: the classifier mirrors the lexer/parser
6433        /// on metacharacter-free tokens. For any such single token, the `Arg`
6434        /// the classifier produces matches the one the real parser produces for
6435        /// the equivalent one-word command — so `execute_argv` reusing the
6436        /// string door's binder is sound. (First proptest in the workspace.)
6437        #[test]
6438        fn classifier_matches_parser_on_clean_tokens(
6439            // No digits: this property tests the *classification* boundary
6440            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6441            // positional), not numeric coercion. The lexer coerces digit runs to
6442            // `Int`/`Float` and drops the literal text (even inside a colon-merged
6443            // word: `00:` → `0:`); the classifier intentionally preserves the raw
6444            // string. Those numeric edges are pinned exactly by the unit tests.
6445            token in "[a-zA-Z_=./@:+-]{1,8}"
6446        ) {
6447            let parsed = match parse(&format!("cmd {token}")) {
6448                Ok(p) => p,
6449                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6450            };
6451            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6452                return Ok(());
6453            };
6454            // Only compare when the token lexed as exactly one argument.
6455            let [arg] = cmd.args.as_slice() else { return Ok(()); };
6456
6457            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6458                return Ok(()); // a non-literal parsed Expr we don't model — skip
6459            };
6460            proptest::prop_assert_eq!(
6461                ours, theirs,
6462                "classifier diverged from parser on token {:?}", token
6463            );
6464        }
6465    }
6466}
6467
6468#[cfg(all(test, feature = "subprocess"))]
6469#[allow(clippy::expect_used)]
6470mod tests {
6471    use super::*;
6472
6473    #[tokio::test]
6474    async fn test_kernel_transient() {
6475        let kernel = Kernel::transient().expect("failed to create kernel");
6476        assert_eq!(kernel.name(), "transient");
6477    }
6478
6479    #[tokio::test]
6480    async fn test_kernel_execute_echo() {
6481        let kernel = Kernel::transient().expect("failed to create kernel");
6482        let result = kernel.execute("echo hello").await.expect("execution failed");
6483        assert!(result.ok());
6484        assert_eq!(result.text_out().trim(), "hello");
6485    }
6486
6487    #[tokio::test]
6488    async fn test_multiple_statements_accumulate_output() {
6489        let kernel = Kernel::transient().expect("failed to create kernel");
6490        let result = kernel
6491            .execute("echo one\necho two\necho three")
6492            .await
6493            .expect("execution failed");
6494        assert!(result.ok());
6495        // Should have all three outputs separated by newlines
6496        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6497        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6498        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6499    }
6500
6501    #[tokio::test]
6502    async fn test_and_chain_accumulates_output() {
6503        let kernel = Kernel::transient().expect("failed to create kernel");
6504        let result = kernel
6505            .execute("echo first && echo second")
6506            .await
6507            .expect("execution failed");
6508        assert!(result.ok());
6509        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6510        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6511    }
6512
6513    #[tokio::test]
6514    async fn test_for_loop_accumulates_output() {
6515        let kernel = Kernel::transient().expect("failed to create kernel");
6516        let result = kernel
6517            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6518            .await
6519            .expect("execution failed");
6520        assert!(result.ok());
6521        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6522        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6523        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6524    }
6525
6526    #[tokio::test]
6527    async fn test_while_loop_accumulates_output() {
6528        let kernel = Kernel::transient().expect("failed to create kernel");
6529        let result = kernel
6530            .execute(r#"
6531                N=3
6532                while [[ ${N} -gt 0 ]]; do
6533                    echo "N=${N}"
6534                    N=$((N - 1))
6535                done
6536            "#)
6537            .await
6538            .expect("execution failed");
6539        assert!(result.ok());
6540        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
6541        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
6542        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
6543    }
6544
6545    #[tokio::test]
6546    async fn test_kernel_set_var() {
6547        let kernel = Kernel::transient().expect("failed to create kernel");
6548
6549        kernel.execute("X=42").await.expect("set failed");
6550
6551        let value = kernel.get_var("X").await;
6552        assert_eq!(value, Some(Value::Int(42)));
6553    }
6554
6555    #[tokio::test]
6556    async fn test_kernel_var_expansion() {
6557        let kernel = Kernel::transient().expect("failed to create kernel");
6558
6559        kernel.execute("NAME=\"world\"").await.expect("set failed");
6560        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
6561
6562        assert!(result.ok());
6563        assert_eq!(result.text_out().trim(), "hello world");
6564    }
6565
6566    #[tokio::test]
6567    async fn test_kernel_last_result() {
6568        let kernel = Kernel::transient().expect("failed to create kernel");
6569
6570        kernel.execute("echo test").await.expect("echo failed");
6571
6572        let last = kernel.last_result().await;
6573        assert!(last.ok());
6574        assert_eq!(last.text_out().trim(), "test");
6575    }
6576
6577    #[tokio::test]
6578    async fn test_kernel_tool_not_found() {
6579        let kernel = Kernel::transient().expect("failed to create kernel");
6580
6581        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
6582        assert!(!result.ok());
6583        assert_eq!(result.code, 127);
6584        assert!(result.err.contains("command not found"));
6585    }
6586
6587    #[tokio::test]
6588    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
6589        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
6590        // engine, …) returns a `ToolResult` with structured `data` — this
6591        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
6592        // and `for r in $(embedder_tool)` see the typed value, not just
6593        // stdout text.
6594        use crate::backend::testing::MockBackend;
6595        use crate::backend::ToolResult;
6596        let (mock, _calls) = MockBackend::new();
6597        let backend = mock.with_tool_result(|_name| {
6598            let mut baggage = std::collections::BTreeMap::new();
6599            baggage.insert("trace_id".to_string(), "abc123".to_string());
6600            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
6601            // construct via with_data + the with_* setters, not a struct literal.
6602            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
6603                .with_content_type("application/json")
6604                .with_baggage(baggage))
6605        });
6606        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6607        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6608            .expect("with_backend kernel");
6609
6610        let result = kernel
6611            .execute("embedder_tool")
6612            .await
6613            .expect("execution failed");
6614        assert!(result.ok(), "backend tool call should succeed: {result:?}");
6615        assert_eq!(
6616            result.data,
6617            Some(Value::Json(serde_json::json!({"key": "value"}))),
6618            "backend tool's structured data must survive into ExecResult, not be dropped"
6619        );
6620        assert_eq!(
6621            result.content_type.as_deref(),
6622            Some("application/json"),
6623            "backend tool's content_type must survive into ExecResult"
6624        );
6625        assert_eq!(
6626            result.baggage.get("trace_id").map(String::as_str),
6627            Some("abc123"),
6628            "backend tool's baggage must survive into ExecResult"
6629        );
6630    }
6631
6632    #[tokio::test]
6633    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
6634        // A backend tool that IS found but fails during execution (`Io`,
6635        // `PermissionDenied`, …) must surface its real error, not get
6636        // misreported as exit-127 "command not found" — that masks a genuine
6637        // failure as a lookup miss.
6638        use crate::backend::testing::MockBackend;
6639        let (mock, _calls) = MockBackend::new();
6640        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
6641        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6642        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6643            .expect("with_backend kernel");
6644
6645        let result = kernel
6646            .execute("embedder_tool")
6647            .await
6648            .expect("execution failed");
6649        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
6650        assert!(!result.ok());
6651        assert!(
6652            result.err.contains("disk exploded"),
6653            "the real backend error must be visible, not masked: {result:?}"
6654        );
6655    }
6656
6657    #[tokio::test]
6658    async fn test_external_command_true() {
6659        // Use REPL config for passthrough filesystem access
6660        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6661
6662        // /bin/true should be available on any Unix system
6663        let result = kernel.execute("true").await.expect("execution failed");
6664        // This should use the builtin true, which returns 0
6665        assert!(result.ok(), "true should succeed: {:?}", result);
6666    }
6667
6668    #[tokio::test]
6669    async fn test_external_command_basic() {
6670        // Use REPL config for passthrough filesystem access
6671        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6672
6673        // Test with /bin/echo which is external
6674        // Note: kaish has a builtin echo, so this will use the builtin
6675        // Let's test with a command that's not a builtin
6676        // Actually, let's just test that PATH resolution works by checking the PATH var
6677        let path_var = std::env::var("PATH").unwrap_or_default();
6678        eprintln!("System PATH: {}", path_var);
6679
6680        // Set PATH in kernel to ensure it's available
6681        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
6682
6683        // Now try an external command like /usr/bin/env
6684        // But env is also a builtin... let's try uname
6685        let result = kernel.execute("uname").await.expect("execution failed");
6686        eprintln!("uname result: {:?}", result);
6687        // uname should succeed if external commands work
6688        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
6689    }
6690
6691    #[tokio::test]
6692    async fn test_kernel_reset() {
6693        let kernel = Kernel::transient().expect("failed to create kernel");
6694
6695        kernel.execute("X=1").await.expect("set failed");
6696        assert!(kernel.get_var("X").await.is_some());
6697
6698        kernel.reset().await.expect("reset failed");
6699        assert!(kernel.get_var("X").await.is_none());
6700    }
6701
6702    #[tokio::test]
6703    async fn test_kernel_reset_preserves_latch_and_trash_config() {
6704        // An embedder configuring `with_latch(true)` must not have the
6705        // confirmation gate silently disabled by a later `reset()` — that
6706        // would let a destructive command through with no nonce and no
6707        // error, exactly the "silent fallback" the latch exists to prevent.
6708        let kernel = Kernel::new(
6709            KernelConfig::transient()
6710                .with_latch(true)
6711                .with_skip_validation(true),
6712        )
6713        .expect("failed to create kernel");
6714
6715        // Write and rm relative to `/` (reset()'s post-reset cwd) so the file
6716        // is reachable identically before and after reset.
6717        kernel.execute("cd /; echo hi > latch-probe.txt").await.expect("setup write failed");
6718
6719        let before = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6720        assert_eq!(before.code, 2, "latch should require confirmation before reset: {before:?}");
6721
6722        kernel.reset().await.expect("reset failed");
6723
6724        // reset() only clears scope/cwd (to `/`), not the VFS — the
6725        // un-deleted probe file (the latch blocked the delete above) is
6726        // still there.
6727        let after = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6728        assert_eq!(
6729            after.code, 2,
6730            "latch must still require confirmation after reset, not silently disable: {after:?}"
6731        );
6732    }
6733
6734    #[tokio::test]
6735    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
6736        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
6737            .expect("failed to create kernel");
6738
6739        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6740        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
6741
6742        kernel.reset().await.expect("reset failed");
6743
6744        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6745        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
6746        assert_eq!(
6747            kernel.get_var("HOME").await,
6748            Some(Value::String("/home/probe".into())),
6749            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
6750        );
6751    }
6752
6753    #[tokio::test]
6754    async fn test_kernel_cwd() {
6755        let kernel = Kernel::transient().expect("failed to create kernel");
6756
6757        // Transient kernel uses sandboxed mode with cwd=$HOME
6758        let cwd = kernel.cwd().await;
6759        let home = std::env::var("HOME")
6760            .map(PathBuf::from)
6761            .unwrap_or_else(|_| PathBuf::from("/"));
6762        assert_eq!(cwd, home);
6763
6764        kernel.set_cwd(PathBuf::from("/tmp")).await;
6765        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
6766    }
6767
6768    #[tokio::test]
6769    async fn test_kernel_list_vars() {
6770        let kernel = Kernel::transient().expect("failed to create kernel");
6771
6772        kernel.execute("A=1").await.ok();
6773        kernel.execute("B=2").await.ok();
6774
6775        let vars = kernel.list_vars().await;
6776        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
6777        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
6778    }
6779
6780    #[tokio::test]
6781    async fn test_is_truthy() {
6782        assert!(!is_truthy(&Value::Null));
6783        assert!(!is_truthy(&Value::Bool(false)));
6784        assert!(is_truthy(&Value::Bool(true)));
6785        assert!(!is_truthy(&Value::Int(0)));
6786        assert!(is_truthy(&Value::Int(1)));
6787        assert!(!is_truthy(&Value::String("".into())));
6788        assert!(is_truthy(&Value::String("x".into())));
6789    }
6790
6791    #[tokio::test]
6792    async fn test_jq_in_pipeline() {
6793        let kernel = Kernel::transient().expect("failed to create kernel");
6794        // kaish uses double quotes only; escape inner quotes
6795        let result = kernel
6796            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
6797            .await
6798            .expect("execution failed");
6799        assert!(result.ok(), "jq pipeline failed: {}", result.err);
6800        assert_eq!(result.text_out().trim(), "Alice");
6801    }
6802
6803    #[tokio::test]
6804    async fn test_user_defined_tool() {
6805        let kernel = Kernel::transient().expect("failed to create kernel");
6806
6807        // Define a function
6808        kernel
6809            .execute(r#"greet() { echo "Hello, $1!" }"#)
6810            .await
6811            .expect("function definition failed");
6812
6813        // Call the function
6814        let result = kernel
6815            .execute(r#"greet "World""#)
6816            .await
6817            .expect("function call failed");
6818
6819        assert!(result.ok(), "greet failed: {}", result.err);
6820        assert_eq!(result.text_out().trim(), "Hello, World!");
6821    }
6822
6823    #[tokio::test]
6824    async fn test_user_tool_positional_args() {
6825        let kernel = Kernel::transient().expect("failed to create kernel");
6826
6827        // Define a function with positional param
6828        kernel
6829            .execute(r#"greet() { echo "Hi $1" }"#)
6830            .await
6831            .expect("function definition failed");
6832
6833        // Call with positional argument
6834        let result = kernel
6835            .execute(r#"greet "Amy""#)
6836            .await
6837            .expect("function call failed");
6838
6839        assert!(result.ok(), "greet failed: {}", result.err);
6840        assert_eq!(result.text_out().trim(), "Hi Amy");
6841    }
6842
6843    #[tokio::test]
6844    async fn test_function_shared_scope() {
6845        let kernel = Kernel::transient().expect("failed to create kernel");
6846
6847        // Set a variable in parent scope
6848        kernel
6849            .execute(r#"SECRET="hidden""#)
6850            .await
6851            .expect("set failed");
6852
6853        // Define a function that accesses and modifies parent variable
6854        kernel
6855            .execute(r#"access_parent() {
6856                echo "${SECRET}"
6857                SECRET="modified"
6858            }"#)
6859            .await
6860            .expect("function definition failed");
6861
6862        // Call the function - it SHOULD see SECRET (shared scope like sh)
6863        let result = kernel.execute("access_parent").await.expect("function call failed");
6864
6865        // Function should have access to parent scope
6866        assert!(
6867            result.text_out().contains("hidden"),
6868            "Function should access parent scope, got: {}",
6869            result.text_out()
6870        );
6871
6872        // Function should have modified the parent variable
6873        let secret = kernel.get_var("SECRET").await;
6874        assert_eq!(
6875            secret,
6876            Some(Value::String("modified".into())),
6877            "Function should modify parent scope"
6878        );
6879    }
6880
6881    #[tokio::test]
6882    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
6883    async fn test_exec_builtin() {
6884        let kernel = Kernel::transient().expect("failed to create kernel");
6885        // argv is now a space-separated string or JSON array string
6886        let result = kernel
6887            .execute(r#"exec command="/bin/echo" argv="hello world""#)
6888            .await
6889            .expect("exec failed");
6890
6891        assert!(result.ok(), "exec failed: {}", result.err);
6892        assert_eq!(result.text_out().trim(), "hello world");
6893    }
6894
6895    #[tokio::test]
6896    async fn test_while_false_never_runs() {
6897        let kernel = Kernel::transient().expect("failed to create kernel");
6898
6899        // A while loop with false condition should never run
6900        let result = kernel
6901            .execute(r#"
6902                while false; do
6903                    echo "should not run"
6904                done
6905            "#)
6906            .await
6907            .expect("while false failed");
6908
6909        assert!(result.ok());
6910        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
6911    }
6912
6913    #[tokio::test]
6914    async fn test_while_string_comparison() {
6915        let kernel = Kernel::transient().expect("failed to create kernel");
6916
6917        // Set a flag
6918        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
6919
6920        // Use string comparison as condition (shell-compatible [[ ]] syntax)
6921        // Note: Put echo last so we can check the output
6922        let result = kernel
6923            .execute(r#"
6924                while [[ ${FLAG} == "go" ]]; do
6925                    FLAG="stop"
6926                    echo "running"
6927                done
6928            "#)
6929            .await
6930            .expect("while with string cmp failed");
6931
6932        assert!(result.ok());
6933        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
6934
6935        // Verify flag was changed
6936        let flag = kernel.get_var("FLAG").await;
6937        assert_eq!(flag, Some(Value::String("stop".into())));
6938    }
6939
6940    #[tokio::test]
6941    async fn test_while_numeric_comparison() {
6942        let kernel = Kernel::transient().expect("failed to create kernel");
6943
6944        // Test > comparison (shell-compatible [[ ]] with -gt)
6945        kernel.execute("N=5").await.expect("set failed");
6946
6947        // Note: Put echo last so we can check the output
6948        let result = kernel
6949            .execute(r#"
6950                while [[ ${N} -gt 3 ]]; do
6951                    N=3
6952                    echo "N was greater"
6953                done
6954            "#)
6955            .await
6956            .expect("while with > failed");
6957
6958        assert!(result.ok());
6959        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
6960    }
6961
6962    #[tokio::test]
6963    async fn test_break_in_while_loop() {
6964        let kernel = Kernel::transient().expect("failed to create kernel");
6965
6966        let result = kernel
6967            .execute(r#"
6968                I=0
6969                while true; do
6970                    I=1
6971                    echo "before break"
6972                    break
6973                    echo "after break"
6974                done
6975            "#)
6976            .await
6977            .expect("while with break failed");
6978
6979        assert!(result.ok());
6980        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
6981        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
6982
6983        // Verify we exited the loop
6984        let i = kernel.get_var("I").await;
6985        assert_eq!(i, Some(Value::Int(1)));
6986    }
6987
6988    #[tokio::test]
6989    async fn test_continue_in_while_loop() {
6990        let kernel = Kernel::transient().expect("failed to create kernel");
6991
6992        // Test continue in a while loop where variables persist
6993        // We use string state transition: "start" -> "middle" -> "end"
6994        // continue on "middle" should skip to next iteration
6995        // Shell-compatible: use [[ ]] for comparisons
6996        let result = kernel
6997            .execute(r#"
6998                STATE="start"
6999                AFTER_CONTINUE="no"
7000                while [[ ${STATE} != "done" ]]; do
7001                    if [[ ${STATE} == "start" ]]; then
7002                        STATE="middle"
7003                        continue
7004                        AFTER_CONTINUE="yes"
7005                    fi
7006                    if [[ ${STATE} == "middle" ]]; then
7007                        STATE="done"
7008                    fi
7009                done
7010            "#)
7011            .await
7012            .expect("while with continue failed");
7013
7014        assert!(result.ok());
7015
7016        // STATE should be "done" (we completed the loop)
7017        let state = kernel.get_var("STATE").await;
7018        assert_eq!(state, Some(Value::String("done".into())));
7019
7020        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7021        let after = kernel.get_var("AFTER_CONTINUE").await;
7022        assert_eq!(after, Some(Value::String("no".into())));
7023    }
7024
7025    #[tokio::test]
7026    async fn test_break_with_level() {
7027        let kernel = Kernel::transient().expect("failed to create kernel");
7028
7029        // Nested loop with break 2 to exit both loops
7030        // We verify by checking OUTER value:
7031        // - If break 2 works, OUTER stays at 1 (set before for loop)
7032        // - If break 2 fails, OUTER becomes 2 (set after for loop)
7033        let result = kernel
7034            .execute(r#"
7035                OUTER=0
7036                while true; do
7037                    OUTER=1
7038                    for X in "1 2"; do
7039                        break 2
7040                    done
7041                    OUTER=2
7042                done
7043            "#)
7044            .await
7045            .expect("nested break failed");
7046
7047        assert!(result.ok());
7048
7049        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7050        let outer = kernel.get_var("OUTER").await;
7051        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7052    }
7053
7054    #[tokio::test]
7055    async fn test_return_from_tool() {
7056        let kernel = Kernel::transient().expect("failed to create kernel");
7057
7058        // Define a function that returns early
7059        kernel
7060            .execute(r#"early_return() {
7061                if [[ $1 == 1 ]]; then
7062                    return 42
7063                fi
7064                echo "not returned"
7065            }"#)
7066            .await
7067            .expect("function definition failed");
7068
7069        // Call with arg=1 should return with exit code 42
7070        // (POSIX shell behavior: return N sets exit code, doesn't output N)
7071        let result = kernel
7072            .execute("early_return 1")
7073            .await
7074            .expect("function call failed");
7075
7076        // Exit code should be 42 (non-zero, so not ok())
7077        assert_eq!(result.code, 42);
7078        // Output should be empty (we returned before echo)
7079        assert!(result.text_out().is_empty());
7080    }
7081
7082    #[tokio::test]
7083    async fn test_return_without_value() {
7084        let kernel = Kernel::transient().expect("failed to create kernel");
7085
7086        // Define a function that returns without a value
7087        kernel
7088            .execute(r#"early_exit() {
7089                if [[ $1 == "stop" ]]; then
7090                    return
7091                fi
7092                echo "continued"
7093            }"#)
7094            .await
7095            .expect("function definition failed");
7096
7097        // Call with arg="stop" should return early
7098        let result = kernel
7099            .execute(r#"early_exit "stop""#)
7100            .await
7101            .expect("function call failed");
7102
7103        assert!(result.ok());
7104        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7105    }
7106
7107    #[tokio::test]
7108    async fn test_exit_stops_execution() {
7109        let kernel = Kernel::transient().expect("failed to create kernel");
7110
7111        // exit should stop further execution
7112        kernel
7113            .execute(r#"
7114                BEFORE="yes"
7115                exit 0
7116                AFTER="yes"
7117            "#)
7118            .await
7119            .expect("execution failed");
7120
7121        // BEFORE should be set, AFTER should not
7122        let before = kernel.get_var("BEFORE").await;
7123        assert_eq!(before, Some(Value::String("yes".into())));
7124
7125        let after = kernel.get_var("AFTER").await;
7126        assert!(after.is_none(), "AFTER should not be set after exit");
7127    }
7128
7129    #[tokio::test]
7130    async fn test_exit_with_code() {
7131        let kernel = Kernel::transient().expect("failed to create kernel");
7132
7133        // exit with code should propagate the exit code
7134        let result = kernel
7135            .execute("exit 42")
7136            .await
7137            .expect("exit failed");
7138
7139        assert_eq!(result.code, 42);
7140        assert!(result.text_out().is_empty(), "exit should not produce stdout");
7141    }
7142
7143    #[tokio::test]
7144    async fn test_set_e_stops_on_failure() {
7145        let kernel = Kernel::transient().expect("failed to create kernel");
7146
7147        // Enable error-exit mode
7148        kernel.execute("set -e").await.expect("set -e failed");
7149
7150        // Run a sequence where the middle command fails
7151        kernel
7152            .execute(r#"
7153                STEP1="done"
7154                false
7155                STEP2="done"
7156            "#)
7157            .await
7158            .expect("execution failed");
7159
7160        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7161        let step1 = kernel.get_var("STEP1").await;
7162        assert_eq!(step1, Some(Value::String("done".into())));
7163
7164        let step2 = kernel.get_var("STEP2").await;
7165        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7166    }
7167
7168    #[tokio::test]
7169    async fn test_set_plus_e_disables_error_exit() {
7170        let kernel = Kernel::transient().expect("failed to create kernel");
7171
7172        // Enable then disable error-exit mode
7173        kernel.execute("set -e").await.expect("set -e failed");
7174        kernel.execute("set +e").await.expect("set +e failed");
7175
7176        // Now failure should NOT stop execution
7177        kernel
7178            .execute(r#"
7179                STEP1="done"
7180                false
7181                STEP2="done"
7182            "#)
7183            .await
7184            .expect("execution failed");
7185
7186        // Both should be set since +e disables error exit
7187        let step1 = kernel.get_var("STEP1").await;
7188        assert_eq!(step1, Some(Value::String("done".into())));
7189
7190        let step2 = kernel.get_var("STEP2").await;
7191        assert_eq!(step2, Some(Value::String("done".into())));
7192    }
7193
7194    #[tokio::test]
7195    async fn test_set_ignores_unknown_options() {
7196        let kernel = Kernel::transient().expect("failed to create kernel");
7197
7198        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7199        let result = kernel
7200            .execute("set -e -u -o pipefail")
7201            .await
7202            .expect("set with unknown options failed");
7203
7204        assert!(result.ok(), "set should succeed with unknown options");
7205
7206        // -e should still be enabled
7207        kernel
7208            .execute(r#"
7209                BEFORE="yes"
7210                false
7211                AFTER="yes"
7212            "#)
7213            .await
7214            .ok();
7215
7216        let after = kernel.get_var("AFTER").await;
7217        assert!(after.is_none(), "-e should be enabled despite unknown options");
7218    }
7219
7220    #[tokio::test]
7221    async fn test_set_no_args_shows_settings() {
7222        let kernel = Kernel::transient().expect("failed to create kernel");
7223
7224        // Enable -e
7225        kernel.execute("set -e").await.expect("set -e failed");
7226
7227        // Call set with no args to see settings
7228        let result = kernel.execute("set").await.expect("set failed");
7229
7230        assert!(result.ok());
7231        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7232    }
7233
7234    #[tokio::test]
7235    async fn test_set_e_in_pipeline() {
7236        let kernel = Kernel::transient().expect("failed to create kernel");
7237
7238        kernel.execute("set -e").await.expect("set -e failed");
7239
7240        // Pipeline failure should trigger exit
7241        kernel
7242            .execute(r#"
7243                BEFORE="yes"
7244                false | cat
7245                AFTER="yes"
7246            "#)
7247            .await
7248            .ok();
7249
7250        let before = kernel.get_var("BEFORE").await;
7251        assert_eq!(before, Some(Value::String("yes".into())));
7252
7253        // AFTER should not be set if pipeline failure triggers exit
7254        // Note: The exit code of a pipeline is the exit code of the last command
7255        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7256        // To test pipeline failure, we need the last command to fail.
7257    }
7258
7259    #[tokio::test]
7260    async fn test_set_e_with_and_chain() {
7261        let kernel = Kernel::transient().expect("failed to create kernel");
7262
7263        kernel.execute("set -e").await.expect("set -e failed");
7264
7265        // Commands in && chain should not trigger -e on the first failure
7266        // because && explicitly handles the error
7267        kernel
7268            .execute(r#"
7269                RESULT="initial"
7270                false && RESULT="chained"
7271                RESULT="continued"
7272            "#)
7273            .await
7274            .ok();
7275
7276        // In bash, commands in && don't trigger -e. The chain handles the failure.
7277        // Our implementation may differ - let's verify current behavior.
7278        let result = kernel.get_var("RESULT").await;
7279        // If we follow bash semantics, RESULT should be "continued"
7280        // If we trigger -e on the false, RESULT stays "initial"
7281        assert!(result.is_some(), "RESULT should be set");
7282    }
7283
7284    #[tokio::test]
7285    async fn test_set_e_exits_in_for_loop() {
7286        let kernel = Kernel::transient().expect("failed to create kernel");
7287
7288        kernel.execute("set -e").await.expect("set -e failed");
7289
7290        kernel
7291            .execute(r#"
7292                REACHED="no"
7293                for x in 1 2 3; do
7294                    false
7295                    REACHED="yes"
7296                done
7297            "#)
7298            .await
7299            .ok();
7300
7301        // With set -e, false should trigger exit; REACHED should remain "no"
7302        let reached = kernel.get_var("REACHED").await;
7303        assert_eq!(reached, Some(Value::String("no".into())),
7304            "set -e should exit on failure in for loop body");
7305    }
7306
7307    #[tokio::test]
7308    async fn test_for_loop_continues_without_set_e() {
7309        let kernel = Kernel::transient().expect("failed to create kernel");
7310
7311        // Without set -e, for loop should continue normally
7312        kernel
7313            .execute(r#"
7314                COUNT=0
7315                for x in 1 2 3; do
7316                    false
7317                    COUNT=$((COUNT + 1))
7318                done
7319            "#)
7320            .await
7321            .ok();
7322
7323        let count = kernel.get_var("COUNT").await;
7324        // Arithmetic produces Int values; accept either Int or String representation
7325        let count_val = match &count {
7326            Some(Value::Int(n)) => *n,
7327            Some(Value::String(s)) => s.parse().unwrap_or(-1),
7328            _ => -1,
7329        };
7330        assert_eq!(count_val, 3,
7331            "without set -e, loop should complete all iterations (got {:?})", count);
7332    }
7333
7334    // ═══════════════════════════════════════════════════════════════════════════
7335    // Source Tests
7336    // ═══════════════════════════════════════════════════════════════════════════
7337
7338    #[tokio::test]
7339    async fn test_source_sets_variables() {
7340        let kernel = Kernel::transient().expect("failed to create kernel");
7341
7342        // Write a script to the VFS
7343        kernel
7344            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7345            .await
7346            .expect("write failed");
7347
7348        // Source the script
7349        let result = kernel
7350            .execute(r#"source "/test.kai""#)
7351            .await
7352            .expect("source failed");
7353
7354        assert!(result.ok(), "source should succeed");
7355
7356        // Variable should be set in current scope
7357        let foo = kernel.get_var("FOO").await;
7358        assert_eq!(foo, Some(Value::String("bar".into())));
7359    }
7360
7361    #[tokio::test]
7362    async fn test_source_with_dot_alias() {
7363        let kernel = Kernel::transient().expect("failed to create kernel");
7364
7365        // Write a script to the VFS
7366        kernel
7367            .execute(r#"write "/vars.kai" 'X=42'"#)
7368            .await
7369            .expect("write failed");
7370
7371        // Source using . alias
7372        let result = kernel
7373            .execute(r#". "/vars.kai""#)
7374            .await
7375            .expect(". failed");
7376
7377        assert!(result.ok(), ". should succeed");
7378
7379        // Variable should be set in current scope
7380        let x = kernel.get_var("X").await;
7381        assert_eq!(x, Some(Value::Int(42)));
7382    }
7383
7384    #[tokio::test]
7385    async fn test_source_not_found() {
7386        let kernel = Kernel::transient().expect("failed to create kernel");
7387
7388        // Try to source a non-existent file
7389        let result = kernel
7390            .execute(r#"source "/nonexistent.kai""#)
7391            .await
7392            .expect("source should not fail with error");
7393
7394        assert!(!result.ok(), "source of non-existent file should fail");
7395        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7396    }
7397
7398    #[tokio::test]
7399    async fn test_source_missing_filename() {
7400        let kernel = Kernel::transient().expect("failed to create kernel");
7401
7402        // Call source with no arguments
7403        let result = kernel
7404            .execute("source")
7405            .await
7406            .expect("source should not fail with error");
7407
7408        assert!(!result.ok(), "source without filename should fail");
7409        assert!(result.err.contains("missing filename"), "error should mention missing filename");
7410    }
7411
7412    #[tokio::test]
7413    async fn test_source_executes_multiple_statements() {
7414        let kernel = Kernel::transient().expect("failed to create kernel");
7415
7416        // Write a script with multiple statements
7417        kernel
7418            .execute(r#"write "/multi.kai" 'A=1
7419B=2
7420C=3'"#)
7421            .await
7422            .expect("write failed");
7423
7424        // Source it
7425        kernel
7426            .execute(r#"source "/multi.kai""#)
7427            .await
7428            .expect("source failed");
7429
7430        // All variables should be set
7431        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7432        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7433        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7434    }
7435
7436    #[tokio::test]
7437    async fn test_source_can_define_functions() {
7438        let kernel = Kernel::transient().expect("failed to create kernel");
7439
7440        // Write a script that defines a function
7441        kernel
7442            .execute(r#"write "/functions.kai" 'greet() {
7443    echo "Hello, $1!"
7444}'"#)
7445            .await
7446            .expect("write failed");
7447
7448        // Source it
7449        kernel
7450            .execute(r#"source "/functions.kai""#)
7451            .await
7452            .expect("source failed");
7453
7454        // Use the defined function
7455        let result = kernel
7456            .execute(r#"greet "World""#)
7457            .await
7458            .expect("greet failed");
7459
7460        assert!(result.ok());
7461        assert!(result.text_out().contains("Hello, World!"));
7462    }
7463
7464    #[tokio::test]
7465    async fn test_source_inherits_error_exit() {
7466        let kernel = Kernel::transient().expect("failed to create kernel");
7467
7468        // Enable error exit
7469        kernel.execute("set -e").await.expect("set -e failed");
7470
7471        // Write a script that has a failure
7472        kernel
7473            .execute(r#"write "/fail.kai" 'BEFORE="yes"
7474false
7475AFTER="yes"'"#)
7476            .await
7477            .expect("write failed");
7478
7479        // Source it (should exit on false due to set -e)
7480        kernel
7481            .execute(r#"source "/fail.kai""#)
7482            .await
7483            .ok();
7484
7485        // BEFORE should be set, AFTER should NOT be set due to error exit
7486        let before = kernel.get_var("BEFORE").await;
7487        assert_eq!(before, Some(Value::String("yes".into())));
7488
7489        // Note: This test depends on whether error exit is checked within source
7490        // Currently our implementation checks per-statement in the main kernel
7491    }
7492
7493    // ═══════════════════════════════════════════════════════════════════════════
7494    // set -e with && / || chains
7495    // ═══════════════════════════════════════════════════════════════════════════
7496
7497    #[tokio::test]
7498    async fn test_set_e_and_chain_left_fails() {
7499        // set -e; false && echo hi; REACHED=1 → REACHED should be set
7500        let kernel = Kernel::transient().expect("failed to create kernel");
7501        kernel.execute("set -e").await.expect("set -e failed");
7502
7503        kernel
7504            .execute("false && echo hi; REACHED=1")
7505            .await
7506            .expect("execution failed");
7507
7508        let reached = kernel.get_var("REACHED").await;
7509        assert_eq!(
7510            reached,
7511            Some(Value::Int(1)),
7512            "set -e should not trigger on left side of &&"
7513        );
7514    }
7515
7516    #[tokio::test]
7517    async fn test_set_e_and_chain_right_fails() {
7518        // set -e; true && false; REACHED=1 → REACHED should NOT be set
7519        let kernel = Kernel::transient().expect("failed to create kernel");
7520        kernel.execute("set -e").await.expect("set -e failed");
7521
7522        kernel
7523            .execute("true && false; REACHED=1")
7524            .await
7525            .expect("execution failed");
7526
7527        let reached = kernel.get_var("REACHED").await;
7528        assert!(
7529            reached.is_none(),
7530            "set -e should trigger when right side of && fails"
7531        );
7532    }
7533
7534    #[tokio::test]
7535    async fn test_set_e_or_chain_recovers() {
7536        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7537        let kernel = Kernel::transient().expect("failed to create kernel");
7538        kernel.execute("set -e").await.expect("set -e failed");
7539
7540        kernel
7541            .execute("false || echo recovered; REACHED=1")
7542            .await
7543            .expect("execution failed");
7544
7545        let reached = kernel.get_var("REACHED").await;
7546        assert_eq!(
7547            reached,
7548            Some(Value::Int(1)),
7549            "set -e should not trigger when || recovers the failure"
7550        );
7551    }
7552
7553    #[tokio::test]
7554    async fn test_set_e_or_chain_both_fail() {
7555        // set -e; false || false; REACHED=1 → REACHED should NOT be set
7556        let kernel = Kernel::transient().expect("failed to create kernel");
7557        kernel.execute("set -e").await.expect("set -e failed");
7558
7559        kernel
7560            .execute("false || false; REACHED=1")
7561            .await
7562            .expect("execution failed");
7563
7564        let reached = kernel.get_var("REACHED").await;
7565        assert!(
7566            reached.is_none(),
7567            "set -e should trigger when || chain ultimately fails"
7568        );
7569    }
7570
7571    // ═══════════════════════════════════════════════════════════════════════════
7572    // Cancellation Tests
7573    // ═══════════════════════════════════════════════════════════════════════════
7574
7575    /// Helper: schedule a cancel after a delay from a background thread.
7576    /// Uses std::thread because cancel() is sync and Kernel is not Send.
7577    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
7578        let k = Arc::clone(kernel);
7579        std::thread::spawn(move || {
7580            std::thread::sleep(delay);
7581            k.cancel();
7582        });
7583    }
7584
7585    #[tokio::test]
7586    async fn test_cancel_interrupts_for_loop() {
7587        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7588
7589        // Schedule cancel after a short delay from a background OS thread
7590        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7591
7592        let result = kernel
7593            .execute("for i in $(seq 1 100000); do X=$i; done")
7594            .await
7595            .expect("execute failed");
7596
7597        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
7598
7599        // The loop variable should be set to something < 100000
7600        let x = kernel.get_var("X").await;
7601        if let Some(Value::Int(n)) = x {
7602            assert!(n < 100000, "loop should have been interrupted before finishing, got X={n}");
7603        }
7604    }
7605
7606    #[tokio::test]
7607    async fn test_cancel_interrupts_while_loop() {
7608        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7609        kernel.execute("COUNT=0").await.expect("init failed");
7610
7611        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7612
7613        let result = kernel
7614            .execute("while true; do COUNT=$((COUNT + 1)); done")
7615            .await
7616            .expect("execute failed");
7617
7618        assert_eq!(result.code, 130);
7619
7620        let count = kernel.get_var("COUNT").await;
7621        if let Some(Value::Int(n)) = count {
7622            assert!(n > 0, "loop should have run at least once");
7623        }
7624    }
7625
7626    #[tokio::test]
7627    async fn test_reset_after_cancel() {
7628        // After cancellation, the next execute() should work normally
7629        let kernel = Kernel::transient().expect("failed to create kernel");
7630        kernel.cancel(); // cancel with nothing running
7631
7632        let result = kernel.execute("echo hello").await.expect("execute failed");
7633        assert!(result.ok(), "execute after cancel should succeed");
7634        assert_eq!(result.text_out().trim(), "hello");
7635    }
7636
7637    #[tokio::test]
7638    async fn test_cancel_interrupts_statement_sequence() {
7639        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7640
7641        // Schedule cancel after the first statement runs but before sleep finishes
7642        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
7643
7644        let result = kernel
7645            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
7646            .await
7647            .expect("execute failed");
7648
7649        assert_eq!(result.code, 130);
7650
7651        // STEP should be 1 (set before sleep), not 2 or 3
7652        let step = kernel.get_var("STEP").await;
7653        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
7654    }
7655
7656    // ═══════════════════════════════════════════════════════════════════════════
7657    // Case Statement Tests
7658    // ═══════════════════════════════════════════════════════════════════════════
7659
7660    #[tokio::test]
7661    async fn test_case_simple_match() {
7662        let kernel = Kernel::transient().expect("failed to create kernel");
7663
7664        let result = kernel
7665            .execute(r#"
7666                case "hello" in
7667                    hello) echo "matched hello" ;;
7668                    world) echo "matched world" ;;
7669                esac
7670            "#)
7671            .await
7672            .expect("case failed");
7673
7674        assert!(result.ok());
7675        assert_eq!(result.text_out().trim(), "matched hello");
7676    }
7677
7678    #[tokio::test]
7679    async fn test_case_wildcard_match() {
7680        let kernel = Kernel::transient().expect("failed to create kernel");
7681
7682        let result = kernel
7683            .execute(r#"
7684                case "main.rs" in
7685                    *.py) echo "Python" ;;
7686                    *.rs) echo "Rust" ;;
7687                    *) echo "Unknown" ;;
7688                esac
7689            "#)
7690            .await
7691            .expect("case failed");
7692
7693        assert!(result.ok());
7694        assert_eq!(result.text_out().trim(), "Rust");
7695    }
7696
7697    #[tokio::test]
7698    async fn test_case_default_match() {
7699        let kernel = Kernel::transient().expect("failed to create kernel");
7700
7701        let result = kernel
7702            .execute(r#"
7703                case "unknown.xyz" in
7704                    *.py) echo "Python" ;;
7705                    *.rs) echo "Rust" ;;
7706                    *) echo "Default" ;;
7707                esac
7708            "#)
7709            .await
7710            .expect("case failed");
7711
7712        assert!(result.ok());
7713        assert_eq!(result.text_out().trim(), "Default");
7714    }
7715
7716    #[tokio::test]
7717    async fn test_case_no_match() {
7718        let kernel = Kernel::transient().expect("failed to create kernel");
7719
7720        // Case with no default branch and no match
7721        let result = kernel
7722            .execute(r#"
7723                case "nope" in
7724                    "yes") echo "yes" ;;
7725                    "no") echo "no" ;;
7726                esac
7727            "#)
7728            .await
7729            .expect("case failed");
7730
7731        assert!(result.ok());
7732        assert!(result.text_out().is_empty(), "no match should produce empty output");
7733    }
7734
7735    #[tokio::test]
7736    async fn test_case_with_variable() {
7737        let kernel = Kernel::transient().expect("failed to create kernel");
7738
7739        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
7740
7741        let result = kernel
7742            .execute(r#"
7743                case ${LANG} in
7744                    python) echo "snake" ;;
7745                    rust) echo "crab" ;;
7746                    go) echo "gopher" ;;
7747                esac
7748            "#)
7749            .await
7750            .expect("case failed");
7751
7752        assert!(result.ok());
7753        assert_eq!(result.text_out().trim(), "crab");
7754    }
7755
7756    #[tokio::test]
7757    async fn test_case_multiple_patterns() {
7758        let kernel = Kernel::transient().expect("failed to create kernel");
7759
7760        let result = kernel
7761            .execute(r#"
7762                case "yes" in
7763                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
7764                    "n"|"no"|"N"|"NO") echo "negative" ;;
7765                esac
7766            "#)
7767            .await
7768            .expect("case failed");
7769
7770        assert!(result.ok());
7771        assert_eq!(result.text_out().trim(), "affirmative");
7772    }
7773
7774    #[tokio::test]
7775    async fn test_case_glob_question_mark() {
7776        let kernel = Kernel::transient().expect("failed to create kernel");
7777
7778        let result = kernel
7779            .execute(r#"
7780                case "test1" in
7781                    test?) echo "matched test?" ;;
7782                    *) echo "default" ;;
7783                esac
7784            "#)
7785            .await
7786            .expect("case failed");
7787
7788        assert!(result.ok());
7789        assert_eq!(result.text_out().trim(), "matched test?");
7790    }
7791
7792    #[tokio::test]
7793    async fn test_case_char_class() {
7794        let kernel = Kernel::transient().expect("failed to create kernel");
7795
7796        let result = kernel
7797            .execute(r#"
7798                case "Yes" in
7799                    [Yy]*) echo "yes-like" ;;
7800                    [Nn]*) echo "no-like" ;;
7801                esac
7802            "#)
7803            .await
7804            .expect("case failed");
7805
7806        assert!(result.ok());
7807        assert_eq!(result.text_out().trim(), "yes-like");
7808    }
7809
7810    // ═══════════════════════════════════════════════════════════════════════════
7811    // Cat Stdin Tests
7812    // ═══════════════════════════════════════════════════════════════════════════
7813
7814    #[tokio::test]
7815    async fn test_cat_from_pipeline() {
7816        let kernel = Kernel::transient().expect("failed to create kernel");
7817
7818        let result = kernel
7819            .execute(r#"echo "piped text" | cat"#)
7820            .await
7821            .expect("cat pipeline failed");
7822
7823        assert!(result.ok(), "cat failed: {}", result.err);
7824        assert_eq!(result.text_out().trim(), "piped text");
7825    }
7826
7827    #[tokio::test]
7828    async fn test_cat_from_pipeline_multiline() {
7829        let kernel = Kernel::transient().expect("failed to create kernel");
7830
7831        let result = kernel
7832            .execute(r#"echo "line1\nline2" | cat -n"#)
7833            .await
7834            .expect("cat pipeline failed");
7835
7836        assert!(result.ok(), "cat failed: {}", result.err);
7837        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
7838    }
7839
7840    // ═══════════════════════════════════════════════════════════════════════════
7841    // Heredoc Tests
7842    // ═══════════════════════════════════════════════════════════════════════════
7843
7844    #[tokio::test]
7845    async fn test_heredoc_basic() {
7846        let kernel = Kernel::transient().expect("failed to create kernel");
7847
7848        let result = kernel
7849            .execute("cat <<EOF\nhello\nEOF")
7850            .await
7851            .expect("heredoc failed");
7852
7853        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7854        assert_eq!(result.text_out().trim(), "hello");
7855    }
7856
7857    #[tokio::test]
7858    async fn test_arithmetic_in_string() {
7859        let kernel = Kernel::transient().expect("failed to create kernel");
7860
7861        let result = kernel
7862            .execute(r#"echo "result: $((1 + 2))""#)
7863            .await
7864            .expect("arithmetic in string failed");
7865
7866        assert!(result.ok(), "echo failed: {}", result.err);
7867        assert_eq!(result.text_out().trim(), "result: 3");
7868    }
7869
7870    #[tokio::test]
7871    async fn test_heredoc_multiline() {
7872        let kernel = Kernel::transient().expect("failed to create kernel");
7873
7874        let result = kernel
7875            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
7876            .await
7877            .expect("heredoc failed");
7878
7879        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7880        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
7881        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
7882        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
7883    }
7884
7885    #[tokio::test]
7886    async fn test_heredoc_variable_expansion() {
7887        // Bug N: unquoted heredoc should expand variables
7888        let kernel = Kernel::transient().expect("failed to create kernel");
7889
7890        kernel.execute("GREETING=hello").await.expect("set var");
7891
7892        let result = kernel
7893            .execute("cat <<EOF\n$GREETING world\nEOF")
7894            .await
7895            .expect("heredoc expansion failed");
7896
7897        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
7898        assert_eq!(result.text_out().trim(), "hello world");
7899    }
7900
7901    #[tokio::test]
7902    async fn test_heredoc_quoted_no_expansion() {
7903        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
7904        let kernel = Kernel::transient().expect("failed to create kernel");
7905
7906        kernel.execute("GREETING=hello").await.expect("set var");
7907
7908        let result = kernel
7909            .execute("cat <<'EOF'\n$GREETING world\nEOF")
7910            .await
7911            .expect("quoted heredoc failed");
7912
7913        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
7914        assert_eq!(result.text_out().trim(), "$GREETING world");
7915    }
7916
7917    #[tokio::test]
7918    async fn test_heredoc_default_value_expansion() {
7919        // Bug N: ${VAR:-default} should expand in unquoted heredocs
7920        let kernel = Kernel::transient().expect("failed to create kernel");
7921
7922        let result = kernel
7923            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
7924            .await
7925            .expect("heredoc default expansion failed");
7926
7927        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
7928        assert_eq!(result.text_out().trim(), "fallback");
7929    }
7930
7931    // ═══════════════════════════════════════════════════════════════════════════
7932    // Read Builtin Tests
7933    // ═══════════════════════════════════════════════════════════════════════════
7934
7935    #[tokio::test]
7936    async fn test_read_from_pipeline() {
7937        let kernel = Kernel::transient().expect("failed to create kernel");
7938
7939        // Pipe input to read
7940        let result = kernel
7941            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
7942            .await
7943            .expect("read pipeline failed");
7944
7945        assert!(result.ok(), "read failed: {}", result.err);
7946        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
7947    }
7948
7949    #[tokio::test]
7950    async fn test_read_multiple_vars_from_pipeline() {
7951        let kernel = Kernel::transient().expect("failed to create kernel");
7952
7953        let result = kernel
7954            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
7955            .await
7956            .expect("read pipeline failed");
7957
7958        assert!(result.ok(), "read failed: {}", result.err);
7959        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
7960    }
7961
7962    // ═══════════════════════════════════════════════════════════════════════════
7963    // Shell-Style Function Tests
7964    // ═══════════════════════════════════════════════════════════════════════════
7965
7966    #[tokio::test]
7967    async fn test_posix_function_with_positional_params() {
7968        let kernel = Kernel::transient().expect("failed to create kernel");
7969
7970        // Define POSIX-style function
7971        kernel
7972            .execute(r#"greet() { echo "Hello, $1!" }"#)
7973            .await
7974            .expect("function definition failed");
7975
7976        // Call the function
7977        let result = kernel
7978            .execute(r#"greet "Amy""#)
7979            .await
7980            .expect("function call failed");
7981
7982        assert!(result.ok(), "greet failed: {}", result.err);
7983        assert_eq!(result.text_out().trim(), "Hello, Amy!");
7984    }
7985
7986    #[tokio::test]
7987    async fn test_posix_function_multiple_args() {
7988        let kernel = Kernel::transient().expect("failed to create kernel");
7989
7990        // Define function using $1 and $2
7991        kernel
7992            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
7993            .await
7994            .expect("function definition failed");
7995
7996        // Call the function
7997        let result = kernel
7998            .execute(r#"add_greeting "Hello" "World""#)
7999            .await
8000            .expect("function call failed");
8001
8002        assert!(result.ok(), "function failed: {}", result.err);
8003        assert_eq!(result.text_out().trim(), "Hello World!");
8004    }
8005
8006    #[tokio::test]
8007    async fn test_bash_function_with_positional_params() {
8008        let kernel = Kernel::transient().expect("failed to create kernel");
8009
8010        // Define bash-style function (function keyword, no parens)
8011        kernel
8012            .execute(r#"function greet { echo "Hi $1" }"#)
8013            .await
8014            .expect("function definition failed");
8015
8016        // Call the function
8017        let result = kernel
8018            .execute(r#"greet "Bob""#)
8019            .await
8020            .expect("function call failed");
8021
8022        assert!(result.ok(), "greet failed: {}", result.err);
8023        assert_eq!(result.text_out().trim(), "Hi Bob");
8024    }
8025
8026    #[tokio::test]
8027    async fn test_shell_function_with_all_args() {
8028        let kernel = Kernel::transient().expect("failed to create kernel");
8029
8030        // Define function using $@ (all args)
8031        kernel
8032            .execute(r#"echo_all() { echo "args: $@" }"#)
8033            .await
8034            .expect("function definition failed");
8035
8036        // Call with multiple args
8037        let result = kernel
8038            .execute(r#"echo_all "a" "b" "c""#)
8039            .await
8040            .expect("function call failed");
8041
8042        assert!(result.ok(), "function failed: {}", result.err);
8043        assert_eq!(result.text_out().trim(), "args: a b c");
8044    }
8045
8046    #[tokio::test]
8047    async fn test_shell_function_with_arg_count() {
8048        let kernel = Kernel::transient().expect("failed to create kernel");
8049
8050        // Define function using $# (arg count)
8051        kernel
8052            .execute(r#"count_args() { echo "count: $#" }"#)
8053            .await
8054            .expect("function definition failed");
8055
8056        // Call with three args
8057        let result = kernel
8058            .execute(r#"count_args "x" "y" "z""#)
8059            .await
8060            .expect("function call failed");
8061
8062        assert!(result.ok(), "function failed: {}", result.err);
8063        assert_eq!(result.text_out().trim(), "count: 3");
8064    }
8065
8066    #[tokio::test]
8067    async fn test_shell_function_shared_scope() {
8068        let kernel = Kernel::transient().expect("failed to create kernel");
8069
8070        // Set a variable in parent scope
8071        kernel
8072            .execute(r#"PARENT_VAR="visible""#)
8073            .await
8074            .expect("set failed");
8075
8076        // Define shell function that reads and writes parent variable
8077        kernel
8078            .execute(r#"modify_parent() {
8079                echo "saw: ${PARENT_VAR}"
8080                PARENT_VAR="changed by function"
8081            }"#)
8082            .await
8083            .expect("function definition failed");
8084
8085        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8086        let result = kernel.execute("modify_parent").await.expect("function failed");
8087
8088        assert!(
8089            result.text_out().contains("visible"),
8090            "Shell function should access parent scope, got: {}",
8091            result.text_out()
8092        );
8093
8094        // Parent variable should be modified
8095        let var = kernel.get_var("PARENT_VAR").await;
8096        assert_eq!(
8097            var,
8098            Some(Value::String("changed by function".into())),
8099            "Shell function should modify parent scope"
8100        );
8101    }
8102
8103    // ═══════════════════════════════════════════════════════════════════════════
8104    // Script Execution via PATH Tests
8105    // ═══════════════════════════════════════════════════════════════════════════
8106
8107    #[tokio::test]
8108    async fn test_script_execution_from_path() {
8109        let kernel = Kernel::transient().expect("failed to create kernel");
8110
8111        // Create /bin directory and script
8112        kernel.execute(r#"mkdir "/bin""#).await.ok();
8113        kernel
8114            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8115            .await
8116            .expect("write script failed");
8117
8118        // Set PATH to /bin
8119        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8120
8121        // Call script by name (without .kai extension)
8122        let result = kernel
8123            .execute("hello")
8124            .await
8125            .expect("script execution failed");
8126
8127        assert!(result.ok(), "script failed: {}", result.err);
8128        assert_eq!(result.text_out().trim(), "Hello from script!");
8129    }
8130
8131    #[tokio::test]
8132    async fn test_script_with_args() {
8133        let kernel = Kernel::transient().expect("failed to create kernel");
8134
8135        // Create script that uses positional params
8136        kernel.execute(r#"mkdir "/bin""#).await.ok();
8137        kernel
8138            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8139            .await
8140            .expect("write script failed");
8141
8142        // Set PATH
8143        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8144
8145        // Call script with arg
8146        let result = kernel
8147            .execute(r#"greet "World""#)
8148            .await
8149            .expect("script execution failed");
8150
8151        assert!(result.ok(), "script failed: {}", result.err);
8152        assert_eq!(result.text_out().trim(), "Hello, World!");
8153    }
8154
8155    #[tokio::test]
8156    async fn test_script_not_found() {
8157        let kernel = Kernel::transient().expect("failed to create kernel");
8158
8159        // Set empty PATH
8160        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8161
8162        // Call non-existent script
8163        let result = kernel
8164            .execute("noscript")
8165            .await
8166            .expect("execution failed");
8167
8168        assert!(!result.ok(), "should fail with command not found");
8169        assert_eq!(result.code, 127);
8170        assert!(result.err.contains("command not found"));
8171    }
8172
8173    #[tokio::test]
8174    async fn test_script_path_search_order() {
8175        let kernel = Kernel::transient().expect("failed to create kernel");
8176
8177        // Create two directories with same-named script
8178        // Note: using "myscript" not "test" to avoid conflict with test builtin
8179        kernel.execute(r#"mkdir "/first""#).await.ok();
8180        kernel.execute(r#"mkdir "/second""#).await.ok();
8181        kernel
8182            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8183            .await
8184            .expect("write failed");
8185        kernel
8186            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8187            .await
8188            .expect("write failed");
8189
8190        // Set PATH with first before second
8191        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8192
8193        // Should find first one
8194        let result = kernel
8195            .execute("myscript")
8196            .await
8197            .expect("script execution failed");
8198
8199        assert!(result.ok(), "script failed: {}", result.err);
8200        assert_eq!(result.text_out().trim(), "from first");
8201    }
8202
8203    // ═══════════════════════════════════════════════════════════════════════════
8204    // Special Variable Tests ($?, $$, unset vars)
8205    // ═══════════════════════════════════════════════════════════════════════════
8206
8207    #[tokio::test]
8208    async fn test_last_exit_code_success() {
8209        let kernel = Kernel::transient().expect("failed to create kernel");
8210
8211        // true exits with 0
8212        let result = kernel.execute("true; echo $?").await.expect("execution failed");
8213        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8214    }
8215
8216    #[tokio::test]
8217    async fn test_last_exit_code_failure() {
8218        let kernel = Kernel::transient().expect("failed to create kernel");
8219
8220        // false exits with 1
8221        let result = kernel.execute("false; echo $?").await.expect("execution failed");
8222        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8223    }
8224
8225    #[tokio::test]
8226    async fn test_current_pid() {
8227        let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229        let result = kernel.execute("echo $$").await.expect("execution failed");
8230        // PID should be a positive number
8231        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8232        assert!(pid > 0, "PID should be positive");
8233    }
8234
8235    #[tokio::test]
8236    async fn test_unset_variable_expands_to_empty() {
8237        let kernel = Kernel::transient().expect("failed to create kernel");
8238
8239        // Unset variable in interpolation should be empty
8240        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8241        assert_eq!(result.text_out().trim(), "prefix::suffix");
8242    }
8243
8244    #[tokio::test]
8245    async fn test_eq_ne_operators() {
8246        let kernel = Kernel::transient().expect("failed to create kernel");
8247
8248        // Test -eq operator
8249        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8250        assert_eq!(result.text_out().trim(), "eq works");
8251
8252        // Test -ne operator
8253        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8254        assert_eq!(result.text_out().trim(), "ne works");
8255
8256        // Test -eq with different values
8257        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8258        assert_eq!(result.text_out().trim(), "correct");
8259    }
8260
8261    #[tokio::test]
8262    async fn test_escaped_dollar_in_string() {
8263        let kernel = Kernel::transient().expect("failed to create kernel");
8264
8265        // \$ should produce literal $
8266        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8267        assert_eq!(result.text_out().trim(), "$100");
8268    }
8269
8270    #[tokio::test]
8271    async fn test_special_vars_in_interpolation() {
8272        let kernel = Kernel::transient().expect("failed to create kernel");
8273
8274        // Test $? in string interpolation
8275        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8276        assert_eq!(result.text_out().trim(), "exit: 0");
8277
8278        // Test $$ in string interpolation
8279        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8280        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8281        let text = result.text_out();
8282        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8283        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8284    }
8285
8286    // ═══════════════════════════════════════════════════════════════════════════
8287    // Command Substitution Tests
8288    // ═══════════════════════════════════════════════════════════════════════════
8289
8290    #[tokio::test]
8291    async fn test_command_subst_assignment() {
8292        let kernel = Kernel::transient().expect("failed to create kernel");
8293
8294        // Command substitution in assignment
8295        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8296        assert_eq!(result.text_out().trim(), "hello");
8297    }
8298
8299    #[tokio::test]
8300    async fn test_command_subst_with_args() {
8301        let kernel = Kernel::transient().expect("failed to create kernel");
8302
8303        // Command substitution with string argument
8304        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8305        assert_eq!(result.text_out().trim(), "a b c");
8306    }
8307
8308    #[tokio::test]
8309    async fn test_command_subst_nested_vars() {
8310        let kernel = Kernel::transient().expect("failed to create kernel");
8311
8312        // Variables inside command substitution
8313        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8314        assert_eq!(result.text_out().trim(), "hello world");
8315    }
8316
8317    #[tokio::test]
8318    async fn test_background_job_basic() {
8319        use std::time::Duration;
8320
8321        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8322
8323        // Run a simple background command
8324        let result = kernel.execute("echo hello &").await.expect("execution failed");
8325        assert!(result.ok(), "background command should succeed: {}", result.err);
8326        assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8327
8328        // Give the job time to complete
8329        tokio::time::sleep(Duration::from_millis(100)).await;
8330
8331        // Check job status
8332        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8333        assert!(status.ok(), "status should succeed: {}", status.err);
8334        assert!(
8335            status.text_out().contains("done:") || status.text_out().contains("running"),
8336            "should have valid status: {}",
8337            status.text_out()
8338        );
8339
8340        // Check stdout
8341        let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
8342        assert!(stdout.ok());
8343        assert!(stdout.text_out().contains("hello"));
8344    }
8345
8346    #[tokio::test]
8347    async fn test_heredoc_piped_to_command() {
8348        // Bug 4: heredoc content should pipe through to next command
8349        let kernel = Kernel::transient().expect("kernel");
8350        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8351        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8352        assert_eq!(result.text_out().trim(), "hello world");
8353    }
8354
8355    /// A transient kernel paired with a real, auto-cleaning tempdir. The
8356    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8357    /// tests need actual files on disk. Hold the returned `TempDir` for the
8358    /// test's lifetime: it removes the directory tree on drop — including on
8359    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8360    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8361    /// as a string for interpolation into scripts.
8362    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8363        let kernel = Kernel::transient().expect("kernel");
8364        let tmp = tempfile::tempdir().expect("tempdir");
8365        let dir = tmp.path().display().to_string();
8366        (kernel, tmp, dir)
8367    }
8368
8369    #[tokio::test]
8370    async fn test_for_loop_glob_iterates() {
8371        // Bug 1: for F in $(glob ...) should iterate per file, not once
8372        let (kernel, _tmp, dir) = transient_with_tempdir();
8373        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8374        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8375        let result = kernel.execute(&format!(r#"
8376            N=0
8377            for F in $(glob "{dir}/*.txt"); do
8378                N=$((N + 1))
8379            done
8380            echo $N
8381        "#)).await.unwrap();
8382        assert!(result.ok(), "for glob failed: {}", result.err);
8383        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8384    }
8385
8386    #[tokio::test]
8387    async fn test_bare_glob_expansion_echo() {
8388        let (kernel, _tmp, dir) = transient_with_tempdir();
8389        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8390        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8391        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8392        kernel.execute(&format!("cd {dir}")).await.unwrap();
8393        let result = kernel.execute("echo *.txt").await.unwrap();
8394        assert!(result.ok(), "echo *.txt failed: {}", result.err);
8395        let out = result.text_out();
8396        let out = out.trim();
8397        // Should contain both .txt files (order may vary)
8398        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8399        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8400        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8401    }
8402
8403    #[tokio::test]
8404    async fn test_bare_glob_no_matches_errors() {
8405        let (kernel, _tmp, dir) = transient_with_tempdir();
8406        kernel.execute(&format!("cd {dir}")).await.unwrap();
8407        let result = kernel.execute("echo *.nonexistent").await;
8408        match &result {
8409            Ok(exec) => {
8410                // No-match glob should produce a non-zero exit code
8411                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8412                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8413            }
8414            Err(e) => {
8415                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8416            }
8417        }
8418    }
8419
8420    #[tokio::test]
8421    async fn test_bare_glob_disabled_with_set() {
8422        let (kernel, _tmp, dir) = transient_with_tempdir();
8423        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8424        kernel.execute(&format!("cd {dir}")).await.unwrap();
8425        // Disable glob expansion
8426        kernel.execute("set +o glob").await.unwrap();
8427        let result = kernel.execute("echo *.txt").await.unwrap();
8428        // With glob disabled, *.txt should be passed as literal string
8429        assert!(result.ok(), "echo should succeed: {}", result.err);
8430        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8431    }
8432
8433    #[tokio::test]
8434    async fn test_bare_glob_quoted_not_expanded() {
8435        let (kernel, _tmp, dir) = transient_with_tempdir();
8436        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8437        kernel.execute(&format!("cd {dir}")).await.unwrap();
8438        // Quoted globs should NOT expand
8439        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8440        assert!(result.ok(), "echo should succeed: {}", result.err);
8441        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8442    }
8443
8444    #[tokio::test]
8445    async fn test_bare_glob_for_loop() {
8446        let (kernel, _tmp, dir) = transient_with_tempdir();
8447        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8448        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8449        kernel.execute(&format!("cd {dir}")).await.unwrap();
8450        let result = kernel.execute(r#"
8451            N=0
8452            for f in *.txt; do
8453                N=$((N + 1))
8454            done
8455            echo $N
8456        "#).await.unwrap();
8457        assert!(result.ok(), "for loop failed: {}", result.err);
8458        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8459    }
8460
8461    #[tokio::test]
8462    async fn test_glob_in_assignment_is_literal() {
8463        let kernel = Kernel::transient().expect("kernel");
8464        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8465        assert!(result.ok());
8466        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8467    }
8468
8469    #[tokio::test]
8470    async fn test_glob_in_test_expr_is_literal() {
8471        let kernel = Kernel::transient().expect("kernel");
8472        let result = kernel.execute(r#"
8473            if [[ *.txt == "*.txt" ]]; then
8474                echo "match"
8475            else
8476                echo "no"
8477            fi
8478        "#).await.unwrap();
8479        assert!(result.ok());
8480        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8481    }
8482
8483    #[tokio::test]
8484    async fn test_command_subst_echo_not_iterable() {
8485        // Regression guard: $(echo "a b c") must remain a single string
8486        let kernel = Kernel::transient().expect("kernel");
8487        let result = kernel.execute(r#"
8488            N=0
8489            for X in $(echo "a b c"); do N=$((N + 1)); done
8490            echo $N
8491        "#).await.unwrap();
8492        assert!(result.ok());
8493        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8494    }
8495
8496    // -- accumulate_result / newline tests --
8497
8498    #[test]
8499    fn test_accumulate_preserves_own_newlines() {
8500        // Outputs concatenate verbatim — a command's own trailing newline is
8501        // kept, none is invented.
8502        let mut acc = ExecResult::success("line1\n");
8503        let new = ExecResult::success("line2\n");
8504        accumulate_result(&mut acc, &new);
8505        assert_eq!(&*acc.text_out(), "line1\nline2\n");
8506        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8507    }
8508
8509    #[test]
8510    fn test_accumulate_inserts_no_separator() {
8511        // No artificial separator: `printf a; printf b` style concatenates to
8512        // `ab`, matching bash (regression for the 2026-06-09 finding).
8513        let mut acc = ExecResult::success("line1");
8514        let new = ExecResult::success("line2");
8515        accumulate_result(&mut acc, &new);
8516        assert_eq!(&*acc.text_out(), "line1line2");
8517    }
8518
8519    #[test]
8520    fn test_accumulate_empty_into_nonempty() {
8521        let mut acc = ExecResult::success("");
8522        let new = ExecResult::success("hello\n");
8523        accumulate_result(&mut acc, &new);
8524        assert_eq!(&*acc.text_out(), "hello\n");
8525    }
8526
8527    #[test]
8528    fn test_accumulate_nonempty_into_empty() {
8529        let mut acc = ExecResult::success("hello\n");
8530        let new = ExecResult::success("");
8531        accumulate_result(&mut acc, &new);
8532        assert_eq!(&*acc.text_out(), "hello\n");
8533    }
8534
8535    #[test]
8536    fn test_accumulate_stderr_no_double_newlines() {
8537        let mut acc = ExecResult::failure(1, "err1\n");
8538        let new = ExecResult::failure(1, "err2\n");
8539        accumulate_result(&mut acc, &new);
8540        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
8541    }
8542
8543    #[tokio::test]
8544    async fn test_multiple_echo_no_blank_lines() {
8545        let kernel = Kernel::transient().expect("kernel");
8546        let result = kernel
8547            .execute("echo one\necho two\necho three")
8548            .await
8549            .expect("execution failed");
8550        assert!(result.ok());
8551        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
8552    }
8553
8554    #[tokio::test]
8555    async fn test_for_loop_no_blank_lines() {
8556        let kernel = Kernel::transient().expect("kernel");
8557        let result = kernel
8558            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8559            .await
8560            .expect("execution failed");
8561        assert!(result.ok());
8562        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
8563    }
8564
8565    #[tokio::test]
8566    async fn test_for_command_subst_no_blank_lines() {
8567        let kernel = Kernel::transient().expect("kernel");
8568        let result = kernel
8569            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
8570            .await
8571            .expect("execution failed");
8572        assert!(result.ok());
8573        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
8574    }
8575
8576    // ------------------------------------------------------------------
8577    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
8578    // ------------------------------------------------------------------
8579
8580    /// Helper: a throwaway schema with one `--pair` param declared as
8581    /// consuming two positionals per occurrence. Modelled after what
8582    /// jq_native will declare for `--arg` / `--argjson`.
8583    fn multi_consume_schema() -> crate::tools::ToolSchema {
8584        use crate::tools::{ParamSchema, ToolSchema};
8585        ToolSchema::new("test", "multi-consume smoke")
8586            .param(
8587                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
8588                    .consumes(2),
8589            )
8590    }
8591
8592    fn pos(s: &str) -> Arg {
8593        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
8594    }
8595
8596    #[tokio::test]
8597    async fn build_args_multi_consume_single_occurrence() {
8598        let kernel = Kernel::transient().expect("kernel");
8599        let schema = multi_consume_schema();
8600        // Simulates:  test --pair NAME VALUE filter
8601        let args = vec![
8602            Arg::LongFlag("pair".into()),
8603            pos("NAME"),
8604            pos("VALUE"),
8605            pos("filter"),
8606        ];
8607        let built = kernel
8608            .build_args_async(&args, Some(&schema))
8609            .await
8610            .expect("build_args should succeed");
8611
8612        // `--pair` + its two positionals are consumed into named["pair"],
8613        // which becomes an outer array of one inner 2-element array.
8614        let pair = built.named.get("pair").expect("named[pair] missing");
8615        match pair {
8616            Value::Json(serde_json::Value::Array(occurrences)) => {
8617                assert_eq!(occurrences.len(), 1, "expected one occurrence");
8618                match &occurrences[0] {
8619                    serde_json::Value::Array(values) => {
8620                        assert_eq!(values.len(), 2, "pair must have 2 values");
8621                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
8622                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
8623                    }
8624                    other => panic!("expected inner array, got {other:?}"),
8625                }
8626            }
8627            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
8628        }
8629
8630        // The un-consumed positional ("filter") remains in `positional`.
8631        assert_eq!(built.positional.len(), 1);
8632        assert_eq!(built.positional[0], Value::String("filter".into()));
8633    }
8634    #[tokio::test]
8635    async fn build_args_multi_consume_two_occurrences_accumulate() {
8636        let kernel = Kernel::transient().expect("kernel");
8637        let schema = multi_consume_schema();
8638        // Simulates:  test --pair A 1 --pair B 2 filter
8639        let args = vec![
8640            Arg::LongFlag("pair".into()),
8641            pos("A"),
8642            pos("1"),
8643            Arg::LongFlag("pair".into()),
8644            pos("B"),
8645            pos("2"),
8646            pos("filter"),
8647        ];
8648        let built = kernel
8649            .build_args_async(&args, Some(&schema))
8650            .await
8651            .expect("build_args should succeed");
8652
8653        let pair = built.named.get("pair").expect("named[pair] missing");
8654        match pair {
8655            Value::Json(serde_json::Value::Array(occurrences)) => {
8656                assert_eq!(occurrences.len(), 2, "expected two occurrences");
8657                // Preserved in invocation order.
8658                match &occurrences[0] {
8659                    serde_json::Value::Array(values) => {
8660                        assert_eq!(values[0], serde_json::Value::String("A".into()));
8661                        assert_eq!(values[1], serde_json::Value::String("1".into()));
8662                    }
8663                    other => panic!("expected inner array, got {other:?}"),
8664                }
8665                match &occurrences[1] {
8666                    serde_json::Value::Array(values) => {
8667                        assert_eq!(values[0], serde_json::Value::String("B".into()));
8668                        assert_eq!(values[1], serde_json::Value::String("2".into()));
8669                    }
8670                    other => panic!("expected inner array, got {other:?}"),
8671                }
8672            }
8673            other => panic!("expected Json(Array(...)), got {other:?}"),
8674        }
8675    }
8676
8677    // ── undeclared space-form flag under map_positionals (kj --type val) ──
8678    //
8679    // A backend/MCP tool whose schema does NOT declare a flag must not let
8680    // `--flag value` (space form) silently divorce the value: that was a
8681    // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
8682    // kaish fails loud rather than guessing.
8683
8684    use crate::tools::{ParamSchema, ToolSchema};
8685
8686    /// Backend-style schema (map_positionals) declaring only a `name`
8687    /// positional — `--type` is intentionally undeclared.
8688    fn kj_like_schema() -> ToolSchema {
8689        ToolSchema::new("kj", "incomplete backend schema")
8690            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8691            .with_positional_mapping()
8692    }
8693
8694    #[tokio::test]
8695    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
8696        let kernel = Kernel::transient().expect("kernel");
8697        let schema = kj_like_schema();
8698        // kj context create exp --type explorer
8699        let args = vec![
8700            pos("context"),
8701            pos("create"),
8702            pos("exp"),
8703            Arg::LongFlag("type".into()),
8704            pos("explorer"),
8705        ];
8706        let err = kernel
8707            .build_args_async(&args, Some(&schema))
8708            .await
8709            .expect_err("undeclared --type with a space value must fail loud");
8710        let msg = err.to_string();
8711        assert!(msg.contains("--type"), "message should name the flag: {msg}");
8712        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
8713        assert!(msg.contains("kj"), "message should name the tool: {msg}");
8714    }
8715
8716    #[tokio::test]
8717    async fn build_args_declared_space_flag_still_binds() {
8718        let kernel = Kernel::transient().expect("kernel");
8719        // Same tool, but now the schema DECLARES --type as a string param.
8720        let schema = ToolSchema::new("kj", "complete schema")
8721            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8722            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
8723            .with_positional_mapping();
8724        let args = vec![
8725            pos("exp"),
8726            Arg::LongFlag("type".into()),
8727            pos("explorer"),
8728        ];
8729        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8730        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8731    }
8732
8733    #[tokio::test]
8734    async fn build_args_equals_form_binds_for_undeclared_flag() {
8735        let kernel = Kernel::transient().expect("kernel");
8736        let schema = kj_like_schema();
8737        // The unambiguous `=` form must keep working even when undeclared.
8738        let args = vec![
8739            pos("exp"),
8740            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
8741        ];
8742        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8743        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8744    }
8745
8746    #[tokio::test]
8747    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
8748        let kernel = Kernel::transient().expect("kernel");
8749        let schema = kj_like_schema();
8750        // No positional follows --force → unambiguously a bare flag.
8751        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
8752        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8753        assert!(built.flags.contains("force"));
8754    }
8755
8756    #[tokio::test]
8757    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
8758        let kernel = Kernel::transient().expect("kernel");
8759        let schema = kj_like_schema();
8760        // --verbose is followed by a flag, not a positional → not ambiguous.
8761        let args = vec![
8762            Arg::LongFlag("verbose".into()),
8763            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
8764        ];
8765        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8766        assert!(built.flags.contains("verbose"));
8767    }
8768
8769    #[tokio::test]
8770    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
8771        let kernel = Kernel::transient().expect("kernel");
8772        // Builtins set map_positionals=false; the ambiguity guard must not
8773        // fire there (clap validates their flags separately).
8774        let schema = ToolSchema::new("frobnicate", "builtin-style")
8775            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
8776        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
8777        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8778        assert!(built.flags.contains("frob"));
8779    }
8780
8781    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
8782    //
8783    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
8784    // params, not the root's. The subcommand-path positionals stay positional
8785    // (kj re-parses them with its own clap), and a value flag declared only on
8786    // a deep leaf still binds in space form.
8787
8788    /// kj → context (alias ctx) → create{--type value, --force bool}.
8789    /// map_positionals defaults false on every node (builtin/kj style).
8790    fn kj_tree_schema() -> ToolSchema {
8791        ToolSchema::new("kj", "subcommand tool").subcommand(
8792            ToolSchema::new("context", "context ops")
8793                .with_command_aliases(["ctx"])
8794                .subcommand(
8795                    ToolSchema::new("create", "create context")
8796                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
8797                        .param(ParamSchema::new("force", "bool")),
8798                ),
8799        )
8800    }
8801
8802    #[tokio::test]
8803    async fn build_args_binds_deep_leaf_value_flag_space_form() {
8804        let kernel = Kernel::transient().expect("kernel");
8805        let schema = kj_tree_schema();
8806        // kj context create --type explorer
8807        let args = vec![
8808            pos("context"),
8809            pos("create"),
8810            Arg::LongFlag("type".into()),
8811            pos("explorer"),
8812        ];
8813        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8814        // --type (declared only on the create leaf) binds in space form.
8815        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8816        // The subcommand path survives as positionals for kj to re-parse.
8817        let positionals: Vec<&str> = built
8818            .positional
8819            .iter()
8820            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8821            .collect();
8822        assert_eq!(positionals, vec!["context", "create"]);
8823    }
8824
8825    #[tokio::test]
8826    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
8827        let kernel = Kernel::transient().expect("kernel");
8828        let schema = kj_tree_schema();
8829        // kj context create --force somearg  → --force is a leaf bool flag,
8830        // it must NOT consume `somearg`.
8831        let args = vec![
8832            pos("context"),
8833            pos("create"),
8834            Arg::LongFlag("force".into()),
8835            pos("somearg"),
8836        ];
8837        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8838        assert!(built.flags.contains("force"), "force should be a bare flag");
8839        let positionals: Vec<&str> = built
8840            .positional
8841            .iter()
8842            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8843            .collect();
8844        assert_eq!(positionals, vec!["context", "create", "somearg"]);
8845    }
8846
8847    #[tokio::test]
8848    async fn build_args_alias_routed_leaf_binds_value_flag() {
8849        let kernel = Kernel::transient().expect("kernel");
8850        let schema = kj_tree_schema();
8851        // kj ctx create -t explorer  → command alias + short flag alias.
8852        let args = vec![
8853            pos("ctx"),
8854            pos("create"),
8855            Arg::ShortFlag("t".into()),
8856            pos("explorer"),
8857        ];
8858        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8859        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8860    }
8861
8862    #[tokio::test]
8863    async fn build_args_computed_subcommand_selector_fails_loud() {
8864        let kernel = Kernel::transient().expect("kernel");
8865        let schema = kj_tree_schema();
8866        // kj $(echo context) — routing can't see the value; fail loud.
8867        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
8868            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
8869        )]))];
8870        let err = kernel
8871            .build_args_async(&args, Some(&schema))
8872            .await
8873            .expect_err("computed subcommand selector must error");
8874        assert!(
8875            err.to_string().contains("subcommand name is required"),
8876            "got: {err}"
8877        );
8878    }
8879
8880    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
8881
8882    #[test]
8883    fn finalize_output_renders_when_kernel_owns_it() {
8884        use crate::interpreter::{OutputData, OutputFormat};
8885        let r = ExecResult::with_output(OutputData::text("RAW"));
8886        let out = finalize_output(r, Some(OutputFormat::Json), false);
8887        // Kernel renders the typed OutputData → JSON; text is no longer bare.
8888        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
8889    }
8890
8891    #[test]
8892    fn finalize_output_skips_when_tool_owns_output() {
8893        use crate::interpreter::{OutputData, OutputFormat};
8894        let r = ExecResult::with_output(OutputData::text("RAW"));
8895        let out = finalize_output(r, Some(OutputFormat::Json), true);
8896        // owns_output: the tool already rendered; kernel leaves bytes untouched.
8897        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
8898    }
8899
8900    #[test]
8901    fn finalize_output_no_format_is_noop() {
8902        use crate::interpreter::OutputData;
8903        let r = ExecResult::with_output(OutputData::text("RAW"));
8904        let out = finalize_output(r, None, false);
8905        assert_eq!(out.text_out(), "RAW");
8906    }
8907
8908    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
8909
8910    #[tokio::test]
8911    async fn test_initial_vars_set_and_exported() {
8912        let config = KernelConfig::transient()
8913            .with_var("INIT_FOO", Value::String("bar".into()));
8914        let kernel = Kernel::new(config).expect("failed to create kernel");
8915
8916        assert_eq!(
8917            kernel.get_var("INIT_FOO").await,
8918            Some(Value::String("bar".into()))
8919        );
8920        assert!(
8921            kernel.scope.read().await.is_exported("INIT_FOO"),
8922            "initial_vars entries must be marked exported"
8923        );
8924    }
8925
8926    #[tokio::test]
8927    async fn test_execute_with_vars_overlay_visible() {
8928        let kernel = Kernel::transient().expect("failed to create kernel");
8929        let mut overlay = HashMap::new();
8930        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
8931
8932        let result = kernel
8933            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
8934            .await
8935            .expect("execute failed");
8936
8937        assert!(result.ok());
8938        assert_eq!(result.text_out().trim(), "yes");
8939    }
8940
8941    #[tokio::test]
8942    async fn test_execute_with_vars_overlay_cleanup() {
8943        let kernel = Kernel::transient().expect("failed to create kernel");
8944        let mut overlay = HashMap::new();
8945        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
8946
8947        kernel
8948            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
8949            .await
8950            .expect("execute failed");
8951
8952        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
8953        assert!(
8954            !kernel.scope.read().await.is_exported("EPHEMERAL"),
8955            "overlay-only export must be cleared on return"
8956        );
8957    }
8958
8959    #[tokio::test]
8960    async fn test_execute_with_vars_does_not_clobber_existing_export() {
8961        let kernel = Kernel::transient().expect("failed to create kernel");
8962        kernel
8963            .execute("export OUTER=outer")
8964            .await
8965            .expect("export failed");
8966
8967        let mut overlay = HashMap::new();
8968        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
8969        let result = kernel
8970            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
8971            .await
8972            .expect("execute failed");
8973        assert_eq!(result.text_out().trim(), "inner");
8974
8975        assert_eq!(
8976            kernel.get_var("OUTER").await,
8977            Some(Value::String("outer".into())),
8978            "outer value must reappear after pop"
8979        );
8980        assert!(
8981            kernel.scope.read().await.is_exported("OUTER"),
8982            "outer export must survive overlay"
8983        );
8984    }
8985
8986    #[tokio::test]
8987    async fn test_execute_with_vars_inner_assignment_is_local() {
8988        let kernel = Kernel::transient().expect("failed to create kernel");
8989        let mut overlay = HashMap::new();
8990        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
8991
8992        // Variable assignment inside a single statement uses set() (innermost
8993        // frame), not set_global() — this matches bash function-local semantics.
8994        // We explicitly use `local FOO=...` style by relying on the pushed
8995        // frame; the assignment in the script body modifies the same frame.
8996        let result = kernel
8997            .execute_with_options(
8998                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
8999                ExecuteOptions::new().with_vars(overlay),
9000            )
9001            .await
9002            .expect("execute failed");
9003        assert!(result.ok());
9004
9005        // After the call the frame is popped, so LOCAL_FOO is gone regardless
9006        // of how the script reassigned it.
9007        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9008    }
9009
9010    #[tokio::test]
9011    async fn test_external_command_sees_exported_var() {
9012        let kernel = Kernel::transient().expect("failed to create kernel");
9013        // PATH must be in scope to resolve the external `printenv` — the kernel
9014        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9015        // what a frontend does through initial_vars.
9016        let path = std::env::var("PATH").unwrap_or_default();
9017        let result = kernel
9018            .execute(&format!(
9019                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9020            ))
9021            .await
9022            .expect("execute failed");
9023
9024        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9025        assert_eq!(result.text_out().trim(), "bar");
9026    }
9027
9028    #[tokio::test]
9029    async fn test_external_command_does_not_see_unexported_var() {
9030        let kernel = Kernel::transient().expect("failed to create kernel");
9031
9032        // Set without exporting; printenv must not see it (exit code != 0,
9033        // empty stdout per printenv semantics).
9034        let result = kernel
9035            .execute("EXT_BAR=hidden; printenv EXT_BAR")
9036            .await
9037            .expect("execute failed");
9038
9039        assert!(!result.ok(), "printenv should fail when var is unexported");
9040        assert!(
9041            result.text_out().trim().is_empty(),
9042            "no stdout when var is missing, got: {}",
9043            result.text_out()
9044        );
9045    }
9046
9047    #[tokio::test]
9048    async fn test_external_command_does_not_see_os_env() {
9049        // The kernel is hermetic: it never reads std::env::vars() and only
9050        // exports what it has been told to export. Cargo always sets PATH for
9051        // tests, so PATH is reliably present in the OS env — but a transient
9052        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9053        // inside the kernel must fail.
9054        assert!(
9055            std::env::var_os("PATH").is_some(),
9056            "test precondition: cargo should set PATH"
9057        );
9058
9059        let kernel = Kernel::transient().expect("failed to create kernel");
9060        let result = kernel
9061            .execute("printenv PATH")
9062            .await
9063            .expect("execute failed");
9064
9065        assert!(
9066            !result.ok(),
9067            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9068            result.text_out()
9069        );
9070        assert!(
9071            result.text_out().trim().is_empty(),
9072            "no PATH in subprocess env, got stdout={:?}",
9073            result.text_out()
9074        );
9075    }
9076
9077    #[tokio::test]
9078    async fn test_execute_with_vars_overlay_reaches_subprocess() {
9079        let kernel = Kernel::transient().expect("failed to create kernel");
9080        let mut overlay = HashMap::new();
9081        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9082        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9083        overlay.insert(
9084            "PATH".to_string(),
9085            Value::String(std::env::var("PATH").unwrap_or_default()),
9086        );
9087
9088        let result = kernel
9089            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9090            .await
9091            .expect("execute failed");
9092
9093        assert!(
9094            result.ok(),
9095            "printenv should succeed: code={} stdout={:?} stderr={:?}",
9096            result.code,
9097            result.text_out(),
9098            result.err
9099        );
9100        assert_eq!(result.text_out().trim(), "subproc");
9101    }
9102
9103    #[tokio::test]
9104    async fn test_classify_command_builtin() {
9105        let kernel = Kernel::transient().expect("failed to create kernel");
9106        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9107        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9108    }
9109
9110    #[tokio::test]
9111    async fn test_classify_command_special_forms() {
9112        let kernel = Kernel::transient().expect("failed to create kernel");
9113        for name in ["true", "false", "source", "."] {
9114            assert_eq!(
9115                kernel.classify_command(name).await,
9116                CommandKind::Special,
9117                "{name} should be a special-form",
9118            );
9119        }
9120    }
9121
9122    #[tokio::test]
9123    async fn test_classify_command_dynamic() {
9124        let kernel = Kernel::transient().expect("failed to create kernel");
9125        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9126        assert_eq!(
9127            kernel.classify_command("$(pick)").await,
9128            CommandKind::Dynamic
9129        );
9130    }
9131
9132    #[tokio::test]
9133    async fn test_classify_command_external() {
9134        let kernel = Kernel::transient().expect("failed to create kernel");
9135        // Not a builtin, user function, or special-form → escapes to PATH.
9136        assert_eq!(
9137            kernel.classify_command("definitely_not_a_kaish_builtin").await,
9138            CommandKind::External
9139        );
9140        // `readonly` is *not* a kaish special-form despite the validator's
9141        // warning heuristic — at runtime it resolves to an external command, so
9142        // a consent gate must see it as External (regression guard against the
9143        // validator/runtime divergence).
9144        assert_eq!(
9145            kernel.classify_command("readonly").await,
9146            CommandKind::External
9147        );
9148        assert!(kernel.classify_command("readonly").await.escapes_kernel());
9149    }
9150
9151    #[tokio::test]
9152    async fn test_classify_command_user_tool_shadows_builtin() {
9153        let kernel = Kernel::transient().expect("failed to create kernel");
9154        kernel
9155            .execute(r#"greet() { echo "hi" }"#)
9156            .await
9157            .expect("function definition failed");
9158        assert_eq!(
9159            kernel.classify_command("greet").await,
9160            CommandKind::UserTool
9161        );
9162
9163        // A user function named after a builtin classifies as UserTool, matching
9164        // the interpreter's user-tools-first resolution.
9165        kernel
9166            .execute(r#"cat() { echo "shadowed" }"#)
9167            .await
9168            .expect("function definition failed");
9169        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9170    }
9171
9172    #[tokio::test]
9173    async fn test_classify_command_alias_to_external_is_external() {
9174        let kernel = Kernel::transient().expect("failed to create kernel");
9175        // An alias whose head is an external binary must NOT report as the
9176        // builtin it shadows — execution expands the alias, so a consent gate
9177        // would otherwise be told an external command is internal.
9178        kernel
9179            .execute("alias cat='/usr/bin/whatever'")
9180            .await
9181            .expect("alias failed");
9182        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9183        assert!(kernel.classify_command("cat").await.escapes_kernel());
9184    }
9185
9186    #[tokio::test]
9187    async fn test_classify_command_alias_to_builtin() {
9188        let kernel = Kernel::transient().expect("failed to create kernel");
9189        kernel.execute("alias g=grep").await.expect("alias failed");
9190        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9191    }
9192
9193    #[tokio::test]
9194    async fn test_classify_command_alias_to_special_form() {
9195        let kernel = Kernel::transient().expect("failed to create kernel");
9196        kernel.execute("alias t=true").await.expect("alias failed");
9197        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9198    }
9199
9200    #[tokio::test]
9201    async fn test_classify_command_braced_var_is_dynamic() {
9202        let kernel = Kernel::transient().expect("failed to create kernel");
9203        // The string API can be handed a `${VAR}` head; it must not be mistaken
9204        // for an external named literally "${VAR}".
9205        assert_eq!(
9206            kernel.classify_command("${CMD}").await,
9207            CommandKind::Dynamic
9208        );
9209    }
9210
9211    /// Drift guard: `classify_command` must agree with what the executor
9212    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9213    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9214    /// precedence, alias expansion); without this test those copies could diverge
9215    /// silently — the exact failure class `classify_command` exists to prevent,
9216    /// just moved inside the kernel. Each case asserts the classification AND
9217    /// observes the real resolution, so a future change to one side without the
9218    /// other fails here.
9219    #[tokio::test]
9220    async fn classify_command_matches_executor() {
9221        let kernel = Kernel::transient().expect("failed to create kernel");
9222
9223        // (1) Special-forms. `SpecialForm::from_name` is the single source of
9224        // truth: classify reports Special via it, and the executor matches the
9225        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9226        // form won't build until both sides handle it). This test pins the other
9227        // half — that each form classifies Special AND actually short-circuits at
9228        // runtime rather than escaping to `PATH`. Every form is executed (not just
9229        // `true`/`false`): an external miss in this PATH-less kernel would be exit
9230        // 127, so a non-127 result that matches the form's own behavior proves the
9231        // short-circuit fired.
9232        for name in ["true", "false", "source", "."] {
9233            assert_eq!(
9234                kernel.classify_command(name).await,
9235                CommandKind::Special,
9236                "{name} should classify Special",
9237            );
9238        }
9239        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9240        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9241        // `source`/`.` short-circuit to execute_source, which (no filename) fails
9242        // with its own message — exit 1, never the 127 of an unresolved external.
9243        for name in ["source", "."] {
9244            let r = kernel.execute(name).await.expect("run source form");
9245            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9246            assert!(
9247                r.err.contains("source: missing filename"),
9248                "{name} did not route to execute_source: {:?}",
9249                r.err,
9250            );
9251        }
9252
9253        // (2) Builtin: classify Builtin AND the executor runs the builtin.
9254        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9255        let r = kernel.execute("echo hi").await.expect("run echo");
9256        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9257
9258        // (3) User function shadows a builtin: classify UserTool AND the executor
9259        // runs the function body, not the `cat` builtin.
9260        kernel
9261            .execute(r#"cat() { echo SHADOWED }"#)
9262            .await
9263            .expect("define cat()");
9264        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9265        let r = kernel.execute("cat").await.expect("run shadowed cat");
9266        assert_eq!(
9267            r.text_out().trim(),
9268            "SHADOWED",
9269            "executor ran the builtin instead of the shadowing function",
9270        );
9271
9272        // (4) Alias whose head is external: classify External AND the executor
9273        // resolves through the alias to a missing external (not a builtin).
9274        kernel
9275            .execute("alias x='/nonexistent/binary'")
9276            .await
9277            .expect("define alias x");
9278        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9279        let r = kernel.execute("x").await.expect("run alias x");
9280        assert!(
9281            !r.ok(),
9282            "alias to a missing external should fail, not resolve internally",
9283        );
9284    }
9285}