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, virtual_cwd_error};
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127    /// LocalFs at "/" — native paths work directly.
128    ///
129    /// Full filesystem access. Use for human-operated REPL sessions where
130    /// native paths like `/home/user/project` should just work.
131    ///
132    /// Mounts:
133    /// - `/` → LocalFs("/")
134    /// - `/v` → MemoryFs (blob storage)
135    #[cfg(feature = "localfs")]
136    Passthrough,
137
138    /// Transparent sandbox — paths look native but access is restricted.
139    ///
140    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141    /// so `/home/user/src/project` just works. But paths outside the sandbox
142    /// root are not accessible.
143    ///
144    /// **Note:** This only restricts VFS (builtin) operations. External commands
145    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146    ///
147    /// Mounts:
148    /// - `/` → MemoryFs (catches paths outside sandbox)
149    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
150    /// - `/tmp` → LocalFs("/tmp")
151    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152    /// - `/v` → MemoryFs (blob storage)
153    #[cfg(feature = "localfs")]
154    Sandboxed {
155        /// Root path for local filesystem. Defaults to `$HOME`.
156        /// Can be restricted further, e.g., `~/src`.
157        root: Option<PathBuf>,
158    },
159
160    /// No local filesystem. Memory only.
161    ///
162    /// Complete isolation — no access to the host filesystem.
163    /// Useful for tests or pure sandboxed execution.
164    ///
165    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166    /// for this mode at kernel construction: with no host filesystem mounted,
167    /// large output must not write a host spill file (`paths::spill_dir()`
168    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169    ///
170    /// Mounts:
171    /// - `/` → MemoryFs
172    /// - `/tmp` → MemoryFs
173    /// - `/v` → MemoryFs
174    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175    NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180    fn default() -> Self {
181        #[cfg(feature = "localfs")]
182        { VfsMountMode::Sandboxed { root: None } }
183        #[cfg(not(feature = "localfs"))]
184        { VfsMountMode::NoLocal }
185    }
186}
187
188/// Configuration for kernel initialization.
189#[derive(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    /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
770    /// installed for the duration of an `execute_with_options` call and
771    /// cleared on exit. Consulted by `is_cancelled()` so every existing
772    /// cancellation checkpoint gains interrupt awareness without new wiring.
773    /// std Mutex for the same sync-access reason as `cancel_token`.
774    interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
775    /// Terminal state for job control (interactive mode only, Unix only).
776    #[cfg(all(unix, feature = "subprocess"))]
777    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
778    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
779    ///
780    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
781    /// through the full Kernel resolution chain.
782    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
783    /// Background job this kernel (a fork) is executing on behalf of, if any.
784    /// Set on the fork created by `execute_background` and inherited by all its
785    /// sub-forks (pipeline stages, scatter workers), so an external command
786    /// spawned anywhere under a background job can record its process group on
787    /// that job for `kill -<sig> %N`. `None` for foreground execution.
788    bg_job_id: Option<crate::scheduler::JobId>,
789    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
790    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
791    /// queue. Background jobs, scatter workers, and concurrent pipeline
792    /// stages do NOT take this lock — they run against a *forked* Kernel
793    /// (see [`Kernel::fork`]) so they never contend with the foreground.
794    execute_lock: tokio::sync::Mutex<()>,
795    /// Current dynamic statement-engine re-entry depth — incremented on entry
796    /// to command substitution, a shell-function call, or a `.kai` source, and
797    /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
798    /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
799    /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
800    /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
801    /// the recursion chain is single-threaded (top-level `execute` is
802    /// serialized by `execute_lock`; concurrency happens on forks).
803    recursion_depth: AtomicUsize,
804}
805
806/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
807/// (in `enter_recursion`) and decrements on drop, so a cancelled or
808/// error-unwound re-entry can never leave the counter inflated (which would
809/// spuriously trip later, unrelated recursions).
810struct RecursionGuard<'a> {
811    counter: &'a AtomicUsize,
812}
813
814impl Drop for RecursionGuard<'_> {
815    fn drop(&mut self) {
816        self.counter.fetch_sub(1, Ordering::Relaxed);
817    }
818}
819
820/// Internal result of [`Kernel::setup_vfs`].
821struct VfsSetupResult {
822    vfs: VfsRouter,
823    budget: Option<Arc<ByteBudget>>,
824    #[cfg(all(feature = "localfs", feature = "overlay"))]
825    overlay_handle: Option<Arc<OverlayHandle>>,
826}
827
828impl Kernel {
829    /// Create a new kernel with the given configuration.
830    pub fn new(config: KernelConfig) -> Result<Self> {
831        let mut setup = Self::setup_vfs(&config)?;
832        let jobs = Arc::new(JobManager::new());
833
834        // Mount JobFs for job observability at /v/jobs
835        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
836
837        #[cfg(all(feature = "localfs", feature = "overlay"))]
838        let overlay_handle = setup.overlay_handle.take();
839
840        // Mode-based construction: the kernel owns its host mounts, so whether
841        // host side channels are allowed is decided by the VFS mode inside
842        // `assemble` (NoLocal forbids them).
843        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
844            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
845        })?;
846
847        #[cfg(all(feature = "localfs", feature = "overlay"))]
848        {
849            let mut kernel = kernel;
850            kernel.overlay_handle = overlay_handle;
851            // Also set it on the ExecContext so builtins can access it.
852            if let Some(ref handle) = kernel.overlay_handle {
853                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
854            }
855            return Ok(kernel);
856        }
857
858        #[allow(unreachable_code)]
859        Ok(kernel)
860    }
861
862    /// Set up VFS based on mount mode.
863    ///
864    /// Returns the router, the budget handle (if bounded), and an optional
865    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
866    /// every `MemoryFs` the kernel creates here holds a clone of the same
867    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
868    /// in-memory content across all kernel-owned memory mounts.
869    ///
870    /// # Errors
871    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
872    /// (overlay is meaningless when everything is already virtual — there is
873    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
874    /// this as an `anyhow::Error`.
875    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
876        let mut vfs = VfsRouter::new();
877
878        // One budget for all memory mounts this kernel owns — labeled so the
879        // error message tells the user exactly which knob to raise.
880        let budget: Option<Arc<ByteBudget>> = config
881            .vfs_budget_bytes
882            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
883
884        /// Helper: construct a `MemoryFs` wired to `budget` if present.
885        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
886            match budget {
887                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
888                None => MemoryFs::new(),
889            }
890        }
891
892        // Overlay handle — populated below if config.overlay is true.
893        #[cfg(all(feature = "localfs", feature = "overlay"))]
894        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
895
896        match &config.vfs_mode {
897            #[cfg(feature = "localfs")]
898            VfsMountMode::Passthrough => {
899                #[cfg(feature = "overlay")]
900                if config.overlay {
901                    // Wrap "/" in an OverlayFs so writes are virtual.
902                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
903                    let overlay_fs = Arc::new(match &budget {
904                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
905                        None => OverlayFs::over(lower),
906                    });
907                    let handle = Arc::new(OverlayHandle {
908                        fs: Arc::clone(&overlay_fs),
909                        mount_path: PathBuf::from("/"),
910                        commit_root: PathBuf::from("/"),
911                    });
912                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
913                    overlay_handle = Some(handle);
914                } else {
915                    // LocalFs at "/" — native paths work directly
916                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
917                }
918                #[cfg(not(feature = "overlay"))]
919                {
920                    if config.overlay {
921                        return Err(anyhow::anyhow!(
922                            "overlay=true requires the `overlay` feature, but this build \
923                             was compiled without it. Recompile with --features overlay \
924                             (or the default feature set) to enable overlay mode."
925                        ));
926                    }
927                    // LocalFs at "/" — native paths work directly
928                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
929                }
930                // Memory for blobs
931                vfs.mount("/v", mem(&budget));
932            }
933            #[cfg(feature = "localfs")]
934            VfsMountMode::Sandboxed { root } => {
935                // Memory at root for safety (catches paths outside sandbox).
936                // Note: /tmp and the XDG runtime dir are LocalFs — writes
937                // there escape the VFS budget and are NOT virtual. This is
938                // intentional: /tmp interop with other processes matters more
939                // than accounting for scratch files there.
940                vfs.mount("/", mem(&budget));
941                vfs.mount("/v", mem(&budget));
942
943                // Synthetic /dev: the host's real /dev isn't reachable here, so
944                // /dev/null and /dev/zero are software-backed (see DevFs).
945                vfs.mount("/dev", DevFs::new());
946
947                // Real /tmp for interop with other processes
948                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
949
950                // Mount XDG runtime dir for spill files and socket access
951                let runtime = crate::paths::xdg_runtime_dir();
952                if runtime.exists() {
953                    let runtime_str = runtime.to_string_lossy().to_string();
954                    vfs.mount(&runtime_str, LocalFs::new(runtime));
955                }
956
957                // Resolve the sandbox root (defaults to $HOME)
958                let local_root = root.clone().unwrap_or_else(|| {
959                    std::env::var("HOME")
960                        .map(PathBuf::from)
961                        .unwrap_or_else(|_| PathBuf::from("/"))
962                });
963
964                let mount_point = local_root.to_string_lossy().to_string();
965
966                #[cfg(feature = "overlay")]
967                if config.overlay {
968                    // Wrap the sandbox root in an OverlayFs.
969                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
970                    let overlay_fs = Arc::new(match &budget {
971                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
972                        None => OverlayFs::over(lower),
973                    });
974                    let handle = Arc::new(OverlayHandle {
975                        fs: Arc::clone(&overlay_fs),
976                        mount_path: PathBuf::from(&mount_point),
977                        commit_root: local_root,
978                    });
979                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
980                    overlay_handle = Some(handle);
981                } else {
982                    // Mount at the real path for transparent access
983                    // e.g., /home/atobey → LocalFs("/home/atobey")
984                    // so /home/atobey/src/kaish just works
985                    vfs.mount(&mount_point, LocalFs::new(local_root));
986                }
987                #[cfg(not(feature = "overlay"))]
988                {
989                    if config.overlay {
990                        return Err(anyhow::anyhow!(
991                            "overlay=true requires the `overlay` feature, but this build \
992                             was compiled without it. Recompile with --features overlay \
993                             (or the default feature set) to enable overlay mode."
994                        ));
995                    }
996                    // Mount at the real path for transparent access
997                    vfs.mount(&mount_point, LocalFs::new(local_root));
998                }
999            }
1000            VfsMountMode::NoLocal => {
1001                if config.overlay {
1002                    return Err(anyhow::anyhow!(
1003                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
1004                         everything is already virtual, there is no real lower layer \
1005                         to wrap. Use with_overlay(false) or switch to a Passthrough \
1006                         or Sandboxed VFS mode."
1007                    ));
1008                }
1009                // Pure memory mode — no local filesystem
1010                vfs.mount("/", mem(&budget));
1011                vfs.mount("/tmp", mem(&budget));
1012                vfs.mount("/v", mem(&budget));
1013                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1014                vfs.mount("/dev", DevFs::new());
1015            }
1016        }
1017
1018        Ok(VfsSetupResult {
1019            vfs,
1020            budget,
1021            #[cfg(all(feature = "localfs", feature = "overlay"))]
1022            overlay_handle,
1023        })
1024    }
1025
1026    /// Create a transient kernel (no persistence).
1027    pub fn transient() -> Result<Self> {
1028        Self::new(KernelConfig::transient())
1029    }
1030
1031    /// Create a kernel with a custom backend and `/v/*` virtual path support.
1032    ///
1033    /// This is the constructor for embedding kaish in other systems that provide
1034    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1035    ///
1036    /// A `VirtualOverlayBackend` routes paths automatically:
1037    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1038    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1039    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
1040    ///   read-only
1041    /// - Everything else → Your custom backend
1042    ///
1043    /// The optional `configure_vfs` closure lets you add additional virtual mounts
1044    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1045    ///
1046    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1047    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1048    /// `skip_validation`, and `interactive`.
1049    ///
1050    /// # Example
1051    ///
1052    /// ```ignore
1053    /// // Simple: default /v/* mounts only
1054    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1055    ///
1056    /// // With custom mounts
1057    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1058    ///     vfs.mount_arc("/v/docs", docs_fs);
1059    ///     vfs.mount_arc("/v/g", git_fs);
1060    /// }, |_| {})?;
1061    ///
1062    /// // With custom tools
1063    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1064    ///     tools.register(MyCustomTool::new());
1065    /// })?;
1066    /// ```
1067    pub fn with_backend(
1068        backend: Arc<dyn KernelBackend>,
1069        config: KernelConfig,
1070        configure_vfs: impl FnOnce(&mut VfsRouter),
1071        configure_tools: impl FnOnce(&mut ToolRegistry),
1072    ) -> Result<Self> {
1073        use crate::backend::VirtualOverlayBackend;
1074
1075        // overlay=true is incompatible with with_backend: the embedder controls
1076        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1077        // semantics. Fail loudly rather than silently ignoring the flag.
1078        if config.overlay {
1079            return Err(anyhow::anyhow!(
1080                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1081                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1082                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1083            ));
1084        }
1085
1086        let mut vfs = VfsRouter::new();
1087        let jobs = Arc::new(JobManager::new());
1088
1089        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1090        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1091        // kernel-owned memory mount here — embedders own the rest of the VFS.
1092        let vfs_budget: Option<Arc<ByteBudget>> = config
1093            .vfs_budget_bytes
1094            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1095
1096        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1097        let blobs_fs = match &vfs_budget {
1098            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1099            None => MemoryFs::new(),
1100        };
1101        vfs.mount("/v/blobs", blobs_fs);
1102
1103        // /dev/null and friends are software-backed (see DevFs) and must not
1104        // depend on the embedder's backend — a read-only embedder backend
1105        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1106        // to /dev/null as a filesystem error instead of discarding them.
1107        vfs.mount("/dev", DevFs::new());
1108
1109        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1110        configure_vfs(&mut vfs);
1111
1112        // A custom-backend kernel owns no host mounts — the embedder supplies
1113        // the entire VFS — so any kernel write to a host filesystem via
1114        // `std::fs` (output spill, job output files) bypasses that VFS and its
1115        // read-only guarantees. Forbid host side channels unconditionally.
1116        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1117            let overlay: Arc<dyn KernelBackend> =
1118                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1119            ExecContext::with_backend(overlay)
1120        })
1121    }
1122
1123    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1124    ///
1125    /// The `make_ctx` closure receives the VFS and tools so backends that need
1126    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1127    /// that already have their own storage can ignore these parameters.
1128    fn assemble(
1129        config: KernelConfig,
1130        mut vfs: VfsRouter,
1131        jobs: Arc<JobManager>,
1132        no_host_filesystem: bool,
1133        vfs_budget: Option<Arc<ByteBudget>>,
1134        configure_tools: impl FnOnce(&mut ToolRegistry),
1135        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1136    ) -> Result<Self> {
1137        // A kernel with no host filesystem of its own must never write to one
1138        // through a side channel. Two paths bypass the VFS by going straight to
1139        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1140        // background-job output files (`Job::write_output_file` → host temp).
1141        // Both would punch through the isolation, so force them off:
1142        // in-memory truncation for spill, no host file for job output.
1143        //
1144        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1145        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1146        // VFS, so the kernel controls no host mounts and any host write is a
1147        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1148        // when there is no kernel-owned host filesystem to spill to.
1149        let no_host_side_channel =
1150            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1151
1152        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;
1153
1154        if no_host_side_channel {
1155            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1156            jobs.set_persist_output_files(false);
1157        }
1158
1159        let mut tools = ToolRegistry::new();
1160        register_builtins(&mut tools);
1161        configure_tools(&mut tools);
1162        let tools = Arc::new(tools);
1163
1164        // Mount BuiltinFs so `ls /v/bin` lists builtins
1165        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1166
1167        let vfs = Arc::new(vfs);
1168
1169        let runner = PipelineRunner::new(tools.clone());
1170
1171        let (stderr_writer, stderr_receiver) = stderr_stream();
1172
1173        let mut exec_ctx = make_ctx(&vfs, &tools);
1174        exec_ctx.set_cwd(cwd);
1175        exec_ctx.set_job_manager(jobs.clone());
1176        exec_ctx.set_tool_schemas(tools.schemas());
1177        exec_ctx.set_tools(tools.clone());
1178        #[cfg(feature = "os-integration")]
1179        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1180        exec_ctx.stderr = Some(stderr_writer);
1181        exec_ctx.ignore_config = ignore_config;
1182        exec_ctx.output_limit = output_limit;
1183        exec_ctx.allow_external_commands = allow_external_commands;
1184        exec_ctx.vfs_budget = vfs_budget.clone();
1185        if let Some(store) = nonce_store {
1186            exec_ctx.nonce_store = store;
1187        }
1188
1189        Ok(Self {
1190            name,
1191            scope: RwLock::new({
1192                let mut scope = Scope::new();
1193                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1194                // HOME is NOT read from the host env here — the kernel is
1195                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1196                // below (from `std::env::vars()`); a hermetic embedder leaves
1197                // `initial_vars` empty and gets no HOME (tilde stays literal).
1198                // Apply caller-supplied initial variables, all marked exported.
1199                // Frontends (REPL, MCP) populate this from std::env::vars()
1200                // for shell-like UX; embedders that want hermetic behavior
1201                // simply leave it empty.
1202                for (name, value) in initial_vars.clone() {
1203                    scope.set_exported(name, value);
1204                }
1205                scope.set_latch_enabled(latch_enabled);
1206                scope.set_trash_enabled(trash_enabled);
1207                scope
1208            }),
1209            initial_vars,
1210            tools,
1211            user_tools: RwLock::new(HashMap::new()),
1212            vfs,
1213            jobs,
1214            runner,
1215            exec_ctx: RwLock::new(exec_ctx),
1216            skip_validation,
1217            interactive,
1218            allow_external_commands,
1219            vfs_budget,
1220            request_timeout,
1221            kill_grace,
1222            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1223            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1224            interrupt: std::sync::Mutex::new(None),
1225            #[cfg(all(unix, feature = "subprocess"))]
1226            terminal_state: None,
1227            self_weak: std::sync::OnceLock::new(),
1228            execute_lock: tokio::sync::Mutex::new(()),
1229            recursion_depth: AtomicUsize::new(0),
1230            bg_job_id: None,
1231            // Overlay handle is set by Kernel::new after assemble returns;
1232            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1233            // with_backend always has None (overlay=true is rejected above).
1234            #[cfg(all(feature = "localfs", feature = "overlay"))]
1235            overlay_handle: None,
1236        })
1237    }
1238
1239    /// Get the kernel name.
1240    pub fn name(&self) -> &str {
1241        &self.name
1242    }
1243
1244    /// Wrap this Kernel in an Arc and initialize its self-reference.
1245    ///
1246    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1247    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1248    /// commands through the full resolution chain (user tools → builtins →
1249    /// .kai scripts → external commands).
1250    pub fn into_arc(self) -> Arc<Self> {
1251        let arc = Arc::new(self);
1252        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1253        arc
1254    }
1255
1256    /// Fork a subsidiary kernel for concurrent execution.
1257    ///
1258    /// The fork is a fully-functional `Kernel` that:
1259    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1260    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1261    ///   the fork do NOT propagate back to the parent — matching bash
1262    ///   subshell / background-job semantics.
1263    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1264    ///   registry, the VFS router, and the job manager. A job registered by
1265    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1266    ///   sees the same VFS mounts.
1267    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1268    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1269    ///   `false` and `terminal_state` is `None`.
1270    ///
1271    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1272    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1273    /// routes through the fork itself, not the parent — which is essential
1274    /// for concurrency safety.
1275    ///
1276    /// Use this for **detached** background concurrency where the fork should
1277    /// survive parent cancellation: the `&` background-job operator and any
1278    /// other "fire and forget" worker. The fork gets a fresh, independent
1279    /// cancellation token.
1280    ///
1281    /// For foreground concurrency (scatter workers, concurrent pipeline
1282    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1283    /// into the fork's external children, use [`Self::fork_attached`].
1284    pub async fn fork(&self) -> Arc<Self> {
1285        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1286            .await
1287    }
1288
1289    /// Fork attached to the parent's cancellation.
1290    ///
1291    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1292    /// the parent's. When the parent cancels (request timeout, embedder
1293    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1294    /// turn kills any external children spawned in the fork via the
1295    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1296    pub async fn fork_attached(&self) -> Arc<Self> {
1297        let child_token = {
1298            #[allow(clippy::expect_used)]
1299            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1300            parent.child_token()
1301        };
1302        self.fork_inner(child_token, self.bg_job_id).await
1303    }
1304
1305    /// Fork for a background job, stamping the job id so external commands
1306    /// spawned anywhere beneath it record their process groups on that job
1307    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1308    /// `JobManager::cancel`.
1309    pub async fn fork_for_background(
1310        &self,
1311        cancel: tokio_util::sync::CancellationToken,
1312        job_id: crate::scheduler::JobId,
1313    ) -> Arc<Self> {
1314        self.fork_inner(cancel, Some(job_id)).await
1315    }
1316
1317    /// Shared fork implementation. Caller decides the cancellation token and
1318    /// which background job (if any) this fork runs on behalf of.
1319    async fn fork_inner(
1320        &self,
1321        cancel: tokio_util::sync::CancellationToken,
1322        bg_job_id: Option<crate::scheduler::JobId>,
1323    ) -> Arc<Self> {
1324        let scope_snapshot = self.scope.read().await.clone();
1325        let user_tools_snapshot = self.user_tools.read().await.clone();
1326
1327        // Snapshot exec_ctx by cloning the cloneable fields, then override
1328        // the ones that should not carry over (stderr channel, dispatcher,
1329        // interactive flag, terminal state, cancel — set from `cancel` arg).
1330        let mut fork_ctx = {
1331            let parent_ctx = self.exec_ctx.read().await;
1332            parent_ctx.child_for_pipeline()
1333        };
1334        let (stderr_writer, stderr_receiver) = stderr_stream();
1335        fork_ctx.stderr = Some(stderr_writer);
1336        // Clear dispatcher; dispatch_command will repopulate it to point at
1337        // the fork on the first dispatch call.
1338        fork_ctx.dispatcher = None;
1339        fork_ctx.interactive = false;
1340        fork_ctx.cancel = cancel.clone();
1341        #[cfg(all(unix, feature = "subprocess"))]
1342        {
1343            fork_ctx.terminal_state = None;
1344        }
1345
1346        let fork = Self {
1347            name: format!("{}:fork", self.name),
1348            scope: RwLock::new(scope_snapshot),
1349            initial_vars: self.initial_vars.clone(),
1350            tools: Arc::clone(&self.tools),
1351            user_tools: RwLock::new(user_tools_snapshot),
1352            vfs: Arc::clone(&self.vfs),
1353            jobs: Arc::clone(&self.jobs),
1354            runner: self.runner.clone(),
1355            exec_ctx: RwLock::new(fork_ctx),
1356            skip_validation: self.skip_validation,
1357            // Forks are never the TTY owner — they run in the background.
1358            interactive: false,
1359            allow_external_commands: self.allow_external_commands,
1360            // Arc-clone the budget so the fork draws from the same pool as the
1361            // parent — background jobs and scatter workers count against the same
1362            // cap as foreground writes.
1363            vfs_budget: self.vfs_budget.clone(),
1364            request_timeout: self.request_timeout,
1365            kill_grace: self.kill_grace,
1366            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1367            cancel_token: std::sync::Mutex::new(cancel),
1368            interrupt: std::sync::Mutex::new(None),
1369            #[cfg(all(unix, feature = "subprocess"))]
1370            terminal_state: None,
1371            self_weak: std::sync::OnceLock::new(),
1372            execute_lock: tokio::sync::Mutex::new(()),
1373            // A fork runs on a fresh stack (spawned task) — its recursion
1374            // budget is independent of the parent's current depth (GH #46).
1375            recursion_depth: AtomicUsize::new(0),
1376            bg_job_id,
1377            // Arc-clone the overlay handle so forks (background jobs, scatter
1378            // workers, pipeline stages) can reach the same overlay transaction
1379            // via `kaish-vfs status/diff/commit/reset`.
1380            #[cfg(all(feature = "localfs", feature = "overlay"))]
1381            overlay_handle: self.overlay_handle.clone(),
1382        };
1383
1384        fork.into_arc()
1385    }
1386
1387    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1388    ///
1389    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1390    /// have been dropped (the `Weak` can no longer upgrade).
1391    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1392        self.self_weak
1393            .get()
1394            .and_then(|weak| weak.upgrade())
1395            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1396    }
1397
1398    /// Initialize terminal state for interactive job control.
1399    ///
1400    /// Call this after kernel creation when running as an interactive REPL
1401    /// and stdin is a TTY. Sets up process groups and signal handling.
1402    #[cfg(all(unix, feature = "subprocess"))]
1403    pub fn init_terminal(&mut self) {
1404        if !self.interactive {
1405            return;
1406        }
1407        match crate::terminal::TerminalState::init() {
1408            Ok(state) => {
1409                let state = Arc::new(state);
1410                self.terminal_state = Some(state.clone());
1411                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1412                self.exec_ctx.get_mut().terminal_state = Some(state);
1413                tracing::debug!("terminal job control initialized");
1414            }
1415            Err(e) => {
1416                tracing::warn!("failed to initialize terminal job control: {}", e);
1417            }
1418        }
1419    }
1420
1421    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1422    ///
1423    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1424    /// built with the `os-integration` feature. Embedders and tests can swap
1425    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1426    /// it — with trash enabled but no backend present, `rm` fails loud
1427    /// rather than falling through to permanent delete.
1428    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1429        self.exec_ctx.get_mut().trash_backend = backend;
1430    }
1431
1432    /// Cancel the current execution.
1433    ///
1434    /// This cancels the current cancellation token, causing any execution
1435    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1436    /// A fresh token is installed for the next `execute()` call.
1437    pub fn cancel(&self) {
1438        #[allow(clippy::expect_used)]
1439        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1440        token.cancel();
1441    }
1442
1443    /// Check if the current execution has been cancelled.
1444    ///
1445    /// Also the polling point for `ExecuteOptions::interrupt`: when the
1446    /// embedder's check reports true, the internal token fires here, so every
1447    /// call site of this method is an interrupt checkpoint for free.
1448    pub fn is_cancelled(&self) -> bool {
1449        let interrupted = {
1450            #[allow(clippy::expect_used)]
1451            let check = self.interrupt.lock().expect("interrupt poisoned");
1452            check.as_ref().is_some_and(|f| f())
1453        };
1454        if interrupted {
1455            self.cancel();
1456        }
1457        #[allow(clippy::expect_used)]
1458        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1459        token.is_cancelled()
1460    }
1461
1462    /// Reset the cancellation token (called at the start of each execute).
1463    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1464        #[allow(clippy::expect_used)]
1465        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1466        if token.is_cancelled() {
1467            *token = tokio_util::sync::CancellationToken::new();
1468        }
1469        token.clone()
1470    }
1471
1472    /// Acquire the per-Kernel execute lock, warning on contention.
1473    ///
1474    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1475    /// the lock is already held, emit a warning so the silent serialization
1476    /// is observable in logs — if you need real parallelism, fork the kernel.
1477    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1478        match self.execute_lock.try_lock() {
1479            Ok(guard) => guard,
1480            Err(_) => {
1481                tracing::warn!(
1482                    target: "kaish::kernel::concurrency",
1483                    kernel = %self.name,
1484                    "execute() contended — serializing concurrent caller; \
1485                     use Kernel::fork() for parallelism instead of sharing"
1486                );
1487                self.execute_lock.lock().await
1488            }
1489        }
1490    }
1491
1492    /// Execute kaish source code with default options.
1493    ///
1494    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1495    /// Returns the result of the last statement executed.
1496    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1497        self.run_inner(input, ExecuteOptions::default(), None, None).await
1498    }
1499
1500    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1501    /// are **already tokenized**.
1502    ///
1503    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1504    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1505    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1506    /// into a string just to have the lexer split it apart again — a round-trip
1507    /// that is lossy for typed values, since `to_argv()` stringifies
1508    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1509    ///
1510    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1511    /// command substitution, no word splitting — the "single-quoted word"
1512    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1513    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1514    /// does still apply, for consistency with the string door: a leading `~` is
1515    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1516    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1517    /// non-string `Value`
1518    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1519    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1520    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1521    /// sees a stringified value; the typed-passthrough win fully lands only for
1522    /// builtins that read `args.positional` directly — the documented pattern.)
1523    ///
1524    /// This is a *peer*, not a subset: a command string can carry pipelines,
1525    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1526    /// doors converge **late** (at the shared dispatch chain) rather than one
1527    /// wrapping the other. From argv classification onward `execute_argv` reuses
1528    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1529    /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1530    /// transform, and the confirmation latch — so a latched `rm` still emits a
1531    /// nonce and an `ls --json` still applies output formatting. The kernel's
1532    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1533    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1534    ///
1535    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1536    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1537    /// external is interrupted at the deadline with exit code 124, the same as the
1538    /// string door). There is no per-call options surface yet — a future
1539    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1540    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1541    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1542        let _guard = self.acquire_execute_lock().await;
1543        // Fresh cancel surface for this call: `execute_pipeline` reads
1544        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1545        // replaced first. The returned clone is the token the watchdog cancels on
1546        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1547        // cascading SIGTERM/SIGKILL to any external child.
1548        let cancel = self.reset_cancel();
1549
1550        // Honor the kernel-configured request timeout for parity with `execute`.
1551        let timeout = self.request_timeout;
1552        if timeout == Some(Duration::ZERO) {
1553            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1554        }
1555
1556        let pipeline = crate::ast::Pipeline {
1557            commands: vec![crate::ast::Command {
1558                name: name.to_string(),
1559                args: argv_to_args(argv),
1560                redirects: Vec::new(),
1561            }],
1562            background: false,
1563        };
1564        let result = self
1565            .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1566            .await?;
1567        self.update_last_result(&result).await;
1568        Ok(result)
1569    }
1570
1571    /// Fulfill a confirmation latch by replaying its exact captured invocation
1572    /// with the nonce — the highest-fidelity approval path.
1573    ///
1574    /// Inspect a gated result with [`ExecResult::latch_request`]; apply whatever
1575    /// policy (allowlist, model review) over `req.command`/`req.paths`; then call
1576    /// this to approve. It replays `execute_argv(req.tool, req.argv)` with
1577    /// `--confirm=<nonce>` prepended — no re-parsing of the human `hint`, so a
1578    /// path with spaces or glob characters round-trips exactly. Share the nonce
1579    /// store ([`KernelConfig::with_nonce_store`]) to confirm from a *later*
1580    /// kernel call than the one that produced the latch.
1581    ///
1582    /// Errors (exit 2) if the latch carries no captured invocation — a latch
1583    /// produced outside a dispatch seam (a direct `tool.execute` in a unit
1584    /// test). Those are confirmable only by re-running with `--confirm=<nonce>`.
1585    ///
1586    /// If `latch.job_id` is set (the gate came from a *backgrounded* job —
1587    /// `rm x &` reaching its gate), a successful replay also retires that job
1588    /// from the `JobManager` (GH #124 part 4) — mirroring the existing manual
1589    /// discard path (`kill --discard %N`), automated. A failed replay leaves
1590    /// the job in place for inspection/retry. Guarded by `is_latched` so a
1591    /// stale/foreign `job_id` can never remove an unrelated running job;
1592    /// idempotent on a repeat confirm (nonces are reusable within TTL, and
1593    /// removing an already-absent job is a no-op).
1594    pub async fn confirm(&self, latch: &LatchRequest) -> Result<ExecResult> {
1595        if latch.tool.is_empty() {
1596            return Ok(ExecResult::failure(
1597                2,
1598                "confirm: latch carries no captured invocation to replay — \
1599                 re-run the command with --confirm=<nonce> instead",
1600            ));
1601        }
1602        // Prepend the nonce as a `--confirm=` flag: `to_argv()` trails a `--`
1603        // positional terminator, so appending would let it swallow the flag.
1604        let mut argv: Vec<Value> = Vec::with_capacity(latch.argv.len() + 1);
1605        argv.push(Value::String(format!("--confirm={}", latch.nonce)));
1606        argv.extend(latch.argv.iter().map(|a| Value::String(a.clone())));
1607        let result = self.execute_argv(&latch.tool, &argv).await?;
1608
1609        if result.ok()
1610            && let Some(id) = latch.job_id
1611        {
1612            let job_id = crate::scheduler::JobId(id);
1613            if self.jobs.is_latched(job_id).await {
1614                self.jobs.remove(job_id).await;
1615            }
1616        }
1617
1618        Ok(result)
1619    }
1620
1621    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1622    /// string door ([`Self::execute_with_options`]) and the argv door
1623    /// ([`Self::execute_argv`]).
1624    ///
1625    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1626    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1627    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1628    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1629    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1630    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1631    /// stale handle would silently suspend nothing). Callers must short-circuit a
1632    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1633    async fn run_under_watchdog<F>(
1634        &self,
1635        timeout: Option<Duration>,
1636        cancel: &tokio_util::sync::CancellationToken,
1637        work: F,
1638    ) -> Result<ExecResult>
1639    where
1640        F: std::future::Future<Output = Result<ExecResult>>,
1641    {
1642        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1643        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1644        {
1645            let mut ec = self.exec_ctx.write().await;
1646            ec.watchdog = watchdog.clone();
1647        }
1648
1649        let result = if let Some(d) = timeout {
1650            #[allow(clippy::expect_used)]
1651            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1652            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1653            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1654            let r = work.await;
1655            timer.abort();
1656            match r {
1657                Ok(mut res) => {
1658                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1659                        res.code = 124;
1660                        if res.err.is_empty() {
1661                            res.err = format!("timeout: timed out after {:?}", d);
1662                        }
1663                    }
1664                    Ok(res)
1665                }
1666                Err(e) => Err(e),
1667            }
1668        } else {
1669            work.await
1670        };
1671
1672        // The timer task is gone (fired or aborted); drop the stale handle.
1673        {
1674            let mut ec = self.exec_ctx.write().await;
1675            ec.watchdog = None;
1676        }
1677        result
1678    }
1679
1680    /// Execute with per-call options. The primary entry point for embedders
1681    /// that don't need per-statement output streaming.
1682    ///
1683    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1684    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1685    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1686    ///
1687    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1688    /// against the kernel's internal token. Either firing cancels and kills
1689    /// external children. The embedder's token is read-only — kernel
1690    /// timeouts do NOT propagate into it. Distinguish via the returned
1691    /// `code`: 124 = timeout, 130 = cancellation.
1692    ///
1693    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1694    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1695    ///
1696    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1697    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1698    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1699    pub async fn execute_with_options(
1700        &self,
1701        input: &str,
1702        opts: ExecuteOptions,
1703    ) -> Result<ExecResult> {
1704        self.run_inner(input, opts, None, None).await
1705    }
1706
1707    /// Same as [`Self::execute_with_options`] but with a per-statement output
1708    /// callback. The callback fires after each top-level statement so the
1709    /// embedder (REPL, MCP streaming) can flush output incrementally.
1710    pub async fn execute_with_options_streaming(
1711        &self,
1712        input: &str,
1713        opts: ExecuteOptions,
1714        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1715    ) -> Result<ExecResult> {
1716        self.run_inner(input, opts, None, Some(on_output)).await
1717    }
1718
1719    /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1720    ///
1721    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1722    /// forces the input to be drained before execution: the reader seeds the
1723    /// first top-level command's `pipe_stdin`, and a command that does not read
1724    /// stdin (`echo`) returns without touching it. This is the seam a
1725    /// non-interactive frontend uses to forward an *open* process stdin without
1726    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1727    ///
1728    /// Embedders that already hold a complete buffer (text or binary) should
1729    /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1730    pub async fn execute_with_pipe_stdin(
1731        &self,
1732        input: &str,
1733        opts: ExecuteOptions,
1734        pipe_stdin: crate::scheduler::PipeReader,
1735    ) -> Result<ExecResult> {
1736        self.run_inner(input, opts, Some(pipe_stdin), None).await
1737    }
1738
1739    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1740    /// `-c`/script frontend uses this to print output incrementally while
1741    /// feeding a lazy process-stdin pipe.
1742    pub async fn execute_with_pipe_stdin_streaming(
1743        &self,
1744        input: &str,
1745        opts: ExecuteOptions,
1746        pipe_stdin: crate::scheduler::PipeReader,
1747        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1748    ) -> Result<ExecResult> {
1749        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1750    }
1751
1752    /// Execute kaish source code with a transient overlay of exported variables.
1753    ///
1754    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1755    /// should use that method directly:
1756    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1757    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1758    pub async fn execute_with_vars(
1759        &self,
1760        input: &str,
1761        vars: HashMap<String, Value>,
1762    ) -> Result<ExecResult> {
1763        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1764    }
1765
1766    /// Execute kaish source code with a per-statement callback.
1767    ///
1768    /// Deprecated thin wrapper. New code should use
1769    /// [`Self::execute_with_options_streaming`].
1770    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1771    pub async fn execute_streaming(
1772        &self,
1773        input: &str,
1774        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1775    ) -> Result<ExecResult> {
1776        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1777    }
1778
1779    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1780    ///
1781    /// The `#[instrument]` execution span resolves its parent from the *current*
1782    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1783    /// captured when the span is first entered — not when the future is
1784    /// constructed. So a thread-local `attach()` scoped to construction is too
1785    /// early to be seen (the integration test confirms this). `with_context`
1786    /// re-attaches the embedder's context on *every* poll of the inner future,
1787    /// so the context is current at first-enter and survives runtime thread
1788    /// hops. With no embedder trace context, the future runs unwrapped.
1789    async fn run_inner(
1790        &self,
1791        input: &str,
1792        opts: ExecuteOptions,
1793        pipe_stdin: Option<crate::scheduler::PipeReader>,
1794        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1795    ) -> Result<ExecResult> {
1796        use opentelemetry::context::FutureExt;
1797
1798        // Capture the embedder's baggage before `opts` is consumed so it can be
1799        // echoed back onto the result on egress (see `merge_egress_baggage`).
1800        let embedder_baggage = opts.baggage.clone();
1801
1802        let result = match crate::telemetry::extract_parent(&opts) {
1803            Some(parent) => self
1804                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1805                .with_context(parent)
1806                .await,
1807            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1808        };
1809
1810        result.map(|mut r| {
1811            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1812            r
1813        })
1814    }
1815
1816    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1817    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1818    /// cwd override, and timeout race.
1819    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1820    async fn execute_with_options_inner(
1821        &self,
1822        input: &str,
1823        opts: ExecuteOptions,
1824        pipe_stdin: Option<crate::scheduler::PipeReader>,
1825        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1826    ) -> Result<ExecResult> {
1827        let _guard = self.acquire_execute_lock().await;
1828
1829        // Always reset to a fresh internal token; this is the kernel's own
1830        // cancel surface for embedders calling `Kernel::cancel()`. The
1831        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1832        // is NOT written into `self.cancel_token`, because doing so would
1833        // (a) leak the embedder's token past this call's lifetime,
1834        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1835        // (c) extend the token's lifetime via the kernel's strong clone.
1836        let internal = self.reset_cancel();
1837
1838        // Install the per-call polled interrupt for `is_cancelled()` to
1839        // consult. The guard clears it on every exit path — a stale check
1840        // must not outlive its call and fire into a later one.
1841        struct ClearInterrupt<'a>(&'a Kernel);
1842        impl Drop for ClearInterrupt<'_> {
1843            fn drop(&mut self) {
1844                if let Ok(mut slot) = self.0.interrupt.lock() {
1845                    *slot = None;
1846                }
1847            }
1848        }
1849        {
1850            #[allow(clippy::expect_used)]
1851            let mut slot = self.interrupt.lock().expect("interrupt poisoned");
1852            *slot = opts.interrupt.clone();
1853        }
1854        let _interrupt_guard = ClearInterrupt(self);
1855
1856        // Race the embedder token against the kernel's internal token via a
1857        // tracked watcher task. We hold the JoinHandle so we can abort the
1858        // task at function exit — otherwise it would wait forever for either
1859        // token to fire and leak per call.
1860        let (effective_cancel, watcher_handle): (
1861            tokio_util::sync::CancellationToken,
1862            Option<tokio::task::JoinHandle<()>>,
1863        ) = if let Some(ext) = opts.cancel_token {
1864            let combined = tokio_util::sync::CancellationToken::new();
1865            let combined_writer = combined.clone();
1866            let i = internal.clone();
1867            let handle = tokio::spawn(async move {
1868                tokio::select! {
1869                    _ = i.cancelled() => combined_writer.cancel(),
1870                    _ = ext.cancelled() => combined_writer.cancel(),
1871                }
1872            });
1873            (combined, Some(handle))
1874        } else {
1875            (internal, None)
1876        };
1877
1878        // Effective timeout: per-call wins over kernel-config default.
1879        let timeout = opts.timeout.or(self.request_timeout);
1880
1881        // ZERO timeout: return 124 immediately without spawning anything.
1882        if timeout == Some(Duration::ZERO) {
1883            if let Some(h) = watcher_handle {
1884                h.abort();
1885            }
1886            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1887        }
1888
1889        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1890        // an RAII guard so a panic inside `execute_streaming_inner` still
1891        // pops the frame and unexports the temporarily-exported names.
1892        struct VarsFrameGuard<'a> {
1893            kernel: &'a Kernel,
1894            newly_exported: Vec<String>,
1895        }
1896        impl Drop for VarsFrameGuard<'_> {
1897            fn drop(&mut self) {
1898                // Best-effort cleanup using try_write. The execute_lock held
1899                // throughout execute_with_options means there is no concurrent
1900                // foreground caller; forks have their own scope and won't
1901                // block this. blocking_write would deadlock the runtime when
1902                // called from a tokio worker thread, so we explicitly do NOT
1903                // fall back to it — if try_write fails (which we've never
1904                // seen in practice), log loudly and accept the leak rather
1905                // than deadlock the entire kernel.
1906                let Ok(mut scope) = self.kernel.scope.try_write() else {
1907                    tracing::error!(
1908                        "vars frame guard: scope lock unexpectedly busy; \
1909                         skipping pop_frame to avoid runtime deadlock — \
1910                         transient vars may leak"
1911                    );
1912                    return;
1913                };
1914                scope.pop_frame();
1915                for name in self.newly_exported.drain(..) {
1916                    scope.unexport(&name);
1917                }
1918            }
1919        }
1920
1921        // Per-call cwd override: save current cwd, set the new one, restore
1922        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1923        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1924        struct CwdGuard<'a> {
1925            kernel: &'a Kernel,
1926            saved: PathBuf,
1927        }
1928        impl Drop for CwdGuard<'_> {
1929            fn drop(&mut self) {
1930                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1931                    tracing::error!(
1932                        "cwd guard: exec_ctx lock unexpectedly busy; \
1933                         skipping cwd restore — kernel cwd may be wrong for next call"
1934                    );
1935                    return;
1936                };
1937                ec.cwd = std::mem::take(&mut self.saved);
1938            }
1939        }
1940        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1941            let mut ec = self.exec_ctx.write().await;
1942            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1943            drop(ec);
1944            Some(CwdGuard { kernel: self, saved })
1945        } else {
1946            None
1947        };
1948
1949        // Per-call stdin: seed the persistent exec_ctx so the first top-level
1950        // command that reads stdin consumes it (it's `take()`n at dispatch).
1951        // Restore the prior value on Drop — normally `None`, so this also drops
1952        // any residual seed an stdin-less program never consumed, keeping it
1953        // from bleeding into the next call. Same RAII pattern as CwdGuard.
1954        struct StdinGuard<'a> {
1955            kernel: &'a Kernel,
1956            saved: Option<Vec<u8>>,
1957        }
1958        impl Drop for StdinGuard<'_> {
1959            fn drop(&mut self) {
1960                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1961                    tracing::error!(
1962                        "stdin guard: exec_ctx lock unexpectedly busy; \
1963                         skipping stdin restore — stale stdin may leak to next call"
1964                    );
1965                    return;
1966                };
1967                ec.stdin = self.saved.take();
1968            }
1969        }
1970        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1971            let mut ec = self.exec_ctx.write().await;
1972            let saved = ec.stdin.replace(stdin);
1973            drop(ec);
1974            Some(StdinGuard { kernel: self, saved })
1975        } else {
1976            None
1977        };
1978
1979        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1980        // persistent exec_ctx so the first stdin-reading command drains it (it's
1981        // `take()`n at pipeline build). The RAII guard restores the prior value
1982        // on Drop (normally `None`), so an unread reader doesn't bleed into the
1983        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1984        struct PipeStdinGuard<'a> {
1985            kernel: &'a Kernel,
1986            saved: Option<crate::scheduler::PipeReader>,
1987        }
1988        impl Drop for PipeStdinGuard<'_> {
1989            fn drop(&mut self) {
1990                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1991                    tracing::error!(
1992                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1993                         skipping restore — stale pipe stdin may leak to next call"
1994                    );
1995                    return;
1996                };
1997                ec.pipe_stdin = self.saved.take();
1998            }
1999        }
2000        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2001            let mut ec = self.exec_ctx.write().await;
2002            let saved = ec.pipe_stdin.replace(reader);
2003            drop(ec);
2004            Some(PipeStdinGuard { kernel: self, saved })
2005        } else {
2006            None
2007        };
2008
2009        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2010            let mut scope = self.scope.write().await;
2011            scope.push_frame();
2012            let mut newly = Vec::with_capacity(opts.vars.len());
2013            for (name, value) in opts.vars {
2014                if !scope.is_exported(&name) {
2015                    newly.push(name.clone());
2016                }
2017                scope.set_exported(name, value);
2018            }
2019            drop(scope);
2020            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2021        } else {
2022            None
2023        };
2024
2025        // Sync the effective cancel into self.exec_ctx so try_execute_external
2026        // (which reads via self.cancel_token) sees cancellation. We also need
2027        // builtins to see it via ctx.cancel — handled in execute_command.
2028        // For simplicity here we mirror effective_cancel into self.cancel_token
2029        // for the duration of this call, then restore the internal token at
2030        // the end (so a later Kernel::cancel still hits our internal surface).
2031        {
2032            #[allow(clippy::expect_used)]
2033            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2034            *cur = effective_cancel.clone();
2035        }
2036
2037        // Run the script under the movable-deadline watchdog (shared with the
2038        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2039        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2040        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2041        // already handled by the early return above.
2042        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2043        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2044            Some(cb) => cb,
2045            None => &mut *noop_cb,
2046        };
2047
2048        let result = self
2049            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2050            .await;
2051
2052        // Restore self.cancel_token to a fresh, uncancelled token so the
2053        // embedder's view of `Kernel::cancel()` stays predictable on the
2054        // next call (it cancels the kernel's own token, not whatever was
2055        // left over from this call's combined token).
2056        {
2057            #[allow(clippy::expect_used)]
2058            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2059            *cur = tokio_util::sync::CancellationToken::new();
2060        }
2061
2062        // Tear down the embedder-token race watcher (if any). Leaving it
2063        // alive would idle forever waiting for tokens that may never fire.
2064        if let Some(h) = watcher_handle {
2065            h.abort();
2066        }
2067
2068        // VarsFrameGuard drops here on the success path and on early-return
2069        // paths above (error path included). Panic safety preserved.
2070        result
2071    }
2072
2073    /// The actual body of `execute_streaming`, run while holding the execute lock.
2074    ///
2075    /// Split out so internal kernel paths that are already under the lock can
2076    /// call this without deadlocking on re-entry. External callers must go
2077    /// through [`Self::execute_streaming`] so they acquire the lock.
2078    async fn execute_streaming_inner(
2079        &self,
2080        input: &str,
2081        on_output: &mut (dyn FnMut(&ExecResult) + Send),
2082    ) -> Result<ExecResult> {
2083        let program = parse(input).map_err(|errors| {
2084            let msg = errors
2085                .iter()
2086                .map(|e| e.format(input))
2087                .collect::<Vec<_>>()
2088                .join("\n");
2089            anyhow::anyhow!("parse error:\n{}", msg)
2090        })?;
2091
2092        // AST display mode: show AST instead of executing
2093        {
2094            let scope = self.scope.read().await;
2095            if scope.show_ast() {
2096                let output = format!("{:#?}\n", program);
2097                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2098            }
2099        }
2100
2101        // Pre-execution validation. Most warnings stay trace-only (every
2102        // external command fires an `UndefinedCommand` warning), but a warning
2103        // whose code opts into agent surfacing is collected here and prepended
2104        // to the result's stderr at each return point below.
2105        let mut surfaced_warnings = String::new();
2106        if !self.skip_validation {
2107            let user_tools = self.user_tools.read().await;
2108            let validator = Validator::new(&self.tools, &user_tools);
2109            let issues = validator.validate(&program);
2110
2111            // Collect errors (warnings are logged but don't prevent execution)
2112            let errors: Vec<_> = issues
2113                .iter()
2114                .filter(|i| i.severity == Severity::Error)
2115                .collect();
2116
2117            if !errors.is_empty() {
2118                let error_msg = errors
2119                    .iter()
2120                    .map(|e| e.format(input))
2121                    .collect::<Vec<_>>()
2122                    .join("\n");
2123                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2124            }
2125
2126            // Log warnings via tracing (trace level to avoid noise); surface the
2127            // opted-in ones to the agent so the guidance is actually seen.
2128            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2129                tracing::trace!("validation: {}", warning.format(input));
2130                if warning.code.surfaces_to_agent() {
2131                    surfaced_warnings.push_str(&warning.format(input));
2132                    surfaced_warnings.push('\n');
2133                }
2134            }
2135        }
2136
2137        // Surface opted-in validation warnings to the streaming frontend once,
2138        // before any command output. The streaming consumer (`-c`, REPL) prints
2139        // per `on_output` and ignores the returned aggregate err; non-streaming
2140        // callers (`kernel.execute`) use a noop callback and read the aggregate
2141        // `result.err` (prepended at each return below). The two paths are
2142        // disjoint, so this prints the advisory exactly once on each.
2143        if !surfaced_warnings.is_empty() {
2144            let mut advisory = ExecResult::success("");
2145            advisory.err = surfaced_warnings.clone();
2146            on_output(&advisory);
2147        }
2148
2149        let mut result = ExecResult::success("");
2150
2151        // Reset cancellation token for this execution.
2152        let cancel = self.reset_cancel();
2153
2154        for stmt in program.statements {
2155            if matches!(stmt, Stmt::Empty) {
2156                continue;
2157            }
2158
2159            // Cancellation checkpoint
2160            if cancel.is_cancelled() {
2161                result.code = 130;
2162                return Ok(result);
2163            }
2164
2165            let flow = self.execute_stmt_flow(&stmt).await?;
2166
2167            // Drain any stderr written by pipeline stages during this statement.
2168            // This captures stderr from intermediate pipeline stages that would
2169            // otherwise be lost (only the last stage's result is returned).
2170            let drained_stderr = {
2171                let mut receiver = self.stderr_receiver.lock().await;
2172                receiver.drain_lossy()
2173            };
2174
2175            match flow {
2176                ControlFlow::Normal(mut r) => {
2177                    if !drained_stderr.is_empty() {
2178                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2179                            r.err.push('\n');
2180                        }
2181                        // Prepend pipeline stderr before the last stage's stderr
2182                        let combined = format!("{}{}", drained_stderr, r.err);
2183                        r.err = combined;
2184                    }
2185                    on_output(&r);
2186                    // Carry the last statement's structured output for MCP TOON encoding.
2187                    // Must be done here (not in accumulate_result) because accumulate_result
2188                    // is also used in loops where per-iteration output would be wrong.
2189                    let last_output = r.output().cloned();
2190                    accumulate_result(&mut result, &r);
2191                    result.set_output(last_output);
2192                }
2193                ControlFlow::Exit { code } => {
2194                    if !drained_stderr.is_empty() {
2195                        result.err.push_str(&drained_stderr);
2196                    }
2197                    result.code = code;
2198                    if !surfaced_warnings.is_empty() {
2199                        result.err = format!("{surfaced_warnings}{}", result.err);
2200                    }
2201                    return Ok(result);
2202                }
2203                ControlFlow::Return { mut value } => {
2204                    if !drained_stderr.is_empty() {
2205                        value.err = format!("{}{}", drained_stderr, value.err);
2206                    }
2207                    on_output(&value);
2208                    // A top-level `return` stops the script, like `exit` —
2209                    // it must not discard prior statements' accumulated
2210                    // output nor let execution continue past it.
2211                    accumulate_result(&mut result, &value);
2212                    if !surfaced_warnings.is_empty() {
2213                        result.err = format!("{surfaced_warnings}{}", result.err);
2214                    }
2215                    return Ok(result);
2216                }
2217                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2218                    if !drained_stderr.is_empty() {
2219                        r.err = format!("{}{}", drained_stderr, r.err);
2220                    }
2221                    on_output(&r);
2222                    accumulate_result(&mut result, &r);
2223                }
2224            }
2225        }
2226
2227        if !surfaced_warnings.is_empty() {
2228            result.err = format!("{surfaced_warnings}{}", result.err);
2229        }
2230        Ok(result)
2231    }
2232
2233    /// Execute a single statement, returning control flow information.
2234    fn execute_stmt_flow<'a>(
2235        &'a self,
2236        stmt: &'a Stmt,
2237    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2238        // No per-statement span here: `execute_stmt_flow` is the largest future
2239        // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2240        // the span's state through every `.await` at every level, costing native
2241        // stack per level (GH #48). Coarser spans on the outer execute entries
2242        // remain. See item 3 of the #48 burndown.
2243        Box::pin(async move {
2244        match stmt {
2245            Stmt::Assignment(assign) => {
2246                // Use async evaluator to support command substitution
2247                let value = self.eval_expr_async(&assign.value).await
2248                    .context("failed to evaluate assignment")?;
2249                let mut scope = self.scope.write().await;
2250                if assign.path.segments.len() == 1 {
2251                    // Plain `NAME=value` — no subscript, so `local` applies.
2252                    if assign.local {
2253                        // local: set in innermost (current function) frame
2254                        scope.set(assign.name(), value.clone());
2255                    } else {
2256                        // non-local: update existing or create in root frame
2257                        scope.set_global(assign.name(), value.clone());
2258                    }
2259                } else {
2260                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2261                    // mutates the existing root wherever it lives — `local` is
2262                    // meaningless here (see docs/arrays-and-hashes.md, "Assignment
2263                    // lvalues").
2264                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2265                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2266                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2267                        ),
2268                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2269                    })?;
2270                }
2271                drop(scope);
2272
2273                // Assignments don't produce output (like sh)
2274                Ok(ControlFlow::ok(ExecResult::success("")))
2275            }
2276            Stmt::Command(cmd) => {
2277                // Route single commands through execute_pipeline for a unified path.
2278                // This ensures all commands go through the dispatcher chain.
2279                let pipeline = crate::ast::Pipeline {
2280                    commands: vec![cmd.clone()],
2281                    background: false,
2282                };
2283                let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2284                self.update_last_result(&result).await;
2285
2286                // Check for error exit mode (set -e)
2287                if !result.ok() {
2288                    let scope = self.scope.read().await;
2289                    if scope.error_exit_enabled() {
2290                        return Ok(ControlFlow::exit_code(result.code));
2291                    }
2292                }
2293
2294                Ok(ControlFlow::ok(result))
2295            }
2296            Stmt::Pipeline(pipeline) => {
2297                let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2298                self.update_last_result(&result).await;
2299
2300                // Check for error exit mode (set -e)
2301                if !result.ok() {
2302                    let scope = self.scope.read().await;
2303                    if scope.error_exit_enabled() {
2304                        return Ok(ControlFlow::exit_code(result.code));
2305                    }
2306                }
2307
2308                Ok(ControlFlow::ok(result))
2309            }
2310            Stmt::If(if_stmt) => {
2311                // Use async evaluator to support command substitution in conditions
2312                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2313
2314                let branch = if is_truthy(&cond_value) {
2315                    &if_stmt.then_branch
2316                } else {
2317                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2318                };
2319
2320                let mut result = ExecResult::success("");
2321                for stmt in branch {
2322                    let flow = self.execute_stmt_flow(stmt).await?;
2323                    match flow {
2324                        ControlFlow::Normal(r) => {
2325                            accumulate_result(&mut result, &r);
2326                            self.drain_stderr_into(&mut result).await;
2327                        }
2328                        other => {
2329                            self.drain_stderr_into(&mut result).await;
2330                            return Ok(other);
2331                        }
2332                    }
2333                }
2334                Ok(ControlFlow::ok(result))
2335            }
2336            Stmt::For(for_loop) => {
2337                // Evaluate all items and collect values for iteration
2338                // Use async evaluator to support command substitution like $(seq 1 5)
2339                let mut items: Vec<Value> = Vec::new();
2340                for item_expr in &for_loop.items {
2341                    // Glob expansion in for-loop items: `for f in *.txt`
2342                    if let Expr::GlobPattern(pattern) = item_expr {
2343                        let glob_enabled = {
2344                            let scope = self.scope.read().await;
2345                            scope.glob_enabled()
2346                        };
2347                        if glob_enabled {
2348                            let (paths, cwd) = {
2349                                let ctx = self.exec_ctx.read().await;
2350                                let paths = ctx.expand_glob(pattern).await
2351                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2352                                let cwd = ctx.resolve_path(".");
2353                                (paths, cwd)
2354                            };
2355                            if paths.is_empty() {
2356                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2357                            }
2358                            for path in paths {
2359                                let display = if !pattern.starts_with('/') {
2360                                    path.strip_prefix(&cwd)
2361                                        .unwrap_or(&path)
2362                                        .to_string_lossy().into_owned()
2363                                } else {
2364                                    path.to_string_lossy().into_owned()
2365                                };
2366                                items.push(Value::String(display));
2367                            }
2368                            continue;
2369                        }
2370                    }
2371                    // Track whether this item came from $(cmd); that's the
2372                    // only position where multi-line stdout auto-splits per
2373                    // line. Arrays still spread element-by-element; bare
2374                    // $VAR is rejected upstream by validator E012. See
2375                    // docs/LANGUAGE.md.
2376                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2377                    let item = self.eval_expr_async(item_expr).await?;
2378                    match item {
2379                        // JSON arrays iterate over elements (preferred path
2380                        // when builtins emit .data — seq, jq, cut, find, …)
2381                        Value::Json(serde_json::Value::Array(arr)) => {
2382                            for elem in arr {
2383                                // Envelope-free: an element that happens to be
2384                                // envelope-shaped (e.g. from `fromjson`) is
2385                                // external data, not an internal bytes round-trip,
2386                                // so it must NOT be re-decoded to Value::Bytes.
2387                                items.push(json_to_value_no_envelope(elem));
2388                            }
2389                        }
2390                        // Strings from $(cmd): empty → 0 iterations,
2391                        // multi-line → split per line (trimming trailing
2392                        // newlines and per-line trailing \r), single-line
2393                        // → one iteration. Whitespace within a line is
2394                        // NOT split — the "$VAR with spaces just works"
2395                        // promise is preserved because this only fires
2396                        // in CommandSubst position.
2397                        Value::String(s) if from_command_subst => {
2398                            let trimmed = s.trim_end_matches(['\n', '\r']);
2399                            if trimmed.is_empty() {
2400                                continue;
2401                            }
2402                            if trimmed.contains('\n') {
2403                                for line in trimmed.split('\n') {
2404                                    let line = line.trim_end_matches('\r');
2405                                    items.push(Value::String(line.to_string()));
2406                                }
2407                            } else {
2408                                items.push(Value::String(trimmed.to_string()));
2409                            }
2410                        }
2411                        // Binary isn't iterable — fail loud rather than loop
2412                        // once over an opaque byte blob.
2413                        Value::Bytes(_) => {
2414                            anyhow::bail!(
2415                                "for: cannot iterate over binary data — decode it \
2416                                 (base64/xxd) first"
2417                            );
2418                        }
2419                        // Strings not from $(cmd) stay as one value.
2420                        other => items.push(other),
2421                    }
2422                }
2423
2424                let mut result = ExecResult::success("");
2425                {
2426                    let mut scope = self.scope.write().await;
2427                    scope.push_frame();
2428                }
2429
2430                'outer: for item in items {
2431                    // Cancellation checkpoint per iteration
2432                    if self.is_cancelled() {
2433                        let mut scope = self.scope.write().await;
2434                        scope.pop_frame();
2435                        result.code = 130;
2436                        return Ok(ControlFlow::ok(result));
2437                    }
2438                    {
2439                        let mut scope = self.scope.write().await;
2440                        scope.set(&for_loop.variable, item);
2441                    }
2442                    for stmt in &for_loop.body {
2443                        let mut flow = match self.execute_stmt_flow(stmt).await {
2444                            Ok(f) => f,
2445                            Err(e) => {
2446                                let mut scope = self.scope.write().await;
2447                                scope.pop_frame();
2448                                return Err(e);
2449                            }
2450                        };
2451                        self.drain_stderr_into(&mut result).await;
2452                        match &mut flow {
2453                            ControlFlow::Normal(r) => {
2454                                accumulate_result(&mut result, r);
2455                                if !r.ok() {
2456                                    let scope = self.scope.read().await;
2457                                    if scope.error_exit_enabled() {
2458                                        drop(scope);
2459                                        let mut scope = self.scope.write().await;
2460                                        scope.pop_frame();
2461                                        return Ok(ControlFlow::exit_code(r.code));
2462                                    }
2463                                }
2464                            }
2465                            ControlFlow::Break { .. } => {
2466                                if flow.decrement_level() {
2467                                    accumulate_flow_output(&mut result, &flow);
2468                                    break 'outer;
2469                                }
2470                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2471                                let mut scope = self.scope.write().await;
2472                                scope.pop_frame();
2473                                return Ok(flow);
2474                            }
2475                            ControlFlow::Continue { .. } => {
2476                                if flow.decrement_level() {
2477                                    accumulate_flow_output(&mut result, &flow);
2478                                    continue 'outer;
2479                                }
2480                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2481                                let mut scope = self.scope.write().await;
2482                                scope.pop_frame();
2483                                return Ok(flow);
2484                            }
2485                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2486                                let mut scope = self.scope.write().await;
2487                                scope.pop_frame();
2488                                return Ok(flow);
2489                            }
2490                        }
2491                    }
2492                }
2493
2494                {
2495                    let mut scope = self.scope.write().await;
2496                    scope.pop_frame();
2497                }
2498                Ok(ControlFlow::ok(result))
2499            }
2500            Stmt::While(while_loop) => {
2501                let mut result = ExecResult::success("");
2502
2503                'outer: loop {
2504                    // Evaluate condition - use async to support command substitution
2505                    // Cancellation checkpoint per iteration
2506                    if self.is_cancelled() {
2507                        result.code = 130;
2508                        return Ok(ControlFlow::ok(result));
2509                    }
2510
2511                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2512
2513                    if !is_truthy(&cond_value) {
2514                        break;
2515                    }
2516
2517                    // Execute body
2518                    for stmt in &while_loop.body {
2519                        let mut flow = self.execute_stmt_flow(stmt).await?;
2520                        self.drain_stderr_into(&mut result).await;
2521                        match &mut flow {
2522                            ControlFlow::Normal(r) => {
2523                                accumulate_result(&mut result, r);
2524                                if !r.ok() {
2525                                    let scope = self.scope.read().await;
2526                                    if scope.error_exit_enabled() {
2527                                        return Ok(ControlFlow::exit_code(r.code));
2528                                    }
2529                                }
2530                            }
2531                            ControlFlow::Break { .. } => {
2532                                if flow.decrement_level() {
2533                                    accumulate_flow_output(&mut result, &flow);
2534                                    break 'outer;
2535                                }
2536                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2537                                return Ok(flow);
2538                            }
2539                            ControlFlow::Continue { .. } => {
2540                                if flow.decrement_level() {
2541                                    accumulate_flow_output(&mut result, &flow);
2542                                    continue 'outer;
2543                                }
2544                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2545                                return Ok(flow);
2546                            }
2547                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2548                                return Ok(flow);
2549                            }
2550                        }
2551                    }
2552                }
2553
2554                Ok(ControlFlow::ok(result))
2555            }
2556            Stmt::Case(case_stmt) => {
2557                // Evaluate the expression to match against. Text sink: a
2558                // `case $bin in ...)` pattern match on binary goes loud
2559                // rather than glob-matching against the `[binary: N bytes]`
2560                // placeholder (Decision E — same class as `==`/`in`).
2561                let match_value = {
2562                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2563                    value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2564                };
2565
2566                // Try each branch until we find a match
2567                for branch in &case_stmt.branches {
2568                    let matched = branch.patterns.iter().any(|pattern| {
2569                        glob_match(pattern, &match_value)
2570                    });
2571
2572                    if matched {
2573                        // Execute the branch body
2574                        let mut result = ExecResult::success("");
2575                        for stmt in &branch.body {
2576                            let flow = self.execute_stmt_flow(stmt).await?;
2577                            match flow {
2578                                ControlFlow::Normal(r) => {
2579                                    accumulate_result(&mut result, &r);
2580                                    self.drain_stderr_into(&mut result).await;
2581                                }
2582                                other => {
2583                                    self.drain_stderr_into(&mut result).await;
2584                                    return Ok(other);
2585                                }
2586                            }
2587                        }
2588                        return Ok(ControlFlow::ok(result));
2589                    }
2590                }
2591
2592                // No match - return success with empty output (like sh)
2593                Ok(ControlFlow::ok(ExecResult::success("")))
2594            }
2595            Stmt::Break(levels) => {
2596                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2597            }
2598            Stmt::Continue(levels) => {
2599                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2600            }
2601            Stmt::Return(expr) => {
2602                // return [N] - N becomes the exit code, NOT stdout
2603                // Shell semantics: return sets exit code, doesn't produce output
2604                let result = if let Some(e) = expr {
2605                    let val = self.eval_expr_async(e).await?;
2606                    let code = crate::interpreter::value_to_exit_code(&val)
2607                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2608                    ExecResult::from_parts(code, String::new(), String::new(), None)
2609                } else {
2610                    ExecResult::success("")
2611                };
2612                Ok(ControlFlow::return_value(result))
2613            }
2614            Stmt::Exit(expr) => {
2615                let code = if let Some(e) = expr {
2616                    let val = self.eval_expr_async(e).await?;
2617                    crate::interpreter::value_to_exit_code(&val)
2618                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2619                } else {
2620                    0
2621                };
2622                Ok(ControlFlow::exit_code(code))
2623            }
2624            Stmt::ToolDef(tool_def) => {
2625                let mut user_tools = self.user_tools.write().await;
2626                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2627                Ok(ControlFlow::ok(ExecResult::success("")))
2628            }
2629            Stmt::AndChain { left, right } => {
2630                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2631                // Suppress errexit for the left side — && handles failure itself.
2632                {
2633                    let mut scope = self.scope.write().await;
2634                    scope.suppress_errexit();
2635                }
2636                let left_flow = match self.execute_stmt_flow(left).await {
2637                    Ok(f) => f,
2638                    Err(e) => {
2639                        let mut scope = self.scope.write().await;
2640                        scope.unsuppress_errexit();
2641                        return Err(e);
2642                    }
2643                };
2644                {
2645                    let mut scope = self.scope.write().await;
2646                    scope.unsuppress_errexit();
2647                }
2648                match left_flow {
2649                    ControlFlow::Normal(mut left_result) => {
2650                        self.drain_stderr_into(&mut left_result).await;
2651                        self.update_last_result(&left_result).await;
2652                        if left_result.ok() {
2653                            let right_flow = self.execute_stmt_flow(right).await?;
2654                            match right_flow {
2655                                ControlFlow::Normal(mut right_result) => {
2656                                    self.drain_stderr_into(&mut right_result).await;
2657                                    self.update_last_result(&right_result).await;
2658                                    let mut combined = left_result;
2659                                    accumulate_result(&mut combined, &right_result);
2660                                    Ok(ControlFlow::ok(combined))
2661                                }
2662                                other => Ok(other),
2663                            }
2664                        } else {
2665                            Ok(ControlFlow::ok(left_result))
2666                        }
2667                    }
2668                    _ => Ok(left_flow),
2669                }
2670            }
2671            Stmt::OrChain { left, right } => {
2672                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2673                // Suppress errexit for the left side — || handles failure itself.
2674                {
2675                    let mut scope = self.scope.write().await;
2676                    scope.suppress_errexit();
2677                }
2678                let left_flow = match self.execute_stmt_flow(left).await {
2679                    Ok(f) => f,
2680                    Err(e) => {
2681                        let mut scope = self.scope.write().await;
2682                        scope.unsuppress_errexit();
2683                        return Err(e);
2684                    }
2685                };
2686                {
2687                    let mut scope = self.scope.write().await;
2688                    scope.unsuppress_errexit();
2689                }
2690                match left_flow {
2691                    ControlFlow::Normal(mut left_result) => {
2692                        self.drain_stderr_into(&mut left_result).await;
2693                        self.update_last_result(&left_result).await;
2694                        if !left_result.ok() {
2695                            let right_flow = self.execute_stmt_flow(right).await?;
2696                            match right_flow {
2697                                ControlFlow::Normal(mut right_result) => {
2698                                    self.drain_stderr_into(&mut right_result).await;
2699                                    self.update_last_result(&right_result).await;
2700                                    let mut combined = left_result;
2701                                    accumulate_result(&mut combined, &right_result);
2702                                    Ok(ControlFlow::ok(combined))
2703                                }
2704                                other => Ok(other),
2705                            }
2706                        } else {
2707                            Ok(ControlFlow::ok(left_result))
2708                        }
2709                    }
2710                    _ => Ok(left_flow), // Propagate non-normal flow
2711                }
2712            }
2713            Stmt::Test(test_expr) => {
2714                let is_true = self.eval_test_async(test_expr).await?;
2715                let result = if is_true {
2716                    ExecResult::success("")
2717                } else {
2718                    ExecResult::failure(1, "")
2719                };
2720                // A bare test writes `$?` and honors `set -e` like any command
2721                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2722                // safe: the chain arms suppress errexit around their left side,
2723                // and `if`/`while` conditions evaluate as expressions, never
2724                // through this statement arm.
2725                self.update_last_result(&result).await;
2726                if !result.ok() {
2727                    let scope = self.scope.read().await;
2728                    if scope.error_exit_enabled() {
2729                        return Ok(ControlFlow::exit_code(result.code));
2730                    }
2731                }
2732                Ok(ControlFlow::ok(result))
2733            }
2734            Stmt::EnvScoped { assignments, body } => {
2735                // Inline env prefix (`NAME=value ... command`): apply the
2736                // assignments as EXPORTED vars in a fresh frame so the command
2737                // — and its subprocess environment — sees them, then unwind so
2738                // they do NOT persist (bash-style command-scoped env). Values
2739                // evaluate left-to-right with earlier ones already in scope, so
2740                // `A=1 B=$A cmd` works.
2741                {
2742                    let mut scope = self.scope.write().await;
2743                    scope.push_frame();
2744                }
2745                let mut prior_export: Vec<(String, bool)> =
2746                    Vec::with_capacity(assignments.len());
2747                let mut setup_err: Option<anyhow::Error> = None;
2748                for assign in assignments {
2749                    match self.eval_expr_async(&assign.value).await {
2750                        Ok(value) => {
2751                            let mut scope = self.scope.write().await;
2752                            prior_export
2753                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
2754                            scope.set_exported(assign.name(), value);
2755                        }
2756                        Err(e) => {
2757                            setup_err = Some(e);
2758                            break;
2759                        }
2760                    }
2761                }
2762
2763                let flow = if setup_err.is_none() {
2764                    self.execute_stmt_flow(body).await
2765                } else {
2766                    Ok(ControlFlow::ok(ExecResult::success("")))
2767                };
2768
2769                // Unwind the env frame and restore export marks unconditionally
2770                // (names that were not exported before must not stay exported).
2771                {
2772                    let mut scope = self.scope.write().await;
2773                    scope.pop_frame();
2774                    for (name, was_exported) in &prior_export {
2775                        if !*was_exported {
2776                            scope.unexport(name);
2777                        }
2778                    }
2779                }
2780
2781                match setup_err {
2782                    Some(e) => Err(e),
2783                    None => flow,
2784                }
2785            }
2786            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2787        }
2788        })
2789    }
2790
2791    /// Build a boxed per-command `ExecContext` snapshot from the persistent
2792    /// kernel state (`ec`/`scope`, both already locked by the caller).
2793    ///
2794    /// Sync on purpose: the ~30 field clones live in this transient frame rather
2795    /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2796    /// — not the 960-byte struct — rides the dispatch await at every recursion
2797    /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2798    /// per-site differences (the pipeline runner uses the kernel's own cancel
2799    /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2800    /// they're parameters; every other field is snapshotted identically.
2801    fn snapshot_exec_ctx(
2802        &self,
2803        ec: &ExecContext,
2804        scope: &Scope,
2805        pipeline_position: PipelinePosition,
2806        cancel: tokio_util::sync::CancellationToken,
2807    ) -> Box<ExecContext> {
2808        Box::new(ExecContext {
2809            backend: ec.backend.clone(),
2810            scope: scope.clone(),
2811            cwd: ec.cwd.clone(),
2812            prev_cwd: ec.prev_cwd.clone(),
2813            stdin: ec.stdin.clone(),
2814            stdin_data: ec.stdin_data.clone(),
2815            stdin_data_rx: None,
2816            pipe_stdin: None,
2817            pipe_stdout: None,
2818            stderr: ec.stderr.clone(),
2819            tool_schemas: ec.tool_schemas.clone(),
2820            tools: ec.tools.clone(),
2821            job_manager: ec.job_manager.clone(),
2822            pipeline_position,
2823            interactive: self.interactive,
2824            aliases: ec.aliases.clone(),
2825            ignore_config: ec.ignore_config.clone(),
2826            output_limit: ec.output_limit.clone(),
2827            allow_external_commands: self.allow_external_commands,
2828            nonce_store: ec.nonce_store.clone(),
2829            trash_backend: ec.trash_backend.clone(),
2830            #[cfg(all(unix, feature = "subprocess"))]
2831            terminal_state: ec.terminal_state.clone(),
2832            dispatcher: self.dispatcher(),
2833            cancel,
2834            output_format: None,
2835            current_invocation: None,
2836            vfs_budget: self.vfs_budget.clone(),
2837            watchdog: ec.watchdog.clone(),
2838            #[cfg(all(feature = "localfs", feature = "overlay"))]
2839            overlay_handle: self.overlay_handle.clone(),
2840        })
2841    }
2842
2843    /// Execute a pipeline.
2844    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2845        if pipeline.commands.is_empty() {
2846            return Ok(ExecResult::success(""));
2847        }
2848
2849        // Handle background execution (`&` operator)
2850        if pipeline.background {
2851            return self.execute_background(pipeline).await;
2852        }
2853
2854        // All commands go through the runner with the Kernel as dispatcher.
2855        // This is the single execution path — no fast path for single commands.
2856        //
2857        // IMPORTANT: We snapshot exec_ctx into a local context and release the
2858        // lock before running. This prevents deadlocks when dispatch_command
2859        // is called from within the pipeline and recursively triggers another
2860        // pipeline (e.g., via user-defined tools).
2861        let (mut ctx, has_pipe_stdin) = {
2862            let ec = self.exec_ctx.read().await;
2863            let scope = self.scope.read().await;
2864            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2865            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2866            // the consume-once block below, so note its presence here.
2867            let has_pipe_stdin = ec.pipe_stdin.is_some();
2868            // The pipeline runner drives stage 0 with the first stage's stdin
2869            // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2870            // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2871            // and uses the kernel's own cancel token so a `cancel()` reaches the
2872            // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2873            let cancel = {
2874                #[allow(clippy::expect_used)]
2875                let token = self.cancel_token.lock().expect("cancel_token poisoned");
2876                token.clone()
2877            };
2878            (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2879        }; // locks released
2880
2881        // Consume-once: move/clear the seeded stdin sources from the persistent
2882        // exec_ctx now that this pipeline's ctx owns them, so a later statement
2883        // in the same call (`cat ; cat`) does not re-receive them — matching
2884        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2885        // (the ctx above was built with `pipe_stdin: None`).
2886        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2887            let mut ec = self.exec_ctx.write().await;
2888            ctx.pipe_stdin = ec.pipe_stdin.take();
2889            ec.stdin = None;
2890            ec.stdin_data = None;
2891        }
2892
2893        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2894
2895        // Post-hoc spill check (catches builtins and fast external commands)
2896        if ctx.output_limit.is_enabled() {
2897            let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2898        }
2899
2900        // Signal spill with exit 3; agent reads the spill file directly
2901        // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2902        if result.did_spill {
2903            result.original_code = Some(result.code);
2904            result.code = 3;
2905        }
2906
2907        // Sync changes back from context
2908        {
2909            let mut ec = self.exec_ctx.write().await;
2910            ec.cwd = ctx.cwd.clone();
2911            ec.prev_cwd = ctx.prev_cwd.clone();
2912            ec.aliases = ctx.aliases.clone();
2913            ec.ignore_config = ctx.ignore_config.clone();
2914            ec.output_limit = ctx.output_limit.clone();
2915        }
2916        {
2917            let mut scope = self.scope.write().await;
2918            *scope = ctx.scope.clone();
2919        }
2920
2921        Ok(result)
2922    }
2923
2924    /// Execute a pipeline in the background.
2925    ///
2926    /// The command is spawned as a tokio task, registered with the JobManager,
2927    /// and its output is captured via BoundedStreams. The job is observable via
2928    /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2929    ///
2930    /// Returns immediately with a job ID like "[1]".
2931    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2932    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2933        use tokio::sync::oneshot;
2934
2935        // Format the command for display in /v/jobs/{id}/command
2936        let command_str = self.format_pipeline(pipeline);
2937
2938        // Create bounded streams for output capture
2939        let stdout = Arc::new(BoundedStream::default_size());
2940        let stderr = Arc::new(BoundedStream::default_size());
2941
2942        // Create channel for result notification
2943        let (tx, rx) = oneshot::channel();
2944
2945        // Register with JobManager to get job ID and create VFS entries
2946        let job_id = self.jobs.register_with_streams(
2947            command_str.clone(),
2948            rx,
2949            stdout.clone(),
2950            stderr.clone(),
2951        ).await;
2952
2953        // Fork the kernel for this background job. The fork snapshots the
2954        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2955        // while sharing the job manager, VFS, and tool registry. The fork's
2956        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2957        // is available here — something BackendDispatcher couldn't provide.
2958        //
2959        // The fork gets its own cancellation token (recorded on the job so
2960        // `kill %N` can stop the job — including a pure-builtin job with no OS
2961        // process group) and is stamped with the job id so any external
2962        // command it spawns records its process group for `kill -<sig> %N`.
2963        let cancel = tokio_util::sync::CancellationToken::new();
2964        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2965        let fork = self.fork_for_background(cancel, job_id).await;
2966        let runner = self.runner.clone();
2967        let commands = pipeline.commands.clone();
2968
2969        // Snapshot the fork's exec_ctx for the spawned task. We have to do
2970        // this before tokio::spawn because the fork's exec_ctx is behind a
2971        // tokio RwLock and we want the spawned task to own its ctx.
2972        let mut bg_ctx = {
2973            let ec = fork.exec_ctx.read().await;
2974            ec.child_for_pipeline()
2975        };
2976        bg_ctx.scope = fork.scope.read().await.clone();
2977        // The fork's dispatcher points at the fork itself; set it here so
2978        // builtins inside the background task (e.g. timeout) re-dispatch
2979        // through the fork, not the parent.
2980        bg_ctx.dispatcher = fork.dispatcher();
2981
2982        // Spawn the background task. Propagate the embedder's trace context
2983        // across the spawn boundary so the job's spans stay in the same trace.
2984        tokio::spawn(crate::telemetry::bind_current_context(async move {
2985            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2986            // gives us that (Kernel implements CommandDispatcher).
2987            let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2988
2989            // Write output to streams
2990            let text = result.text_out();
2991            if !text.is_empty() {
2992                stdout.write(text.as_bytes()).await;
2993            }
2994            if !result.err.is_empty() {
2995                stderr.write(result.err.as_bytes()).await;
2996            }
2997
2998            // Close streams
2999            stdout.close().await;
3000            stderr.close().await;
3001
3002            // Send result to JobManager (ignore error if receiver dropped)
3003            let _ = tx.send(result);
3004        }));
3005
3006        Ok(ExecResult::success(format!("[{}]", job_id)))
3007    }
3008
3009    /// Format a pipeline as a command string for display.
3010    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3011        pipeline.commands
3012            .iter()
3013            .map(|cmd| {
3014                let mut parts = vec![cmd.name.clone()];
3015                for arg in &cmd.args {
3016                    match arg {
3017                        Arg::Positional(expr) => {
3018                            parts.push(self.format_expr(expr));
3019                        }
3020                        Arg::Named { key, value } => {
3021                            parts.push(format!("--{}={}", key, self.format_expr(value)));
3022                        }
3023                        Arg::WordAssign { key, value } => {
3024                            parts.push(format!("{}={}", key, self.format_expr(value)));
3025                        }
3026                        Arg::ShortFlag(name) => {
3027                            parts.push(format!("-{}", name));
3028                        }
3029                        Arg::LongFlag(name) => {
3030                            parts.push(format!("--{}", name));
3031                        }
3032                        Arg::DoubleDash => {
3033                            parts.push("--".to_string());
3034                        }
3035                    }
3036                }
3037                parts.join(" ")
3038            })
3039            .collect::<Vec<_>>()
3040            .join(" | ")
3041    }
3042
3043    /// Format an expression as a string for display.
3044    fn format_expr(&self, expr: &Expr) -> String {
3045        match expr {
3046            Expr::Literal(Value::String(s)) => {
3047                if s.contains(' ') || s.contains('"') {
3048                    format!("'{}'", s.replace('\'', "\\'"))
3049                } else {
3050                    s.clone()
3051                }
3052            }
3053            Expr::Literal(Value::Int(i)) => i.to_string(),
3054            Expr::Literal(Value::Float(f)) => f.to_string(),
3055            Expr::Literal(Value::Bool(b)) => b.to_string(),
3056            Expr::Literal(Value::Null) => "null".to_string(),
3057            Expr::VarRef(path) => {
3058                let mut name = String::new();
3059                for (i, seg) in path.segments.iter().enumerate() {
3060                    match seg {
3061                        crate::ast::VarSegment::Field(f) => {
3062                            if i > 0 {
3063                                name.push('.');
3064                            }
3065                            name.push_str(f);
3066                        }
3067                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3068                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3069                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3070                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3071                            "[{}:{}]",
3072                            a.map(|n| n.to_string()).unwrap_or_default(),
3073                            b.map(|n| n.to_string()).unwrap_or_default()
3074                        )),
3075                    }
3076                }
3077                format!("${{{}}}", name)
3078            }
3079            Expr::Interpolated(_) => "\"...\"".to_string(),
3080            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3081            _ => "...".to_string(),
3082        }
3083    }
3084
3085    /// Execute a single command.
3086    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3087        self.execute_command_depth(name, args, 0).await
3088    }
3089
3090    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3091        // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3092        // most-recursed function on the ring, so wrapping its future in
3093        // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3094        // every level (GH #48, item 3). A `trace!` event records the command name
3095        // without living in the future.
3096        tracing::trace!(command = %name, alias_depth, "dispatch");
3097        // Special built-ins. `SpecialForm::from_name` is the single source of
3098        // truth (shared with `classify_command` via `is_runtime_special_form`),
3099        // and this match on the enum is *exhaustive* — adding a special-form is a
3100        // compile error until both the name mapping and the behavior here are
3101        // updated. A name that is not a special-form falls through to alias /
3102        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3103        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3104            return match form {
3105                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3106                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3107                crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3108            };
3109        }
3110
3111        // Alias expansion (with recursion limit)
3112        if alias_depth < 10 {
3113            let alias_value = {
3114                let ctx = self.exec_ctx.read().await;
3115                ctx.aliases.get(name).cloned()
3116            };
3117            if let Some(alias_val) = alias_value {
3118                // Split alias value into command + args
3119                let parts: Vec<&str> = alias_val.split_whitespace().collect();
3120                if let Some((alias_cmd, alias_args)) = parts.split_first() {
3121                    let mut new_args: Vec<Arg> = alias_args
3122                        .iter()
3123                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3124                        .collect();
3125                    new_args.extend_from_slice(args);
3126                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3127                }
3128            }
3129        }
3130
3131        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3132        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3133            return match self.tools.get(builtin_name) {
3134                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3135                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3136            };
3137        }
3138
3139        // Check user-defined tools first
3140        {
3141            let user_tools = self.user_tools.read().await;
3142            if let Some(tool_def) = user_tools.get(name) {
3143                let tool_def = tool_def.clone();
3144                drop(user_tools);
3145                return Box::pin(self.execute_user_tool(tool_def, args)).await;
3146            }
3147        }
3148
3149        // Look up builtin tool
3150        let tool = match self.tools.get(name) {
3151            Some(t) => t,
3152            None => {
3153                // Try executing as .kai script from PATH
3154                if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3155                    return Ok(result);
3156                }
3157                // Try executing as external command from PATH — boxed because its
3158                // future is the heaviest branch here (holds a `tokio::process::Command`,
3159                // argv, the child's stdio streams, and kill/reap drop guards); leaving
3160                // it inline fattens every `execute_command_depth` frame on the recursion
3161                // ring even when the command is a builtin.
3162                if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3163                    return Ok(result);
3164                }
3165
3166                // Try backend-registered tools (embedder engines, etc.)
3167                // Look up tool schema for positional→named mapping.
3168                // Clone backend and drop read lock before awaiting (may involve network I/O).
3169                // Backend tools expect named JSON params, so enable positional mapping.
3170                let backend = self.exec_ctx.read().await.backend.clone();
3171                let tool_schema = backend
3172                    .get_tool(name)
3173                    .await
3174                    .unwrap_or_else(|e| {
3175                        // Schema lookup failing just means positionals won't
3176                        // get name-mapped below — `call_tool` is still
3177                        // attempted. Trace it so the degradation is visible
3178                        // rather than silently swallowed.
3179                        tracing::debug!("backend get_tool error for {name}: {e}");
3180                        None
3181                    })
3182                    .map(|t| {
3183                    let mut s = t.schema;
3184                    // Flat backend/MCP tools expect named JSON params, so map
3185                    // bare positionals onto named params. Subcommand-aware tools
3186                    // route positionals through the subcommand path and declare
3187                    // map_positionals per leaf (kj keeps it false so it re-parses
3188                    // the argv with its own clap) — don't blanket-override them.
3189                    if s.subcommands.is_empty() {
3190                        s.map_positionals = true;
3191                    }
3192                    s
3193                });
3194                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3195                let mut ctx = self.exec_ctx.write().await;
3196                {
3197                    let scope = self.scope.read().await;
3198                    ctx.scope = scope.clone();
3199                }
3200                let backend = ctx.backend.clone();
3201                match backend.call_tool(name, tool_args, &mut *ctx).await {
3202                    Ok(tool_result) => {
3203                        let mut scope = self.scope.write().await;
3204                        *scope = ctx.scope.clone();
3205                        // Preserve every field (data/content_type/baggage/latch,
3206                        // not just stdout text) — this is the embedder seam:
3207                        // `x=$(embedder_tool)` and structured iteration over
3208                        // its result depend on `.data` surviving the crossing
3209                        // back into the kernel.
3210                        return Ok(ExecResult::from(tool_result));
3211                    }
3212                    Err(BackendError::ToolNotFound(_)) => {
3213                        // The backend confirms no such tool exists — fall
3214                        // through to "command not found" below.
3215                    }
3216                    Err(e) => {
3217                        // The tool was found (dispatch reached real
3218                        // execution) but running it failed — a genuine
3219                        // execution error, not "command not found". Surface
3220                        // it loudly instead of masking it as exit-127.
3221                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3222                    }
3223                }
3224
3225                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3226            }
3227        };
3228
3229        // Build arguments (async to support command substitution, schema-aware for flag values)
3230        let schema = tool.schema();
3231        let tool_args = self.build_args_async(args, Some(&schema)).await?;
3232
3233        // --help / -h: show the generic whole-tool help, unless either the tool's
3234        // root schema claims that flag OR the tool owns its output. Owned-output
3235        // tools re-parse their own argv and route their own `--help` — including
3236        // leaf/subcommand help — through their internal (clap) parser, so the root
3237        // schema can't express "this leaf claims help" and intercepting here would
3238        // render top-level help and return before `execute()` ever sees the
3239        // request (#51). Pass it through and let the tool render its own help.
3240        let schema_claims = |flag: &str| -> bool {
3241            let bare = flag.trim_start_matches('-');
3242            schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3243        };
3244        let wants_help = !schema.owns_output
3245            && ((tool_args.flags.contains("help") && !schema_claims("help"))
3246                || (tool_args.flags.contains("h") && !schema_claims("-h")));
3247        if wants_help {
3248            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3249            let ctx = self.exec_ctx.read().await;
3250            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3251            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3252        }
3253
3254        // `owns_output` is the only thing read from `schema` after the recursive
3255        // `tool.execute` await below; capture the bool and drop the (heap-backed)
3256        // `ToolSchema` now so it doesn't ride that await in every command's frame
3257        // (GH #48, item 7).
3258        let owns_output = schema.owns_output;
3259        drop(schema);
3260
3261        // Snapshot exec_ctx into a local context and release the write lock
3262        // before calling tool.execute. Holding the write across tool execution
3263        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3264        // (timeout, scatter) — the inner dispatch_command needs its own
3265        // exec_ctx.write() and would block forever.
3266        let mut ctx = {
3267            let ec = self.exec_ctx.write().await;
3268            let scope = self.scope.read().await;
3269            // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3270            // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3271            // child token — e.g. timeout's — reaches the spawned external via
3272            // wait_or_kill; it falls back to the kernel's own token on a
3273            // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3274            self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3275        }; // both locks released — tool.execute can re-dispatch safely
3276
3277        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3278        // semantics): take() so a later dispatch doesn't see stale stdin.
3279        // Done after the snapshot above so we hold the write briefly.
3280        {
3281            let mut ec = self.exec_ctx.write().await;
3282            ctx.stdin = ec.stdin.take();
3283            ctx.stdin_data = ec.stdin_data.take();
3284            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3285            ctx.pipe_stdin = ec.pipe_stdin.take();
3286            ctx.pipe_stdout = ec.pipe_stdout.take();
3287        }
3288
3289        // Honor --json before the builtin runs so its setting survives a clap
3290        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3291        // --json on the floor when `try_parse_from` returns Err early).
3292        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3293        GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3294
3295        // Capture the exact invocation at the dispatch seam so a latch producer
3296        // (`latch_result`/`gate_overwrites`) can stamp it into the LatchRequest
3297        // for a precise `Kernel::confirm` replay — no re-parsing of the human
3298        // `hint`. `to_argv()` is computed before `tool_args` moves into execute.
3299        //
3300        // Captured unconditionally, NOT gated on `latch_enabled`: `kaish-trash
3301        // empty` gates every time (it's inherently destructive, independent of
3302        // `set -o latch`), so a `latch_enabled`-only gate would leave its
3303        // `tool`/`argv` empty and break `confirm`. The cost is a small argv
3304        // clone per command — marginal beside the per-command ExecContext
3305        // snapshot above — and it does NOT reintroduce the deep-`$()` stack
3306        // overflow (that was the inline `LatchRequest` in `ExecResult`, now
3307        // boxed; the capture's temporaries don't survive into the recursive
3308        // `tool.execute` below).
3309        //
3310        // `to_argv()` can now fail loud on a `Value::Bytes` named argument (GH
3311        // #164). This capture is best-effort bookkeeping only, so a failure
3312        // here must NOT gate whether the tool runs: not every builtin routes
3313        // its own named args through `to_argv()` — `export`'s `NAME=VALUE`
3314        // pairs are arbitrary user variable names, not schema flags, so
3315        // `export` reads `args.named` directly and a Bytes value there is
3316        // completely legitimate (see `export.rs`). A builtin that DOES call
3317        // `to_argv()` internally (nearly all of them) will raise the
3318        // identical loud, tool-prefixed error a few lines below inside
3319        // `tool.execute()`; dropping the error here only empties this
3320        // side-channel capture, never the command's real result.
3321        let argv = tool_args.to_argv().unwrap_or_default();
3322        ctx.current_invocation = Some(Box::new((name.to_string(), argv)));
3323
3324        let result = tool.execute(tool_args, &mut *ctx).await;
3325
3326        // Sync mutations back. Tools may have changed scope (set/cd),
3327        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3328        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3329        // hands them back to the pipeline runner — the runner uses
3330        // stage_ctx.pipe_stdout to write the result to the next stage when
3331        // the tool itself didn't take and write to it.
3332        {
3333            let mut scope = self.scope.write().await;
3334            *scope = ctx.scope.clone();
3335        }
3336        {
3337            let mut ec = self.exec_ctx.write().await;
3338            ec.cwd = ctx.cwd;
3339            ec.prev_cwd = ctx.prev_cwd;
3340            ec.aliases = ctx.aliases;
3341            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3342            // mutate the runtime output limit; without this sync the change is
3343            // dropped here and never reaches dispatch_command's read-back, so
3344            // it would not survive past the current statement.
3345            ec.output_limit = ctx.output_limit.clone();
3346            // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3347            // was missing from this sync, so every runtime ignore mutation
3348            // silently died at the end of its own statement — including the
3349            // documented `kaish-ignore add .gitignore` rc-file recipe.
3350            ec.ignore_config = ctx.ignore_config.clone();
3351            ec.pipe_stdin = ctx.pipe_stdin.take();
3352            ec.pipe_stdout = ctx.pipe_stdout.take();
3353        }
3354
3355        // Builtins parse --json via the GlobalFlags flatten in their clap
3356        // struct and write ctx.output_format. The kernel applies it — unless the
3357        // tool owns its own output (renders --json itself), in which case we
3358        // leave its bytes untouched.
3359        let result = finalize_output(result, ctx.output_format, owns_output);
3360
3361        Ok(result)
3362    }
3363
3364    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3365    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3366    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3367    /// unexpanded rather than leaking the host home directory.
3368    async fn scope_home(&self) -> Option<String> {
3369        match self.scope.read().await.get("HOME") {
3370            Some(Value::String(s)) => Some(s.clone()),
3371            _ => None,
3372        }
3373    }
3374
3375    /// Build tool arguments from AST args.
3376    ///
3377    /// Uses async evaluation to support command substitution in arguments.
3378    /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3379    /// now only supplies the evaluator — `self` implements `ArgValueSource`
3380    /// against the kernel's own session state (full recursion through the
3381    /// async pipeline, real glob expansion, tilde expansion). Before this,
3382    /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3383    /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3384    /// scatter/gather's own option parsing and the `#[cfg(test)]`
3385    /// `BackendDispatcher`) that could — and did — drift from this method,
3386    /// the same drift-class GH #133 fixed for the external-command spawn
3387    /// sites. Now both paths call the one `bind_tool_args` core, differing
3388    /// only in which `ArgValueSource` they hand it.
3389    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3390        bind_tool_args(args, schema, self).await
3391    }
3392
3393    /// Build arguments as flat string list for external commands.
3394    ///
3395    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3396    /// this preserves the original flag format as strings for external commands:
3397    /// - `-l` stays as `-l`
3398    /// - `--verbose` stays as `--verbose`
3399    /// - `key=value` stays as `key=value`
3400    ///
3401    /// This is what external commands expect in their argv.
3402    #[cfg(feature = "subprocess")]
3403    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3404        let mut argv = Vec::new();
3405        let home = self.scope_home().await;
3406        for arg in args {
3407            match arg {
3408                Arg::Positional(expr) => {
3409                    // Glob expansion for external commands
3410                    if let Expr::GlobPattern(pattern) = expr {
3411                        let glob_enabled = {
3412                            let scope = self.scope.read().await;
3413                            scope.glob_enabled()
3414                        };
3415                        if glob_enabled {
3416                            let (paths, cwd) = {
3417                                let ctx = self.exec_ctx.read().await;
3418                                let paths = ctx.expand_glob(pattern).await
3419                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3420                                let cwd = ctx.resolve_path(".");
3421                                (paths, cwd)
3422                            };
3423                            if paths.is_empty() {
3424                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3425                            }
3426                            for path in paths {
3427                                let display = if !pattern.starts_with('/') {
3428                                    path.strip_prefix(&cwd)
3429                                        .unwrap_or(&path)
3430                                        .to_string_lossy().into_owned()
3431                                } else {
3432                                    path.to_string_lossy().into_owned()
3433                                };
3434                                argv.push(display);
3435                            }
3436                            continue;
3437                        }
3438                    }
3439                    let value = self.eval_expr_async(expr).await?;
3440                    // Decision D: a bare collection can't cross the external
3441                    // process boundary as an argv element — refuse rather than
3442                    // silently JSON-serializing it. A quoted `"$x"` already
3443                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3444                    // so only a live, un-interpolated `$x` trips this.
3445                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3446                        return Err(anyhow::anyhow!(msg));
3447                    }
3448                    let value = apply_tilde_expansion(value, home.as_deref());
3449                    // External-command argv is a text sink: a bare `$BIN` binary
3450                    // word goes loud, never the `[binary: N bytes]` placeholder.
3451                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3452                }
3453                Arg::Named { key, value } => {
3454                    let val = self.eval_expr_async(value).await?;
3455                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3456                        return Err(anyhow::anyhow!(msg));
3457                    }
3458                    let val = apply_tilde_expansion(val, home.as_deref());
3459                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3460                    argv.push(format!("--{key}={val_str}"));
3461                }
3462                Arg::WordAssign { key, value } => {
3463                    let val = self.eval_expr_async(value).await?;
3464                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3465                        return Err(anyhow::anyhow!(msg));
3466                    }
3467                    let val = apply_tilde_expansion(val, home.as_deref());
3468                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3469                    argv.push(format!("{key}={val_str}"));
3470                }
3471                Arg::ShortFlag(name) => {
3472                    // Preserve original format: -l, -la (combined flags)
3473                    argv.push(format!("-{}", name));
3474                }
3475                Arg::LongFlag(name) => {
3476                    // Preserve original format: --verbose
3477                    argv.push(format!("--{}", name));
3478                }
3479                Arg::DoubleDash => {
3480                    // Preserve the -- marker
3481                    argv.push("--".to_string());
3482                }
3483            }
3484        }
3485        Ok(argv)
3486    }
3487
3488    /// Async expression evaluator that supports command substitution.
3489    ///
3490    /// This is used for contexts where expressions may contain `$(...)` command
3491    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3492    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3493        Box::pin(async move {
3494        match expr {
3495            Expr::Literal(value) => Ok(value.clone()),
3496            Expr::VarRef(path) => {
3497                let scope = self.scope.read().await;
3498                match scope.resolve_path(path) {
3499                    Ok(v) => Ok(v),
3500                    Err(PathError::UndefinedRoot(_)) => {
3501                        Err(anyhow::anyhow!("undefined variable"))
3502                    }
3503                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3504                        Err(anyhow::anyhow!(msg))
3505                    }
3506                }
3507            }
3508            Expr::Interpolated(parts) => {
3509                let mut result = String::new();
3510                for part in parts {
3511                    result.push_str(&self.eval_string_part_async(part).await?);
3512                }
3513                Ok(Value::String(result))
3514            }
3515            Expr::HereDocBody { parts, strip_tabs } => {
3516                // Assemble part-by-part so `<<-` tab stripping applies to the
3517                // literal source, not to tabs from a `$var` value (bash strips
3518                // source-line tabs before parameter expansion).
3519                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3520                for sp in parts {
3521                    match &sp.part {
3522                        StringPart::Literal(s) => asm.push_literal(s),
3523                        other => {
3524                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
3525                        }
3526                    }
3527                }
3528                Ok(Value::String(asm.into_string()))
3529            }
3530            Expr::BinaryOp { left, op, right } => match op {
3531                BinaryOp::And => {
3532                    let left_val = self.eval_expr_async(left).await?;
3533                    if !is_truthy(&left_val) {
3534                        return Ok(left_val);
3535                    }
3536                    self.eval_expr_async(right).await
3537                }
3538                BinaryOp::Or => {
3539                    let left_val = self.eval_expr_async(left).await?;
3540                    if is_truthy(&left_val) {
3541                        return Ok(left_val);
3542                    }
3543                    self.eval_expr_async(right).await
3544                }
3545            },
3546            Expr::CommandSubst(stmts) => {
3547                // Snapshot scope, cwd, and session config before running —
3548                // only output escapes, not side effects like `cd`, variable
3549                // assignments, or config mutations (`kaish-ignore`,
3550                // `kaish-output-limit`, `alias`/`unalias`) — matching how
3551                // every other execution context (background forks, scatter
3552                // workers) already isolates mutations (GH #139).
3553                // Boxed: this ~470 B scope snapshot is held across the nested
3554                // `$(…)` recursion await below, so inlining it grows every
3555                // command-substitution level's future (GH #48, item 4).
3556                let saved_scope = Box::new(self.scope.read().await.clone());
3557                let saved_ec = {
3558                    let ec = self.exec_ctx.read().await;
3559                    (
3560                        ec.cwd.clone(),
3561                        ec.prev_cwd.clone(),
3562                        ec.aliases.clone(),
3563                        ec.ignore_config.clone(),
3564                        ec.output_limit.clone(),
3565                    )
3566                };
3567
3568                // Capture result without `?` — restore state unconditionally
3569                let run_result = self.execute_block_capturing(stmts).await;
3570
3571                // Restore scope and cwd regardless of success/failure
3572                {
3573                    let mut scope = self.scope.write().await;
3574                    *scope = *saved_scope;
3575                    if let Ok(ref r) = run_result {
3576                        scope.set_last_result(r.clone());
3577                    }
3578                }
3579                {
3580                    let mut ec = self.exec_ctx.write().await;
3581                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3582                    ec.cwd = cwd;
3583                    ec.prev_cwd = prev_cwd;
3584                    ec.aliases = aliases;
3585                    ec.ignore_config = ignore_config;
3586                    ec.output_limit = output_limit;
3587                }
3588
3589                // Now propagate the error
3590                let result = run_result?;
3591
3592                // A binary result is preserved as bytes — never lossy-decoded to
3593                // a string. No trailing-newline trim (every byte is significant).
3594                if let Some(bytes) = result.out_bytes() {
3595                    Ok(Value::Bytes(bytes.to_vec()))
3596                // Prefer structured data (enables `for i in $(cmd)` iteration)
3597                } else if let Some(data) = &result.data {
3598                    Ok(data.clone())
3599                } else if let Some(output) = result.output() {
3600                    // Flat non-text node lists (glob, ls, tree) → iterable array
3601                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3602                        let items: Vec<serde_json::Value> = output.root.iter()
3603                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
3604                            .collect();
3605                        Ok(Value::Json(serde_json::Value::Array(items)))
3606                    } else {
3607                        // Strip trailing newlines only (POSIX command-subst),
3608                        // not all trailing whitespace — spaces/tabs are
3609                        // significant. Use the exact same trim as the quoted
3610                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3611                        // `trim_end_matches('\n')`) so bare and quoted command
3612                        // substitution agree.
3613                        Ok(Value::String(
3614                            result.text_out().trim_end_matches('\n').to_string(),
3615                        ))
3616                    }
3617                } else {
3618                    // Otherwise return stdout as single string (NO implicit splitting)
3619                    Ok(Value::String(
3620                        result.text_out().trim_end_matches('\n').to_string(),
3621                    ))
3622                }
3623            }
3624            Expr::Test(test_expr) => {
3625                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3626            }
3627            Expr::Positional(n) => {
3628                let scope = self.scope.read().await;
3629                match scope.get_positional(*n) {
3630                    Some(s) => Ok(Value::String(s.to_string())),
3631                    None => Ok(Value::String(String::new())),
3632                }
3633            }
3634            Expr::AllArgs => {
3635                let scope = self.scope.read().await;
3636                Ok(Value::String(scope.all_args().join(" ")))
3637            }
3638            Expr::ArgCount => {
3639                let scope = self.scope.read().await;
3640                Ok(Value::Int(scope.arg_count() as i64))
3641            }
3642            Expr::VarLength(path) => {
3643                let scope = self.scope.read().await;
3644                crate::interpreter::resolve_length(&scope, path)
3645                    .map(Value::Int)
3646                    .map_err(|msg| anyhow::anyhow!(msg))
3647            }
3648            Expr::VarWithDefault { path, default } => {
3649                // Resolve inside a scoped guard so the lock is released before the
3650                // recursive default evaluation.
3651                let resolved = {
3652                    let scope = self.scope.read().await;
3653                    crate::interpreter::resolve_default(&scope, path)
3654                        .map_err(|msg| anyhow::anyhow!(msg))?
3655                };
3656                match resolved {
3657                    Some(value) => Ok(value),
3658                    None => self.eval_string_parts_async(default).await.map(Value::String),
3659                }
3660            }
3661            Expr::Arithmetic(expr_str) => {
3662                let scope = self.scope.read().await;
3663                crate::arithmetic::eval_arithmetic(expr_str, &scope)
3664                    .map(Value::Int)
3665                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3666            }
3667            Expr::Command(cmd) => {
3668                // Execute command and return boolean based on exit code
3669                let result = self.execute_command(&cmd.name, &cmd.args).await?;
3670                Ok(Value::Bool(result.code == 0))
3671            }
3672            Expr::LastExitCode => {
3673                let scope = self.scope.read().await;
3674                Ok(Value::Int(scope.last_result().code))
3675            }
3676            Expr::CurrentPid => {
3677                let scope = self.scope.read().await;
3678                Ok(Value::Int(scope.pid() as i64))
3679            }
3680            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3681            Expr::ListLiteral(elems) => {
3682                // Spread must itself be a list — a scalar/record spread is a
3683                // loud error, never silently coerced or dropped (mirrors the
3684                // sync `Evaluator::eval_list_literal`; wording shared via
3685                // `spread_non_list_message` so the two paths can't diverge).
3686                let mut out = Vec::with_capacity(elems.len());
3687                for elem in elems {
3688                    match elem {
3689                        ListElem::Item(e) => {
3690                            let value = self.eval_expr_async(e).await?;
3691                            out.push(crate::interpreter::value_to_json(&value));
3692                        }
3693                        ListElem::Spread(e) => {
3694                            let value = self.eval_expr_async(e).await?;
3695                            match value {
3696                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3697                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3698                            }
3699                        }
3700                    }
3701                }
3702                Ok(Value::Json(serde_json::Value::Array(out)))
3703            }
3704            Expr::RecordLiteral(entries) => {
3705                // Insertion order preserved (workspace serde_json has
3706                // `preserve_order`); a duplicate key keeps the last value
3707                // written, matching plain map-insert semantics.
3708                let mut map = serde_json::Map::new();
3709                for entry in entries {
3710                    let key = match &entry.key {
3711                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3712                        // `{"$k": v}` resolves like any double-quoted string
3713                        // (used to silently create a literal "$k" key).
3714                        RecordKey::Interpolated(parts) => {
3715                            self.eval_string_parts_async(parts).await?
3716                        }
3717                    };
3718                    let value = self.eval_expr_async(&entry.value).await?;
3719                    map.insert(key, crate::interpreter::value_to_json(&value));
3720                }
3721                Ok(Value::Json(serde_json::Value::Object(map)))
3722            }
3723        }
3724        })
3725    }
3726
3727    /// Async helper to evaluate multiple StringParts into a single string.
3728    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3729        Box::pin(async move {
3730            let mut result = String::new();
3731            for part in parts {
3732                result.push_str(&self.eval_string_part_async(part).await?);
3733            }
3734            Ok(result)
3735        })
3736    }
3737
3738    /// Async helper to evaluate a StringPart.
3739    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3740    /// through the VFS backend instead of using raw `std::path`.
3741    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3742        Box::pin(async move {
3743            match test_expr {
3744                TestExpr::FileTest { op, path } => {
3745                    let path_value = self.eval_expr_async(path).await?;
3746                    // Expand `~` against the session HOME before stat'ing, the
3747                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3748                    // stats the literal `~/x` and is always false.
3749                    let home = self.scope_home().await;
3750                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
3751                    // A binary `[[ -f $bin ]]` operand goes loud rather than
3752                    // silently stat'ing a file literally named
3753                    // `[binary: N bytes]` (the same path-positional guard
3754                    // builtins like `stat`/`cp` use).
3755                    let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
3756                        .map_err(|e| anyhow::anyhow!("{e}"))?;
3757                    // Resolve against the *session* cwd, not the process cwd, so a
3758                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
3759                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
3760                    // relative path against the process cwd otherwise.
3761                    let (resolved, backend) = {
3762                        let ctx = self.exec_ctx.read().await;
3763                        (ctx.resolve_path(&path_str), ctx.backend.clone())
3764                    };
3765                    let entry = backend.stat(&resolved).await.ok();
3766                    Ok(match op {
3767                        FileTestOp::Exists => entry.is_some(),
3768                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3769                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3770                        FileTestOp::Readable => entry.is_some(),
3771                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3772                            e.permissions.is_none_or(|p| p & 0o222 != 0)
3773                        }),
3774                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3775                            e.permissions.is_some_and(|p| p & 0o111 != 0)
3776                        }),
3777                    })
3778                }
3779                TestExpr::StringTest { op, value } => match op {
3780                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
3781                        let val = self.eval_expr_async(value).await?;
3782                        // Decision E: a collection operand is a loud Shape error
3783                        // here too — must not diverge from the sync path in
3784                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
3785                        let symbol = match op {
3786                            crate::ast::StringTestOp::IsEmpty => "-z",
3787                            crate::ast::StringTestOp::IsNonEmpty => "-n",
3788                            crate::ast::StringTestOp::IsList
3789                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
3790                        };
3791                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
3792                            anyhow::bail!(msg);
3793                        }
3794                        let s = value_to_string(&val);
3795                        Ok(match op {
3796                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3797                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3798                            crate::ast::StringTestOp::IsList
3799                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
3800                        })
3801                    }
3802                    // Shape guard: propagates eval errors like -z/-n (a bare
3803                    // `$unset` is an undefined-variable error, not a silent
3804                    // false). A defined-but-wrong-shaped value is false. Must
3805                    // not diverge from the sync path in interpreter/eval.rs.
3806                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
3807                        let val = self.eval_expr_async(value).await?;
3808                        Ok(op.matches_shape(&val))
3809                    }
3810                },
3811                TestExpr::Comparison { left, op, right } => {
3812                    // Evaluate operands async (handles $(cmd)), then compare sync
3813                    let left_val = self.eval_expr_async(left).await?;
3814                    let right_val = self.eval_expr_async(right).await?;
3815                    let resolved = TestExpr::Comparison {
3816                        left: Box::new(Expr::Literal(left_val)),
3817                        op: *op,
3818                        right: Box::new(Expr::Literal(right_val)),
3819                    };
3820                    let expr = Expr::Test(Box::new(resolved));
3821                    let mut scope = self.scope.write().await;
3822                    let value = eval_expr(&expr, &mut scope)
3823                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3824                    Ok(value_to_bool(&value))
3825                }
3826                TestExpr::And { left, right } => {
3827                    if !self.eval_test_async(left).await? {
3828                        Ok(false)
3829                    } else {
3830                        self.eval_test_async(right).await
3831                    }
3832                }
3833                TestExpr::Or { left, right } => {
3834                    if self.eval_test_async(left).await? {
3835                        Ok(true)
3836                    } else {
3837                        self.eval_test_async(right).await
3838                    }
3839                }
3840                TestExpr::Not { expr } => {
3841                    Ok(!self.eval_test_async(expr).await?)
3842                }
3843                TestExpr::In { left, right } => {
3844                    let left_val = self.eval_expr_async(left).await?;
3845                    let right_val = self.eval_expr_async(right).await?;
3846                    let resolved = TestExpr::In {
3847                        left: Box::new(Expr::Literal(left_val)),
3848                        right: Box::new(Expr::Literal(right_val)),
3849                    };
3850                    let expr = Expr::Test(Box::new(resolved));
3851                    let mut scope = self.scope.write().await;
3852                    let value = eval_expr(&expr, &mut scope)
3853                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3854                    Ok(value_to_bool(&value))
3855                }
3856                TestExpr::NotIn { left, right } => {
3857                    let left_val = self.eval_expr_async(left).await?;
3858                    let right_val = self.eval_expr_async(right).await?;
3859                    let resolved = TestExpr::NotIn {
3860                        left: Box::new(Expr::Literal(left_val)),
3861                        right: Box::new(Expr::Literal(right_val)),
3862                    };
3863                    let expr = Expr::Test(Box::new(resolved));
3864                    let mut scope = self.scope.write().await;
3865                    let value = eval_expr(&expr, &mut scope)
3866                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3867                    Ok(value_to_bool(&value))
3868                }
3869            }
3870        })
3871    }
3872
3873    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3874        Box::pin(async move {
3875            match part {
3876                StringPart::Literal(s) => Ok(s.clone()),
3877                StringPart::Var(path) => {
3878                    let scope = self.scope.read().await;
3879                    match scope.resolve_path(path) {
3880                        // Text sink: binary goes loud, never the placeholder —
3881                        // a `b=$(cat blob)` capture holds real bytes; splicing
3882                        // `[binary: N bytes]` into "$b" would be silent loss.
3883                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3884                        // Unset vars expand to empty; loud path errors surface.
3885                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
3886                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3887                            Err(anyhow::anyhow!(msg))
3888                        }
3889                    }
3890                }
3891                StringPart::VarWithDefault { path, default } => {
3892                    let resolved = {
3893                        let scope = self.scope.read().await;
3894                        crate::interpreter::resolve_default(&scope, path)
3895                            .map_err(|msg| anyhow::anyhow!(msg))?
3896                    };
3897                    match resolved {
3898                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3899                        None => self.eval_string_parts_async(default).await,
3900                    }
3901                }
3902            StringPart::VarLength(path) => {
3903                let scope = self.scope.read().await;
3904                crate::interpreter::resolve_length(&scope, path)
3905                    .map(|n| n.to_string())
3906                    .map_err(|msg| anyhow::anyhow!(msg))
3907            }
3908            StringPart::Positional(n) => {
3909                let scope = self.scope.read().await;
3910                match scope.get_positional(*n) {
3911                    Some(s) => Ok(s.to_string()),
3912                    None => Ok(String::new()),
3913                }
3914            }
3915            StringPart::AllArgs => {
3916                let scope = self.scope.read().await;
3917                Ok(scope.all_args().join(" "))
3918            }
3919            StringPart::ArgCount => {
3920                let scope = self.scope.read().await;
3921                Ok(scope.arg_count().to_string())
3922            }
3923            StringPart::Arithmetic(expr) => {
3924                // Loud on purpose (GH #183): this used to be `Err(_) =>
3925                // Ok(String::new())`, silently splicing in "" for e.g.
3926                // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
3927                // at exit 0 instead of failing. Matches the bare (non-string)
3928                // `Expr::Arithmetic` arm above, which already propagates.
3929                let scope = self.scope.read().await;
3930                crate::arithmetic::eval_arithmetic(expr, &scope)
3931                    .map(|value| value.to_string())
3932                    .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
3933            }
3934            StringPart::CommandSubst(stmts) => {
3935                // Snapshot scope, cwd, and session config — command
3936                // substitution in strings must not leak side effects (e.g.,
3937                // `"dir: $(cd /; pwd)"` must not change cwd, and
3938                // `"$(kaish-ignore clear)"` must not change the session's
3939                // ignore config) — matching how every other execution
3940                // context (background forks, scatter workers) already
3941                // isolates mutations (GH #139).
3942                // Boxed: this ~470 B scope snapshot is held across the nested
3943                // `$(…)` recursion await below, so inlining it grows every
3944                // command-substitution level's future (GH #48, item 4).
3945                let saved_scope = Box::new(self.scope.read().await.clone());
3946                let saved_ec = {
3947                    let ec = self.exec_ctx.read().await;
3948                    (
3949                        ec.cwd.clone(),
3950                        ec.prev_cwd.clone(),
3951                        ec.aliases.clone(),
3952                        ec.ignore_config.clone(),
3953                        ec.output_limit.clone(),
3954                    )
3955                };
3956
3957                // Capture result without `?` — restore state unconditionally
3958                let run_result = self.execute_block_capturing(stmts).await;
3959
3960                // Restore scope and cwd regardless of success/failure
3961                {
3962                    let mut scope = self.scope.write().await;
3963                    *scope = *saved_scope;
3964                    if let Ok(ref r) = run_result {
3965                        scope.set_last_result(r.clone());
3966                    }
3967                }
3968                {
3969                    let mut ec = self.exec_ctx.write().await;
3970                    let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3971                    ec.cwd = cwd;
3972                    ec.prev_cwd = prev_cwd;
3973                    ec.aliases = aliases;
3974                    ec.ignore_config = ignore_config;
3975                    ec.output_limit = output_limit;
3976                }
3977
3978                // Now propagate the error
3979                let result = run_result?;
3980
3981                // Embedding binary into a string is a text context: fail loud
3982                // rather than splice in U+FFFD garbage.
3983                match result.try_text_out() {
3984                    // Text wins when present — unchanged behavior.
3985                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
3986                    // `.out` is empty: a builtin/tool that set only structured
3987                    // `.data` must not silently evaporate to "" (SILENT DATA
3988                    // LOSS). Render it the same way a bare `"$x"`
3989                    // collection-valued variable renders — compact JSON for
3990                    // lists/records, plain form for scalars — by reusing
3991                    // `value_to_string` (the exact `StringPart::Var` helper
3992                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
3993                    // identically. No trailing-newline trim here: that's a
3994                    // text-path artifact, not applicable to a freshly
3995                    // rendered JSON/scalar string.
3996                    Ok(_) => match &result.data {
3997                        Some(data) => Ok(value_to_string(data)),
3998                        None => Ok(String::new()),
3999                    },
4000                    Err(e) => anyhow::bail!(
4001                        "command substitution in a string produced binary data ({e}) — \
4002                         pipe through base64/xxd"
4003                    ),
4004                }
4005            }
4006            StringPart::LastExitCode => {
4007                let scope = self.scope.read().await;
4008                Ok(scope.last_result().code.to_string())
4009            }
4010            StringPart::CurrentPid => {
4011                let scope = self.scope.read().await;
4012                Ok(scope.pid().to_string())
4013            }
4014        }
4015        })
4016    }
4017
4018    /// Update the last result in scope.
4019    async fn update_last_result(&self, result: &ExecResult) {
4020        let mut scope = self.scope.write().await;
4021        scope.set_last_result(result.clone());
4022    }
4023
4024    /// Drain accumulated pipeline stderr into a result.
4025    ///
4026    /// Called after each sub-statement inside control structures (`if`, `for`,
4027    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4028    /// than batching until the entire structure finishes.
4029    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4030        let drained = {
4031            let mut receiver = self.stderr_receiver.lock().await;
4032            receiver.drain_lossy()
4033        };
4034        if !drained.is_empty() {
4035            if !result.err.is_empty() && !result.err.ends_with('\n') {
4036                result.err.push('\n');
4037            }
4038            result.err.push_str(&drained);
4039        }
4040    }
4041
4042    /// Execute a user-defined function with local variable scoping.
4043    ///
4044    /// Functions push a new scope frame for local variables. Variables declared
4045    /// with `local` are scoped to the function; other assignments modify outer
4046    /// scopes (or create in root if new).
4047    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4048        let _depth = self.enter_recursion("a shell function")?;
4049
4050        // 1. Build function args from AST args (async to support command substitution)
4051        let tool_args = self.build_args_async(args, None).await?;
4052
4053        // 2. Push a new scope frame for local variables
4054        {
4055            let mut scope = self.scope.write().await;
4056            scope.push_frame();
4057        }
4058
4059        // 3. Save current positional parameters and set new ones for this function
4060        let saved_positional = {
4061            let mut scope = self.scope.write().await;
4062            let saved = scope.save_positional();
4063
4064            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4065            let positional_args: Vec<String> = tool_args.positional
4066                .iter()
4067                .map(value_to_string)
4068                .collect();
4069            scope.set_positional(&def.name, positional_args);
4070
4071            saved
4072        };
4073
4074        // 3. Execute body statements with control flow handling
4075        // Accumulate output across statements (like sh)
4076        // Accumulate stdout as raw bytes so a binary-producing statement in a
4077        // function body survives instead of being lossy-decoded here.
4078        let mut accumulated_out: Vec<u8> = Vec::new();
4079        let mut accumulated_err = String::new();
4080        let mut last_code = 0i64;
4081        let mut last_data: Option<Value> = None;
4082
4083        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4084            match r.out_bytes() {
4085                Some(b) => buf.extend_from_slice(b),
4086                None => buf.extend_from_slice(r.text_out().as_bytes()),
4087            }
4088        }
4089
4090        // Track execution error for propagation after cleanup
4091        let mut exec_error: Option<anyhow::Error> = None;
4092        let mut exit_code: Option<i64> = None;
4093
4094        for stmt in &def.body {
4095            match self.execute_stmt_flow(stmt).await {
4096                Ok(flow) => {
4097                    // Drain pipeline stderr after each sub-statement.
4098                    let drained = {
4099                        let mut receiver = self.stderr_receiver.lock().await;
4100                        receiver.drain_lossy()
4101                    };
4102                    if !drained.is_empty() {
4103                        accumulated_err.push_str(&drained);
4104                    }
4105
4106                    match flow {
4107                        ControlFlow::Normal(r) => {
4108                            push_out(&mut accumulated_out, &r);
4109                            accumulated_err.push_str(&r.err);
4110                            last_code = r.code;
4111                            last_data = r.data;
4112                        }
4113                        ControlFlow::Return { value } => {
4114                            push_out(&mut accumulated_out, &value);
4115                            accumulated_err.push_str(&value.err);
4116                            last_code = value.code;
4117                            last_data = value.data;
4118                            break;
4119                        }
4120                        ControlFlow::Exit { code } => {
4121                            exit_code = Some(code);
4122                            break;
4123                        }
4124                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4125                            push_out(&mut accumulated_out, &r);
4126                            accumulated_err.push_str(&r.err);
4127                            last_code = r.code;
4128                            last_data = r.data;
4129                        }
4130                    }
4131                }
4132                Err(e) => {
4133                    exec_error = Some(e);
4134                    break;
4135                }
4136            }
4137        }
4138
4139        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4140        {
4141            let mut scope = self.scope.write().await;
4142            scope.pop_frame();
4143            scope.set_positional(saved_positional.0, saved_positional.1);
4144        }
4145
4146        // 5. Propagate error or exit after cleanup
4147        if let Some(e) = exec_error {
4148            return Err(e);
4149        }
4150        let code = exit_code.unwrap_or(last_code);
4151        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4152        result.err = accumulated_err;
4153        result.data = last_data;
4154        Ok(result)
4155    }
4156
4157    /// Execute a command-substitution body — a block of statements — and return
4158    /// the combined result. Stdout/stderr accumulate across statements with **no
4159    /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4160    /// and the last statement's exit code and structured `.data` ride through,
4161    /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4162    /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4163    /// cwd) is the caller's responsibility.
4164    /// Enter one level of dynamic statement-engine re-entry (command
4165    /// substitution / function call / script source), returning an RAII guard
4166    /// that releases the level on drop. Past [`MAX_RECURSION_DEPTH`] it returns
4167    /// a loud, catchable error instead of letting the native stack overflow
4168    /// (GH #46). `what` names the re-entry kind for the message.
4169    ///
4170    /// The guard is constructed *before* the ceiling check so the error path
4171    /// unwinds it too — the counter is always balanced, even when we reject.
4172    fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4173        let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4174        let guard = RecursionGuard { counter: &self.recursion_depth };
4175        if depth > MAX_RECURSION_DEPTH {
4176            return Err(anyhow::anyhow!(
4177                "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4178                 a runaway or mutually recursive script (deeply nested $(…), or \
4179                 functions/scripts that call each other without a base case) was \
4180                 stopped before it could overflow the stack"
4181            ));
4182        }
4183        Ok(guard)
4184    }
4185
4186    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4187        let _depth = self.enter_recursion("command substitution")?;
4188        // Accumulate stdout as raw bytes so a binary-producing statement
4189        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4190        // caller can preserve it. The final result is text iff valid UTF-8.
4191        let mut accumulated_out: Vec<u8> = Vec::new();
4192        let mut accumulated_err = String::new();
4193        let mut last_code = 0i64;
4194        let mut last_data: Option<Value> = None;
4195
4196        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4197        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4198            match r.out_bytes() {
4199                Some(b) => buf.extend_from_slice(b),
4200                None => buf.extend_from_slice(r.text_out().as_bytes()),
4201            }
4202        }
4203
4204        for stmt in stmts {
4205            let flow = self.execute_stmt_flow(stmt).await?;
4206
4207            // Drain pipeline stderr after each sub-statement (incremental, like
4208            // the control-structure and function-body executors).
4209            let drained = {
4210                let mut receiver = self.stderr_receiver.lock().await;
4211                receiver.drain_lossy()
4212            };
4213            if !drained.is_empty() {
4214                accumulated_err.push_str(&drained);
4215            }
4216
4217            match flow {
4218                ControlFlow::Normal(r)
4219                | ControlFlow::Break { result: r, .. }
4220                | ControlFlow::Continue { result: r, .. } => {
4221                    push_out(&mut accumulated_out, &r);
4222                    accumulated_err.push_str(&r.err);
4223                    last_code = r.code;
4224                    last_data = r.data;
4225                }
4226                ControlFlow::Return { value } => {
4227                    push_out(&mut accumulated_out, &value);
4228                    accumulated_err.push_str(&value.err);
4229                    last_code = value.code;
4230                    last_data = value.data;
4231                    break;
4232                }
4233                ControlFlow::Exit { code } => {
4234                    last_code = code;
4235                    break;
4236                }
4237            }
4238        }
4239
4240        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4241        result.err = accumulated_err;
4242        result.data = last_data;
4243        Ok(result)
4244    }
4245
4246    /// Execute the `source` / `.` command to include and run a script.
4247    ///
4248    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4249    /// allowing the sourced script to set variables and modify shell state.
4250    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4251        // `source`/`.` is the fourth dynamic re-entry point: it runs the
4252        // sourced file's statements inline via `execute_stmt_flow`, so a file
4253        // that sources itself recurses unbounded just like a runaway function
4254        // (GH #46). It's intercepted as a special form *before* the other
4255        // guarded paths, so it needs its own guard.
4256        let _depth = self.enter_recursion("source")?;
4257
4258        // Get the file path from the first positional argument
4259        let tool_args = self.build_args_async(args, None).await?;
4260        let path = match tool_args.positional.first() {
4261            Some(Value::String(s)) => s.clone(),
4262            Some(v) => value_to_string(v),
4263            None => {
4264                return Ok(ExecResult::failure(1, "source: missing filename"));
4265            }
4266        };
4267
4268        // Resolve path relative to cwd
4269        let full_path = {
4270            let ctx = self.exec_ctx.read().await;
4271            if path.starts_with('/') {
4272                std::path::PathBuf::from(&path)
4273            } else {
4274                ctx.cwd.join(&path)
4275            }
4276        };
4277
4278        // Read file content via backend
4279        let content = {
4280            let ctx = self.exec_ctx.read().await;
4281            match ctx.backend.read(&full_path, None).await {
4282                Ok(bytes) => {
4283                    String::from_utf8(bytes).map_err(|e| {
4284                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4285                    })?
4286                }
4287                Err(e) => {
4288                    return Ok(ExecResult::failure(
4289                        1,
4290                        format!("source: {}: {}", path, e),
4291                    ));
4292                }
4293            }
4294        };
4295
4296        // Parse the content
4297        let program = match crate::parser::parse(&content) {
4298            Ok(p) => p,
4299            Err(errors) => {
4300                let msg = errors
4301                    .iter()
4302                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4303                    .collect::<Vec<_>>()
4304                    .join("\n");
4305                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4306            }
4307        };
4308
4309        // Execute each statement in the CURRENT scope (not isolated), accumulating
4310        // stdout/stderr across statements like `execute_user_tool` — a sourced
4311        // script's earlier statements must not be silently dropped in favor of
4312        // just the last one.
4313        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4314            match r.out_bytes() {
4315                Some(b) => buf.extend_from_slice(b),
4316                None => buf.extend_from_slice(r.text_out().as_bytes()),
4317            }
4318        }
4319
4320        let mut accumulated_out: Vec<u8> = Vec::new();
4321        let mut accumulated_err = String::new();
4322        let mut last_code = 0i64;
4323        let mut last_data: Option<Value> = None;
4324
4325        for stmt in program.statements {
4326            if matches!(stmt, crate::ast::Stmt::Empty) {
4327                continue;
4328            }
4329
4330            match self.execute_stmt_flow(&stmt).await {
4331                Ok(flow) => {
4332                    let drained = {
4333                        let mut receiver = self.stderr_receiver.lock().await;
4334                        receiver.drain_lossy()
4335                    };
4336                    if !drained.is_empty() {
4337                        accumulated_err.push_str(&drained);
4338                    }
4339                    match flow {
4340                        ControlFlow::Normal(r) => {
4341                            push_out(&mut accumulated_out, &r);
4342                            accumulated_err.push_str(&r.err);
4343                            last_code = r.code;
4344                            last_data = r.data.clone();
4345                            self.update_last_result(&r).await;
4346                        }
4347                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4348                            return Err(anyhow::anyhow!(
4349                                "source: {}: unexpected break/continue outside loop",
4350                                path
4351                            ));
4352                        }
4353                        ControlFlow::Return { value } => {
4354                            push_out(&mut accumulated_out, &value);
4355                            accumulated_err.push_str(&value.err);
4356                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4357                                .with_code(value.code);
4358                            result.err = accumulated_err;
4359                            result.data = value.data;
4360                            return Ok(result);
4361                        }
4362                        ControlFlow::Exit { code } => {
4363                            let mut result =
4364                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4365                            result.err = accumulated_err;
4366                            result.data = last_data;
4367                            return Ok(result);
4368                        }
4369                    }
4370                }
4371                Err(e) => {
4372                    return Err(e.context(format!("source: {}", path)));
4373                }
4374            }
4375        }
4376
4377        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4378        result.err = accumulated_err;
4379        result.data = last_data;
4380        Ok(result)
4381    }
4382
4383    /// Try to execute a script from PATH directories.
4384    ///
4385    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4386    /// (like user-defined tools). Returns None if no script is found.
4387    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4388        // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4389        // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4390        // A non-script command pays only a transient, balanced increment during
4391        // the probe before falling through to the external path.
4392        let _depth = self.enter_recursion("a .kai script")?;
4393
4394        // Get PATH from scope (default to "/bin")
4395        let path_value = {
4396            let scope = self.scope.read().await;
4397            scope
4398                .get("PATH")
4399                .map(value_to_string)
4400                .unwrap_or_else(|| "/bin".to_string())
4401        };
4402
4403        // Search PATH directories for script
4404        for dir in path_value.split(':') {
4405            if dir.is_empty() {
4406                continue;
4407            }
4408
4409            // Build script path: {dir}/{name}.kai
4410            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4411
4412            // Check if script exists
4413            let exists = {
4414                let ctx = self.exec_ctx.read().await;
4415                ctx.backend.exists(&script_path).await
4416            };
4417
4418            if !exists {
4419                continue;
4420            }
4421
4422            // Read script content
4423            let content = {
4424                let ctx = self.exec_ctx.read().await;
4425                match ctx.backend.read(&script_path, None).await {
4426                    Ok(bytes) => match String::from_utf8(bytes) {
4427                        Ok(s) => s,
4428                        Err(e) => {
4429                            return Ok(Some(ExecResult::failure(
4430                                1,
4431                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4432                            )));
4433                        }
4434                    },
4435                    Err(e) => {
4436                        return Ok(Some(ExecResult::failure(
4437                            1,
4438                            format!("{}: {}", script_path.display(), e),
4439                        )));
4440                    }
4441                }
4442            };
4443
4444            // Parse the script
4445            let program = match crate::parser::parse(&content) {
4446                Ok(p) => p,
4447                Err(errors) => {
4448                    let msg = errors
4449                        .iter()
4450                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4451                        .collect::<Vec<_>>()
4452                        .join("\n");
4453                    return Ok(Some(ExecResult::failure(1, msg)));
4454                }
4455            };
4456
4457            // Build tool_args from args (async for command substitution support)
4458            let tool_args = self.build_args_async(args, None).await?;
4459
4460            // Create isolated scope (like user tools)
4461            let mut isolated_scope = Scope::new();
4462
4463            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4464            let positional_args: Vec<String> = tool_args.positional
4465                .iter()
4466                .map(value_to_string)
4467                .collect();
4468            isolated_scope.set_positional(name, positional_args);
4469
4470            // Save current scope and swap with isolated scope
4471            let original_scope = {
4472                let mut scope = self.scope.write().await;
4473                std::mem::replace(&mut *scope, isolated_scope)
4474            };
4475
4476            // Execute script statements — accumulate stdout/stderr across
4477            // statements like `execute_user_tool`, rather than keeping only the
4478            // last one's result.
4479            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4480                match r.out_bytes() {
4481                    Some(b) => buf.extend_from_slice(b),
4482                    None => buf.extend_from_slice(r.text_out().as_bytes()),
4483                }
4484            }
4485
4486            let mut accumulated_out: Vec<u8> = Vec::new();
4487            let mut accumulated_err = String::new();
4488            let mut last_code = 0i64;
4489            let mut last_data: Option<Value> = None;
4490            let mut exec_error: Option<anyhow::Error> = None;
4491            let mut exit_code: Option<i64> = None;
4492
4493            for stmt in program.statements {
4494                if matches!(stmt, crate::ast::Stmt::Empty) {
4495                    continue;
4496                }
4497
4498                match self.execute_stmt_flow(&stmt).await {
4499                    Ok(flow) => {
4500                        let drained = {
4501                            let mut receiver = self.stderr_receiver.lock().await;
4502                            receiver.drain_lossy()
4503                        };
4504                        if !drained.is_empty() {
4505                            accumulated_err.push_str(&drained);
4506                        }
4507                        match flow {
4508                            ControlFlow::Normal(r) => {
4509                                push_out(&mut accumulated_out, &r);
4510                                accumulated_err.push_str(&r.err);
4511                                last_code = r.code;
4512                                last_data = r.data;
4513                            }
4514                            ControlFlow::Return { value } => {
4515                                push_out(&mut accumulated_out, &value);
4516                                accumulated_err.push_str(&value.err);
4517                                last_code = value.code;
4518                                last_data = value.data;
4519                                break;
4520                            }
4521                            ControlFlow::Exit { code } => {
4522                                exit_code = Some(code);
4523                                break;
4524                            }
4525                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4526                                push_out(&mut accumulated_out, &r);
4527                                accumulated_err.push_str(&r.err);
4528                                last_code = r.code;
4529                                last_data = r.data;
4530                            }
4531                        }
4532                    }
4533                    Err(e) => {
4534                        exec_error = Some(e);
4535                        break;
4536                    }
4537                }
4538            }
4539
4540            // Restore original scope unconditionally
4541            {
4542                let mut scope = self.scope.write().await;
4543                *scope = original_scope;
4544            }
4545
4546            // Propagate error or exit after cleanup
4547            if let Some(e) = exec_error {
4548                return Err(e.context(format!("script: {}", script_path.display())));
4549            }
4550            let code = exit_code.unwrap_or(last_code);
4551            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4552            result.err = accumulated_err;
4553            result.data = last_data;
4554            return Ok(Some(result));
4555        }
4556
4557        // No script found
4558        Ok(None)
4559    }
4560
4561    /// Try to execute an external command from PATH.
4562    ///
4563    /// This is the fallback when no builtin or user-defined tool matches.
4564    /// External commands receive a clean argv (flags preserved in their original format).
4565    ///
4566    /// # Requirements
4567    /// - Command must be found in PATH
4568    /// - Current working directory must be on a real filesystem (not virtual like /v)
4569    ///
4570    /// # Returns
4571    /// - `Ok(Some(result))` if command was found and executed
4572    /// - `Ok(None)` if command was not found in PATH
4573    /// - `Err` on execution errors
4574    #[cfg(not(feature = "subprocess"))]
4575    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4576        Ok(None)
4577    }
4578
4579    /// Try to execute an external command from PATH.
4580    #[cfg(feature = "subprocess")]
4581    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4582    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4583        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4584        // populates from the inbound ctx.cancel on every dispatch. This is
4585        // what makes the `timeout` builtin's swapped child token reach the
4586        // wait_or_kill discipline below — reading `self.cancel_token` would
4587        // give the kernel-wide token and miss the timeout's child cascade.
4588        let cancel = {
4589            let ec = self.exec_ctx.read().await;
4590            ec.cancel.clone()
4591        };
4592        let kill_grace = self.kill_grace;
4593        if !self.allow_external_commands {
4594            return Ok(None);
4595        }
4596
4597        // Get the shell's cwd and its real filesystem location, if any. A
4598        // `None` real path means the cwd is virtual (a CoW overlay, an
4599        // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4600        // process to run. Don't bail out here: a bare command name that isn't
4601        // in PATH at all is a genuine "not found" regardless of cwd, and the
4602        // virtual-cwd error would blame the wrong thing for that case. Once
4603        // the command actually resolves, `real_cwd` is checked again below
4604        // and the honest reason is given then (issue #181).
4605        let (cwd, real_cwd) = {
4606            let ctx = self.exec_ctx.read().await;
4607            (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4608        };
4609
4610        let executable = if name.contains('/') {
4611            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4612            let resolved = if std::path::Path::new(name).is_absolute() {
4613                std::path::PathBuf::from(name)
4614            } else {
4615                match &real_cwd {
4616                    Some(real_cwd) => real_cwd.join(name),
4617                    // A relative path can't be resolved without a real cwd to
4618                    // join against, so we can't even tell whether it would
4619                    // exist — name the actual blocker instead of a
4620                    // misleading "No such file or directory".
4621                    None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4622                }
4623            };
4624            if !resolved.exists() {
4625                return Ok(Some(ExecResult::failure(
4626                    127,
4627                    format!("{}: No such file or directory", name),
4628                )));
4629            }
4630            if !resolved.is_file() {
4631                return Ok(Some(ExecResult::failure(
4632                    126,
4633                    format!("{}: Is a directory", name),
4634                )));
4635            }
4636            #[cfg(unix)]
4637            {
4638                use std::os::unix::fs::PermissionsExt;
4639                let mode = std::fs::metadata(&resolved)
4640                    .map(|m| m.permissions().mode())
4641                    .unwrap_or(0);
4642                if mode & 0o111 == 0 {
4643                    return Ok(Some(ExecResult::failure(
4644                        126,
4645                        format!("{}: Permission denied", name),
4646                    )));
4647                }
4648            }
4649            resolved.to_string_lossy().into_owned()
4650        } else {
4651            // Get PATH from scope only. The kernel never reads OS env: a
4652            // frontend that wants host PATH seeds it via initial_vars (the REPL
4653            // does, with os_env_vars()). No PATH in scope → nothing resolves.
4654            let path_var = {
4655                let scope = self.scope.read().await;
4656                scope.get("PATH").map(value_to_string).unwrap_or_default()
4657            };
4658
4659            // Resolve command in PATH
4660            match resolve_in_path(name, &path_var) {
4661                Some(path) => path,
4662                None => return Ok(None), // Not found - let caller handle error
4663            }
4664        };
4665
4666        // The executable resolved — found in PATH, or a path that exists and
4667        // is executable — but there's still nowhere to run it without a real
4668        // cwd to spawn the child process in.
4669        let real_cwd = match real_cwd {
4670            Some(p) => p,
4671            None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4672        };
4673
4674        tracing::debug!(executable = %executable, "resolved external command");
4675
4676        // Build flat argv (preserves flag format)
4677        let argv = self.build_args_flat(args).await?;
4678
4679        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4680        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4681        // byte vector. Take both out under the lock but do NOT drain here — a
4682        // pipe read can block on its producer (a still-running upstream stage),
4683        // so draining before spawn would serialize the pipeline (deadlocking
4684        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4685        // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
4686        // are mutually exclusive in practice; prefer the pipe.
4687        let (pipe_stdin, stdin_bytes) = {
4688            let mut ctx = self.exec_ctx.write().await;
4689            (ctx.pipe_stdin.take(), ctx.take_stdin())
4690        };
4691        let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
4692
4693        // Build and spawn the command
4694        use tokio::process::Command;
4695
4696        let mut cmd = Command::new(&executable);
4697        cmd.args(&argv);
4698        cmd.current_dir(&real_cwd);
4699
4700        // Hermetic env: child sees only kaish's exported vars, not the kaish
4701        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4702        // populate it via KernelConfig::initial_vars at construction.
4703        cmd.env_clear();
4704        {
4705            let scope = self.scope.read().await;
4706            let exported = scope.exported_vars();
4707            // A structured value can't cross the process boundary; refuse rather
4708            // than silently JSON-serialize it into the child's environment.
4709            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4710                return Err(anyhow::anyhow!(msg));
4711            }
4712            for (var_name, value) in exported {
4713                // Binary can't cross the process boundary as an env var value
4714                // either — loud, not the `[binary: N bytes]` placeholder
4715                // silently exported in its place (kept in sync with
4716                // dispatch.rs::try_external and env.rs::execute_with_env).
4717                let value_str = crate::interpreter::value_to_text_sink_named(
4718                    &value,
4719                    "an exported environment variable value",
4720                )
4721                .map_err(|e| anyhow::anyhow!("{e}"))?;
4722                cmd.env(var_name, value_str);
4723            }
4724        }
4725
4726        // Handle stdin
4727        cmd.stdin(if has_stdin {
4728            std::process::Stdio::piped()
4729        } else if self.interactive {
4730            std::process::Stdio::inherit()
4731        } else {
4732            std::process::Stdio::null()
4733        });
4734
4735        // In interactive mode, standalone or last-in-pipeline commands inherit
4736        // the terminal's stdout/stderr so output streams in real-time.
4737        // First/middle commands must capture stdout for the pipe — same as bash.
4738        let pipeline_position = {
4739            let ctx = self.exec_ctx.read().await;
4740            ctx.pipeline_position
4741        };
4742        let inherit_output = self.interactive
4743            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4744
4745        if inherit_output {
4746            cmd.stdout(std::process::Stdio::inherit());
4747            cmd.stderr(std::process::Stdio::inherit());
4748        } else {
4749            cmd.stdout(std::process::Stdio::piped());
4750            cmd.stderr(std::process::Stdio::piped());
4751        }
4752
4753        // On Unix, always put the child in its own process group so cancellation
4754        // can `killpg` the whole tree (the child plus any grandchildren).
4755        // Restoring default tty-related signal handlers stays gated on
4756        // job-control mode — those only matter when the child has a controlling
4757        // terminal.
4758        #[cfg(unix)]
4759        {
4760            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4761            // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
4762            #[allow(unsafe_code)]
4763            unsafe {
4764                cmd.pre_exec(move || {
4765                    // Own process group — for kill scope.
4766                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4767                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4768                    if restore_jc_signals {
4769                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4770                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
4771                        sa.sa_sigaction = SIG_DFL;
4772                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4773                            return Err(std::io::Error::last_os_error());
4774                        }
4775                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4776                            return Err(std::io::Error::last_os_error());
4777                        }
4778                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4779                            return Err(std::io::Error::last_os_error());
4780                        }
4781                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4782                            return Err(std::io::Error::last_os_error());
4783                        }
4784                    }
4785                    Ok(())
4786                });
4787            }
4788        }
4789
4790        // Backstop for kill on drop in case our explicit kill path is bypassed
4791        // (panic, early return, etc) on the **capture** wait path. We do NOT
4792        // set this on the JC inherit path: that uses sync `waitpid` outside
4793        // tokio's view of the child, so on drop tokio would try to kill an
4794        // already-reaped (possibly-reused) PID. The JC path has its own
4795        // cancel handling via the side-task watcher.
4796        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4797        if !in_jc_inherit_path {
4798            cmd.kill_on_drop(true);
4799        }
4800
4801        // Spawn the process. Capture a `KillTarget` immediately so cancel/
4802        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4803        // to this process's generation, immune to PID reuse if the OS reaps
4804        // the child before our kill syscalls fire.
4805        let mut child = match cmd.spawn() {
4806            Ok(child) => child,
4807            Err(e) => {
4808                return Ok(Some(ExecResult::failure(
4809                    127,
4810                    format!("{}: {}", name, e),
4811                )));
4812            }
4813        };
4814        let kill_target = crate::pidfd::KillTarget::from_child(&child);
4815
4816        // If this external runs on behalf of a background job, record its
4817        // process group on the job so `kill -<sig> %N` can signal the real
4818        // process directly (STOP/CONT/USR1/…, not just terminate). The child
4819        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4820        if let Some(job_id) = self.bg_job_id
4821            && let Some(pid) = child.id()
4822        {
4823            self.jobs.add_pgid(job_id, pid).await;
4824        }
4825
4826        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4827        // detached task (bounded memory, no pre-drain) so an upstream stage and
4828        // this child run concurrently — and a child that never reads stdin (or
4829        // is killed) just breaks the copy, which stops. A buffered byte vector
4830        // is written verbatim (no text detour), so binary stdin survives.
4831        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4832            child.stdin.take().map(|mut child_stdin| {
4833                tokio::spawn(async move {
4834                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4835                    let mut buf = [0u8; 8192];
4836                    loop {
4837                        match pipe_in.read(&mut buf).await {
4838                            Ok(0) => break, // EOF
4839                            Ok(n) => {
4840                                if child_stdin.write_all(&buf[..n]).await.is_err() {
4841                                    break; // child closed stdin
4842                                }
4843                            }
4844                            Err(_) => break,
4845                        }
4846                    }
4847                    // Dropping child_stdin signals EOF to the child.
4848                })
4849            })
4850        } else if let Some(data) = stdin_bytes {
4851            // Write the buffered bytes from a detached task too — NOT inline.
4852            // An inline write blocks once the stdin pipe fills, and the output
4853            // drain hasn't spawned yet, so a child that emits a lot before
4854            // consuming all its input (every pipe buffer full) deadlocks. A
4855            // write error here is normal, not a failure: a child that closes
4856            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
4857            // signals EOF.
4858            child.stdin.take().map(|mut child_stdin| {
4859                tokio::spawn(async move {
4860                    use tokio::io::AsyncWriteExt;
4861                    let _ = child_stdin.write_all(&data).await;
4862                })
4863            })
4864        } else {
4865            None
4866        };
4867
4868        // Abort the stdin-copy task on EVERY exit path (the capture path, both
4869        // interactive `inherit_output` returns, and any early error return).
4870        // Once the child is reaped the copy has nothing left to deliver; if it
4871        // were left parked on `pipe_in.read()` it would leak and hold the
4872        // upstream pipe reader open. A drop guard is the single place that
4873        // covers all returns — explicit per-return aborts were error-prone (an
4874        // earlier version missed the two inherit_output returns).
4875        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
4876        impl Drop for AbortStdinCopyOnDrop {
4877            fn drop(&mut self) {
4878                if let Some(t) = self.0.take() {
4879                    t.abort();
4880                }
4881            }
4882        }
4883        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
4884
4885        if inherit_output {
4886            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
4887            #[cfg(unix)]
4888            if let Some(ref term) = self.terminal_state {
4889                let child_id = child.id().unwrap_or(0);
4890                let pid = nix::unistd::Pid::from_raw(child_id as i32);
4891                let pgid = pid; // child is its own pgid leader
4892
4893                // Give the terminal to the child's process group
4894                if let Err(e) = term.give_terminal_to(pgid) {
4895                    tracing::warn!("failed to give terminal to child: {}", e);
4896                }
4897
4898                let term_clone = term.clone();
4899                let cmd_name = name.to_string();
4900                let cmd_display = format!("{} {}", name, argv.join(" "));
4901                let jobs = self.jobs.clone();
4902
4903                // Side task that watches for cancellation while the blocking
4904                // waitpid runs. On cancel, it SIGTERMs the process group, waits
4905                // the grace period, then SIGKILLs. The blocking waitpid returns
4906                // when the child dies. AbortOnDrop guard cancels the watcher
4907                // on the success path so it doesn't keep running after wait
4908                // returns naturally.
4909                //
4910                // `wait_complete` shrinks the PID-reuse race: the watcher
4911                // checks it before each kill syscall and bails out if
4912                // wait_for_foreground has already reaped the child. This
4913                // doesn't fully eliminate the race (atomic load + kill is
4914                // not atomic with the OS reap+reuse), but narrows the window
4915                // to nanoseconds — enough to be ignorable in practice.
4916                let wait_complete = std::sync::Arc::new(
4917                    std::sync::atomic::AtomicBool::new(false)
4918                );
4919                let cancel_watcher = {
4920                    let cancel = cancel.clone();
4921                    let wc = wait_complete.clone();
4922                    // Ownership transfer: the JC path's sync wait inside
4923                    // block_in_place owns the child's reaping, so the
4924                    // cancel_watcher drives the kill side via KillTarget
4925                    // (pidfd-bound on Linux). When kill_target is None
4926                    // (older kernel + open failure, or non-Linux), falls
4927                    // through to the older PID-based path the closure
4928                    // captures from `pid`.
4929                    let target = kill_target.as_ref().map(|t| {
4930                        // Re-borrow the components we need into Owned-ish form
4931                        // so the spawned task is 'static. We can't move
4932                        // KillTarget directly because try_execute_external
4933                        // still uses it after the spawn — but on the JC path
4934                        // there is no further use after the watcher spawn,
4935                        // so a clone-of-pid + owned None pidfd is safe.
4936                        // Simpler: signal via the existing target by cloning
4937                        // a fresh pidfd; the original keeps its handle.
4938                        // Pidfd is just an OwnedFd — not Clone — so do it
4939                        // by re-opening from the pid. Fall back if reopen
4940                        // fails (race already reaped → best-effort kill).
4941                        crate::pidfd::KillTarget::from_pid(t.pid())
4942                    });
4943                    tokio::spawn(async move {
4944                        cancel.cancelled().await;
4945                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4946                        use nix::sys::signal::Signal;
4947                        if let Some(t) = &target {
4948                            t.signal(Signal::SIGTERM);
4949                            t.signal_pg(Signal::SIGTERM);
4950                        } else {
4951                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
4952                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
4953                        }
4954                        if kill_grace > Duration::ZERO {
4955                            tokio::time::sleep(kill_grace).await;
4956                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4957                        }
4958                        if let Some(t) = &target {
4959                            t.signal(Signal::SIGKILL);
4960                            t.signal_pg(Signal::SIGKILL);
4961                        } else {
4962                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
4963                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
4964                        }
4965                    })
4966                };
4967                struct AbortOnDrop(tokio::task::JoinHandle<()>);
4968                impl Drop for AbortOnDrop {
4969                    fn drop(&mut self) {
4970                        self.0.abort();
4971                    }
4972                }
4973                let _watcher_guard = AbortOnDrop(cancel_watcher);
4974
4975                let wait_complete_setter = wait_complete.clone();
4976                let code = tokio::task::block_in_place(move || {
4977                    let result = term_clone.wait_for_foreground(pid);
4978                    // Mark wait done before the watcher might fire.
4979                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
4980
4981                    // Always reclaim the terminal
4982                    if let Err(e) = term_clone.reclaim_terminal() {
4983                        tracing::warn!("failed to reclaim terminal: {}", e);
4984                    }
4985
4986                    match result {
4987                        crate::terminal::WaitResult::Exited(code) => code as i64,
4988                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
4989                        crate::terminal::WaitResult::Stopped(_sig) => {
4990                            // Register as a stopped job
4991                            let rt = tokio::runtime::Handle::current();
4992                            let job_id = rt.block_on(jobs.register_stopped(
4993                                cmd_display,
4994                                child_id,
4995                                child_id, // pgid = pid for group leader
4996                            ));
4997                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
4998                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
4999                        }
5000                    }
5001                });
5002
5003                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5004            }
5005
5006            // Non-job-control path with inherited stdio.
5007            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5008                Ok(s) => s,
5009                Err(e) => {
5010                    return Ok(Some(ExecResult::failure(
5011                        1,
5012                        format!("{}: failed to wait: {}", name, e),
5013                    )));
5014                }
5015            };
5016
5017            let code = exit_code_from_status(&status);
5018
5019            // stdout/stderr already went to the terminal
5020            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5021        } else {
5022            // Capture output via bounded streams
5023            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5024            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5025
5026            let stdout_pipe = child.stdout.take();
5027            let stderr_pipe = child.stderr.take();
5028
5029            let stdout_clone = stdout_stream.clone();
5030            let stderr_clone = stderr_stream.clone();
5031
5032            let stdout_task = stdout_pipe.map(|pipe| {
5033                tokio::spawn(async move {
5034                    drain_to_stream(pipe, stdout_clone).await;
5035                })
5036            });
5037
5038            let stderr_task = stderr_pipe.map(|pipe| {
5039                tokio::spawn(async move {
5040                    drain_to_stream(pipe, stderr_clone).await;
5041                })
5042            });
5043
5044            let cancelled_before_wait = cancel.is_cancelled();
5045            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5046                Ok(s) => s,
5047                Err(e) => {
5048                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5049                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5050                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5051                    return Ok(Some(ExecResult::failure(
5052                        1,
5053                        format!("{}: failed to wait: {}", name, e),
5054                    )));
5055                }
5056            };
5057
5058            // On cancel, abort the drain tasks (the child's pipes are gone;
5059            // late output is lost but predictable death beats partial capture).
5060            // On normal exit, await drains so we don't lose buffered output.
5061            if cancelled_before_wait || cancel.is_cancelled() {
5062                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5063                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5064            } else {
5065                if let Some(task) = stdout_task {
5066                    // Ignore join error — the drain task logs its own errors
5067                    let _ = task.await;
5068                }
5069                if let Some(task) = stderr_task {
5070                    let _ = task.await;
5071                }
5072            }
5073
5074            let code = exit_code_from_status(&status);
5075
5076            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5077            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5078            // intact. stderr stays text. See docs/binary-data.md.
5079            let stdout = stdout_stream.read().await;
5080            let mut stderr = stderr_stream.read_string().await;
5081            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5082
5083            // Both streams are fixed-size rings regardless of `ctx.output_limit`
5084            // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5085            // when enabled). With the limit disabled — the repl/embedded/test
5086            // default — an overflow here used to be silent: `write` evicted the
5087            // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5088            // counter, so a >10MB stdout reported clean success with its head
5089            // quietly gone (GH #191). Surface it loudly instead.
5090            if stderr_stream.has_overflowed().await {
5091                let stats = stderr_stream.stats().await;
5092                stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5093            }
5094            if stdout_stream.has_overflowed().await {
5095                // The marker goes in stderr, never prepended into `result`'s
5096                // stdout payload: stdout may be binary
5097                // (`success_text_or_bytes` yields a `Bytes` result for
5098                // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5099                // string-formatting a marker into it would lossily reinterpret
5100                // bytes as text, introducing a SECOND, different kind of
5101                // corruption on top of the eviction itself.
5102                //
5103                // Only stdout overflow flips `did_spill` — exit-code integrity
5104                // tracks stdout, matching the enabled-limit path's contract
5105                // (stderr overflow alone doesn't remap the exit code).
5106                let stats = stdout_stream.stats().await;
5107                stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5108                result.did_spill = true;
5109            }
5110            result.err = stderr;
5111            Ok(Some(result))
5112        }
5113    }
5114
5115    // --- Variable Access ---
5116
5117    /// Get a variable value.
5118    pub async fn get_var(&self, name: &str) -> Option<Value> {
5119        let scope = self.scope.read().await;
5120        scope.get(name).cloned()
5121    }
5122
5123    /// Check if error-exit mode is enabled (for testing).
5124    #[cfg(test)]
5125    pub async fn error_exit_enabled(&self) -> bool {
5126        let scope = self.scope.read().await;
5127        scope.error_exit_enabled()
5128    }
5129
5130    /// Set a variable value.
5131    pub async fn set_var(&self, name: &str, value: Value) {
5132        let mut scope = self.scope.write().await;
5133        scope.set(name.to_string(), value);
5134    }
5135
5136    /// Set positional parameters ($0 script name and $1-$9 args).
5137    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5138        let mut scope = self.scope.write().await;
5139        scope.set_positional(script_name, args);
5140    }
5141
5142    /// List all variables.
5143    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5144        let scope = self.scope.read().await;
5145        scope.all()
5146    }
5147
5148    /// List exported variables (name, value), sorted by name. These are the
5149    /// vars a child process would see (see `dispatch`'s hermetic env build).
5150    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5151        let scope = self.scope.read().await;
5152        scope.exported_vars()
5153    }
5154
5155    // --- CWD ---
5156
5157    /// Get current working directory.
5158    pub async fn cwd(&self) -> PathBuf {
5159        self.exec_ctx.read().await.cwd.clone()
5160    }
5161
5162    /// Set current working directory.
5163    pub async fn set_cwd(&self, path: PathBuf) {
5164        let mut ctx = self.exec_ctx.write().await;
5165        ctx.set_cwd(path);
5166    }
5167
5168    /// Set the working directory only if `path` resolves to a directory in the
5169    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5170    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5171    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5172    /// disappeared. Returns whether the cwd was changed.
5173    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5174        // Clone the backend Arc out before the stat so we never hold the
5175        // exec_ctx lock across the await.
5176        let backend = self.exec_ctx.read().await.backend.clone();
5177        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5178        if is_dir {
5179            self.exec_ctx.write().await.set_cwd(path);
5180        }
5181        is_dir
5182    }
5183
5184    // --- Last Result ---
5185
5186    /// Get the last result ($?).
5187    pub async fn last_result(&self) -> ExecResult {
5188        let scope = self.scope.read().await;
5189        scope.last_result().clone()
5190    }
5191
5192    // --- Tools ---
5193
5194    /// Check if a user-defined function exists.
5195    pub async fn has_function(&self, name: &str) -> bool {
5196        self.user_tools.read().await.contains_key(name)
5197    }
5198
5199    /// Get available tool schemas.
5200    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5201        self.tools.schemas()
5202    }
5203
5204    /// Classify how the kernel will resolve a command name.
5205    ///
5206    /// This is the supported, single source of truth for command resolution that
5207    /// embedders should call instead of re-deriving the rules. Walk a parsed
5208    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5209    /// this per command name to bucket each into builtin / user-function /
5210    /// special-form / dynamic / external — for example a consent gate that blocks
5211    /// a script until external commands are approved.
5212    ///
5213    /// The classification mirrors the interpreter's real resolution order
5214    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5215    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5216    /// re-checking special-forms each step, exactly as execution does), then user
5217    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5218    /// name that is a variable or command-substitution expansion (`$cmd`,
5219    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5220    /// be resolved statically.
5221    ///
5222    /// Aliases are resolved against the kernel's current alias table, so an
5223    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5224    /// thing it would actually run. The safe direction of any residual imprecision
5225    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5226    /// `.kai`/backend-tool resolution are reported `External` even though some of
5227    /// those resolve in-process, so a consent gate over-gates rather than letting
5228    /// a `PATH` escape slip through.
5229    pub async fn classify_command(&self, name: &str) -> CommandKind {
5230        // Resolve the command head the way `execute_command_depth` does: a
5231        // special-form short-circuits before any alias lookup, otherwise expand
5232        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5233        // can't be resolved at all.
5234        let mut name = name.to_string();
5235        let mut alias_depth = 0u8;
5236        loop {
5237            if !crate::validator::is_static_command_name(&name) {
5238                return CommandKind::Dynamic;
5239            }
5240            if crate::validator::is_runtime_special_form(&name) {
5241                return CommandKind::Special;
5242            }
5243            if alias_depth >= 10 {
5244                break;
5245            }
5246            let alias_value = {
5247                let ctx = self.exec_ctx.read().await;
5248                ctx.aliases.get(&name).cloned()
5249            };
5250            // Expand to the alias's head command. An empty alias value (no head)
5251            // is ignored by execution, so resolution continues with this name.
5252            match alias_value
5253                .as_deref()
5254                .and_then(|v| v.split_whitespace().next())
5255            {
5256                Some(head) => {
5257                    name = head.to_string();
5258                    alias_depth += 1;
5259                }
5260                None => break,
5261            }
5262        }
5263
5264        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5265        let is_builtin = self.tools.contains(&name);
5266        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5267    }
5268
5269    // --- Jobs ---
5270
5271    /// Get job manager.
5272    pub fn jobs(&self) -> Arc<JobManager> {
5273        self.jobs.clone()
5274    }
5275
5276    // --- VFS ---
5277
5278    /// Get VFS router.
5279    pub fn vfs(&self) -> Arc<VfsRouter> {
5280        self.vfs.clone()
5281    }
5282
5283    // --- State ---
5284
5285    /// Reset kernel to initial state.
5286    ///
5287    /// Clears in-memory variables and resets cwd to root. History is not
5288    /// cleared (it persists across resets). The kernel's `$$` identity, the
5289    /// confirmation latch / trash-on-delete configuration, and any
5290    /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
5291    /// are re-applied to the fresh scope rather than silently reverting to
5292    /// defaults — an embedder that opted into `with_latch(true)` must not
5293    /// find the gate quietly disabled after a `reset()` between requests.
5294    pub async fn reset(&self) -> Result<()> {
5295        {
5296            let mut scope = self.scope.write().await;
5297            let pid = scope.pid();
5298            let latch_enabled = scope.latch_enabled();
5299            let trash_enabled = scope.trash_enabled();
5300            let mut fresh = Scope::new();
5301            fresh.set_pid(pid);
5302            for (name, value) in self.initial_vars.clone() {
5303                fresh.set_exported(name, value);
5304            }
5305            fresh.set_latch_enabled(latch_enabled);
5306            fresh.set_trash_enabled(trash_enabled);
5307            *scope = fresh;
5308        }
5309        {
5310            let mut ctx = self.exec_ctx.write().await;
5311            ctx.cwd = PathBuf::from("/");
5312        }
5313        Ok(())
5314    }
5315
5316    /// Shutdown the kernel.
5317    pub async fn shutdown(self) -> Result<()> {
5318        // Wait for all background jobs
5319        self.jobs.wait_all().await;
5320        Ok(())
5321    }
5322
5323    /// Dispatch a single command using the full resolution chain.
5324    ///
5325    /// This is the core of `CommandDispatcher` — it syncs state between the
5326    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5327    /// then delegates to `execute_command` for the actual dispatch.
5328    ///
5329    /// State flow:
5330    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5331    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5332    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5333    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5334        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5335        // its inner command via ctx.dispatcher) routes through THIS kernel,
5336        // not a stale parent. Critical for forks: the fork's builtins must
5337        // use the fork's dispatcher, not the parent's.
5338        if let Some(d) = self.dispatcher() {
5339            ctx.dispatcher = Some(d);
5340        }
5341
5342        // 1. Sync ctx → self internals
5343        {
5344            let mut scope = self.scope.write().await;
5345            *scope = ctx.scope.clone();
5346        }
5347        {
5348            let mut ec = self.exec_ctx.write().await;
5349            ec.cwd = ctx.cwd.clone();
5350            ec.prev_cwd = ctx.prev_cwd.clone();
5351            ec.stdin = ctx.stdin.take();
5352            ec.stdin_data = ctx.stdin_data.take();
5353            // The structured-data sideband receiver (set by the concurrent
5354            // pipeline runner on the stage ctx) must reach the tool's snapshot
5355            // too — same reason as the pipe endpoints below. Without this a
5356            // pipeline consumer never sees the producer's `.data`.
5357            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5358            // Streaming pipe endpoints and kernel stderr must flow to the
5359            // tool via self.exec_ctx — execute_command reads that, not the
5360            // passed-in ctx. Without moving these, concurrent pipeline
5361            // stages dispatched via a fork get pipe_stdin = None and
5362            // silently read nothing.
5363            ec.pipe_stdin = ctx.pipe_stdin.take();
5364            ec.pipe_stdout = ctx.pipe_stdout.take();
5365            if let Some(stderr) = ctx.stderr.clone() {
5366                ec.stderr = Some(stderr);
5367            }
5368            ec.aliases = ctx.aliases.clone();
5369            ec.ignore_config = ctx.ignore_config.clone();
5370            ec.output_limit = ctx.output_limit.clone();
5371            ec.pipeline_position = ctx.pipeline_position;
5372            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5373            // swap ctx.cancel to a derived child token before re-dispatching;
5374            // execute_command's snapshot reads ec.cancel (kept aligned by
5375            // this sync), so try_execute_external sees the right token.
5376            ec.cancel = ctx.cancel.clone();
5377            // Same alignment for the watchdog: a fork dispatching through its
5378            // own kernel must hand the shared script clock to the snapshot so
5379            // patient holds in forked stages suspend the right timer.
5380            ec.watchdog = ctx.watchdog.clone();
5381        }
5382
5383        // 2. Execute via the full dispatch chain
5384        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5385
5386        // 3. Sync self → ctx
5387        {
5388            let scope = self.scope.read().await;
5389            ctx.scope = scope.clone();
5390        }
5391        {
5392            let mut ec = self.exec_ctx.write().await;
5393            ctx.cwd = ec.cwd.clone();
5394            ctx.prev_cwd = ec.prev_cwd.clone();
5395            ctx.aliases = ec.aliases.clone();
5396            ctx.ignore_config = ec.ignore_config.clone();
5397            ctx.output_limit = ec.output_limit.clone();
5398            // Return any pipe endpoints that the tool didn't consume.
5399            // `take()` here keeps the fork's exec_ctx in a clean state for
5400            // the next dispatch — these are per-command and shouldn't leak
5401            // between calls.
5402            ctx.pipe_stdin = ec.pipe_stdin.take();
5403            ctx.pipe_stdout = ec.pipe_stdout.take();
5404        }
5405
5406        Ok(result)
5407    }
5408}
5409
5410/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5411/// shared arg-binding core behind both `Kernel::build_args_async`
5412/// (production: full recursion through the async pipeline, command
5413/// substitution, real glob expansion) and the reduced sync evaluator behind
5414/// scatter/gather's own option parsing and the `#[cfg(test)]`
5415/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5416/// `SyncEvalSource`). GH #188 closes the drift class between those two
5417/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5418/// is now the ONLY implementation; only expression evaluation, which is
5419/// capability-bound (recursing into command substitution needs a live async
5420/// pipeline the reduced context doesn't have), still has two providers.
5421#[async_trait]
5422pub(crate) trait ArgValueSource: Send + Sync {
5423    /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5424    /// this evaluator" — the reduced sync evaluator's bash-compatible
5425    /// "coalesce" convention for an unset bare variable, or an expression
5426    /// form it doesn't support (a binary op) — and the caller drops the
5427    /// argument the same way an unset bare variable always has. The real
5428    /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5429    /// evaluate.
5430    async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5431
5432    /// Expand a bare glob-pattern positional to display strings, or `None`
5433    /// if this evaluator doesn't expand globs here (disabled, or the reduced
5434    /// sync context, which never has — matching its documented "no
5435    /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5436    /// back to `eval` (which hands back the pattern text as a literal
5437    /// string) when this returns `None`. An enabled expansion that matches
5438    /// nothing is a genuine error, not `Ok(None)`.
5439    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5440
5441    /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5442    /// — the reduced sync evaluator's existing behavior (it never expanded
5443    /// `~`).
5444    async fn home(&self) -> Option<String>;
5445}
5446
5447#[async_trait]
5448impl ArgValueSource for Kernel {
5449    async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5450        Ok(Some(self.eval_expr_async(expr).await?))
5451    }
5452
5453    async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5454        let glob_enabled = self.scope.read().await.glob_enabled();
5455        if !glob_enabled {
5456            return Ok(None);
5457        }
5458        let (paths, cwd) = {
5459            let ctx = self.exec_ctx.read().await;
5460            let paths = ctx
5461                .expand_glob(pattern)
5462                .await
5463                .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
5464            let cwd = ctx.resolve_path(".");
5465            (paths, cwd)
5466        };
5467        if paths.is_empty() {
5468            anyhow::bail!("no matches: {}", pattern);
5469        }
5470        let display = paths
5471            .into_iter()
5472            .map(|path| {
5473                if !pattern.starts_with('/') {
5474                    path.strip_prefix(&cwd)
5475                        .unwrap_or(&path)
5476                        .to_string_lossy()
5477                        .into_owned()
5478                } else {
5479                    path.to_string_lossy().into_owned()
5480                }
5481            })
5482            .collect();
5483        Ok(Some(display))
5484    }
5485
5486    async fn home(&self) -> Option<String> {
5487        self.scope_home().await
5488    }
5489}
5490
5491/// Pull `consumes` positional args after a non-bool flag and stash them on
5492/// `tool_args.named` under the canonical param name. Shared core behind
5493/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
5494/// function's doc comment for the unification story (GH #188).
5495///
5496/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
5497///   single scalar value (last write wins on the rare duplicate).
5498/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
5499///   inside `named[canonical] = Value::Json(Array(...))`, preserving
5500///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
5501///   a repeated single-value flag must keep every value, not silently drop
5502///   all but the last (a "no silent corruption" violation).
5503/// - `consumes > 1` accumulates each occurrence as an inner
5504///   `serde_json::Value::Array` inside `named[canonical] =
5505///   Value::Json(Array(...))`, preserving invocation order. This is the
5506///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
5507///
5508/// Errors loudly if the flag is missing required positionals — matches
5509/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
5510/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
5511/// can't represent — Kernel's evaluator never returns this) falls back to a
5512/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
5513/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
5514/// error rather than a silently-partial array.
5515#[allow(clippy::too_many_arguments)]
5516async fn consume_flag_positionals(
5517    source: &dyn ArgValueSource,
5518    home: Option<&str>,
5519    args: &[Arg],
5520    flag_name: &str,
5521    canonical: &str,
5522    consumes: usize,
5523    repeatable: bool,
5524    positional_indices: &[usize],
5525    consumed: &mut std::collections::HashSet<usize>,
5526    current_idx: usize,
5527    tool_args: &mut ToolArgs,
5528) -> Result<()> {
5529    let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
5530    for _ in 0..consumes.max(1) {
5531        // A `key=value` (WordAssign) token is consumable only by a
5532        // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
5533        // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
5534        // filter` would reassemble `x=1` into the first slot and steal the
5535        // filter into the second. Multi-value flags take plain positionals.
5536        let allow_word_assign = consumes <= 1;
5537        let next_pos = positional_indices
5538            .iter()
5539            .find(|idx| {
5540                **idx > current_idx
5541                    && !consumed.contains(idx)
5542                    && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
5543            })
5544            .copied();
5545        match next_pos {
5546            Some(pos_idx) => match &args[pos_idx] {
5547                Arg::Positional(expr) => match source.eval(expr).await? {
5548                    Some(value) => {
5549                        let value = apply_tilde_expansion(value, home);
5550                        collected.push(value);
5551                        consumed.insert(pos_idx);
5552                    }
5553                    None if collected.is_empty() => {
5554                        tool_args.flags.insert(flag_name.to_string());
5555                        return Ok(());
5556                    }
5557                    None => anyhow::bail!(
5558                        "--{flag_name}: could not evaluate argument {} in this context",
5559                        collected.len() + 1
5560                    ),
5561                },
5562                // `-v a=1`: reassemble the `key=value` token as the flag's
5563                // scalar value (see `positional_indices` construction).
5564                Arg::WordAssign { key, value } => match source.eval(value).await? {
5565                    Some(val) => {
5566                        let val = apply_tilde_expansion(val, home);
5567                        // Loud on binary (GH #116): `-v a=$BIN` must not silently
5568                        // reassemble the `[binary: N bytes]` placeholder into the
5569                        // flag's value — same text-sink boundary as the primary
5570                        // sinks fixed in #93 item 1.
5571                        let val_str = crate::interpreter::value_to_text_sink_named(
5572                            &val,
5573                            "a key=value argument",
5574                        )
5575                        .map_err(|e| anyhow::anyhow!("{e}"))?;
5576                        collected.push(Value::String(format!("{key}={val_str}")));
5577                        consumed.insert(pos_idx);
5578                    }
5579                    None if collected.is_empty() => {
5580                        tool_args.flags.insert(flag_name.to_string());
5581                        return Ok(());
5582                    }
5583                    None => anyhow::bail!(
5584                        "--{flag_name}: could not evaluate argument {} in this context",
5585                        collected.len() + 1
5586                    ),
5587                },
5588                _ => {}
5589            },
5590            None => {
5591                if consumes <= 1 && collected.is_empty() {
5592                    // Back-compat: a flag with no follow-up positional
5593                    // becomes a bare flag. `--path` with nothing after
5594                    // lands in `flags`, same as before this refactor.
5595                    tool_args.flags.insert(flag_name.to_string());
5596                    return Ok(());
5597                }
5598                anyhow::bail!(
5599                    "--{flag_name} requires {consumes} argument{}, got {}",
5600                    if consumes == 1 { "" } else { "s" },
5601                    collected.len()
5602                );
5603            }
5604        }
5605    }
5606
5607    if consumes <= 1 {
5608        if let Some(v) = collected.pop() {
5609            if repeatable {
5610                push_repeatable_value(tool_args, flag_name, canonical, v)?;
5611            } else {
5612                tool_args.named.insert(canonical.to_string(), v);
5613            }
5614        }
5615        return Ok(());
5616    }
5617
5618    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
5619    let occ: Vec<serde_json::Value> = collected
5620        .into_iter()
5621        .map(|v| crate::interpreter::value_to_json(&v))
5622        .collect();
5623    let entry = tool_args
5624        .named
5625        .entry(canonical.to_string())
5626        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5627    if let Value::Json(serde_json::Value::Array(outer)) = entry {
5628        outer.push(serde_json::Value::Array(occ));
5629    } else {
5630        anyhow::bail!(
5631            "--{flag_name}: named[{canonical}] already holds a non-array value"
5632        );
5633    }
5634    Ok(())
5635}
5636
5637/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
5638/// (GH #188) shared by `Kernel::build_args_async` (production) and the
5639/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
5640/// scatter/gather's own option parsing and the `#[cfg(test)]`
5641/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
5642/// pass: Kernel's evaluates full expressions (including `$(...)` command
5643/// substitution) and expands real globs/tilde; the reduced one can't recurse
5644/// into the async pipeline this early (scatter/gather's own flags bind
5645/// before any worker forks) so it evaluates a smaller expression subset and
5646/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
5647///
5648/// If a schema is provided, uses it to determine argument types:
5649/// - For `--flag` where schema says type is non-bool: consume next
5650///   positional(s) as value(s) (`consumes`/`repeatable`-aware).
5651/// - For `--flag` where schema says type is bool (or unknown): treat as a
5652///   boolean flag.
5653///
5654/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
5655pub(crate) async fn bind_tool_args(
5656    args: &[Arg],
5657    schema: Option<&crate::tools::ToolSchema>,
5658    source: &dyn ArgValueSource,
5659) -> Result<ToolArgs> {
5660    let mut tool_args = ToolArgs::new();
5661    let home = source.home().await;
5662
5663    // A glob-passthrough tool (`glob`) consumes patterns as data: skip
5664    // argv glob expansion so the pattern reaches the tool as written —
5665    // otherwise `glob **/*.rs` binds the first *matching path* as its
5666    // pattern. The eval fallback turns `Expr::GlobPattern` into its
5667    // literal string.
5668    let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
5669
5670    // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
5671    // in source order with types preserved — operators (`-f`, `=`, `!`) as
5672    // strings, operands keeping their `Value` — leaving `flags`/`named`
5673    // empty. A position-sensitive command needs the *true* argv: an operand
5674    // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
5675    // hoisted into the unordered flag set the normal binder splits into.
5676    // Globs still expand and `~` still resolves, matching normal positional
5677    // binding — so `test -f *.rs` errors on too many args, not a literal
5678    // pattern stat.
5679    if schema.is_some_and(|s| s.raw_argv) {
5680        for arg in args {
5681            match arg {
5682                Arg::Positional(expr) => {
5683                    let glob = if let Expr::GlobPattern(p) = expr {
5684                        (!glob_passthrough).then(|| p.clone())
5685                    } else {
5686                        None
5687                    };
5688                    if let Some(pattern) = glob {
5689                        match source.expand_glob(&pattern).await? {
5690                            Some(paths) => {
5691                                for path in paths {
5692                                    tool_args.positional.push(Value::String(path));
5693                                }
5694                            }
5695                            None => {
5696                                let value = source.eval(expr).await?.ok_or_else(|| {
5697                                    anyhow::anyhow!(
5698                                        "raw-argv positional could not be evaluated in this context"
5699                                    )
5700                                })?;
5701                                let value = apply_tilde_expansion(value, home.as_deref());
5702                                tool_args.positional.push(value);
5703                            }
5704                        }
5705                    } else {
5706                        let value = source.eval(expr).await?.ok_or_else(|| {
5707                            anyhow::anyhow!(
5708                                "raw-argv positional could not be evaluated in this context"
5709                            )
5710                        })?;
5711                        let value = apply_tilde_expansion(value, home.as_deref());
5712                        tool_args.positional.push(value);
5713                    }
5714                }
5715                Arg::ShortFlag(name) => {
5716                    tool_args.positional.push(Value::String(format!("-{name}")));
5717                }
5718                Arg::LongFlag(name) => {
5719                    tool_args.positional.push(Value::String(format!("--{name}")));
5720                }
5721                Arg::Named { key, value } => {
5722                    let val = source.eval(value).await?.ok_or_else(|| {
5723                        anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
5724                    })?;
5725                    let val = apply_tilde_expansion(val, home.as_deref());
5726                    // Loud on binary (GH #116): `test --k=$BIN` must not
5727                    // silently reassemble the placeholder into the raw-argv
5728                    // positional stream `test` binds against.
5729                    let val_str = crate::interpreter::value_to_text_sink_named(
5730                        &val,
5731                        "a --key=value argument",
5732                    )
5733                    .map_err(|e| anyhow::anyhow!("{e}"))?;
5734                    tool_args
5735                        .positional
5736                        .push(Value::String(format!("--{key}={val_str}")));
5737                }
5738                Arg::WordAssign { key, value } => {
5739                    let val = source.eval(value).await?.ok_or_else(|| {
5740                        anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
5741                    })?;
5742                    let val = apply_tilde_expansion(val, home.as_deref());
5743                    // Loud on binary (GH #116): same reasoning as the Named
5744                    // arm above, for the bare `key=value` raw-argv form.
5745                    let val_str = crate::interpreter::value_to_text_sink_named(
5746                        &val,
5747                        "a key=value argument",
5748                    )
5749                    .map_err(|e| anyhow::anyhow!("{e}"))?;
5750                    tool_args
5751                        .positional
5752                        .push(Value::String(format!("{key}={val_str}")));
5753                }
5754                Arg::DoubleDash => {
5755                    tool_args.positional.push(Value::String("--".to_string()));
5756                }
5757            }
5758        }
5759        return Ok(tool_args);
5760    }
5761
5762    // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
5763    // schemas; pick the leaf the leading positionals route to and bind
5764    // flags against *its* params. Flat tools return the root. select_leaf
5765    // errors (fail loud) if a computed positional sits where a subcommand
5766    // selector is required.
5767    let leaf = match schema {
5768        Some(s) => Some(select_leaf(s, args)?),
5769        None => None,
5770    };
5771    // Bind against the leaf's params, but MERGE the root schema's params on
5772    // top as "global" flags: a value-flag declared at the tool's top level
5773    // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
5774    // it trails the subcommand path (`kj context retag a b --confirm <n>`).
5775    // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
5776    // merge is a harmless no-op.
5777    let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
5778    if let Some(l) = leaf {
5779        param_lookup.extend(schema_param_lookup(l));
5780    }
5781    // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
5782    // not the leaf — it's a property of the command, not the subcommand.
5783    let accepts_word_assign = schema
5784        .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
5785        .unwrap_or(false);
5786
5787    // Track which positional indices have been consumed as flag values
5788    let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
5789    let mut past_double_dash = false;
5790
5791    // Indices a value-flag may consume as its value. Positionals always
5792    // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
5793    // itself treat `key=value` as an assignment (everything but
5794    // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
5795    // `-v`, rather than skipping it and grabbing the next positional (the
5796    // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
5797    // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
5798    let positional_indices: Vec<usize> = args
5799        .iter()
5800        .enumerate()
5801        .filter_map(|(i, a)| {
5802            let consumable = matches!(a, Arg::Positional(_))
5803                || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
5804            consumable.then_some(i)
5805        })
5806        .collect();
5807
5808    let mut i = 0;
5809    while i < args.len() {
5810        match &args[i] {
5811            Arg::DoubleDash => {
5812                past_double_dash = true;
5813            }
5814            Arg::Positional(expr) => {
5815                if !consumed.contains(&i) {
5816                    // Glob expansion: bare glob patterns expand to matching files
5817                    if let Expr::GlobPattern(pattern) = expr {
5818                        if !glob_passthrough {
5819                            if let Some(paths) = source.expand_glob(pattern).await? {
5820                                for path in paths {
5821                                    tool_args.positional.push(Value::String(path));
5822                                }
5823                                i += 1;
5824                                continue;
5825                            }
5826                        }
5827                    }
5828                    if let Some(value) = source.eval(expr).await? {
5829                        let value = apply_tilde_expansion(value, home.as_deref());
5830                        tool_args.positional.push(value);
5831                    }
5832                }
5833            }
5834            Arg::Named { key, value } => {
5835                if let Some(val) = source.eval(value).await? {
5836                    let val = apply_tilde_expansion(val, home.as_deref());
5837                    // A repeatable flag in `--flag=value` form must accumulate too,
5838                    // not overwrite — otherwise `--expression=A --expression=B`
5839                    // would silently keep only B, and mixing with the `-e` space
5840                    // form would clobber the array. Route it through the same
5841                    // accumulator the space form uses.
5842                    let is_declared_value_flag = param_lookup
5843                        .get(key.as_str())
5844                        .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
5845                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
5846                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
5847                    } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
5848                        // Flagify at bind time (GH #189): `--flag=true`/
5849                        // `--flag=false` binds the same way the bare
5850                        // `--flag`/its absence already do (true → flag
5851                        // presence, false → dropped) instead of landing in
5852                        // `named` as a literal `Value::Bool` that a clap
5853                        // `bool` field's `SetTrue` action rejects
5854                        // (`seq --json=true` used to exit 2 with a clap
5855                        // parse error). Covers both a schema-declared bool
5856                        // param AND an undeclared flag — `--json` itself is
5857                        // deliberately excluded from every builtin's schema
5858                        // (`clap_schema::is_skipped`), so this is what makes
5859                        // `--json=true` work universally instead of only for
5860                        // the builtins that happen to call
5861                        // `ToolArgs::flagify_bool_named` themselves. A
5862                        // declared VALUE-taking flag's own `=true` literal
5863                        // (`spawn --command=true`) is excluded by
5864                        // `is_declared_value_flag` and still falls to
5865                        // `named` below.
5866                        if let Value::Bool(true) = val {
5867                            tool_args.flags.insert(key.clone());
5868                        }
5869                        // Value::Bool(false): absent == false, nothing to insert.
5870                    } else {
5871                        tool_args.named.insert(key.clone(), val);
5872                    }
5873                }
5874            }
5875            Arg::WordAssign { key, value } => {
5876                // Already pulled in as a preceding value-flag's argument
5877                // (`awk -v a=1`); don't also emit it as a positional.
5878                if consumed.contains(&i) {
5879                    i += 1;
5880                    continue;
5881                }
5882                if let Some(val) = source.eval(value).await? {
5883                    let val = apply_tilde_expansion(val, home.as_deref());
5884                    // Past `--`, EVERY token is raw data — including for
5885                    // export/alias, whose `key=value` is normally a shell
5886                    // assignment (GH #189). `export -- A=1` must bind `A=1`
5887                    // as a literal positional, not silently re-enter the
5888                    // named-assignment path `past_double_dash` exists to
5889                    // suppress for flags right above this arm.
5890                    if accepts_word_assign && !past_double_dash {
5891                        tool_args.named.insert(key.clone(), val);
5892                    } else {
5893                        // Stringify "key=value" and pass as a positional.
5894                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
5895                        // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
5896                        // must not silently become a path/operand literally named
5897                        // `foo=[binary: N bytes]`.
5898                        let val_str = crate::interpreter::value_to_text_sink_named(
5899                            &val,
5900                            "a key=value argument",
5901                        )
5902                        .map_err(|e| anyhow::anyhow!("{e}"))?;
5903                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
5904                    }
5905                }
5906            }
5907            Arg::ShortFlag(name) => {
5908                if past_double_dash {
5909                    tool_args.positional.push(Value::String(format!("-{name}")));
5910                } else if name.len() == 1 {
5911                    let flag_name = name.as_str();
5912                    let lookup = param_lookup.get(flag_name);
5913
5914                    // Same ambiguity guard as the `LongFlag` arm below (GH
5915                    // #189 item 4): an undeclared short flag immediately
5916                    // followed by an unconsumed positional under a
5917                    // map_positionals (backend/MCP) schema is exactly as
5918                    // ambiguous as the long-flag case — kaish can't tell a
5919                    // space-form value (`-t explorer`) from a bool flag
5920                    // sitting before a real positional (`-f file.txt`).
5921                    // Unlike `--flag`, there is no `-f=value` escape hatch to
5922                    // suggest: a glued `-f=val` is two tokens with a dangling
5923                    // `=` that the parser's no-token-pasting guard already
5924                    // rejects — the only fix is declaring the flag.
5925                    let ambiguous_value = (lookup.is_none()
5926                        && leaf.is_some_and(|s| s.map_positionals)
5927                        && !consumed.contains(&(i + 1)))
5928                        .then(|| match args.get(i + 1) {
5929                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
5930                                Some(s.clone())
5931                            }
5932                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
5933                            _ => None,
5934                        })
5935                        .flatten();
5936                    if let Some(val) = ambiguous_value {
5937                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
5938                        anyhow::bail!(
5939                            "{tool}: -{name} is not a declared flag, so the \
5940                             space-separated value ({val:?}) would be silently \
5941                             dropped. Have {tool} declare -{name} in its schema \
5942                             (short flags have no -{name}=value form to fall \
5943                             back on)."
5944                        );
5945                    }
5946
5947                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
5948
5949                    if is_bool {
5950                        tool_args.flags.insert(flag_name.to_string());
5951                    } else {
5952                        // Non-bool: consume `consumes` positionals as value(s)
5953                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
5954                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
5955                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
5956                        consume_flag_positionals(
5957                            source,
5958                            home.as_deref(),
5959                            args,
5960                            name,
5961                            canonical,
5962                            consumes,
5963                            repeatable,
5964                            &positional_indices,
5965                            &mut consumed,
5966                            i,
5967                            &mut tool_args,
5968                        )
5969                        .await?;
5970                    }
5971                } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
5972                    // Multi-char short flag matches a schema param (POSIX style: -name value)
5973                    if is_bool_type(typ) {
5974                        tool_args.flags.insert(canonical.to_string());
5975                    } else {
5976                        consume_flag_positionals(
5977                            source,
5978                            home.as_deref(),
5979                            args,
5980                            name,
5981                            canonical,
5982                            consumes,
5983                            repeatable,
5984                            &positional_indices,
5985                            &mut consumed,
5986                            i,
5987                            &mut tool_args,
5988                        )
5989                        .await?;
5990                    }
5991                } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
5992                    .get(&name[..1])
5993                    .filter(|(_, typ, ..)| !is_bool_type(typ))
5994                {
5995                    // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
5996                    // `grep -A1`, `sed -e1d`. The first char is a declared
5997                    // value-taking short flag, so the rest of the token is its
5998                    // value — the coreutils idiom. The lexer's flag char class is
5999                    // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6000                    // (safe to slice) and the tail is a plain literal.
6001                    bind_glued_short_value(
6002                        &mut tool_args,
6003                        &name[..1],
6004                        canonical,
6005                        consumes,
6006                        repeatable,
6007                        name[1..].to_string(),
6008                    )?;
6009                } else {
6010                    // Multi-char combined short flags. Bool flags stack
6011                    // (`-la`), but the FIRST value-taking flag reached
6012                    // consumes the rest of the token as its glued value
6013                    // (`-ivC3` → C=3) or, if it is the last char, the next
6014                    // positional (`grep -ivC 3` → C=3). Before this, a
6015                    // trailing value-flag was silently treated as a bool,
6016                    // stranding its argument as a stray positional (arity
6017                    // error). Undeclared/bool chars stay bare flags, so a
6018                    // schemaless tool keeps the old all-boolean behavior.
6019                    // The first char being value-taking is handled by the
6020                    // glued arm above, so it never reaches here. The flag
6021                    // char class is ASCII, so byte indexing is char indexing
6022                    // (no `Vec<char>` allocation needed).
6023                    let bytes = name.as_bytes();
6024                    let mut p = 0;
6025                    while p < bytes.len() {
6026                        let key = &name[p..p + 1];
6027                        match param_lookup.get(key) {
6028                            Some(&(canonical, typ, consumes, repeatable))
6029                                if !is_bool_type(typ) =>
6030                            {
6031                                let glued = name[p + 1..].to_string();
6032                                if glued.is_empty() {
6033                                    // Value flag is the last char: take the
6034                                    // next positional. `consume_flag_positionals`
6035                                    // respects `consumes`.
6036                                    consume_flag_positionals(
6037                                        source,
6038                                        home.as_deref(),
6039                                        args,
6040                                        key,
6041                                        canonical,
6042                                        consumes,
6043                                        repeatable,
6044                                        &positional_indices,
6045                                        &mut consumed,
6046                                        i,
6047                                        &mut tool_args,
6048                                    )
6049                                    .await?;
6050                                } else {
6051                                    bind_glued_short_value(
6052                                        &mut tool_args,
6053                                        key,
6054                                        canonical,
6055                                        consumes,
6056                                        repeatable,
6057                                        glued,
6058                                    )?;
6059                                }
6060                                break;
6061                            }
6062                            _ => {
6063                                tool_args.flags.insert(key.to_string());
6064                                p += 1;
6065                            }
6066                        }
6067                    }
6068                }
6069            }
6070            Arg::LongFlag(name) => {
6071                if past_double_dash {
6072                    tool_args.positional.push(Value::String(format!("--{name}")));
6073                } else {
6074                    let lookup = param_lookup.get(name.as_str());
6075                    // An *undeclared* long flag under a `map_positionals`
6076                    // (backend/MCP) schema that is immediately followed by an
6077                    // unconsumed positional is ambiguous: kaish can't tell the
6078                    // space-form value (`--type explorer`) from a bool flag
6079                    // before a real positional (`--force file.txt`). Defaulting
6080                    // to bool here silently divorces the value and misroutes it
6081                    // — a privilege-escalation-by-typo against deny-by-default
6082                    // embedders (docs/issues.md). Fail loud instead of guessing.
6083                    let ambiguous_value = (lookup.is_none()
6084                        && leaf.is_some_and(|s| s.map_positionals)
6085                        && !consumed.contains(&(i + 1)))
6086                        .then(|| match args.get(i + 1) {
6087                            // Echo a concrete value for a copy-pasteable fix
6088                            // when it's a plain literal; fall back to VALUE.
6089                            Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6090                                Some(s.clone())
6091                            }
6092                            Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6093                            _ => None,
6094                        })
6095                        .flatten();
6096                    if let Some(val) = ambiguous_value {
6097                        let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6098                        anyhow::bail!(
6099                            "{tool}: --{name} is not a declared flag, so the \
6100                             space-separated value would be silently dropped. \
6101                             Use --{name}={val}, or have {tool} declare --{name} \
6102                             in its schema."
6103                        );
6104                    }
6105                    let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6106
6107                    if is_bool {
6108                        tool_args.flags.insert(name.clone());
6109                    } else {
6110                        let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6111                        let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6112                        let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6113                        consume_flag_positionals(
6114                            source,
6115                            home.as_deref(),
6116                            args,
6117                            name,
6118                            canonical,
6119                            consumes,
6120                            repeatable,
6121                            &positional_indices,
6122                            &mut consumed,
6123                            i,
6124                            &mut tool_args,
6125                        )
6126                        .await?;
6127                    }
6128                }
6129            }
6130        }
6131        i += 1;
6132    }
6133
6134    // Map remaining positionals to unfilled non-bool schema params (in order).
6135    // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6136    // Positionals that appeared after `--` are never mapped (they're raw data).
6137    // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6138    // Keyed off the routed leaf so a subcommand tool maps against the active
6139    // leaf's params (kj leaves keep map_positionals=false → block skipped).
6140    if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6141        let pre_dash_count = if past_double_dash {
6142            let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6143            positional_indices.iter()
6144                .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6145                .count()
6146        } else {
6147            tool_args.positional.len()
6148        };
6149
6150        let mut remaining = Vec::new();
6151        let mut positional_iter = tool_args.positional.drain(..).enumerate();
6152
6153        for param in &schema.params {
6154            if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
6155                continue;
6156            }
6157            if is_bool_type(&param.param_type) {
6158                continue;
6159            }
6160            loop {
6161                match positional_iter.next() {
6162                    Some((idx, val)) if idx < pre_dash_count => {
6163                        tool_args.named.insert(param.name.clone(), val);
6164                        break;
6165                    }
6166                    Some((_, val)) => {
6167                        remaining.push(val);
6168                    }
6169                    None => break,
6170                }
6171            }
6172        }
6173
6174        remaining.extend(positional_iter.map(|(_, v)| v));
6175        tool_args.positional = remaining;
6176    }
6177
6178    Ok(tool_args)
6179}
6180
6181#[async_trait]
6182impl CommandDispatcher for Kernel {
6183    /// Dispatch a command through the Kernel's full resolution chain.
6184    ///
6185    /// This is the single path for all command execution when called from
6186    /// the pipeline runner. It provides the full dispatch chain:
6187    /// user tools → builtins → .kai scripts → external commands → backend tools.
6188    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6189        self.dispatch_command(cmd, ctx).await
6190    }
6191
6192    /// Evaluate an expression through the kernel's async chain, including
6193    /// command substitution. Delegates to `eval_expr_async`, which snapshots
6194    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6195    /// only command output escapes. The `ctx` is unused here because the
6196    /// kernel evaluates against its own session state (a fork carries the
6197    /// pipeline stage's snapshot); var refs resolve against that scope.
6198    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6199        self.eval_expr_async(expr).await
6200    }
6201
6202    /// Produce a forked dispatcher with independent mutable state (detached).
6203    ///
6204    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6205    /// recursing into the trait method we're defining) and coerces the
6206    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6207    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6208        let fork: Arc<Kernel> = Kernel::fork(self).await;
6209        fork
6210    }
6211
6212    /// Produce a forked dispatcher with cancellation cascading from this kernel.
6213    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6214        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6215        fork
6216    }
6217}
6218
6219/// Apply the requested output format to a builtin's result, unless the tool
6220/// owns its own output — and even then, only on success.
6221///
6222/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6223/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6224/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6225/// gather never render a structured error themselves, so a failure
6226/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6227/// never "already rendered" by the tool. Skipping `apply_output_format` on
6228/// that path just leaked the raw diagnostic under `--json` instead of the
6229/// uniform `{"error","code"}` envelope every other builtin's failure gets
6230/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6231/// whole owns_output error-path class). Gating the skip on `result.ok()`
6232/// keeps the intentional success-path opt-out while closing that gap.
6233fn finalize_output(
6234    result: ExecResult,
6235    format: Option<crate::interpreter::OutputFormat>,
6236    owns_output: bool,
6237) -> ExecResult {
6238    match format {
6239        Some(_) if owns_output && result.ok() => result,
6240        Some(format) => apply_output_format(result, format),
6241        None => result,
6242    }
6243}
6244
6245/// Accumulate output from one result into another.
6246///
6247/// Appends stdout and stderr verbatim and updates the exit code to match the
6248/// new result. Used to preserve output from multiple statements, loop
6249/// iterations, and command chains. No separator is inserted between outputs —
6250/// each command's output concatenates raw, matching bash (`printf a; printf b`
6251/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6252/// when a command emits its own, as `echo` does).
6253fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6254    // Materialize lazy OutputData into .out before accumulating.
6255    // Without this, the first command's output stays in .output while
6256    // the second's text gets appended to .out, losing the first.
6257    accumulated.materialize();
6258    match new.out_bytes() {
6259        // A binary result must not be lossy-decoded by text_out(): concatenate
6260        // raw bytes so the combined output stays binary (this is the path every
6261        // top-level statement's result flows through). See docs/binary-data.md.
6262        Some(new_bytes) => {
6263            let mut combined: Vec<u8> = match accumulated.out_bytes() {
6264                Some(b) => b.to_vec(),
6265                None => accumulated.text_out().into_owned().into_bytes(),
6266            };
6267            combined.extend_from_slice(new_bytes);
6268            accumulated.set_out_bytes(combined);
6269        }
6270        None => accumulated.push_out(&new.text_out()),
6271    }
6272    accumulated.err.push_str(&new.err);
6273    accumulated.code = new.code;
6274    accumulated.data = new.data.clone();
6275    accumulated.did_spill = new.did_spill;
6276    accumulated.original_code = new.original_code;
6277    accumulated.content_type = new.content_type.clone();
6278    accumulated.baggage.clone_from(&new.baggage);
6279    // A latch gate (exit-2 + nonce) is the last statement's result; carry its
6280    // control-plane field through accumulation or the confirmation is lost.
6281    accumulated.latch = new.latch.clone();
6282}
6283
6284/// Fold a loop's accumulated output into a break/continue signal that is
6285/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
6286/// (with `N > 1`) would otherwise be discarded when the signal replaces the
6287/// loop's result on its way up. The loop's output comes first (it ran before
6288/// the signal was raised), then the signal's already-carried output.
6289fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
6290    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6291        let mut merged = loop_output;
6292        accumulate_result(&mut merged, result);
6293        *result = merged;
6294    }
6295}
6296
6297/// Accumulate the output a break/continue signal carried (from inner loops it
6298/// propagated through) into the loop that finally handles it, so it survives
6299/// into that loop's result.
6300fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6301    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6302        accumulate_result(accumulated, result);
6303    }
6304}
6305
6306/// Check if a value is truthy.
6307fn is_truthy(value: &Value) -> bool {
6308    match value {
6309        Value::Null => false,
6310        Value::Bool(b) => *b,
6311        Value::Int(i) => *i != 0,
6312        Value::Float(f) => *f != 0.0,
6313        Value::String(s) => !s.is_empty(),
6314        Value::Json(json) => match json {
6315            serde_json::Value::Null => false,
6316            serde_json::Value::Array(arr) => !arr.is_empty(),
6317            serde_json::Value::Object(obj) => !obj.is_empty(),
6318            serde_json::Value::Bool(b) => *b,
6319            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6320            serde_json::Value::String(s) => !s.is_empty(),
6321        },
6322        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6323    }
6324}
6325
6326/// Apply tilde expansion to a value.
6327///
6328/// Only string values starting with `~` are expanded. `home` is the session
6329/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6330/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6331fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6332    match value {
6333        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6334        _ => value,
6335    }
6336}
6337
6338/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6339/// how the lexer tokenizes the equivalent minimally-quoted command string —
6340/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6341/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6342/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6343/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6344///
6345/// Classification matches the lexer's word classes:
6346/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6347///   the binder's `past_double_dash` arms, exactly as for the string door).
6348/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6349/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6350///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6351///   they fall through to a positional, not a flag).
6352/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6353///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6354///   `key=value` positional, per the command's word-assign allowlist).
6355/// - everything else → a literal [`Arg::Positional`].
6356///
6357/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6358/// positional — it can never be a flag — and rides through as-is. That is the
6359/// typed passthrough the string-native door cannot offer.
6360pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6361    argv.iter().map(classify_argv_token).collect()
6362}
6363
6364fn classify_argv_token(token: &Value) -> Arg {
6365    let Value::String(s) = token else {
6366        return Arg::Positional(Expr::Literal(token.clone()));
6367    };
6368
6369    if s == "--" {
6370        return Arg::DoubleDash;
6371    }
6372
6373    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6374    // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6375    // literal word (GH #137), matching this classifier's own literal
6376    // fallback — so they fall through to a literal positional rather than a
6377    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6378    if let Some(rest) = s.strip_prefix("--") {
6379        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6380            return match rest.split_once('=') {
6381                Some((key, val)) => Arg::Named {
6382                    key: key.to_string(),
6383                    value: Expr::Literal(Value::String(val.to_string())),
6384                },
6385                None => Arg::LongFlag(rest.to_string()),
6386            };
6387        }
6388    } else if let Some(rest) = s.strip_prefix('-') {
6389        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6390        // token carrying any other char — notably `=` (`-k=v` is a parse error in
6391        // the string door) — or a leading digit (`-1` lexes as a number) is not a
6392        // short-flag word, so it falls through to a literal positional instead of
6393        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6394        if is_short_flag_body(rest) {
6395            return Arg::ShortFlag(rest.to_string());
6396        }
6397    }
6398
6399    if let Some((key, val)) = s.split_once('=') {
6400        if is_shell_identifier(key) {
6401            return Arg::WordAssign {
6402                key: key.to_string(),
6403                value: Expr::Literal(Value::String(val.to_string())),
6404            };
6405        }
6406    }
6407
6408    Arg::Positional(Expr::Literal(Value::String(s.clone())))
6409}
6410
6411/// A short-flag word: a leading ASCII letter, then only ASCII
6412/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6413/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6414/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6415/// (`=` is the assignment operator — a parse error in the string door), and
6416/// any non-ASCII tail (never produced by the lexer, and not safe for the
6417/// combined-short-flag binder's byte-index slicing) do not, so they fall
6418/// through to a literal positional instead of a malformed `ShortFlag`.
6419fn is_short_flag_body(s: &str) -> bool {
6420    s.starts_with(|c: char| c.is_ascii_alphabetic())
6421        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6422}
6423
6424/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6425fn is_shell_identifier(s: &str) -> bool {
6426    let mut chars = s.chars();
6427    match chars.next() {
6428        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6429        _ => return false,
6430    }
6431    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6432}
6433
6434/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6435/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6436/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6437/// must keep every value, not silently drop all but the last. Used by every flag
6438/// surface that can carry the same flag twice — the space form
6439/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6440/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6441/// ordered array.
6442pub(crate) fn push_repeatable_value(
6443    tool_args: &mut ToolArgs,
6444    flag_name: &str,
6445    canonical: &str,
6446    v: Value,
6447) -> anyhow::Result<()> {
6448    let occ = crate::interpreter::value_to_json(&v);
6449    let entry = tool_args
6450        .named
6451        .entry(canonical.to_string())
6452        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6453    if let Value::Json(serde_json::Value::Array(items)) = entry {
6454        items.push(occ);
6455        Ok(())
6456    } else {
6457        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6458    }
6459}
6460
6461/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6462/// is one token, so it carries a single value: a repeatable flag accumulates
6463/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6464/// first-char glued arm and the combined-bundle arm so the two can't drift on
6465/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6466/// is a loud error, not a silent single-value bind.
6467pub(crate) fn bind_glued_short_value(
6468    tool_args: &mut ToolArgs,
6469    flag_name: &str,
6470    canonical: &str,
6471    consumes: usize,
6472    repeatable: bool,
6473    value: String,
6474) -> anyhow::Result<()> {
6475    if consumes > 1 {
6476        anyhow::bail!(
6477            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6478        );
6479    }
6480    if repeatable {
6481        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6482    } else {
6483        tool_args
6484            .named
6485            .insert(canonical.to_string(), Value::String(value));
6486        Ok(())
6487    }
6488}
6489
6490/// Map a child's exit status to a shell-style exit code.
6491///
6492/// `ExitStatus::code()` is `None` when the process died from a signal rather
6493/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6494/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6495/// number. Shared by both external-command spawn sites — production
6496/// (`try_execute_external`, below) and the test-only twin
6497/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6498/// this mapping again (GH #133 item 1).
6499#[cfg(feature = "subprocess")]
6500pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6501    status.code().unwrap_or_else(|| {
6502        #[cfg(unix)]
6503        {
6504            use std::os::unix::process::ExitStatusExt;
6505            128 + status.signal().unwrap_or(0)
6506        }
6507        #[cfg(not(unix))]
6508        {
6509            -1
6510        }
6511    }) as i64
6512}
6513
6514/// Wait for a child to exit, killing it if `cancel` fires first.
6515///
6516/// `target` carries a Linux pidfd (when available) for race-free direct-child
6517/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6518/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6519#[cfg(all(unix, feature = "subprocess"))]
6520pub(crate) async fn wait_or_kill(
6521    child: &mut tokio::process::Child,
6522    target: Option<&crate::pidfd::KillTarget>,
6523    cancel: &tokio_util::sync::CancellationToken,
6524    grace: Duration,
6525) -> std::io::Result<std::process::ExitStatus> {
6526    tokio::select! {
6527        biased;
6528        status = child.wait() => status,
6529        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6530    }
6531}
6532
6533#[cfg(all(not(unix), feature = "subprocess"))]
6534pub(crate) async fn wait_or_kill(
6535    child: &mut tokio::process::Child,
6536    _target: Option<&()>,
6537    cancel: &tokio_util::sync::CancellationToken,
6538    _grace: Duration,
6539) -> std::io::Result<std::process::ExitStatus> {
6540    tokio::select! {
6541        biased;
6542        status = child.wait() => status,
6543        _ = cancel.cancelled() => {
6544            let _ = child.start_kill();
6545            child.wait().await
6546        }
6547    }
6548}
6549
6550/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6551///
6552/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6553/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6554/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6555#[cfg(all(unix, feature = "subprocess"))]
6556pub(crate) async fn kill_with_grace(
6557    child: &mut tokio::process::Child,
6558    target: Option<&crate::pidfd::KillTarget>,
6559    grace: Duration,
6560) -> std::io::Result<std::process::ExitStatus> {
6561    use nix::sys::signal::Signal;
6562
6563    if let Some(t) = target {
6564        t.signal(Signal::SIGTERM);
6565        t.signal_pg(Signal::SIGTERM);
6566        if grace > Duration::ZERO
6567            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6568        {
6569            return status;
6570        }
6571        t.signal(Signal::SIGKILL);
6572        t.signal_pg(Signal::SIGKILL);
6573    }
6574    child.wait().await
6575}
6576
6577#[cfg(test)]
6578#[allow(clippy::unwrap_used, clippy::expect_used)]
6579mod argv_classify_tests {
6580    use super::*;
6581
6582    /// A normalized, comparable view of one `Arg` representing its *logical
6583    /// argument* (what the command observably receives), not its exact AST shape:
6584    ///
6585    /// - Value-bearing arms compare by *stringified* value, so the parser's
6586    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6587    ///   (`String("-1")`) count as the same argument.
6588    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6589    ///   For every command except the `export`/`alias` allowlist, a bareword
6590    ///   `key=value` is stringified straight back to a `"key=value"` positional
6591    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6592    ///   converge observably even when they disagree on the AST tag — e.g. the
6593    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6594    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
6595    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6596    ///   separately by `classifies_each_word_class`.
6597    ///
6598    /// Returns `None` for shapes we deliberately don't compare:
6599    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6600    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6601    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
6602    ///   string. That divergence is *intentional* — `execute_argv` preserves a
6603    ///   literal numeric string (pass `Value::Int` for a number), the string door
6604    ///   can only guess — so the property skips it rather than demanding the
6605    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
6606    ///   by `classifies_each_word_class`.
6607    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6608        // Only a *string*-valued literal is comparable; a coerced number is not.
6609        let lit = |e: &Expr| match e {
6610            Expr::Literal(Value::String(s)) => Some(s.clone()),
6611            _ => None,
6612        };
6613        Some(match arg {
6614            Arg::DoubleDash => ("dash", String::new(), String::new()),
6615            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6616            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6617            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6618            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6619            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6620        })
6621    }
6622
6623    /// Classify a single string token the way `execute_argv` would.
6624    fn classify(token: &str) -> Arg {
6625        classify_argv_token(&Value::String(token.to_string()))
6626    }
6627
6628    #[test]
6629    fn classifies_each_word_class() {
6630        assert_eq!(classify("--"), Arg::DoubleDash);
6631        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6632        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6633        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6634        assert_eq!(
6635            classify("--key=value"),
6636            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6637        );
6638        assert_eq!(
6639            classify("NAME=val"),
6640            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6641        );
6642        // Digits after the first flag char are ordinary (kept verbatim).
6643        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6644        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6645        // Leading-digit dash is a number to the lexer, not a flag → positional.
6646        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6647        // Numeric strings keep their literal text — `execute_argv` does NOT
6648        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6649        // who wants a number passes `Value::Int`; a string stays the string.
6650        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6651        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6652        // A lone dash (stdin convention) is a positional, not a flag.
6653        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6654        // Non-identifier LHS is not an assignment.
6655        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6656        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6657    }
6658
6659    #[test]
6660    fn typed_values_pass_through_as_literal_positionals() {
6661        // The whole point of the `&[Value]` signature: a non-string value is a
6662        // literal positional carrying the *exact* value, never stringified and
6663        // never flag-interpreted.
6664        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6665        assert_eq!(
6666            classify_argv_token(&bytes),
6667            Arg::Positional(Expr::Literal(bytes.clone()))
6668        );
6669        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6670        assert_eq!(
6671            classify_argv_token(&json),
6672            Arg::Positional(Expr::Literal(json.clone()))
6673        );
6674        // An integer token that *looks* like a flag is still a positional value
6675        // (only strings are inspected for a leading dash).
6676        assert_eq!(
6677            classify_argv_token(&Value::Int(-9)),
6678            Arg::Positional(Expr::Literal(Value::Int(-9)))
6679        );
6680    }
6681
6682    #[test]
6683    fn double_dash_only_matches_exactly() {
6684        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6685        // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6686        // as a single argv token here it's likewise literal.
6687        assert_eq!(classify("--"), Arg::DoubleDash);
6688        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6689        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6690    }
6691
6692    #[test]
6693    fn malformed_flag_words_fall_back_to_literal_positionals() {
6694        // A token that isn't a well-formed flag word must NOT be silently misbound
6695        // into the arg binder (house rule: loud/visible over silent-wrong). Each
6696        // of these is a parse error or different tokenization in the string door,
6697        // so the argv door keeps them as literal positionals.
6698        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6699        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6700        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6701        assert_eq!(classify("-k=v"), pos("-k=v"));
6702        assert_eq!(classify("-="), pos("-="));
6703        // Empty long-flag key.
6704        assert_eq!(classify("--=v"), pos("--=v"));
6705        // `--` followed by a non-letter is not a long flag.
6706        assert_eq!(classify("--1"), pos("--1"));
6707        // A bare dash and a number-dash are positionals (covered above too).
6708        assert_eq!(classify("-"), pos("-"));
6709        assert_eq!(classify("-9"), pos("-9"));
6710        // A non-ASCII tail is not part of the lexer's short-flag char class
6711        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6712        // absorbs) — classifying it as `ShortFlag` would hand the combined
6713        // short-flag binder a byte string it (correctly, for real ASCII flag
6714        // words) slices by *byte* index, panicking on a multi-byte char
6715        // boundary. Fall back to a literal positional instead.
6716        assert_eq!(classify("-lé"), pos("-lé"));
6717        assert_eq!(classify("-é"), pos("-é"));
6718    }
6719
6720    #[tokio::test]
6721    async fn non_ascii_short_flag_bundle_does_not_panic() {
6722        // Regression: `execute_argv`'s combined-short-flag loop assumed the
6723        // flag body was ASCII (safe to byte-slice) because the lexer's
6724        // grammar guarantees that on the *string* door. The argv door's
6725        // classifier let a non-ASCII tail through as `ShortFlag`, so
6726        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6727        let kernel = Kernel::transient().expect("failed to create kernel");
6728        let result = kernel
6729            .execute_argv("ls", &[Value::String("-lé".into())])
6730            .await
6731            .expect("execute_argv must not panic on a non-ASCII short-flag token");
6732        // Not a well-formed flag word, so it's a literal positional — `ls`
6733        // then reports it as a missing path rather than mangling flags.
6734        assert_ne!(result.code, 0);
6735    }
6736
6737    proptest::proptest! {
6738        /// The core correctness claim: the classifier mirrors the lexer/parser
6739        /// on metacharacter-free tokens. For any such single token, the `Arg`
6740        /// the classifier produces matches the one the real parser produces for
6741        /// the equivalent one-word command — so `execute_argv` reusing the
6742        /// string door's binder is sound. (First proptest in the workspace.)
6743        #[test]
6744        fn classifier_matches_parser_on_clean_tokens(
6745            // No digits: this property tests the *classification* boundary
6746            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6747            // positional), not numeric coercion. The lexer coerces digit runs to
6748            // `Int`/`Float` and drops the literal text (even inside a colon-merged
6749            // word: `00:` → `0:`); the classifier intentionally preserves the raw
6750            // string. Those numeric edges are pinned exactly by the unit tests.
6751            token in "[a-zA-Z_=./@:+-]{1,8}"
6752        ) {
6753            let parsed = match parse(&format!("cmd {token}")) {
6754                Ok(p) => p,
6755                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6756            };
6757            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6758                return Ok(());
6759            };
6760            // Only compare when the token lexed as exactly one argument.
6761            let [arg] = cmd.args.as_slice() else { return Ok(()); };
6762
6763            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6764                return Ok(()); // a non-literal parsed Expr we don't model — skip
6765            };
6766            proptest::prop_assert_eq!(
6767                ours, theirs,
6768                "classifier diverged from parser on token {:?}", token
6769            );
6770        }
6771    }
6772}
6773
6774#[cfg(all(test, feature = "subprocess"))]
6775#[allow(clippy::expect_used)]
6776mod tests {
6777    use super::*;
6778
6779    #[tokio::test]
6780    async fn test_kernel_transient() {
6781        let kernel = Kernel::transient().expect("failed to create kernel");
6782        assert_eq!(kernel.name(), "transient");
6783    }
6784
6785    #[tokio::test]
6786    async fn test_kernel_execute_echo() {
6787        let kernel = Kernel::transient().expect("failed to create kernel");
6788        let result = kernel.execute("echo hello").await.expect("execution failed");
6789        assert!(result.ok());
6790        assert_eq!(result.text_out().trim(), "hello");
6791    }
6792
6793    #[tokio::test]
6794    async fn test_multiple_statements_accumulate_output() {
6795        let kernel = Kernel::transient().expect("failed to create kernel");
6796        let result = kernel
6797            .execute("echo one\necho two\necho three")
6798            .await
6799            .expect("execution failed");
6800        assert!(result.ok());
6801        // Should have all three outputs separated by newlines
6802        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6803        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6804        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6805    }
6806
6807    #[tokio::test]
6808    async fn test_and_chain_accumulates_output() {
6809        let kernel = Kernel::transient().expect("failed to create kernel");
6810        let result = kernel
6811            .execute("echo first && echo second")
6812            .await
6813            .expect("execution failed");
6814        assert!(result.ok());
6815        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6816        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6817    }
6818
6819    #[tokio::test]
6820    async fn test_for_loop_accumulates_output() {
6821        let kernel = Kernel::transient().expect("failed to create kernel");
6822        let result = kernel
6823            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6824            .await
6825            .expect("execution failed");
6826        assert!(result.ok());
6827        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6828        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6829        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6830    }
6831
6832    #[tokio::test]
6833    async fn test_while_loop_accumulates_output() {
6834        let kernel = Kernel::transient().expect("failed to create kernel");
6835        let result = kernel
6836            .execute(r#"
6837                N=3
6838                while [[ ${N} -gt 0 ]]; do
6839                    echo "N=${N}"
6840                    N=$((N - 1))
6841                done
6842            "#)
6843            .await
6844            .expect("execution failed");
6845        assert!(result.ok());
6846        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
6847        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
6848        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
6849    }
6850
6851    #[tokio::test]
6852    async fn test_kernel_set_var() {
6853        let kernel = Kernel::transient().expect("failed to create kernel");
6854
6855        kernel.execute("X=42").await.expect("set failed");
6856
6857        let value = kernel.get_var("X").await;
6858        assert_eq!(value, Some(Value::Int(42)));
6859    }
6860
6861    #[tokio::test]
6862    async fn test_kernel_var_expansion() {
6863        let kernel = Kernel::transient().expect("failed to create kernel");
6864
6865        kernel.execute("NAME=\"world\"").await.expect("set failed");
6866        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
6867
6868        assert!(result.ok());
6869        assert_eq!(result.text_out().trim(), "hello world");
6870    }
6871
6872    #[tokio::test]
6873    async fn test_kernel_last_result() {
6874        let kernel = Kernel::transient().expect("failed to create kernel");
6875
6876        kernel.execute("echo test").await.expect("echo failed");
6877
6878        let last = kernel.last_result().await;
6879        assert!(last.ok());
6880        assert_eq!(last.text_out().trim(), "test");
6881    }
6882
6883    #[tokio::test]
6884    async fn test_kernel_tool_not_found() {
6885        let kernel = Kernel::transient().expect("failed to create kernel");
6886
6887        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
6888        assert!(!result.ok());
6889        assert_eq!(result.code, 127);
6890        assert!(result.err.contains("command not found"));
6891    }
6892
6893    #[tokio::test]
6894    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
6895        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
6896        // engine, …) returns a `ToolResult` with structured `data` — this
6897        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
6898        // and `for r in $(embedder_tool)` see the typed value, not just
6899        // stdout text.
6900        use crate::backend::testing::MockBackend;
6901        use crate::backend::ToolResult;
6902        let (mock, _calls) = MockBackend::new();
6903        let backend = mock.with_tool_result(|_name| {
6904            let mut baggage = std::collections::BTreeMap::new();
6905            baggage.insert("trace_id".to_string(), "abc123".to_string());
6906            // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
6907            // construct via with_data + the with_* setters, not a struct literal.
6908            Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
6909                .with_content_type("application/json")
6910                .with_baggage(baggage))
6911        });
6912        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6913        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6914            .expect("with_backend kernel");
6915
6916        let result = kernel
6917            .execute("embedder_tool")
6918            .await
6919            .expect("execution failed");
6920        assert!(result.ok(), "backend tool call should succeed: {result:?}");
6921        assert_eq!(
6922            result.data,
6923            Some(Value::Json(serde_json::json!({"key": "value"}))),
6924            "backend tool's structured data must survive into ExecResult, not be dropped"
6925        );
6926        assert_eq!(
6927            result.content_type.as_deref(),
6928            Some("application/json"),
6929            "backend tool's content_type must survive into ExecResult"
6930        );
6931        assert_eq!(
6932            result.baggage.get("trace_id").map(String::as_str),
6933            Some("abc123"),
6934            "backend tool's baggage must survive into ExecResult"
6935        );
6936    }
6937
6938    #[tokio::test]
6939    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
6940        // A backend tool that IS found but fails during execution (`Io`,
6941        // `PermissionDenied`, …) must surface its real error, not get
6942        // misreported as exit-127 "command not found" — that masks a genuine
6943        // failure as a lookup miss.
6944        use crate::backend::testing::MockBackend;
6945        let (mock, _calls) = MockBackend::new();
6946        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
6947        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6948        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6949            .expect("with_backend kernel");
6950
6951        let result = kernel
6952            .execute("embedder_tool")
6953            .await
6954            .expect("execution failed");
6955        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
6956        assert!(!result.ok());
6957        assert!(
6958            result.err.contains("disk exploded"),
6959            "the real backend error must be visible, not masked: {result:?}"
6960        );
6961    }
6962
6963    #[tokio::test]
6964    async fn test_external_command_true() {
6965        // Use REPL config for passthrough filesystem access
6966        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6967
6968        // /bin/true should be available on any Unix system
6969        let result = kernel.execute("true").await.expect("execution failed");
6970        // This should use the builtin true, which returns 0
6971        assert!(result.ok(), "true should succeed: {:?}", result);
6972    }
6973
6974    #[tokio::test]
6975    async fn test_external_command_basic() {
6976        // Use REPL config for passthrough filesystem access
6977        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6978
6979        // Test with /bin/echo which is external
6980        // Note: kaish has a builtin echo, so this will use the builtin
6981        // Let's test with a command that's not a builtin
6982        // Actually, let's just test that PATH resolution works by checking the PATH var
6983        let path_var = std::env::var("PATH").unwrap_or_default();
6984        eprintln!("System PATH: {}", path_var);
6985
6986        // Set PATH in kernel to ensure it's available
6987        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
6988
6989        // Now try an external command like /usr/bin/env
6990        // But env is also a builtin... let's try uname
6991        let result = kernel.execute("uname").await.expect("execution failed");
6992        eprintln!("uname result: {:?}", result);
6993        // uname should succeed if external commands work
6994        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
6995    }
6996
6997    #[tokio::test]
6998    async fn test_kernel_reset() {
6999        let kernel = Kernel::transient().expect("failed to create kernel");
7000
7001        kernel.execute("X=1").await.expect("set failed");
7002        assert!(kernel.get_var("X").await.is_some());
7003
7004        kernel.reset().await.expect("reset failed");
7005        assert!(kernel.get_var("X").await.is_none());
7006    }
7007
7008    #[tokio::test]
7009    async fn test_kernel_reset_preserves_latch_and_trash_config() {
7010        // An embedder configuring `with_latch(true)` must not have the
7011        // confirmation gate silently disabled by a later `reset()` — that
7012        // would let a destructive command through with no nonce and no
7013        // error, exactly the "silent fallback" the latch exists to prevent.
7014        let kernel = Kernel::new(
7015            KernelConfig::transient()
7016                .with_latch(true)
7017                .with_skip_validation(true),
7018        )
7019        .expect("failed to create kernel");
7020
7021        // Write and rm relative to `/` (reset()'s post-reset cwd) so the file
7022        // is reachable identically before and after reset.
7023        kernel.execute("cd /; echo hi > latch-probe.txt").await.expect("setup write failed");
7024
7025        let before = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
7026        assert_eq!(before.code, 2, "latch should require confirmation before reset: {before:?}");
7027
7028        kernel.reset().await.expect("reset failed");
7029
7030        // reset() only clears scope/cwd (to `/`), not the VFS — the
7031        // un-deleted probe file (the latch blocked the delete above) is
7032        // still there.
7033        let after = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
7034        assert_eq!(
7035            after.code, 2,
7036            "latch must still require confirmation after reset, not silently disable: {after:?}"
7037        );
7038    }
7039
7040    #[tokio::test]
7041    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7042        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7043            .expect("failed to create kernel");
7044
7045        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7046        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7047
7048        kernel.reset().await.expect("reset failed");
7049
7050        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7051        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7052        assert_eq!(
7053            kernel.get_var("HOME").await,
7054            Some(Value::String("/home/probe".into())),
7055            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7056        );
7057    }
7058
7059    #[tokio::test]
7060    async fn test_kernel_cwd() {
7061        let kernel = Kernel::transient().expect("failed to create kernel");
7062
7063        // Transient kernel uses sandboxed mode with cwd=$HOME
7064        let cwd = kernel.cwd().await;
7065        let home = std::env::var("HOME")
7066            .map(PathBuf::from)
7067            .unwrap_or_else(|_| PathBuf::from("/"));
7068        assert_eq!(cwd, home);
7069
7070        kernel.set_cwd(PathBuf::from("/tmp")).await;
7071        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7072    }
7073
7074    #[tokio::test]
7075    async fn test_kernel_list_vars() {
7076        let kernel = Kernel::transient().expect("failed to create kernel");
7077
7078        kernel.execute("A=1").await.ok();
7079        kernel.execute("B=2").await.ok();
7080
7081        let vars = kernel.list_vars().await;
7082        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7083        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7084    }
7085
7086    #[tokio::test]
7087    async fn test_is_truthy() {
7088        assert!(!is_truthy(&Value::Null));
7089        assert!(!is_truthy(&Value::Bool(false)));
7090        assert!(is_truthy(&Value::Bool(true)));
7091        assert!(!is_truthy(&Value::Int(0)));
7092        assert!(is_truthy(&Value::Int(1)));
7093        assert!(!is_truthy(&Value::String("".into())));
7094        assert!(is_truthy(&Value::String("x".into())));
7095    }
7096
7097    #[tokio::test]
7098    async fn test_jq_in_pipeline() {
7099        let kernel = Kernel::transient().expect("failed to create kernel");
7100        // kaish uses double quotes only; escape inner quotes
7101        let result = kernel
7102            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7103            .await
7104            .expect("execution failed");
7105        assert!(result.ok(), "jq pipeline failed: {}", result.err);
7106        assert_eq!(result.text_out().trim(), "Alice");
7107    }
7108
7109    #[tokio::test]
7110    async fn test_user_defined_tool() {
7111        let kernel = Kernel::transient().expect("failed to create kernel");
7112
7113        // Define a function
7114        kernel
7115            .execute(r#"greet() { echo "Hello, $1!" }"#)
7116            .await
7117            .expect("function definition failed");
7118
7119        // Call the function
7120        let result = kernel
7121            .execute(r#"greet "World""#)
7122            .await
7123            .expect("function call failed");
7124
7125        assert!(result.ok(), "greet failed: {}", result.err);
7126        assert_eq!(result.text_out().trim(), "Hello, World!");
7127    }
7128
7129    #[tokio::test]
7130    async fn test_user_tool_positional_args() {
7131        let kernel = Kernel::transient().expect("failed to create kernel");
7132
7133        // Define a function with positional param
7134        kernel
7135            .execute(r#"greet() { echo "Hi $1" }"#)
7136            .await
7137            .expect("function definition failed");
7138
7139        // Call with positional argument
7140        let result = kernel
7141            .execute(r#"greet "Amy""#)
7142            .await
7143            .expect("function call failed");
7144
7145        assert!(result.ok(), "greet failed: {}", result.err);
7146        assert_eq!(result.text_out().trim(), "Hi Amy");
7147    }
7148
7149    #[tokio::test]
7150    async fn test_function_shared_scope() {
7151        let kernel = Kernel::transient().expect("failed to create kernel");
7152
7153        // Set a variable in parent scope
7154        kernel
7155            .execute(r#"SECRET="hidden""#)
7156            .await
7157            .expect("set failed");
7158
7159        // Define a function that accesses and modifies parent variable
7160        kernel
7161            .execute(r#"access_parent() {
7162                echo "${SECRET}"
7163                SECRET="modified"
7164            }"#)
7165            .await
7166            .expect("function definition failed");
7167
7168        // Call the function - it SHOULD see SECRET (shared scope like sh)
7169        let result = kernel.execute("access_parent").await.expect("function call failed");
7170
7171        // Function should have access to parent scope
7172        assert!(
7173            result.text_out().contains("hidden"),
7174            "Function should access parent scope, got: {}",
7175            result.text_out()
7176        );
7177
7178        // Function should have modified the parent variable
7179        let secret = kernel.get_var("SECRET").await;
7180        assert_eq!(
7181            secret,
7182            Some(Value::String("modified".into())),
7183            "Function should modify parent scope"
7184        );
7185    }
7186
7187    #[tokio::test]
7188    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7189    async fn test_exec_builtin() {
7190        let kernel = Kernel::transient().expect("failed to create kernel");
7191        // argv is now a space-separated string or JSON array string
7192        let result = kernel
7193            .execute(r#"exec command="/bin/echo" argv="hello world""#)
7194            .await
7195            .expect("exec failed");
7196
7197        assert!(result.ok(), "exec failed: {}", result.err);
7198        assert_eq!(result.text_out().trim(), "hello world");
7199    }
7200
7201    #[tokio::test]
7202    async fn test_while_false_never_runs() {
7203        let kernel = Kernel::transient().expect("failed to create kernel");
7204
7205        // A while loop with false condition should never run
7206        let result = kernel
7207            .execute(r#"
7208                while false; do
7209                    echo "should not run"
7210                done
7211            "#)
7212            .await
7213            .expect("while false failed");
7214
7215        assert!(result.ok());
7216        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7217    }
7218
7219    #[tokio::test]
7220    async fn test_while_string_comparison() {
7221        let kernel = Kernel::transient().expect("failed to create kernel");
7222
7223        // Set a flag
7224        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7225
7226        // Use string comparison as condition (shell-compatible [[ ]] syntax)
7227        // Note: Put echo last so we can check the output
7228        let result = kernel
7229            .execute(r#"
7230                while [[ ${FLAG} == "go" ]]; do
7231                    FLAG="stop"
7232                    echo "running"
7233                done
7234            "#)
7235            .await
7236            .expect("while with string cmp failed");
7237
7238        assert!(result.ok());
7239        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7240
7241        // Verify flag was changed
7242        let flag = kernel.get_var("FLAG").await;
7243        assert_eq!(flag, Some(Value::String("stop".into())));
7244    }
7245
7246    #[tokio::test]
7247    async fn test_while_numeric_comparison() {
7248        let kernel = Kernel::transient().expect("failed to create kernel");
7249
7250        // Test > comparison (shell-compatible [[ ]] with -gt)
7251        kernel.execute("N=5").await.expect("set failed");
7252
7253        // Note: Put echo last so we can check the output
7254        let result = kernel
7255            .execute(r#"
7256                while [[ ${N} -gt 3 ]]; do
7257                    N=3
7258                    echo "N was greater"
7259                done
7260            "#)
7261            .await
7262            .expect("while with > failed");
7263
7264        assert!(result.ok());
7265        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7266    }
7267
7268    #[tokio::test]
7269    async fn test_break_in_while_loop() {
7270        let kernel = Kernel::transient().expect("failed to create kernel");
7271
7272        let result = kernel
7273            .execute(r#"
7274                I=0
7275                while true; do
7276                    I=1
7277                    echo "before break"
7278                    break
7279                    echo "after break"
7280                done
7281            "#)
7282            .await
7283            .expect("while with break failed");
7284
7285        assert!(result.ok());
7286        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7287        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7288
7289        // Verify we exited the loop
7290        let i = kernel.get_var("I").await;
7291        assert_eq!(i, Some(Value::Int(1)));
7292    }
7293
7294    #[tokio::test]
7295    async fn test_continue_in_while_loop() {
7296        let kernel = Kernel::transient().expect("failed to create kernel");
7297
7298        // Test continue in a while loop where variables persist
7299        // We use string state transition: "start" -> "middle" -> "end"
7300        // continue on "middle" should skip to next iteration
7301        // Shell-compatible: use [[ ]] for comparisons
7302        let result = kernel
7303            .execute(r#"
7304                STATE="start"
7305                AFTER_CONTINUE="no"
7306                while [[ ${STATE} != "done" ]]; do
7307                    if [[ ${STATE} == "start" ]]; then
7308                        STATE="middle"
7309                        continue
7310                        AFTER_CONTINUE="yes"
7311                    fi
7312                    if [[ ${STATE} == "middle" ]]; then
7313                        STATE="done"
7314                    fi
7315                done
7316            "#)
7317            .await
7318            .expect("while with continue failed");
7319
7320        assert!(result.ok());
7321
7322        // STATE should be "done" (we completed the loop)
7323        let state = kernel.get_var("STATE").await;
7324        assert_eq!(state, Some(Value::String("done".into())));
7325
7326        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7327        let after = kernel.get_var("AFTER_CONTINUE").await;
7328        assert_eq!(after, Some(Value::String("no".into())));
7329    }
7330
7331    #[tokio::test]
7332    async fn test_break_with_level() {
7333        let kernel = Kernel::transient().expect("failed to create kernel");
7334
7335        // Nested loop with break 2 to exit both loops
7336        // We verify by checking OUTER value:
7337        // - If break 2 works, OUTER stays at 1 (set before for loop)
7338        // - If break 2 fails, OUTER becomes 2 (set after for loop)
7339        let result = kernel
7340            .execute(r#"
7341                OUTER=0
7342                while true; do
7343                    OUTER=1
7344                    for X in "1 2"; do
7345                        break 2
7346                    done
7347                    OUTER=2
7348                done
7349            "#)
7350            .await
7351            .expect("nested break failed");
7352
7353        assert!(result.ok());
7354
7355        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7356        let outer = kernel.get_var("OUTER").await;
7357        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7358    }
7359
7360    #[tokio::test]
7361    async fn test_return_from_tool() {
7362        let kernel = Kernel::transient().expect("failed to create kernel");
7363
7364        // Define a function that returns early
7365        kernel
7366            .execute(r#"early_return() {
7367                if [[ $1 == 1 ]]; then
7368                    return 42
7369                fi
7370                echo "not returned"
7371            }"#)
7372            .await
7373            .expect("function definition failed");
7374
7375        // Call with arg=1 should return with exit code 42
7376        // (POSIX shell behavior: return N sets exit code, doesn't output N)
7377        let result = kernel
7378            .execute("early_return 1")
7379            .await
7380            .expect("function call failed");
7381
7382        // Exit code should be 42 (non-zero, so not ok())
7383        assert_eq!(result.code, 42);
7384        // Output should be empty (we returned before echo)
7385        assert!(result.text_out().is_empty());
7386    }
7387
7388    #[tokio::test]
7389    async fn test_return_without_value() {
7390        let kernel = Kernel::transient().expect("failed to create kernel");
7391
7392        // Define a function that returns without a value
7393        kernel
7394            .execute(r#"early_exit() {
7395                if [[ $1 == "stop" ]]; then
7396                    return
7397                fi
7398                echo "continued"
7399            }"#)
7400            .await
7401            .expect("function definition failed");
7402
7403        // Call with arg="stop" should return early
7404        let result = kernel
7405            .execute(r#"early_exit "stop""#)
7406            .await
7407            .expect("function call failed");
7408
7409        assert!(result.ok());
7410        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7411    }
7412
7413    #[tokio::test]
7414    async fn test_exit_stops_execution() {
7415        let kernel = Kernel::transient().expect("failed to create kernel");
7416
7417        // exit should stop further execution
7418        kernel
7419            .execute(r#"
7420                BEFORE="yes"
7421                exit 0
7422                AFTER="yes"
7423            "#)
7424            .await
7425            .expect("execution failed");
7426
7427        // BEFORE should be set, AFTER should not
7428        let before = kernel.get_var("BEFORE").await;
7429        assert_eq!(before, Some(Value::String("yes".into())));
7430
7431        let after = kernel.get_var("AFTER").await;
7432        assert!(after.is_none(), "AFTER should not be set after exit");
7433    }
7434
7435    #[tokio::test]
7436    async fn test_exit_with_code() {
7437        let kernel = Kernel::transient().expect("failed to create kernel");
7438
7439        // exit with code should propagate the exit code
7440        let result = kernel
7441            .execute("exit 42")
7442            .await
7443            .expect("exit failed");
7444
7445        assert_eq!(result.code, 42);
7446        assert!(result.text_out().is_empty(), "exit should not produce stdout");
7447    }
7448
7449    #[tokio::test]
7450    async fn test_set_e_stops_on_failure() {
7451        let kernel = Kernel::transient().expect("failed to create kernel");
7452
7453        // Enable error-exit mode
7454        kernel.execute("set -e").await.expect("set -e failed");
7455
7456        // Run a sequence where the middle command fails
7457        kernel
7458            .execute(r#"
7459                STEP1="done"
7460                false
7461                STEP2="done"
7462            "#)
7463            .await
7464            .expect("execution failed");
7465
7466        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7467        let step1 = kernel.get_var("STEP1").await;
7468        assert_eq!(step1, Some(Value::String("done".into())));
7469
7470        let step2 = kernel.get_var("STEP2").await;
7471        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7472    }
7473
7474    #[tokio::test]
7475    async fn test_set_plus_e_disables_error_exit() {
7476        let kernel = Kernel::transient().expect("failed to create kernel");
7477
7478        // Enable then disable error-exit mode
7479        kernel.execute("set -e").await.expect("set -e failed");
7480        kernel.execute("set +e").await.expect("set +e failed");
7481
7482        // Now failure should NOT stop execution
7483        kernel
7484            .execute(r#"
7485                STEP1="done"
7486                false
7487                STEP2="done"
7488            "#)
7489            .await
7490            .expect("execution failed");
7491
7492        // Both should be set since +e disables error exit
7493        let step1 = kernel.get_var("STEP1").await;
7494        assert_eq!(step1, Some(Value::String("done".into())));
7495
7496        let step2 = kernel.get_var("STEP2").await;
7497        assert_eq!(step2, Some(Value::String("done".into())));
7498    }
7499
7500    #[tokio::test]
7501    async fn test_set_ignores_unknown_options() {
7502        let kernel = Kernel::transient().expect("failed to create kernel");
7503
7504        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7505        let result = kernel
7506            .execute("set -e -u -o pipefail")
7507            .await
7508            .expect("set with unknown options failed");
7509
7510        assert!(result.ok(), "set should succeed with unknown options");
7511
7512        // -e should still be enabled
7513        kernel
7514            .execute(r#"
7515                BEFORE="yes"
7516                false
7517                AFTER="yes"
7518            "#)
7519            .await
7520            .ok();
7521
7522        let after = kernel.get_var("AFTER").await;
7523        assert!(after.is_none(), "-e should be enabled despite unknown options");
7524    }
7525
7526    #[tokio::test]
7527    async fn test_set_no_args_shows_settings() {
7528        let kernel = Kernel::transient().expect("failed to create kernel");
7529
7530        // Enable -e
7531        kernel.execute("set -e").await.expect("set -e failed");
7532
7533        // Call set with no args to see settings
7534        let result = kernel.execute("set").await.expect("set failed");
7535
7536        assert!(result.ok());
7537        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7538    }
7539
7540    #[tokio::test]
7541    async fn test_set_e_in_pipeline() {
7542        let kernel = Kernel::transient().expect("failed to create kernel");
7543
7544        kernel.execute("set -e").await.expect("set -e failed");
7545
7546        // Pipeline failure should trigger exit
7547        kernel
7548            .execute(r#"
7549                BEFORE="yes"
7550                false | cat
7551                AFTER="yes"
7552            "#)
7553            .await
7554            .ok();
7555
7556        let before = kernel.get_var("BEFORE").await;
7557        assert_eq!(before, Some(Value::String("yes".into())));
7558
7559        // AFTER should not be set if pipeline failure triggers exit
7560        // Note: The exit code of a pipeline is the exit code of the last command
7561        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7562        // To test pipeline failure, we need the last command to fail.
7563    }
7564
7565    #[tokio::test]
7566    async fn test_set_e_with_and_chain() {
7567        let kernel = Kernel::transient().expect("failed to create kernel");
7568
7569        kernel.execute("set -e").await.expect("set -e failed");
7570
7571        // Commands in && chain should not trigger -e on the first failure
7572        // because && explicitly handles the error
7573        kernel
7574            .execute(r#"
7575                RESULT="initial"
7576                false && RESULT="chained"
7577                RESULT="continued"
7578            "#)
7579            .await
7580            .ok();
7581
7582        // In bash, commands in && don't trigger -e. The chain handles the failure.
7583        // Our implementation may differ - let's verify current behavior.
7584        let result = kernel.get_var("RESULT").await;
7585        // If we follow bash semantics, RESULT should be "continued"
7586        // If we trigger -e on the false, RESULT stays "initial"
7587        assert!(result.is_some(), "RESULT should be set");
7588    }
7589
7590    #[tokio::test]
7591    async fn test_set_e_exits_in_for_loop() {
7592        let kernel = Kernel::transient().expect("failed to create kernel");
7593
7594        kernel.execute("set -e").await.expect("set -e failed");
7595
7596        kernel
7597            .execute(r#"
7598                REACHED="no"
7599                for x in 1 2 3; do
7600                    false
7601                    REACHED="yes"
7602                done
7603            "#)
7604            .await
7605            .ok();
7606
7607        // With set -e, false should trigger exit; REACHED should remain "no"
7608        let reached = kernel.get_var("REACHED").await;
7609        assert_eq!(reached, Some(Value::String("no".into())),
7610            "set -e should exit on failure in for loop body");
7611    }
7612
7613    #[tokio::test]
7614    async fn test_for_loop_continues_without_set_e() {
7615        let kernel = Kernel::transient().expect("failed to create kernel");
7616
7617        // Without set -e, for loop should continue normally
7618        kernel
7619            .execute(r#"
7620                COUNT=0
7621                for x in 1 2 3; do
7622                    false
7623                    COUNT=$((COUNT + 1))
7624                done
7625            "#)
7626            .await
7627            .ok();
7628
7629        let count = kernel.get_var("COUNT").await;
7630        // Arithmetic produces Int values; accept either Int or String representation
7631        let count_val = match &count {
7632            Some(Value::Int(n)) => *n,
7633            Some(Value::String(s)) => s.parse().unwrap_or(-1),
7634            _ => -1,
7635        };
7636        assert_eq!(count_val, 3,
7637            "without set -e, loop should complete all iterations (got {:?})", count);
7638    }
7639
7640    // ═══════════════════════════════════════════════════════════════════════════
7641    // Source Tests
7642    // ═══════════════════════════════════════════════════════════════════════════
7643
7644    #[tokio::test]
7645    async fn test_source_sets_variables() {
7646        let kernel = Kernel::transient().expect("failed to create kernel");
7647
7648        // Write a script to the VFS
7649        kernel
7650            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7651            .await
7652            .expect("write failed");
7653
7654        // Source the script
7655        let result = kernel
7656            .execute(r#"source "/test.kai""#)
7657            .await
7658            .expect("source failed");
7659
7660        assert!(result.ok(), "source should succeed");
7661
7662        // Variable should be set in current scope
7663        let foo = kernel.get_var("FOO").await;
7664        assert_eq!(foo, Some(Value::String("bar".into())));
7665    }
7666
7667    #[tokio::test]
7668    async fn test_source_with_dot_alias() {
7669        let kernel = Kernel::transient().expect("failed to create kernel");
7670
7671        // Write a script to the VFS
7672        kernel
7673            .execute(r#"write "/vars.kai" 'X=42'"#)
7674            .await
7675            .expect("write failed");
7676
7677        // Source using . alias
7678        let result = kernel
7679            .execute(r#". "/vars.kai""#)
7680            .await
7681            .expect(". failed");
7682
7683        assert!(result.ok(), ". should succeed");
7684
7685        // Variable should be set in current scope
7686        let x = kernel.get_var("X").await;
7687        assert_eq!(x, Some(Value::Int(42)));
7688    }
7689
7690    #[tokio::test]
7691    async fn test_source_not_found() {
7692        let kernel = Kernel::transient().expect("failed to create kernel");
7693
7694        // Try to source a non-existent file
7695        let result = kernel
7696            .execute(r#"source "/nonexistent.kai""#)
7697            .await
7698            .expect("source should not fail with error");
7699
7700        assert!(!result.ok(), "source of non-existent file should fail");
7701        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7702    }
7703
7704    #[tokio::test]
7705    async fn test_source_missing_filename() {
7706        let kernel = Kernel::transient().expect("failed to create kernel");
7707
7708        // Call source with no arguments
7709        let result = kernel
7710            .execute("source")
7711            .await
7712            .expect("source should not fail with error");
7713
7714        assert!(!result.ok(), "source without filename should fail");
7715        assert!(result.err.contains("missing filename"), "error should mention missing filename");
7716    }
7717
7718    #[tokio::test]
7719    async fn test_source_executes_multiple_statements() {
7720        let kernel = Kernel::transient().expect("failed to create kernel");
7721
7722        // Write a script with multiple statements
7723        kernel
7724            .execute(r#"write "/multi.kai" 'A=1
7725B=2
7726C=3'"#)
7727            .await
7728            .expect("write failed");
7729
7730        // Source it
7731        kernel
7732            .execute(r#"source "/multi.kai""#)
7733            .await
7734            .expect("source failed");
7735
7736        // All variables should be set
7737        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7738        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7739        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7740    }
7741
7742    #[tokio::test]
7743    async fn test_source_can_define_functions() {
7744        let kernel = Kernel::transient().expect("failed to create kernel");
7745
7746        // Write a script that defines a function
7747        kernel
7748            .execute(r#"write "/functions.kai" 'greet() {
7749    echo "Hello, $1!"
7750}'"#)
7751            .await
7752            .expect("write failed");
7753
7754        // Source it
7755        kernel
7756            .execute(r#"source "/functions.kai""#)
7757            .await
7758            .expect("source failed");
7759
7760        // Use the defined function
7761        let result = kernel
7762            .execute(r#"greet "World""#)
7763            .await
7764            .expect("greet failed");
7765
7766        assert!(result.ok());
7767        assert!(result.text_out().contains("Hello, World!"));
7768    }
7769
7770    #[tokio::test]
7771    async fn test_source_inherits_error_exit() {
7772        let kernel = Kernel::transient().expect("failed to create kernel");
7773
7774        // Enable error exit
7775        kernel.execute("set -e").await.expect("set -e failed");
7776
7777        // Write a script that has a failure
7778        kernel
7779            .execute(r#"write "/fail.kai" 'BEFORE="yes"
7780false
7781AFTER="yes"'"#)
7782            .await
7783            .expect("write failed");
7784
7785        // Source it (should exit on false due to set -e)
7786        kernel
7787            .execute(r#"source "/fail.kai""#)
7788            .await
7789            .ok();
7790
7791        // BEFORE should be set, AFTER should NOT be set due to error exit
7792        let before = kernel.get_var("BEFORE").await;
7793        assert_eq!(before, Some(Value::String("yes".into())));
7794
7795        // Note: This test depends on whether error exit is checked within source
7796        // Currently our implementation checks per-statement in the main kernel
7797    }
7798
7799    // ═══════════════════════════════════════════════════════════════════════════
7800    // set -e with && / || chains
7801    // ═══════════════════════════════════════════════════════════════════════════
7802
7803    #[tokio::test]
7804    async fn test_set_e_and_chain_left_fails() {
7805        // set -e; false && echo hi; REACHED=1 → REACHED should be set
7806        let kernel = Kernel::transient().expect("failed to create kernel");
7807        kernel.execute("set -e").await.expect("set -e failed");
7808
7809        kernel
7810            .execute("false && echo hi; REACHED=1")
7811            .await
7812            .expect("execution failed");
7813
7814        let reached = kernel.get_var("REACHED").await;
7815        assert_eq!(
7816            reached,
7817            Some(Value::Int(1)),
7818            "set -e should not trigger on left side of &&"
7819        );
7820    }
7821
7822    #[tokio::test]
7823    async fn test_set_e_and_chain_right_fails() {
7824        // set -e; true && false; REACHED=1 → REACHED should NOT be set
7825        let kernel = Kernel::transient().expect("failed to create kernel");
7826        kernel.execute("set -e").await.expect("set -e failed");
7827
7828        kernel
7829            .execute("true && false; REACHED=1")
7830            .await
7831            .expect("execution failed");
7832
7833        let reached = kernel.get_var("REACHED").await;
7834        assert!(
7835            reached.is_none(),
7836            "set -e should trigger when right side of && fails"
7837        );
7838    }
7839
7840    #[tokio::test]
7841    async fn test_set_e_or_chain_recovers() {
7842        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7843        let kernel = Kernel::transient().expect("failed to create kernel");
7844        kernel.execute("set -e").await.expect("set -e failed");
7845
7846        kernel
7847            .execute("false || echo recovered; REACHED=1")
7848            .await
7849            .expect("execution failed");
7850
7851        let reached = kernel.get_var("REACHED").await;
7852        assert_eq!(
7853            reached,
7854            Some(Value::Int(1)),
7855            "set -e should not trigger when || recovers the failure"
7856        );
7857    }
7858
7859    #[tokio::test]
7860    async fn test_set_e_or_chain_both_fail() {
7861        // set -e; false || false; REACHED=1 → REACHED should NOT be set
7862        let kernel = Kernel::transient().expect("failed to create kernel");
7863        kernel.execute("set -e").await.expect("set -e failed");
7864
7865        kernel
7866            .execute("false || false; REACHED=1")
7867            .await
7868            .expect("execution failed");
7869
7870        let reached = kernel.get_var("REACHED").await;
7871        assert!(
7872            reached.is_none(),
7873            "set -e should trigger when || chain ultimately fails"
7874        );
7875    }
7876
7877    // ═══════════════════════════════════════════════════════════════════════════
7878    // Cancellation Tests
7879    // ═══════════════════════════════════════════════════════════════════════════
7880
7881    /// Helper: schedule a cancel after a delay from a background thread.
7882    /// Uses std::thread because cancel() is sync and Kernel is not Send.
7883    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
7884        let k = Arc::clone(kernel);
7885        std::thread::spawn(move || {
7886            std::thread::sleep(delay);
7887            k.cancel();
7888        });
7889    }
7890
7891    #[tokio::test]
7892    async fn test_cancel_interrupts_for_loop() {
7893        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7894
7895        // Schedule cancel after a short delay from a background OS thread
7896        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7897
7898        // #149: a bare `X=$i` body has no await point, so the for-loop's
7899        // cancellation checkpoint (checked once per iteration, see the
7900        // `Stmt::For` arm above) never gets a chance to run mid-body — under
7901        // host load, 100_000 trivial iterations could complete and return
7902        // before the background thread's 10ms sleep ever elapsed, racing a
7903        // natural exit-0 completion against the scheduled cancel. Rather than
7904        // widen the margin (there's no bound on how slow "under load" can be),
7905        // make completion deterministically impossible inside the test
7906        // window: `sleep` is a real interruptible await point (it races
7907        // `tokio::time::sleep` against the same cancellation token — see
7908        // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
7909        // cancellation somewhere to land almost immediately AND, at enough
7910        // iterations, makes natural completion take far longer than the
7911        // bounded wait below. The outer timeout is the "must not hang CI if
7912        // cancellation is broken" backstop: it fails loudly well before the
7913        // loop could ever finish on its own.
7914        const ITERATIONS: u32 = 2000;
7915        const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
7916        let bound = std::time::Duration::from_secs(10);
7917        let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
7918
7919        let result = tokio::time::timeout(bound, kernel.execute(&script))
7920            .await
7921            .unwrap_or_else(|_| {
7922                panic!(
7923                    "for-loop did not return within {bound:?} — cancellation support looks \
7924                     broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
7925                     longer than this bound)",
7926                    ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
7927                )
7928            })
7929            .expect("execute failed");
7930
7931        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
7932
7933        // The loop variable should be set to something well short of the full
7934        // iteration count — i.e. cancellation landed long before the loop
7935        // could complete on its own.
7936        let x = kernel.get_var("X").await;
7937        if let Some(Value::Int(n)) = x {
7938            assert!(
7939                n < i64::from(ITERATIONS),
7940                "loop should have been interrupted before finishing, got X={n}"
7941            );
7942        }
7943    }
7944
7945    #[tokio::test]
7946    async fn test_cancel_interrupts_while_loop() {
7947        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7948        kernel.execute("COUNT=0").await.expect("init failed");
7949
7950        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7951
7952        let result = kernel
7953            .execute("while true; do COUNT=$((COUNT + 1)); done")
7954            .await
7955            .expect("execute failed");
7956
7957        assert_eq!(result.code, 130);
7958
7959        let count = kernel.get_var("COUNT").await;
7960        if let Some(Value::Int(n)) = count {
7961            assert!(n > 0, "loop should have run at least once");
7962        }
7963    }
7964
7965    #[tokio::test]
7966    async fn test_reset_after_cancel() {
7967        // After cancellation, the next execute() should work normally
7968        let kernel = Kernel::transient().expect("failed to create kernel");
7969        kernel.cancel(); // cancel with nothing running
7970
7971        let result = kernel.execute("echo hello").await.expect("execute failed");
7972        assert!(result.ok(), "execute after cancel should succeed");
7973        assert_eq!(result.text_out().trim(), "hello");
7974    }
7975
7976    #[tokio::test]
7977    async fn test_cancel_interrupts_statement_sequence() {
7978        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7979
7980        // Schedule cancel after the first statement runs but before sleep finishes
7981        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
7982
7983        let result = kernel
7984            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
7985            .await
7986            .expect("execute failed");
7987
7988        assert_eq!(result.code, 130);
7989
7990        // STEP should be 1 (set before sleep), not 2 or 3
7991        let step = kernel.get_var("STEP").await;
7992        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
7993    }
7994
7995    // ═══════════════════════════════════════════════════════════════════════════
7996    // Case Statement Tests
7997    // ═══════════════════════════════════════════════════════════════════════════
7998
7999    #[tokio::test]
8000    async fn test_case_simple_match() {
8001        let kernel = Kernel::transient().expect("failed to create kernel");
8002
8003        let result = kernel
8004            .execute(r#"
8005                case "hello" in
8006                    hello) echo "matched hello" ;;
8007                    world) echo "matched world" ;;
8008                esac
8009            "#)
8010            .await
8011            .expect("case failed");
8012
8013        assert!(result.ok());
8014        assert_eq!(result.text_out().trim(), "matched hello");
8015    }
8016
8017    #[tokio::test]
8018    async fn test_case_wildcard_match() {
8019        let kernel = Kernel::transient().expect("failed to create kernel");
8020
8021        let result = kernel
8022            .execute(r#"
8023                case "main.rs" in
8024                    *.py) echo "Python" ;;
8025                    *.rs) echo "Rust" ;;
8026                    *) echo "Unknown" ;;
8027                esac
8028            "#)
8029            .await
8030            .expect("case failed");
8031
8032        assert!(result.ok());
8033        assert_eq!(result.text_out().trim(), "Rust");
8034    }
8035
8036    #[tokio::test]
8037    async fn test_case_default_match() {
8038        let kernel = Kernel::transient().expect("failed to create kernel");
8039
8040        let result = kernel
8041            .execute(r#"
8042                case "unknown.xyz" in
8043                    *.py) echo "Python" ;;
8044                    *.rs) echo "Rust" ;;
8045                    *) echo "Default" ;;
8046                esac
8047            "#)
8048            .await
8049            .expect("case failed");
8050
8051        assert!(result.ok());
8052        assert_eq!(result.text_out().trim(), "Default");
8053    }
8054
8055    #[tokio::test]
8056    async fn test_case_no_match() {
8057        let kernel = Kernel::transient().expect("failed to create kernel");
8058
8059        // Case with no default branch and no match
8060        let result = kernel
8061            .execute(r#"
8062                case "nope" in
8063                    "yes") echo "yes" ;;
8064                    "no") echo "no" ;;
8065                esac
8066            "#)
8067            .await
8068            .expect("case failed");
8069
8070        assert!(result.ok());
8071        assert!(result.text_out().is_empty(), "no match should produce empty output");
8072    }
8073
8074    #[tokio::test]
8075    async fn test_case_with_variable() {
8076        let kernel = Kernel::transient().expect("failed to create kernel");
8077
8078        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8079
8080        let result = kernel
8081            .execute(r#"
8082                case ${LANG} in
8083                    python) echo "snake" ;;
8084                    rust) echo "crab" ;;
8085                    go) echo "gopher" ;;
8086                esac
8087            "#)
8088            .await
8089            .expect("case failed");
8090
8091        assert!(result.ok());
8092        assert_eq!(result.text_out().trim(), "crab");
8093    }
8094
8095    #[tokio::test]
8096    async fn test_case_multiple_patterns() {
8097        let kernel = Kernel::transient().expect("failed to create kernel");
8098
8099        let result = kernel
8100            .execute(r#"
8101                case "yes" in
8102                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8103                    "n"|"no"|"N"|"NO") echo "negative" ;;
8104                esac
8105            "#)
8106            .await
8107            .expect("case failed");
8108
8109        assert!(result.ok());
8110        assert_eq!(result.text_out().trim(), "affirmative");
8111    }
8112
8113    #[tokio::test]
8114    async fn test_case_glob_question_mark() {
8115        let kernel = Kernel::transient().expect("failed to create kernel");
8116
8117        let result = kernel
8118            .execute(r#"
8119                case "test1" in
8120                    test?) echo "matched test?" ;;
8121                    *) echo "default" ;;
8122                esac
8123            "#)
8124            .await
8125            .expect("case failed");
8126
8127        assert!(result.ok());
8128        assert_eq!(result.text_out().trim(), "matched test?");
8129    }
8130
8131    #[tokio::test]
8132    async fn test_case_char_class() {
8133        let kernel = Kernel::transient().expect("failed to create kernel");
8134
8135        let result = kernel
8136            .execute(r#"
8137                case "Yes" in
8138                    [Yy]*) echo "yes-like" ;;
8139                    [Nn]*) echo "no-like" ;;
8140                esac
8141            "#)
8142            .await
8143            .expect("case failed");
8144
8145        assert!(result.ok());
8146        assert_eq!(result.text_out().trim(), "yes-like");
8147    }
8148
8149    // ═══════════════════════════════════════════════════════════════════════════
8150    // Cat Stdin Tests
8151    // ═══════════════════════════════════════════════════════════════════════════
8152
8153    #[tokio::test]
8154    async fn test_cat_from_pipeline() {
8155        let kernel = Kernel::transient().expect("failed to create kernel");
8156
8157        let result = kernel
8158            .execute(r#"echo "piped text" | cat"#)
8159            .await
8160            .expect("cat pipeline failed");
8161
8162        assert!(result.ok(), "cat failed: {}", result.err);
8163        assert_eq!(result.text_out().trim(), "piped text");
8164    }
8165
8166    #[tokio::test]
8167    async fn test_cat_from_pipeline_multiline() {
8168        let kernel = Kernel::transient().expect("failed to create kernel");
8169
8170        let result = kernel
8171            .execute(r#"echo "line1\nline2" | cat -n"#)
8172            .await
8173            .expect("cat pipeline failed");
8174
8175        assert!(result.ok(), "cat failed: {}", result.err);
8176        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8177    }
8178
8179    // ═══════════════════════════════════════════════════════════════════════════
8180    // Heredoc Tests
8181    // ═══════════════════════════════════════════════════════════════════════════
8182
8183    #[tokio::test]
8184    async fn test_heredoc_basic() {
8185        let kernel = Kernel::transient().expect("failed to create kernel");
8186
8187        let result = kernel
8188            .execute("cat <<EOF\nhello\nEOF")
8189            .await
8190            .expect("heredoc failed");
8191
8192        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8193        assert_eq!(result.text_out().trim(), "hello");
8194    }
8195
8196    #[tokio::test]
8197    async fn test_arithmetic_in_string() {
8198        let kernel = Kernel::transient().expect("failed to create kernel");
8199
8200        let result = kernel
8201            .execute(r#"echo "result: $((1 + 2))""#)
8202            .await
8203            .expect("arithmetic in string failed");
8204
8205        assert!(result.ok(), "echo failed: {}", result.err);
8206        assert_eq!(result.text_out().trim(), "result: 3");
8207    }
8208
8209    #[tokio::test]
8210    async fn test_heredoc_multiline() {
8211        let kernel = Kernel::transient().expect("failed to create kernel");
8212
8213        let result = kernel
8214            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8215            .await
8216            .expect("heredoc failed");
8217
8218        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8219        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8220        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8221        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8222    }
8223
8224    #[tokio::test]
8225    async fn test_heredoc_variable_expansion() {
8226        // Bug N: unquoted heredoc should expand variables
8227        let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229        kernel.execute("GREETING=hello").await.expect("set var");
8230
8231        let result = kernel
8232            .execute("cat <<EOF\n$GREETING world\nEOF")
8233            .await
8234            .expect("heredoc expansion failed");
8235
8236        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8237        assert_eq!(result.text_out().trim(), "hello world");
8238    }
8239
8240    #[tokio::test]
8241    async fn test_heredoc_quoted_no_expansion() {
8242        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8243        let kernel = Kernel::transient().expect("failed to create kernel");
8244
8245        kernel.execute("GREETING=hello").await.expect("set var");
8246
8247        let result = kernel
8248            .execute("cat <<'EOF'\n$GREETING world\nEOF")
8249            .await
8250            .expect("quoted heredoc failed");
8251
8252        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8253        assert_eq!(result.text_out().trim(), "$GREETING world");
8254    }
8255
8256    #[tokio::test]
8257    async fn test_heredoc_default_value_expansion() {
8258        // Bug N: ${VAR:-default} should expand in unquoted heredocs
8259        let kernel = Kernel::transient().expect("failed to create kernel");
8260
8261        let result = kernel
8262            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8263            .await
8264            .expect("heredoc default expansion failed");
8265
8266        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8267        assert_eq!(result.text_out().trim(), "fallback");
8268    }
8269
8270    // ═══════════════════════════════════════════════════════════════════════════
8271    // Read Builtin Tests
8272    // ═══════════════════════════════════════════════════════════════════════════
8273
8274    #[tokio::test]
8275    async fn test_read_from_pipeline() {
8276        let kernel = Kernel::transient().expect("failed to create kernel");
8277
8278        // Pipe input to read
8279        let result = kernel
8280            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8281            .await
8282            .expect("read pipeline failed");
8283
8284        assert!(result.ok(), "read failed: {}", result.err);
8285        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8286    }
8287
8288    #[tokio::test]
8289    async fn test_read_multiple_vars_from_pipeline() {
8290        let kernel = Kernel::transient().expect("failed to create kernel");
8291
8292        let result = kernel
8293            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8294            .await
8295            .expect("read pipeline failed");
8296
8297        assert!(result.ok(), "read failed: {}", result.err);
8298        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8299    }
8300
8301    // ═══════════════════════════════════════════════════════════════════════════
8302    // Shell-Style Function Tests
8303    // ═══════════════════════════════════════════════════════════════════════════
8304
8305    #[tokio::test]
8306    async fn test_posix_function_with_positional_params() {
8307        let kernel = Kernel::transient().expect("failed to create kernel");
8308
8309        // Define POSIX-style function
8310        kernel
8311            .execute(r#"greet() { echo "Hello, $1!" }"#)
8312            .await
8313            .expect("function definition failed");
8314
8315        // Call the function
8316        let result = kernel
8317            .execute(r#"greet "Amy""#)
8318            .await
8319            .expect("function call failed");
8320
8321        assert!(result.ok(), "greet failed: {}", result.err);
8322        assert_eq!(result.text_out().trim(), "Hello, Amy!");
8323    }
8324
8325    #[tokio::test]
8326    async fn test_posix_function_multiple_args() {
8327        let kernel = Kernel::transient().expect("failed to create kernel");
8328
8329        // Define function using $1 and $2
8330        kernel
8331            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8332            .await
8333            .expect("function definition failed");
8334
8335        // Call the function
8336        let result = kernel
8337            .execute(r#"add_greeting "Hello" "World""#)
8338            .await
8339            .expect("function call failed");
8340
8341        assert!(result.ok(), "function failed: {}", result.err);
8342        assert_eq!(result.text_out().trim(), "Hello World!");
8343    }
8344
8345    #[tokio::test]
8346    async fn test_bash_function_with_positional_params() {
8347        let kernel = Kernel::transient().expect("failed to create kernel");
8348
8349        // Define bash-style function (function keyword, no parens)
8350        kernel
8351            .execute(r#"function greet { echo "Hi $1" }"#)
8352            .await
8353            .expect("function definition failed");
8354
8355        // Call the function
8356        let result = kernel
8357            .execute(r#"greet "Bob""#)
8358            .await
8359            .expect("function call failed");
8360
8361        assert!(result.ok(), "greet failed: {}", result.err);
8362        assert_eq!(result.text_out().trim(), "Hi Bob");
8363    }
8364
8365    #[tokio::test]
8366    async fn test_shell_function_with_all_args() {
8367        let kernel = Kernel::transient().expect("failed to create kernel");
8368
8369        // Define function using $@ (all args)
8370        kernel
8371            .execute(r#"echo_all() { echo "args: $@" }"#)
8372            .await
8373            .expect("function definition failed");
8374
8375        // Call with multiple args
8376        let result = kernel
8377            .execute(r#"echo_all "a" "b" "c""#)
8378            .await
8379            .expect("function call failed");
8380
8381        assert!(result.ok(), "function failed: {}", result.err);
8382        assert_eq!(result.text_out().trim(), "args: a b c");
8383    }
8384
8385    #[tokio::test]
8386    async fn test_shell_function_with_arg_count() {
8387        let kernel = Kernel::transient().expect("failed to create kernel");
8388
8389        // Define function using $# (arg count)
8390        kernel
8391            .execute(r#"count_args() { echo "count: $#" }"#)
8392            .await
8393            .expect("function definition failed");
8394
8395        // Call with three args
8396        let result = kernel
8397            .execute(r#"count_args "x" "y" "z""#)
8398            .await
8399            .expect("function call failed");
8400
8401        assert!(result.ok(), "function failed: {}", result.err);
8402        assert_eq!(result.text_out().trim(), "count: 3");
8403    }
8404
8405    #[tokio::test]
8406    async fn test_shell_function_shared_scope() {
8407        let kernel = Kernel::transient().expect("failed to create kernel");
8408
8409        // Set a variable in parent scope
8410        kernel
8411            .execute(r#"PARENT_VAR="visible""#)
8412            .await
8413            .expect("set failed");
8414
8415        // Define shell function that reads and writes parent variable
8416        kernel
8417            .execute(r#"modify_parent() {
8418                echo "saw: ${PARENT_VAR}"
8419                PARENT_VAR="changed by function"
8420            }"#)
8421            .await
8422            .expect("function definition failed");
8423
8424        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8425        let result = kernel.execute("modify_parent").await.expect("function failed");
8426
8427        assert!(
8428            result.text_out().contains("visible"),
8429            "Shell function should access parent scope, got: {}",
8430            result.text_out()
8431        );
8432
8433        // Parent variable should be modified
8434        let var = kernel.get_var("PARENT_VAR").await;
8435        assert_eq!(
8436            var,
8437            Some(Value::String("changed by function".into())),
8438            "Shell function should modify parent scope"
8439        );
8440    }
8441
8442    // ═══════════════════════════════════════════════════════════════════════════
8443    // Script Execution via PATH Tests
8444    // ═══════════════════════════════════════════════════════════════════════════
8445
8446    #[tokio::test]
8447    async fn test_script_execution_from_path() {
8448        let kernel = Kernel::transient().expect("failed to create kernel");
8449
8450        // Create /bin directory and script
8451        kernel.execute(r#"mkdir "/bin""#).await.ok();
8452        kernel
8453            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8454            .await
8455            .expect("write script failed");
8456
8457        // Set PATH to /bin
8458        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8459
8460        // Call script by name (without .kai extension)
8461        let result = kernel
8462            .execute("hello")
8463            .await
8464            .expect("script execution failed");
8465
8466        assert!(result.ok(), "script failed: {}", result.err);
8467        assert_eq!(result.text_out().trim(), "Hello from script!");
8468    }
8469
8470    #[tokio::test]
8471    async fn test_script_with_args() {
8472        let kernel = Kernel::transient().expect("failed to create kernel");
8473
8474        // Create script that uses positional params
8475        kernel.execute(r#"mkdir "/bin""#).await.ok();
8476        kernel
8477            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8478            .await
8479            .expect("write script failed");
8480
8481        // Set PATH
8482        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8483
8484        // Call script with arg
8485        let result = kernel
8486            .execute(r#"greet "World""#)
8487            .await
8488            .expect("script execution failed");
8489
8490        assert!(result.ok(), "script failed: {}", result.err);
8491        assert_eq!(result.text_out().trim(), "Hello, World!");
8492    }
8493
8494    #[tokio::test]
8495    async fn test_script_not_found() {
8496        let kernel = Kernel::transient().expect("failed to create kernel");
8497
8498        // Set empty PATH
8499        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8500
8501        // Call non-existent script
8502        let result = kernel
8503            .execute("noscript")
8504            .await
8505            .expect("execution failed");
8506
8507        assert!(!result.ok(), "should fail with command not found");
8508        assert_eq!(result.code, 127);
8509        assert!(result.err.contains("command not found"));
8510    }
8511
8512    #[tokio::test]
8513    async fn test_script_path_search_order() {
8514        let kernel = Kernel::transient().expect("failed to create kernel");
8515
8516        // Create two directories with same-named script
8517        // Note: using "myscript" not "test" to avoid conflict with test builtin
8518        kernel.execute(r#"mkdir "/first""#).await.ok();
8519        kernel.execute(r#"mkdir "/second""#).await.ok();
8520        kernel
8521            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8522            .await
8523            .expect("write failed");
8524        kernel
8525            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8526            .await
8527            .expect("write failed");
8528
8529        // Set PATH with first before second
8530        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8531
8532        // Should find first one
8533        let result = kernel
8534            .execute("myscript")
8535            .await
8536            .expect("script execution failed");
8537
8538        assert!(result.ok(), "script failed: {}", result.err);
8539        assert_eq!(result.text_out().trim(), "from first");
8540    }
8541
8542    // ═══════════════════════════════════════════════════════════════════════════
8543    // Special Variable Tests ($?, $$, unset vars)
8544    // ═══════════════════════════════════════════════════════════════════════════
8545
8546    #[tokio::test]
8547    async fn test_last_exit_code_success() {
8548        let kernel = Kernel::transient().expect("failed to create kernel");
8549
8550        // true exits with 0
8551        let result = kernel.execute("true; echo $?").await.expect("execution failed");
8552        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8553    }
8554
8555    #[tokio::test]
8556    async fn test_last_exit_code_failure() {
8557        let kernel = Kernel::transient().expect("failed to create kernel");
8558
8559        // false exits with 1
8560        let result = kernel.execute("false; echo $?").await.expect("execution failed");
8561        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8562    }
8563
8564    #[tokio::test]
8565    async fn test_current_pid() {
8566        let kernel = Kernel::transient().expect("failed to create kernel");
8567
8568        let result = kernel.execute("echo $$").await.expect("execution failed");
8569        // PID should be a positive number
8570        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8571        assert!(pid > 0, "PID should be positive");
8572    }
8573
8574    #[tokio::test]
8575    async fn test_unset_variable_expands_to_empty() {
8576        let kernel = Kernel::transient().expect("failed to create kernel");
8577
8578        // Unset variable in interpolation should be empty
8579        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8580        assert_eq!(result.text_out().trim(), "prefix::suffix");
8581    }
8582
8583    #[tokio::test]
8584    async fn test_eq_ne_operators() {
8585        let kernel = Kernel::transient().expect("failed to create kernel");
8586
8587        // Test -eq operator
8588        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8589        assert_eq!(result.text_out().trim(), "eq works");
8590
8591        // Test -ne operator
8592        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8593        assert_eq!(result.text_out().trim(), "ne works");
8594
8595        // Test -eq with different values
8596        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8597        assert_eq!(result.text_out().trim(), "correct");
8598    }
8599
8600    #[tokio::test]
8601    async fn test_escaped_dollar_in_string() {
8602        let kernel = Kernel::transient().expect("failed to create kernel");
8603
8604        // \$ should produce literal $
8605        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8606        assert_eq!(result.text_out().trim(), "$100");
8607    }
8608
8609    #[tokio::test]
8610    async fn test_special_vars_in_interpolation() {
8611        let kernel = Kernel::transient().expect("failed to create kernel");
8612
8613        // Test $? in string interpolation
8614        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8615        assert_eq!(result.text_out().trim(), "exit: 0");
8616
8617        // Test $$ in string interpolation
8618        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8619        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8620        let text = result.text_out();
8621        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8622        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8623    }
8624
8625    // ═══════════════════════════════════════════════════════════════════════════
8626    // Command Substitution Tests
8627    // ═══════════════════════════════════════════════════════════════════════════
8628
8629    #[tokio::test]
8630    async fn test_command_subst_assignment() {
8631        let kernel = Kernel::transient().expect("failed to create kernel");
8632
8633        // Command substitution in assignment
8634        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8635        assert_eq!(result.text_out().trim(), "hello");
8636    }
8637
8638    #[tokio::test]
8639    async fn test_command_subst_with_args() {
8640        let kernel = Kernel::transient().expect("failed to create kernel");
8641
8642        // Command substitution with string argument
8643        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8644        assert_eq!(result.text_out().trim(), "a b c");
8645    }
8646
8647    #[tokio::test]
8648    async fn test_command_subst_nested_vars() {
8649        let kernel = Kernel::transient().expect("failed to create kernel");
8650
8651        // Variables inside command substitution
8652        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8653        assert_eq!(result.text_out().trim(), "hello world");
8654    }
8655
8656    #[tokio::test]
8657    async fn test_background_job_basic() {
8658        use std::time::Duration;
8659
8660        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8661
8662        // Run a simple background command
8663        let result = kernel.execute("echo hello &").await.expect("execution failed");
8664        assert!(result.ok(), "background command should succeed: {}", result.err);
8665        assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8666
8667        // Give the job time to complete
8668        tokio::time::sleep(Duration::from_millis(100)).await;
8669
8670        // Check job status
8671        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8672        assert!(status.ok(), "status should succeed: {}", status.err);
8673        assert!(
8674            status.text_out().contains("done:") || status.text_out().contains("running"),
8675            "should have valid status: {}",
8676            status.text_out()
8677        );
8678
8679        // Check stdout
8680        let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
8681        assert!(stdout.ok());
8682        assert!(stdout.text_out().contains("hello"));
8683    }
8684
8685    #[tokio::test]
8686    async fn test_heredoc_piped_to_command() {
8687        // Bug 4: heredoc content should pipe through to next command
8688        let kernel = Kernel::transient().expect("kernel");
8689        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8690        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8691        assert_eq!(result.text_out().trim(), "hello world");
8692    }
8693
8694    /// A transient kernel paired with a real, auto-cleaning tempdir. The
8695    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8696    /// tests need actual files on disk. Hold the returned `TempDir` for the
8697    /// test's lifetime: it removes the directory tree on drop — including on
8698    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8699    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8700    /// as a string for interpolation into scripts.
8701    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8702        let kernel = Kernel::transient().expect("kernel");
8703        let tmp = tempfile::tempdir().expect("tempdir");
8704        let dir = tmp.path().display().to_string();
8705        (kernel, tmp, dir)
8706    }
8707
8708    #[tokio::test]
8709    async fn test_for_loop_glob_iterates() {
8710        // Bug 1: for F in $(glob ...) should iterate per file, not once
8711        let (kernel, _tmp, dir) = transient_with_tempdir();
8712        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8713        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8714        let result = kernel.execute(&format!(r#"
8715            N=0
8716            for F in $(glob "{dir}/*.txt"); do
8717                N=$((N + 1))
8718            done
8719            echo $N
8720        "#)).await.unwrap();
8721        assert!(result.ok(), "for glob failed: {}", result.err);
8722        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8723    }
8724
8725    #[tokio::test]
8726    async fn test_bare_glob_expansion_echo() {
8727        let (kernel, _tmp, dir) = transient_with_tempdir();
8728        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8729        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8730        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8731        kernel.execute(&format!("cd {dir}")).await.unwrap();
8732        let result = kernel.execute("echo *.txt").await.unwrap();
8733        assert!(result.ok(), "echo *.txt failed: {}", result.err);
8734        let out = result.text_out();
8735        let out = out.trim();
8736        // Should contain both .txt files (order may vary)
8737        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8738        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8739        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8740    }
8741
8742    #[tokio::test]
8743    async fn test_bare_glob_no_matches_errors() {
8744        let (kernel, _tmp, dir) = transient_with_tempdir();
8745        kernel.execute(&format!("cd {dir}")).await.unwrap();
8746        let result = kernel.execute("echo *.nonexistent").await;
8747        match &result {
8748            Ok(exec) => {
8749                // No-match glob should produce a non-zero exit code
8750                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8751                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8752            }
8753            Err(e) => {
8754                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8755            }
8756        }
8757    }
8758
8759    #[tokio::test]
8760    async fn test_bare_glob_disabled_with_set() {
8761        let (kernel, _tmp, dir) = transient_with_tempdir();
8762        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8763        kernel.execute(&format!("cd {dir}")).await.unwrap();
8764        // Disable glob expansion
8765        kernel.execute("set +o glob").await.unwrap();
8766        let result = kernel.execute("echo *.txt").await.unwrap();
8767        // With glob disabled, *.txt should be passed as literal string
8768        assert!(result.ok(), "echo should succeed: {}", result.err);
8769        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8770    }
8771
8772    #[tokio::test]
8773    async fn test_bare_glob_quoted_not_expanded() {
8774        let (kernel, _tmp, dir) = transient_with_tempdir();
8775        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8776        kernel.execute(&format!("cd {dir}")).await.unwrap();
8777        // Quoted globs should NOT expand
8778        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8779        assert!(result.ok(), "echo should succeed: {}", result.err);
8780        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8781    }
8782
8783    #[tokio::test]
8784    async fn test_bare_glob_for_loop() {
8785        let (kernel, _tmp, dir) = transient_with_tempdir();
8786        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8787        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8788        kernel.execute(&format!("cd {dir}")).await.unwrap();
8789        let result = kernel.execute(r#"
8790            N=0
8791            for f in *.txt; do
8792                N=$((N + 1))
8793            done
8794            echo $N
8795        "#).await.unwrap();
8796        assert!(result.ok(), "for loop failed: {}", result.err);
8797        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8798    }
8799
8800    #[tokio::test]
8801    async fn test_glob_in_assignment_is_literal() {
8802        let kernel = Kernel::transient().expect("kernel");
8803        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8804        assert!(result.ok());
8805        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8806    }
8807
8808    #[tokio::test]
8809    async fn test_glob_in_test_expr_is_literal() {
8810        let kernel = Kernel::transient().expect("kernel");
8811        let result = kernel.execute(r#"
8812            if [[ *.txt == "*.txt" ]]; then
8813                echo "match"
8814            else
8815                echo "no"
8816            fi
8817        "#).await.unwrap();
8818        assert!(result.ok());
8819        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8820    }
8821
8822    #[tokio::test]
8823    async fn test_command_subst_echo_not_iterable() {
8824        // Regression guard: $(echo "a b c") must remain a single string
8825        let kernel = Kernel::transient().expect("kernel");
8826        let result = kernel.execute(r#"
8827            N=0
8828            for X in $(echo "a b c"); do N=$((N + 1)); done
8829            echo $N
8830        "#).await.unwrap();
8831        assert!(result.ok());
8832        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8833    }
8834
8835    // -- accumulate_result / newline tests --
8836
8837    #[test]
8838    fn test_accumulate_preserves_own_newlines() {
8839        // Outputs concatenate verbatim — a command's own trailing newline is
8840        // kept, none is invented.
8841        let mut acc = ExecResult::success("line1\n");
8842        let new = ExecResult::success("line2\n");
8843        accumulate_result(&mut acc, &new);
8844        assert_eq!(&*acc.text_out(), "line1\nline2\n");
8845        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8846    }
8847
8848    #[test]
8849    fn test_accumulate_inserts_no_separator() {
8850        // No artificial separator: `printf a; printf b` style concatenates to
8851        // `ab`, matching bash (regression for the 2026-06-09 finding).
8852        let mut acc = ExecResult::success("line1");
8853        let new = ExecResult::success("line2");
8854        accumulate_result(&mut acc, &new);
8855        assert_eq!(&*acc.text_out(), "line1line2");
8856    }
8857
8858    #[test]
8859    fn test_accumulate_empty_into_nonempty() {
8860        let mut acc = ExecResult::success("");
8861        let new = ExecResult::success("hello\n");
8862        accumulate_result(&mut acc, &new);
8863        assert_eq!(&*acc.text_out(), "hello\n");
8864    }
8865
8866    #[test]
8867    fn test_accumulate_nonempty_into_empty() {
8868        let mut acc = ExecResult::success("hello\n");
8869        let new = ExecResult::success("");
8870        accumulate_result(&mut acc, &new);
8871        assert_eq!(&*acc.text_out(), "hello\n");
8872    }
8873
8874    #[test]
8875    fn test_accumulate_stderr_no_double_newlines() {
8876        let mut acc = ExecResult::failure(1, "err1\n");
8877        let new = ExecResult::failure(1, "err2\n");
8878        accumulate_result(&mut acc, &new);
8879        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
8880    }
8881
8882    #[tokio::test]
8883    async fn test_multiple_echo_no_blank_lines() {
8884        let kernel = Kernel::transient().expect("kernel");
8885        let result = kernel
8886            .execute("echo one\necho two\necho three")
8887            .await
8888            .expect("execution failed");
8889        assert!(result.ok());
8890        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
8891    }
8892
8893    #[tokio::test]
8894    async fn test_for_loop_no_blank_lines() {
8895        let kernel = Kernel::transient().expect("kernel");
8896        let result = kernel
8897            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8898            .await
8899            .expect("execution failed");
8900        assert!(result.ok());
8901        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
8902    }
8903
8904    #[tokio::test]
8905    async fn test_for_command_subst_no_blank_lines() {
8906        let kernel = Kernel::transient().expect("kernel");
8907        let result = kernel
8908            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
8909            .await
8910            .expect("execution failed");
8911        assert!(result.ok());
8912        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
8913    }
8914
8915    // ------------------------------------------------------------------
8916    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
8917    // ------------------------------------------------------------------
8918
8919    /// Helper: a throwaway schema with one `--pair` param declared as
8920    /// consuming two positionals per occurrence. Modelled after what
8921    /// jq_native will declare for `--arg` / `--argjson`.
8922    fn multi_consume_schema() -> crate::tools::ToolSchema {
8923        use crate::tools::{ParamSchema, ToolSchema};
8924        ToolSchema::new("test", "multi-consume smoke")
8925            .param(
8926                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
8927                    .consumes(2),
8928            )
8929    }
8930
8931    fn pos(s: &str) -> Arg {
8932        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
8933    }
8934
8935    #[tokio::test]
8936    async fn build_args_multi_consume_single_occurrence() {
8937        let kernel = Kernel::transient().expect("kernel");
8938        let schema = multi_consume_schema();
8939        // Simulates:  test --pair NAME VALUE filter
8940        let args = vec![
8941            Arg::LongFlag("pair".into()),
8942            pos("NAME"),
8943            pos("VALUE"),
8944            pos("filter"),
8945        ];
8946        let built = kernel
8947            .build_args_async(&args, Some(&schema))
8948            .await
8949            .expect("build_args should succeed");
8950
8951        // `--pair` + its two positionals are consumed into named["pair"],
8952        // which becomes an outer array of one inner 2-element array.
8953        let pair = built.named.get("pair").expect("named[pair] missing");
8954        match pair {
8955            Value::Json(serde_json::Value::Array(occurrences)) => {
8956                assert_eq!(occurrences.len(), 1, "expected one occurrence");
8957                match &occurrences[0] {
8958                    serde_json::Value::Array(values) => {
8959                        assert_eq!(values.len(), 2, "pair must have 2 values");
8960                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
8961                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
8962                    }
8963                    other => panic!("expected inner array, got {other:?}"),
8964                }
8965            }
8966            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
8967        }
8968
8969        // The un-consumed positional ("filter") remains in `positional`.
8970        assert_eq!(built.positional.len(), 1);
8971        assert_eq!(built.positional[0], Value::String("filter".into()));
8972    }
8973    #[tokio::test]
8974    async fn build_args_multi_consume_two_occurrences_accumulate() {
8975        let kernel = Kernel::transient().expect("kernel");
8976        let schema = multi_consume_schema();
8977        // Simulates:  test --pair A 1 --pair B 2 filter
8978        let args = vec![
8979            Arg::LongFlag("pair".into()),
8980            pos("A"),
8981            pos("1"),
8982            Arg::LongFlag("pair".into()),
8983            pos("B"),
8984            pos("2"),
8985            pos("filter"),
8986        ];
8987        let built = kernel
8988            .build_args_async(&args, Some(&schema))
8989            .await
8990            .expect("build_args should succeed");
8991
8992        let pair = built.named.get("pair").expect("named[pair] missing");
8993        match pair {
8994            Value::Json(serde_json::Value::Array(occurrences)) => {
8995                assert_eq!(occurrences.len(), 2, "expected two occurrences");
8996                // Preserved in invocation order.
8997                match &occurrences[0] {
8998                    serde_json::Value::Array(values) => {
8999                        assert_eq!(values[0], serde_json::Value::String("A".into()));
9000                        assert_eq!(values[1], serde_json::Value::String("1".into()));
9001                    }
9002                    other => panic!("expected inner array, got {other:?}"),
9003                }
9004                match &occurrences[1] {
9005                    serde_json::Value::Array(values) => {
9006                        assert_eq!(values[0], serde_json::Value::String("B".into()));
9007                        assert_eq!(values[1], serde_json::Value::String("2".into()));
9008                    }
9009                    other => panic!("expected inner array, got {other:?}"),
9010                }
9011            }
9012            other => panic!("expected Json(Array(...)), got {other:?}"),
9013        }
9014    }
9015
9016    // ── undeclared space-form flag under map_positionals (kj --type val) ──
9017    //
9018    // A backend/MCP tool whose schema does NOT declare a flag must not let
9019    // `--flag value` (space form) silently divorce the value: that was a
9020    // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
9021    // kaish fails loud rather than guessing.
9022
9023    use crate::tools::{ParamSchema, ToolSchema};
9024
9025    /// Backend-style schema (map_positionals) declaring only a `name`
9026    /// positional — `--type` is intentionally undeclared.
9027    fn kj_like_schema() -> ToolSchema {
9028        ToolSchema::new("kj", "incomplete backend schema")
9029            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9030            .with_positional_mapping()
9031    }
9032
9033    #[tokio::test]
9034    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9035        let kernel = Kernel::transient().expect("kernel");
9036        let schema = kj_like_schema();
9037        // kj context create exp --type explorer
9038        let args = vec![
9039            pos("context"),
9040            pos("create"),
9041            pos("exp"),
9042            Arg::LongFlag("type".into()),
9043            pos("explorer"),
9044        ];
9045        let err = kernel
9046            .build_args_async(&args, Some(&schema))
9047            .await
9048            .expect_err("undeclared --type with a space value must fail loud");
9049        let msg = err.to_string();
9050        assert!(msg.contains("--type"), "message should name the flag: {msg}");
9051        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9052        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9053    }
9054
9055    #[tokio::test]
9056    async fn build_args_declared_space_flag_still_binds() {
9057        let kernel = Kernel::transient().expect("kernel");
9058        // Same tool, but now the schema DECLARES --type as a string param.
9059        let schema = ToolSchema::new("kj", "complete schema")
9060            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9061            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9062            .with_positional_mapping();
9063        let args = vec![
9064            pos("exp"),
9065            Arg::LongFlag("type".into()),
9066            pos("explorer"),
9067        ];
9068        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9069        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9070    }
9071
9072    #[tokio::test]
9073    async fn build_args_equals_form_binds_for_undeclared_flag() {
9074        let kernel = Kernel::transient().expect("kernel");
9075        let schema = kj_like_schema();
9076        // The unambiguous `=` form must keep working even when undeclared.
9077        let args = vec![
9078            pos("exp"),
9079            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9080        ];
9081        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9082        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9083    }
9084
9085    #[tokio::test]
9086    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9087        let kernel = Kernel::transient().expect("kernel");
9088        let schema = kj_like_schema();
9089        // No positional follows --force → unambiguously a bare flag.
9090        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9091        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9092        assert!(built.flags.contains("force"));
9093    }
9094
9095    #[tokio::test]
9096    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9097        let kernel = Kernel::transient().expect("kernel");
9098        let schema = kj_like_schema();
9099        // --verbose is followed by a flag, not a positional → not ambiguous.
9100        let args = vec![
9101            Arg::LongFlag("verbose".into()),
9102            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9103        ];
9104        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9105        assert!(built.flags.contains("verbose"));
9106    }
9107
9108    #[tokio::test]
9109    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9110        let kernel = Kernel::transient().expect("kernel");
9111        // Builtins set map_positionals=false; the ambiguity guard must not
9112        // fire there (clap validates their flags separately).
9113        let schema = ToolSchema::new("frobnicate", "builtin-style")
9114            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9115        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9116        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9117        assert!(built.flags.contains("frob"));
9118    }
9119
9120    // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9121    //
9122    // The long-flag guard above was closed by GH #188; an undeclared SHORT
9123    // flag under a map_positionals schema was still silently defaulting to
9124    // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9125    // same way the long-flag case used to.
9126
9127    #[tokio::test]
9128    async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9129        let kernel = Kernel::transient().expect("kernel");
9130        let schema = kj_like_schema();
9131        // kj exp -t explorer
9132        let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9133        let err = kernel
9134            .build_args_async(&args, Some(&schema))
9135            .await
9136            .expect_err("undeclared -t with a space value must fail loud");
9137        let msg = err.to_string();
9138        assert!(msg.contains("-t"), "message should name the flag: {msg}");
9139        assert!(msg.contains("kj"), "message should name the tool: {msg}");
9140    }
9141
9142    #[tokio::test]
9143    async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9144        let kernel = Kernel::transient().expect("kernel");
9145        // Builtins set map_positionals=false; the ambiguity guard must not
9146        // fire there.
9147        let schema = ToolSchema::new("frobnicate", "builtin-style")
9148            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9149        let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9150        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9151        assert!(built.flags.contains("t"));
9152    }
9153
9154    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9155    //
9156    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9157    // params, not the root's. The subcommand-path positionals stay positional
9158    // (kj re-parses them with its own clap), and a value flag declared only on
9159    // a deep leaf still binds in space form.
9160
9161    /// kj → context (alias ctx) → create{--type value, --force bool}.
9162    /// map_positionals defaults false on every node (builtin/kj style).
9163    fn kj_tree_schema() -> ToolSchema {
9164        ToolSchema::new("kj", "subcommand tool").subcommand(
9165            ToolSchema::new("context", "context ops")
9166                .with_command_aliases(["ctx"])
9167                .subcommand(
9168                    ToolSchema::new("create", "create context")
9169                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9170                        .param(ParamSchema::new("force", "bool")),
9171                ),
9172        )
9173    }
9174
9175    #[tokio::test]
9176    async fn build_args_binds_deep_leaf_value_flag_space_form() {
9177        let kernel = Kernel::transient().expect("kernel");
9178        let schema = kj_tree_schema();
9179        // kj context create --type explorer
9180        let args = vec![
9181            pos("context"),
9182            pos("create"),
9183            Arg::LongFlag("type".into()),
9184            pos("explorer"),
9185        ];
9186        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9187        // --type (declared only on the create leaf) binds in space form.
9188        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9189        // The subcommand path survives as positionals for kj to re-parse.
9190        let positionals: Vec<&str> = built
9191            .positional
9192            .iter()
9193            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9194            .collect();
9195        assert_eq!(positionals, vec!["context", "create"]);
9196    }
9197
9198    #[tokio::test]
9199    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9200        let kernel = Kernel::transient().expect("kernel");
9201        let schema = kj_tree_schema();
9202        // kj context create --force somearg  → --force is a leaf bool flag,
9203        // it must NOT consume `somearg`.
9204        let args = vec![
9205            pos("context"),
9206            pos("create"),
9207            Arg::LongFlag("force".into()),
9208            pos("somearg"),
9209        ];
9210        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9211        assert!(built.flags.contains("force"), "force should be a bare flag");
9212        let positionals: Vec<&str> = built
9213            .positional
9214            .iter()
9215            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9216            .collect();
9217        assert_eq!(positionals, vec!["context", "create", "somearg"]);
9218    }
9219
9220    #[tokio::test]
9221    async fn build_args_alias_routed_leaf_binds_value_flag() {
9222        let kernel = Kernel::transient().expect("kernel");
9223        let schema = kj_tree_schema();
9224        // kj ctx create -t explorer  → command alias + short flag alias.
9225        let args = vec![
9226            pos("ctx"),
9227            pos("create"),
9228            Arg::ShortFlag("t".into()),
9229            pos("explorer"),
9230        ];
9231        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9232        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9233    }
9234
9235    #[tokio::test]
9236    async fn build_args_computed_subcommand_selector_fails_loud() {
9237        let kernel = Kernel::transient().expect("kernel");
9238        let schema = kj_tree_schema();
9239        // kj $(echo context) — routing can't see the value; fail loud.
9240        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9241            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9242        )]))];
9243        let err = kernel
9244            .build_args_async(&args, Some(&schema))
9245            .await
9246            .expect_err("computed subcommand selector must error");
9247        assert!(
9248            err.to_string().contains("subcommand name is required"),
9249            "got: {err}"
9250        );
9251    }
9252
9253    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9254
9255    #[test]
9256    fn finalize_output_renders_when_kernel_owns_it() {
9257        use crate::interpreter::{OutputData, OutputFormat};
9258        let r = ExecResult::with_output(OutputData::text("RAW"));
9259        let out = finalize_output(r, Some(OutputFormat::Json), false);
9260        // Kernel renders the typed OutputData → JSON; text is no longer bare.
9261        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9262    }
9263
9264    #[test]
9265    fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9266        use crate::interpreter::{OutputData, OutputFormat};
9267        let r = ExecResult::with_output(OutputData::text("RAW"));
9268        let out = finalize_output(r, Some(OutputFormat::Json), true);
9269        // owns_output + success: the tool already rendered; kernel leaves bytes
9270        // untouched.
9271        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9272    }
9273
9274    #[test]
9275    fn finalize_output_renders_owns_output_failure() {
9276        // scatter/gather (the only owns_output tools) never render their own
9277        // JSONL/array on a FAILURE path — their error returns are plain-text
9278        // `ExecResult::failure(code, msg)`, identical in shape to any other
9279        // builtin's. owns_output means "the tool already rendered its own
9280        // SUCCESS output", not "never touch this tool's bytes" — a failure
9281        // must still get the uniform --json error envelope like every other
9282        // builtin (kaibo review finding on merged PR #215; confirmed
9283        // pre-existing for scatter/gather's whole error-path class, including
9284        // the clap-parse-failure path).
9285        use crate::interpreter::OutputFormat;
9286        let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9287        let out = finalize_output(r, Some(OutputFormat::Json), true);
9288        let parsed: serde_json::Value =
9289            serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9290        assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9291        assert_eq!(parsed["code"], 2);
9292    }
9293
9294    #[test]
9295    fn finalize_output_no_format_is_noop() {
9296        use crate::interpreter::OutputData;
9297        let r = ExecResult::with_output(OutputData::text("RAW"));
9298        let out = finalize_output(r, None, false);
9299        assert_eq!(out.text_out(), "RAW");
9300    }
9301
9302    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9303
9304    #[tokio::test]
9305    async fn test_initial_vars_set_and_exported() {
9306        let config = KernelConfig::transient()
9307            .with_var("INIT_FOO", Value::String("bar".into()));
9308        let kernel = Kernel::new(config).expect("failed to create kernel");
9309
9310        assert_eq!(
9311            kernel.get_var("INIT_FOO").await,
9312            Some(Value::String("bar".into()))
9313        );
9314        assert!(
9315            kernel.scope.read().await.is_exported("INIT_FOO"),
9316            "initial_vars entries must be marked exported"
9317        );
9318    }
9319
9320    #[tokio::test]
9321    async fn test_execute_with_vars_overlay_visible() {
9322        let kernel = Kernel::transient().expect("failed to create kernel");
9323        let mut overlay = HashMap::new();
9324        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9325
9326        let result = kernel
9327            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9328            .await
9329            .expect("execute failed");
9330
9331        assert!(result.ok());
9332        assert_eq!(result.text_out().trim(), "yes");
9333    }
9334
9335    #[tokio::test]
9336    async fn test_execute_with_vars_overlay_cleanup() {
9337        let kernel = Kernel::transient().expect("failed to create kernel");
9338        let mut overlay = HashMap::new();
9339        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9340
9341        kernel
9342            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9343            .await
9344            .expect("execute failed");
9345
9346        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9347        assert!(
9348            !kernel.scope.read().await.is_exported("EPHEMERAL"),
9349            "overlay-only export must be cleared on return"
9350        );
9351    }
9352
9353    #[tokio::test]
9354    async fn test_execute_with_vars_does_not_clobber_existing_export() {
9355        let kernel = Kernel::transient().expect("failed to create kernel");
9356        kernel
9357            .execute("export OUTER=outer")
9358            .await
9359            .expect("export failed");
9360
9361        let mut overlay = HashMap::new();
9362        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9363        let result = kernel
9364            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9365            .await
9366            .expect("execute failed");
9367        assert_eq!(result.text_out().trim(), "inner");
9368
9369        assert_eq!(
9370            kernel.get_var("OUTER").await,
9371            Some(Value::String("outer".into())),
9372            "outer value must reappear after pop"
9373        );
9374        assert!(
9375            kernel.scope.read().await.is_exported("OUTER"),
9376            "outer export must survive overlay"
9377        );
9378    }
9379
9380    #[tokio::test]
9381    async fn test_execute_with_vars_inner_assignment_is_local() {
9382        let kernel = Kernel::transient().expect("failed to create kernel");
9383        let mut overlay = HashMap::new();
9384        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9385
9386        // Variable assignment inside a single statement uses set() (innermost
9387        // frame), not set_global() — this matches bash function-local semantics.
9388        // We explicitly use `local FOO=...` style by relying on the pushed
9389        // frame; the assignment in the script body modifies the same frame.
9390        let result = kernel
9391            .execute_with_options(
9392                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9393                ExecuteOptions::new().with_vars(overlay),
9394            )
9395            .await
9396            .expect("execute failed");
9397        assert!(result.ok());
9398
9399        // After the call the frame is popped, so LOCAL_FOO is gone regardless
9400        // of how the script reassigned it.
9401        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9402    }
9403
9404    #[tokio::test]
9405    async fn test_external_command_sees_exported_var() {
9406        let kernel = Kernel::transient().expect("failed to create kernel");
9407        // PATH must be in scope to resolve the external `printenv` — the kernel
9408        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9409        // what a frontend does through initial_vars.
9410        let path = std::env::var("PATH").unwrap_or_default();
9411        let result = kernel
9412            .execute(&format!(
9413                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9414            ))
9415            .await
9416            .expect("execute failed");
9417
9418        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9419        assert_eq!(result.text_out().trim(), "bar");
9420    }
9421
9422    #[tokio::test]
9423    async fn test_external_command_does_not_see_unexported_var() {
9424        let kernel = Kernel::transient().expect("failed to create kernel");
9425
9426        // Set without exporting; printenv must not see it (exit code != 0,
9427        // empty stdout per printenv semantics).
9428        let result = kernel
9429            .execute("EXT_BAR=hidden; printenv EXT_BAR")
9430            .await
9431            .expect("execute failed");
9432
9433        assert!(!result.ok(), "printenv should fail when var is unexported");
9434        assert!(
9435            result.text_out().trim().is_empty(),
9436            "no stdout when var is missing, got: {}",
9437            result.text_out()
9438        );
9439    }
9440
9441    #[tokio::test]
9442    async fn test_external_command_does_not_see_os_env() {
9443        // The kernel is hermetic: it never reads std::env::vars() and only
9444        // exports what it has been told to export. Cargo always sets PATH for
9445        // tests, so PATH is reliably present in the OS env — but a transient
9446        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9447        // inside the kernel must fail.
9448        assert!(
9449            std::env::var_os("PATH").is_some(),
9450            "test precondition: cargo should set PATH"
9451        );
9452
9453        let kernel = Kernel::transient().expect("failed to create kernel");
9454        let result = kernel
9455            .execute("printenv PATH")
9456            .await
9457            .expect("execute failed");
9458
9459        assert!(
9460            !result.ok(),
9461            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9462            result.text_out()
9463        );
9464        assert!(
9465            result.text_out().trim().is_empty(),
9466            "no PATH in subprocess env, got stdout={:?}",
9467            result.text_out()
9468        );
9469    }
9470
9471    #[tokio::test]
9472    async fn test_execute_with_vars_overlay_reaches_subprocess() {
9473        let kernel = Kernel::transient().expect("failed to create kernel");
9474        let mut overlay = HashMap::new();
9475        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9476        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9477        overlay.insert(
9478            "PATH".to_string(),
9479            Value::String(std::env::var("PATH").unwrap_or_default()),
9480        );
9481
9482        let result = kernel
9483            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9484            .await
9485            .expect("execute failed");
9486
9487        assert!(
9488            result.ok(),
9489            "printenv should succeed: code={} stdout={:?} stderr={:?}",
9490            result.code,
9491            result.text_out(),
9492            result.err
9493        );
9494        assert_eq!(result.text_out().trim(), "subproc");
9495    }
9496
9497    #[tokio::test]
9498    async fn test_classify_command_builtin() {
9499        let kernel = Kernel::transient().expect("failed to create kernel");
9500        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9501        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9502    }
9503
9504    #[tokio::test]
9505    async fn test_classify_command_special_forms() {
9506        let kernel = Kernel::transient().expect("failed to create kernel");
9507        for name in ["true", "false", "source", "."] {
9508            assert_eq!(
9509                kernel.classify_command(name).await,
9510                CommandKind::Special,
9511                "{name} should be a special-form",
9512            );
9513        }
9514    }
9515
9516    #[tokio::test]
9517    async fn test_classify_command_dynamic() {
9518        let kernel = Kernel::transient().expect("failed to create kernel");
9519        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9520        assert_eq!(
9521            kernel.classify_command("$(pick)").await,
9522            CommandKind::Dynamic
9523        );
9524    }
9525
9526    #[tokio::test]
9527    async fn test_classify_command_external() {
9528        let kernel = Kernel::transient().expect("failed to create kernel");
9529        // Not a builtin, user function, or special-form → escapes to PATH.
9530        assert_eq!(
9531            kernel.classify_command("definitely_not_a_kaish_builtin").await,
9532            CommandKind::External
9533        );
9534        // `readonly` is *not* a kaish special-form despite the validator's
9535        // warning heuristic — at runtime it resolves to an external command, so
9536        // a consent gate must see it as External (regression guard against the
9537        // validator/runtime divergence).
9538        assert_eq!(
9539            kernel.classify_command("readonly").await,
9540            CommandKind::External
9541        );
9542        assert!(kernel.classify_command("readonly").await.escapes_kernel());
9543    }
9544
9545    #[tokio::test]
9546    async fn test_classify_command_user_tool_shadows_builtin() {
9547        let kernel = Kernel::transient().expect("failed to create kernel");
9548        kernel
9549            .execute(r#"greet() { echo "hi" }"#)
9550            .await
9551            .expect("function definition failed");
9552        assert_eq!(
9553            kernel.classify_command("greet").await,
9554            CommandKind::UserTool
9555        );
9556
9557        // A user function named after a builtin classifies as UserTool, matching
9558        // the interpreter's user-tools-first resolution.
9559        kernel
9560            .execute(r#"cat() { echo "shadowed" }"#)
9561            .await
9562            .expect("function definition failed");
9563        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9564    }
9565
9566    #[tokio::test]
9567    async fn test_classify_command_alias_to_external_is_external() {
9568        let kernel = Kernel::transient().expect("failed to create kernel");
9569        // An alias whose head is an external binary must NOT report as the
9570        // builtin it shadows — execution expands the alias, so a consent gate
9571        // would otherwise be told an external command is internal.
9572        kernel
9573            .execute("alias cat='/usr/bin/whatever'")
9574            .await
9575            .expect("alias failed");
9576        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9577        assert!(kernel.classify_command("cat").await.escapes_kernel());
9578    }
9579
9580    #[tokio::test]
9581    async fn test_classify_command_alias_to_builtin() {
9582        let kernel = Kernel::transient().expect("failed to create kernel");
9583        kernel.execute("alias g=grep").await.expect("alias failed");
9584        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9585    }
9586
9587    #[tokio::test]
9588    async fn test_classify_command_alias_to_special_form() {
9589        let kernel = Kernel::transient().expect("failed to create kernel");
9590        kernel.execute("alias t=true").await.expect("alias failed");
9591        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9592    }
9593
9594    #[tokio::test]
9595    async fn test_classify_command_braced_var_is_dynamic() {
9596        let kernel = Kernel::transient().expect("failed to create kernel");
9597        // The string API can be handed a `${VAR}` head; it must not be mistaken
9598        // for an external named literally "${VAR}".
9599        assert_eq!(
9600            kernel.classify_command("${CMD}").await,
9601            CommandKind::Dynamic
9602        );
9603    }
9604
9605    /// Drift guard: `classify_command` must agree with what the executor
9606    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9607    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9608    /// precedence, alias expansion); without this test those copies could diverge
9609    /// silently — the exact failure class `classify_command` exists to prevent,
9610    /// just moved inside the kernel. Each case asserts the classification AND
9611    /// observes the real resolution, so a future change to one side without the
9612    /// other fails here.
9613    #[tokio::test]
9614    async fn classify_command_matches_executor() {
9615        let kernel = Kernel::transient().expect("failed to create kernel");
9616
9617        // (1) Special-forms. `SpecialForm::from_name` is the single source of
9618        // truth: classify reports Special via it, and the executor matches the
9619        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9620        // form won't build until both sides handle it). This test pins the other
9621        // half — that each form classifies Special AND actually short-circuits at
9622        // runtime rather than escaping to `PATH`. Every form is executed (not just
9623        // `true`/`false`): an external miss in this PATH-less kernel would be exit
9624        // 127, so a non-127 result that matches the form's own behavior proves the
9625        // short-circuit fired.
9626        for name in ["true", "false", "source", "."] {
9627            assert_eq!(
9628                kernel.classify_command(name).await,
9629                CommandKind::Special,
9630                "{name} should classify Special",
9631            );
9632        }
9633        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9634        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9635        // `source`/`.` short-circuit to execute_source, which (no filename) fails
9636        // with its own message — exit 1, never the 127 of an unresolved external.
9637        for name in ["source", "."] {
9638            let r = kernel.execute(name).await.expect("run source form");
9639            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9640            assert!(
9641                r.err.contains("source: missing filename"),
9642                "{name} did not route to execute_source: {:?}",
9643                r.err,
9644            );
9645        }
9646
9647        // (2) Builtin: classify Builtin AND the executor runs the builtin.
9648        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9649        let r = kernel.execute("echo hi").await.expect("run echo");
9650        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9651
9652        // (3) User function shadows a builtin: classify UserTool AND the executor
9653        // runs the function body, not the `cat` builtin.
9654        kernel
9655            .execute(r#"cat() { echo SHADOWED }"#)
9656            .await
9657            .expect("define cat()");
9658        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9659        let r = kernel.execute("cat").await.expect("run shadowed cat");
9660        assert_eq!(
9661            r.text_out().trim(),
9662            "SHADOWED",
9663            "executor ran the builtin instead of the shadowing function",
9664        );
9665
9666        // (4) Alias whose head is external: classify External AND the executor
9667        // resolves through the alias to a missing external (not a builtin).
9668        kernel
9669            .execute("alias x='/nonexistent/binary'")
9670            .await
9671            .expect("define alias x");
9672        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9673        let r = kernel.execute("x").await.expect("run alias x");
9674        assert!(
9675            !r.ok(),
9676            "alias to a missing external should fail, not resolve internally",
9677        );
9678    }
9679}