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, 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
47use async_trait::async_trait;
48
49use crate::ast::{
50    spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
51    StringPart, TestExpr, ToolDef, Value,
52};
53pub use kaish_types::{CommandKind, ExecuteOptions};
54use crate::backend::{BackendError, KernelBackend};
55use kaish_glob::glob_match;
56use crate::dispatch::{CommandDispatcher, PipelinePosition};
57use 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};
58use crate::parser::parse;
59use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, BoundedStream, JobManager, PipelineRunner, StderrReceiver};
60#[cfg(feature = "subprocess")]
61use crate::scheduler::{drain_to_stream, DEFAULT_STREAM_MAX_SIZE};
62use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
63#[cfg(feature = "subprocess")]
64use crate::tools::resolve_in_path;
65use crate::validator::{Severity, Validator};
66#[cfg(feature = "localfs")]
67use crate::vfs::LocalFs;
68use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
69use kaish_vfs::ByteBudget;
70#[cfg(all(feature = "localfs", feature = "overlay"))]
71use kaish_vfs::OverlayFs;
72
73/// VFS mount mode determines how the local filesystem is exposed.
74///
75/// Different modes trade off convenience vs. security:
76/// - `Passthrough` gives native path access (best for human REPL use)
77/// - `Sandboxed` restricts access to a subtree (safer for agents)
78/// - `NoLocal` provides complete isolation (tests, pure memory mode)
79#[derive(Debug, Clone)]
80pub enum VfsMountMode {
81    /// LocalFs at "/" — native paths work directly.
82    ///
83    /// Full filesystem access. Use for human-operated REPL sessions where
84    /// native paths like `/home/user/project` should just work.
85    ///
86    /// Mounts:
87    /// - `/` → LocalFs("/")
88    /// - `/v` → MemoryFs (blob storage)
89    #[cfg(feature = "localfs")]
90    Passthrough,
91
92    /// Transparent sandbox — paths look native but access is restricted.
93    ///
94    /// The local filesystem is mounted at its real path (e.g., `/home/user`),
95    /// so `/home/user/src/project` just works. But paths outside the sandbox
96    /// root are not accessible.
97    ///
98    /// **Note:** This only restricts VFS (builtin) operations. External commands
99    /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
100    ///
101    /// Mounts:
102    /// - `/` → MemoryFs (catches paths outside sandbox)
103    /// - `{root}` → LocalFs(root)  (e.g., `/home/user` → LocalFs)
104    /// - `/tmp` → LocalFs("/tmp")
105    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
106    /// - `/v` → MemoryFs (blob storage)
107    #[cfg(feature = "localfs")]
108    Sandboxed {
109        /// Root path for local filesystem. Defaults to `$HOME`.
110        /// Can be restricted further, e.g., `~/src`.
111        root: Option<PathBuf>,
112    },
113
114    /// No local filesystem. Memory only.
115    ///
116    /// Complete isolation — no access to the host filesystem.
117    /// Useful for tests or pure sandboxed execution.
118    ///
119    /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
120    /// for this mode at kernel construction: with no host filesystem mounted,
121    /// large output must not write a host spill file (`paths::spill_dir()`
122    /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
123    ///
124    /// Mounts:
125    /// - `/` → MemoryFs
126    /// - `/tmp` → MemoryFs
127    /// - `/v` → MemoryFs
128    /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
129    NoLocal,
130}
131
132#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
133impl Default for VfsMountMode {
134    fn default() -> Self {
135        #[cfg(feature = "localfs")]
136        { VfsMountMode::Sandboxed { root: None } }
137        #[cfg(not(feature = "localfs"))]
138        { VfsMountMode::NoLocal }
139    }
140}
141
142/// Configuration for kernel initialization.
143#[derive(Debug, Clone)]
144pub struct KernelConfig {
145    /// Name of this kernel (for identification).
146    pub name: String,
147
148    /// VFS mount mode — controls how local filesystem is exposed.
149    pub vfs_mode: VfsMountMode,
150
151    /// Initial working directory (VFS path).
152    pub cwd: PathBuf,
153
154    /// Whether to skip pre-execution validation.
155    ///
156    /// When false (default), scripts are validated before execution to catch
157    /// errors early. Set to true to skip validation for performance or to
158    /// allow dynamic/external commands.
159    pub skip_validation: bool,
160
161    /// When true, standalone external commands inherit stdio for real-time output.
162    ///
163    /// Set by script runner and REPL for human-visible output.
164    /// Not set by MCP server (output must be captured for structured responses).
165    pub interactive: bool,
166
167    /// Ignore file configuration for file-walking tools.
168    pub ignore_config: crate::ignore_config::IgnoreConfig,
169
170    /// Output size limit configuration for agent safety.
171    pub output_limit: crate::output_limit::OutputLimitConfig,
172
173    /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
174    ///
175    /// When `true` (default), commands not found as builtins are resolved via PATH
176    /// and executed as child processes. When `false`, only kaish builtins and
177    /// backend-registered tools are available.
178    ///
179    /// **Security:** External commands bypass the VFS sandbox entirely — they see
180    /// the real filesystem, network, and environment. Set to `false` when running
181    /// untrusted input.
182    pub allow_external_commands: bool,
183
184    /// Enable confirmation latch for dangerous operations (set -o latch).
185    ///
186    /// When enabled, destructive operations like `rm` require nonce confirmation.
187    /// Can also be enabled at runtime with `set -o latch` or via `KAISH_LATCH=1`.
188    pub latch_enabled: bool,
189
190    /// Enable trash-on-delete for rm (set -o trash).
191    ///
192    /// When enabled, small files are moved to freedesktop.org Trash instead of
193    /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
194    /// or via `KAISH_TRASH=1`.
195    pub trash_enabled: bool,
196
197    /// Shared nonce store for cross-request confirmation latch.
198    ///
199    /// When `Some`, the kernel uses this store instead of creating a fresh one.
200    /// This allows nonces issued in one MCP `execute()` call to be validated
201    /// in a subsequent call. When `None` (default), a fresh store is created.
202    pub nonce_store: Option<crate::nonce::NonceStore>,
203
204    /// Variables to populate the root scope with at construction, all marked
205    /// for export to child processes.
206    ///
207    /// The kernel itself is hermetic — it never reads `std::env::vars()` —
208    /// so frontends that want OS-env passthrough (REPL, MCP) populate this
209    /// from `std::env::vars()`. Embedders that want isolation pass nothing
210    /// (or only the keys they curate).
211    pub initial_vars: HashMap<String, Value>,
212
213    /// Default per-request timeout. When `Some`, every `execute_with_options`
214    /// call without an explicit `ExecuteOptions::timeout` uses this duration.
215    /// When elapsed, the kernel cancels the request, kills any external
216    /// children with the configured grace, and returns exit code 124.
217    ///
218    /// `None` means no default timeout — only explicit per-call timeouts apply.
219    pub request_timeout: Option<Duration>,
220
221    /// Grace period between SIGTERM and SIGKILL when killing an external
222    /// child on cancellation or timeout.
223    ///
224    /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
225    /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
226    pub kill_grace: Duration,
227
228    /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
229    ///
230    /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
231    /// construction and handed to every `MemoryFs` the kernel builds in
232    /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
233    /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
234    /// `StorageFull` — an in-band error a model reads and adapts to; fail
235    /// loud over quietly eating RAM.
236    ///
237    /// **Why the agent preset is bounded by default:** an agent embedder
238    /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
239    /// is per-call, not per-session. Embedders that know their workload needs
240    /// more opt out with `without_vfs_budget()` or raise the cap with
241    /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
242    /// All other profiles default to `None` (unbounded).
243    ///
244    /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
245    pub vfs_budget_bytes: Option<u64>,
246
247    /// Enable copy-on-write overlay mode (opt-in).
248    ///
249    /// When `true`, the primary local filesystem mount is wrapped in an
250    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
251    /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
252    /// overlay transaction.
253    ///
254    /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
255    /// **Sandboxed{root}:** the `{root}` mount becomes
256    /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
257    /// mounts stay as real `LocalFs` (real writes escape the transaction —
258    /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
259    /// **NoLocal:** incompatible — construction fails loudly (everything is
260    /// already virtual; an overlay adds no value and no lower layer to wrap).
261    /// **with_backend:** incompatible — the embedder controls the VFS; the
262    /// kernel cannot wrap it without bypassing the embedder's semantics.
263    ///
264    /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
265    /// making the overlay a per-call transaction — `kaish-vfs commit` must run
266    /// in the same call as the writes, or the transaction is discarded on drop.
267    /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
268    pub overlay: bool,
269}
270
271/// Get the default sandbox root ($HOME).
272#[cfg(feature = "localfs")]
273fn default_sandbox_root() -> PathBuf {
274    std::env::var("HOME")
275        .map(PathBuf::from)
276        .unwrap_or_else(|_| PathBuf::from("/"))
277}
278
279impl Default for KernelConfig {
280    fn default() -> Self {
281        #[cfg(feature = "localfs")]
282        {
283            let home = default_sandbox_root();
284            Self {
285                name: "default".to_string(),
286                vfs_mode: VfsMountMode::Sandboxed { root: None },
287                cwd: home,
288                skip_validation: false,
289                interactive: false,
290                ignore_config: crate::ignore_config::IgnoreConfig::none(),
291                output_limit: crate::output_limit::OutputLimitConfig::none(),
292                allow_external_commands: cfg!(feature = "subprocess"),
293                latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
294                trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
295                nonce_store: None,
296                initial_vars: HashMap::new(),
297                request_timeout: None,
298                kill_grace: Duration::from_secs(2),
299                vfs_budget_bytes: None,
300                overlay: false,
301            }
302        }
303        #[cfg(not(feature = "localfs"))]
304        {
305            Self {
306                name: "default".to_string(),
307                vfs_mode: VfsMountMode::NoLocal,
308                cwd: PathBuf::from("/"),
309                skip_validation: false,
310                interactive: false,
311                ignore_config: crate::ignore_config::IgnoreConfig::none(),
312                output_limit: crate::output_limit::OutputLimitConfig::none(),
313                allow_external_commands: false,
314                latch_enabled: false,
315                trash_enabled: false,
316                nonce_store: None,
317                initial_vars: HashMap::new(),
318                request_timeout: None,
319                kill_grace: Duration::from_secs(2),
320                vfs_budget_bytes: None,
321                overlay: false,
322            }
323        }
324    }
325}
326
327impl KernelConfig {
328    /// Create a transient kernel config (sandboxed, for temporary use).
329    #[cfg(feature = "localfs")]
330    pub fn transient() -> Self {
331        let home = default_sandbox_root();
332        Self {
333            name: "transient".to_string(),
334            vfs_mode: VfsMountMode::Sandboxed { root: None },
335            cwd: home,
336            skip_validation: false,
337            interactive: false,
338            ignore_config: crate::ignore_config::IgnoreConfig::none(),
339            output_limit: crate::output_limit::OutputLimitConfig::none(),
340            allow_external_commands: cfg!(feature = "subprocess"),
341            latch_enabled: false,
342            trash_enabled: false,
343            nonce_store: None,
344            initial_vars: HashMap::new(),
345            request_timeout: None,
346            kill_grace: Duration::from_secs(2),
347            vfs_budget_bytes: None,
348            overlay: false,
349        }
350    }
351
352    /// Create a transient kernel config (isolated, no-default-features).
353    #[cfg(not(feature = "localfs"))]
354    pub fn transient() -> Self {
355        Self::isolated()
356    }
357
358    /// Create a kernel config with the given name (sandboxed by default).
359    #[cfg(feature = "localfs")]
360    pub fn named(name: &str) -> Self {
361        let home = default_sandbox_root();
362        Self {
363            name: name.to_string(),
364            vfs_mode: VfsMountMode::Sandboxed { root: None },
365            cwd: home,
366            skip_validation: false,
367            interactive: false,
368            ignore_config: crate::ignore_config::IgnoreConfig::none(),
369            output_limit: crate::output_limit::OutputLimitConfig::none(),
370            allow_external_commands: cfg!(feature = "subprocess"),
371            latch_enabled: false,
372            trash_enabled: false,
373            nonce_store: None,
374            initial_vars: HashMap::new(),
375            request_timeout: None,
376            kill_grace: Duration::from_secs(2),
377            vfs_budget_bytes: None,
378            overlay: false,
379        }
380    }
381
382    /// Create a kernel config with the given name (isolated, no-default-features).
383    #[cfg(not(feature = "localfs"))]
384    pub fn named(name: &str) -> Self {
385        Self {
386            name: name.to_string(),
387            ..Self::isolated()
388        }
389    }
390
391    /// Create a REPL config with passthrough filesystem access.
392    ///
393    /// Native paths like `/home/user/project` work directly.
394    /// The cwd is set to the actual current working directory.
395    #[cfg(feature = "localfs")]
396    pub fn repl() -> Self {
397        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
398        Self {
399            name: "repl".to_string(),
400            vfs_mode: VfsMountMode::Passthrough,
401            cwd,
402            skip_validation: false,
403            interactive: false,
404            ignore_config: crate::ignore_config::IgnoreConfig::none(),
405            output_limit: crate::output_limit::OutputLimitConfig::none(),
406            allow_external_commands: cfg!(feature = "subprocess"),
407            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
408            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
409            nonce_store: None,
410            initial_vars: HashMap::new(),
411            request_timeout: None,
412            kill_grace: Duration::from_secs(2),
413            vfs_budget_bytes: None,
414            overlay: false,
415        }
416    }
417
418    /// Create a sandboxed-agent config with sandboxed filesystem access.
419    ///
420    /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
421    /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
422    /// memory and output. Local filesystem is accessible at its real path (e.g.,
423    /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
424    /// accessible through builtins. External commands still access the real
425    /// filesystem — use `.with_allow_external_commands(false)` to block them.
426    ///
427    /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
428    /// embedder typically creates a fresh kernel per call). Raise or remove with
429    /// `with_vfs_budget` / `without_vfs_budget`.
430    #[cfg(feature = "localfs")]
431    pub fn agent() -> Self {
432        let home = default_sandbox_root();
433        Self {
434            name: "agent".to_string(),
435            vfs_mode: VfsMountMode::Sandboxed { root: None },
436            cwd: home,
437            skip_validation: false,
438            interactive: false,
439            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
440            output_limit: crate::output_limit::OutputLimitConfig::agent(),
441            allow_external_commands: cfg!(feature = "subprocess"),
442            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
443            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
444            nonce_store: None,
445            initial_vars: HashMap::new(),
446            request_timeout: None,
447            kill_grace: Duration::from_secs(2),
448            vfs_budget_bytes: Some(64 * 1024 * 1024),
449            overlay: false,
450        }
451    }
452
453    /// Create a sandboxed-agent config with a custom sandbox root.
454    ///
455    /// Use this to restrict access to a subdirectory like `~/src`.
456    ///
457    /// VFS memory is bounded at 64 MiB per `execute()` call by default.
458    /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
459    #[cfg(feature = "localfs")]
460    pub fn agent_with_root(root: PathBuf) -> Self {
461        Self {
462            name: "agent".to_string(),
463            vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
464            cwd: root,
465            skip_validation: false,
466            interactive: false,
467            ignore_config: crate::ignore_config::IgnoreConfig::agent(),
468            output_limit: crate::output_limit::OutputLimitConfig::agent(),
469            allow_external_commands: cfg!(feature = "subprocess"),
470            latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
471            trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
472            nonce_store: None,
473            initial_vars: HashMap::new(),
474            request_timeout: None,
475            kill_grace: Duration::from_secs(2),
476            vfs_budget_bytes: Some(64 * 1024 * 1024),
477            overlay: false,
478        }
479    }
480
481    /// Create a config with no local filesystem (memory only).
482    ///
483    /// Complete isolation: no local filesystem and external commands are disabled.
484    /// Useful for tests or pure sandboxed execution.
485    pub fn isolated() -> Self {
486        Self {
487            name: "isolated".to_string(),
488            vfs_mode: VfsMountMode::NoLocal,
489            cwd: PathBuf::from("/"),
490            skip_validation: false,
491            interactive: false,
492            ignore_config: crate::ignore_config::IgnoreConfig::none(),
493            output_limit: crate::output_limit::OutputLimitConfig::none(),
494            allow_external_commands: false,
495            latch_enabled: false,
496            trash_enabled: false,
497            nonce_store: None,
498            initial_vars: HashMap::new(),
499            request_timeout: None,
500            kill_grace: Duration::from_secs(2),
501            vfs_budget_bytes: None,
502            overlay: false,
503        }
504    }
505
506    /// Set the VFS mount mode.
507    pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
508        self.vfs_mode = mode;
509        self
510    }
511
512    /// Set the initial working directory.
513    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
514        self.cwd = cwd;
515        self
516    }
517
518    /// Skip pre-execution validation.
519    pub fn with_skip_validation(mut self, skip: bool) -> Self {
520        self.skip_validation = skip;
521        self
522    }
523
524    /// Enable interactive mode (external commands inherit stdio).
525    pub fn with_interactive(mut self, interactive: bool) -> Self {
526        self.interactive = interactive;
527        self
528    }
529
530    /// Set the ignore file configuration.
531    pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
532        self.ignore_config = config;
533        self
534    }
535
536    /// Set the output limit configuration.
537    pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
538        self.output_limit = config;
539        self
540    }
541
542    /// Set whether external command execution is allowed.
543    ///
544    /// When `false`, commands not found as builtins produce "command not found"
545    /// instead of searching PATH. The `exec` and `spawn` builtins also return
546    /// errors. Use this to prevent VFS sandbox bypass via external binaries.
547    pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
548        self.allow_external_commands = allow;
549        self
550    }
551
552    /// Enable or disable confirmation latch at startup.
553    pub fn with_latch(mut self, enabled: bool) -> Self {
554        self.latch_enabled = enabled;
555        self
556    }
557
558    /// Enable or disable trash-on-delete at startup.
559    pub fn with_trash(mut self, enabled: bool) -> Self {
560        self.trash_enabled = enabled;
561        self
562    }
563
564    /// Use a shared nonce store for cross-request confirmation latch.
565    ///
566    /// Pass a `NonceStore` that outlives individual kernel instances so nonces
567    /// issued in one MCP `execute()` call can be validated in subsequent calls.
568    pub fn with_nonce_store(mut self, store: crate::nonce::NonceStore) -> Self {
569        self.nonce_store = Some(store);
570        self
571    }
572
573    /// Add a single initial variable; marked exported when the kernel boots.
574    ///
575    /// Repeated calls add (last write wins on key collision).
576    pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
577        self.initial_vars.insert(name.into(), value);
578        self
579    }
580
581    /// Replace the entire initial-vars map. All entries are marked exported.
582    pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
583        self.initial_vars = vars;
584        self
585    }
586
587    /// Extend the initial-vars map with the given entries (last write wins).
588    pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
589        self.initial_vars.extend(vars);
590        self
591    }
592
593    /// Set the default per-request timeout (kernel-wide).
594    ///
595    /// Each `execute_with_options` call without an explicit timeout uses
596    /// this. On elapsed, the kernel cancels and returns exit code 124.
597    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
598        self.request_timeout = Some(timeout);
599        self
600    }
601
602    /// Set the SIGTERM-to-SIGKILL grace period for child kills.
603    pub fn with_kill_grace(mut self, grace: Duration) -> Self {
604        self.kill_grace = grace;
605        self
606    }
607
608    /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
609    /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
610    /// created at kernel construction and passed to every `MemoryFs` the
611    /// kernel builds (see `setup_vfs` and `with_backend`).
612    ///
613    /// Writes that would exceed the cap fail loudly with `StorageFull` — an
614    /// in-band error a model reads and adapts to; fail loud over quietly eating
615    /// RAM. Use `without_vfs_budget` to remove the cap entirely.
616    pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
617        self.vfs_budget_bytes = Some(bytes);
618        self
619    }
620
621    /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
622    ///
623    /// Use when the caller knows the workload and the default 64 MiB cap
624    /// (set by `KernelConfig::agent`) is too conservative.
625    pub fn without_vfs_budget(mut self) -> Self {
626        self.vfs_budget_bytes = None;
627        self
628    }
629
630    /// Enable or disable copy-on-write overlay mode.
631    ///
632    /// When `true`, the primary local filesystem mount is wrapped in an
633    /// `OverlayFs` so writes are virtual — the lower layer is never touched.
634    /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
635    /// and `with_backend` kernels (same — the embedder controls the VFS).
636    pub fn with_overlay(mut self, overlay: bool) -> Self {
637        self.overlay = overlay;
638        self
639    }
640}
641
642/// Handle to an active overlay session, kept on the kernel and shared to
643/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
644///
645/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
646/// `/home/user`); `commit_root` is the real filesystem path the overlay's
647/// lower is backed by (used as the target for `kaish-vfs commit`).
648#[cfg(all(feature = "localfs", feature = "overlay"))]
649#[derive(Clone)]
650pub struct OverlayHandle {
651    /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
652    /// methods without holding a VfsRouter lock.
653    pub fs: Arc<OverlayFs>,
654    /// VFS path this overlay is mounted at (e.g. `/home/user`).
655    pub mount_path: PathBuf,
656    /// Real filesystem root to commit into. Same as the lower's root.
657    pub commit_root: PathBuf,
658}
659
660/// The Kernel (核) — executes kaish code.
661///
662/// This is the primary interface for running kaish commands. It owns all
663/// the runtime state: variables, tools, VFS, jobs, and persistence.
664pub struct Kernel {
665    /// Kernel name.
666    name: String,
667    /// Variable scope.
668    scope: RwLock<Scope>,
669    /// Tool registry.
670    tools: Arc<ToolRegistry>,
671    /// User-defined tools (from `tool name { body }` statements).
672    user_tools: RwLock<HashMap<String, ToolDef>>,
673    /// Virtual filesystem router.
674    vfs: Arc<VfsRouter>,
675    /// Background job manager.
676    jobs: Arc<JobManager>,
677    /// Pipeline runner.
678    runner: PipelineRunner,
679    /// Execution context (cwd, stdin, etc.).
680    exec_ctx: RwLock<ExecContext>,
681    /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
682    /// retained past construction so `reset()` can re-seed them into the fresh
683    /// scope instead of silently dropping them.
684    initial_vars: HashMap<String, Value>,
685    /// Whether to skip pre-execution validation.
686    skip_validation: bool,
687    /// When true, standalone external commands inherit stdio for real-time output.
688    interactive: bool,
689    /// Whether external command execution is allowed.
690    allow_external_commands: bool,
691    /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
692    ///
693    /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
694    /// `Some` is Arc-cloned into forks so all concurrent execution draws from
695    /// the same pool — a background job's writes reduce the same cap as
696    /// foreground writes, which is the correct behaviour.
697    vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
698    /// Active overlay session handle, if this kernel was constructed with
699    /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
700    /// `kaish-vfs` builtin) can inspect and mutate the overlay without
701    /// holding a kernel write lock. Propagated to forks via `fork_inner`
702    /// and `child_for_pipeline` so `kaish-vfs` works inside background
703    /// jobs, scatter workers, and pipeline stages.
704    #[cfg(all(feature = "localfs", feature = "overlay"))]
705    overlay_handle: Option<Arc<OverlayHandle>>,
706    /// Default per-request timeout (None = no default).
707    request_timeout: Option<Duration>,
708    /// SIGTERM-to-SIGKILL grace period for child kills.
709    kill_grace: Duration,
710    /// Receiver for the kernel stderr stream.
711    ///
712    /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
713    /// The kernel drains this after each statement in `execute_streaming`.
714    stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
715    /// Cancellation token for interrupting execution (Ctrl-C).
716    ///
717    /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
718    /// needs sync access. Each `execute()` call gets a fresh child token;
719    /// `cancel()` cancels the current token and replaces it.
720    cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
721    /// Terminal state for job control (interactive mode only, Unix only).
722    #[cfg(all(unix, feature = "subprocess"))]
723    terminal_state: Option<Arc<crate::terminal::TerminalState>>,
724    /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
725    ///
726    /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
727    /// through the full Kernel resolution chain.
728    self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
729    /// Background job this kernel (a fork) is executing on behalf of, if any.
730    /// Set on the fork created by `execute_background` and inherited by all its
731    /// sub-forks (pipeline stages, scatter workers), so an external command
732    /// spawned anywhere under a background job can record its process group on
733    /// that job for `kill -<sig> %N`. `None` for foreground execution.
734    bg_job_id: Option<crate::scheduler::JobId>,
735    /// Serializes concurrent `execute()` / `execute_streaming()` callers on
736    /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
737    /// queue. Background jobs, scatter workers, and concurrent pipeline
738    /// stages do NOT take this lock — they run against a *forked* Kernel
739    /// (see [`Kernel::fork`]) so they never contend with the foreground.
740    execute_lock: tokio::sync::Mutex<()>,
741}
742
743/// Internal result of [`Kernel::setup_vfs`].
744struct VfsSetupResult {
745    vfs: VfsRouter,
746    budget: Option<Arc<ByteBudget>>,
747    #[cfg(all(feature = "localfs", feature = "overlay"))]
748    overlay_handle: Option<Arc<OverlayHandle>>,
749}
750
751impl Kernel {
752    /// Create a new kernel with the given configuration.
753    pub fn new(config: KernelConfig) -> Result<Self> {
754        let mut setup = Self::setup_vfs(&config)?;
755        let jobs = Arc::new(JobManager::new());
756
757        // Mount JobFs for job observability at /v/jobs
758        setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
759
760        #[cfg(all(feature = "localfs", feature = "overlay"))]
761        let overlay_handle = setup.overlay_handle.take();
762
763        // Mode-based construction: the kernel owns its host mounts, so whether
764        // host side channels are allowed is decided by the VFS mode inside
765        // `assemble` (NoLocal forbids them).
766        let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
767            ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
768        })?;
769
770        #[cfg(all(feature = "localfs", feature = "overlay"))]
771        {
772            let mut kernel = kernel;
773            kernel.overlay_handle = overlay_handle;
774            // Also set it on the ExecContext so builtins can access it.
775            if let Some(ref handle) = kernel.overlay_handle {
776                kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
777            }
778            return Ok(kernel);
779        }
780
781        #[allow(unreachable_code)]
782        Ok(kernel)
783    }
784
785    /// Set up VFS based on mount mode.
786    ///
787    /// Returns the router, the budget handle (if bounded), and an optional
788    /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
789    /// every `MemoryFs` the kernel creates here holds a clone of the same
790    /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
791    /// in-memory content across all kernel-owned memory mounts.
792    ///
793    /// # Errors
794    /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
795    /// (overlay is meaningless when everything is already virtual — there is
796    /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
797    /// this as an `anyhow::Error`.
798    fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
799        let mut vfs = VfsRouter::new();
800
801        // One budget for all memory mounts this kernel owns — labeled so the
802        // error message tells the user exactly which knob to raise.
803        let budget: Option<Arc<ByteBudget>> = config
804            .vfs_budget_bytes
805            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
806
807        /// Helper: construct a `MemoryFs` wired to `budget` if present.
808        fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
809            match budget {
810                Some(b) => MemoryFs::with_budget(Arc::clone(b)),
811                None => MemoryFs::new(),
812            }
813        }
814
815        // Overlay handle — populated below if config.overlay is true.
816        #[cfg(all(feature = "localfs", feature = "overlay"))]
817        let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
818
819        match &config.vfs_mode {
820            #[cfg(feature = "localfs")]
821            VfsMountMode::Passthrough => {
822                #[cfg(feature = "overlay")]
823                if config.overlay {
824                    // Wrap "/" in an OverlayFs so writes are virtual.
825                    let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
826                    let overlay_fs = Arc::new(match &budget {
827                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
828                        None => OverlayFs::over(lower),
829                    });
830                    let handle = Arc::new(OverlayHandle {
831                        fs: Arc::clone(&overlay_fs),
832                        mount_path: PathBuf::from("/"),
833                        commit_root: PathBuf::from("/"),
834                    });
835                    vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
836                    overlay_handle = Some(handle);
837                } else {
838                    // LocalFs at "/" — native paths work directly
839                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
840                }
841                #[cfg(not(feature = "overlay"))]
842                {
843                    if config.overlay {
844                        return Err(anyhow::anyhow!(
845                            "overlay=true requires the `overlay` feature, but this build \
846                             was compiled without it. Recompile with --features overlay \
847                             (or the default feature set) to enable overlay mode."
848                        ));
849                    }
850                    // LocalFs at "/" — native paths work directly
851                    vfs.mount("/", LocalFs::new(PathBuf::from("/")));
852                }
853                // Memory for blobs
854                vfs.mount("/v", mem(&budget));
855            }
856            #[cfg(feature = "localfs")]
857            VfsMountMode::Sandboxed { root } => {
858                // Memory at root for safety (catches paths outside sandbox).
859                // Note: /tmp and the XDG runtime dir are LocalFs — writes
860                // there escape the VFS budget and are NOT virtual. This is
861                // intentional: /tmp interop with other processes matters more
862                // than accounting for scratch files there.
863                vfs.mount("/", mem(&budget));
864                vfs.mount("/v", mem(&budget));
865
866                // Synthetic /dev: the host's real /dev isn't reachable here, so
867                // /dev/null and /dev/zero are software-backed (see DevFs).
868                vfs.mount("/dev", DevFs::new());
869
870                // Real /tmp for interop with other processes
871                vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
872
873                // Mount XDG runtime dir for spill files and socket access
874                let runtime = crate::paths::xdg_runtime_dir();
875                if runtime.exists() {
876                    let runtime_str = runtime.to_string_lossy().to_string();
877                    vfs.mount(&runtime_str, LocalFs::new(runtime));
878                }
879
880                // Resolve the sandbox root (defaults to $HOME)
881                let local_root = root.clone().unwrap_or_else(|| {
882                    std::env::var("HOME")
883                        .map(PathBuf::from)
884                        .unwrap_or_else(|_| PathBuf::from("/"))
885                });
886
887                let mount_point = local_root.to_string_lossy().to_string();
888
889                #[cfg(feature = "overlay")]
890                if config.overlay {
891                    // Wrap the sandbox root in an OverlayFs.
892                    let lower = Arc::new(LocalFs::read_only(local_root.clone()));
893                    let overlay_fs = Arc::new(match &budget {
894                        Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
895                        None => OverlayFs::over(lower),
896                    });
897                    let handle = Arc::new(OverlayHandle {
898                        fs: Arc::clone(&overlay_fs),
899                        mount_path: PathBuf::from(&mount_point),
900                        commit_root: local_root,
901                    });
902                    vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
903                    overlay_handle = Some(handle);
904                } else {
905                    // Mount at the real path for transparent access
906                    // e.g., /home/atobey → LocalFs("/home/atobey")
907                    // so /home/atobey/src/kaish just works
908                    vfs.mount(&mount_point, LocalFs::new(local_root));
909                }
910                #[cfg(not(feature = "overlay"))]
911                {
912                    if config.overlay {
913                        return Err(anyhow::anyhow!(
914                            "overlay=true requires the `overlay` feature, but this build \
915                             was compiled without it. Recompile with --features overlay \
916                             (or the default feature set) to enable overlay mode."
917                        ));
918                    }
919                    // Mount at the real path for transparent access
920                    vfs.mount(&mount_point, LocalFs::new(local_root));
921                }
922            }
923            VfsMountMode::NoLocal => {
924                if config.overlay {
925                    return Err(anyhow::anyhow!(
926                        "overlay=true is incompatible with VfsMountMode::NoLocal: \
927                         everything is already virtual, there is no real lower layer \
928                         to wrap. Use with_overlay(false) or switch to a Passthrough \
929                         or Sandboxed VFS mode."
930                    ));
931                }
932                // Pure memory mode — no local filesystem
933                vfs.mount("/", mem(&budget));
934                vfs.mount("/tmp", mem(&budget));
935                vfs.mount("/v", mem(&budget));
936                // Synthetic /dev so /dev/null and /dev/zero work hermetically.
937                vfs.mount("/dev", DevFs::new());
938            }
939        }
940
941        Ok(VfsSetupResult {
942            vfs,
943            budget,
944            #[cfg(all(feature = "localfs", feature = "overlay"))]
945            overlay_handle,
946        })
947    }
948
949    /// Create a transient kernel (no persistence).
950    pub fn transient() -> Result<Self> {
951        Self::new(KernelConfig::transient())
952    }
953
954    /// Create a kernel with a custom backend and `/v/*` virtual path support.
955    ///
956    /// This is the constructor for embedding kaish in other systems that provide
957    /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
958    ///
959    /// A `VirtualOverlayBackend` routes paths automatically:
960    /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
961    /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
962    ///   `/dev/urandom`) — kernel-owned so it works even when your backend is
963    ///   read-only
964    /// - Everything else → Your custom backend
965    ///
966    /// The optional `configure_vfs` closure lets you add additional virtual mounts
967    /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
968    ///
969    /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
970    /// is handled by your custom backend. The config is only used for `name`, `cwd`,
971    /// `skip_validation`, and `interactive`.
972    ///
973    /// # Example
974    ///
975    /// ```ignore
976    /// // Simple: default /v/* mounts only
977    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
978    ///
979    /// // With custom mounts
980    /// let kernel = Kernel::with_backend(backend, config, |vfs| {
981    ///     vfs.mount_arc("/v/docs", docs_fs);
982    ///     vfs.mount_arc("/v/g", git_fs);
983    /// }, |_| {})?;
984    ///
985    /// // With custom tools
986    /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
987    ///     tools.register(MyCustomTool::new());
988    /// })?;
989    /// ```
990    pub fn with_backend(
991        backend: Arc<dyn KernelBackend>,
992        config: KernelConfig,
993        configure_vfs: impl FnOnce(&mut VfsRouter),
994        configure_tools: impl FnOnce(&mut ToolRegistry),
995    ) -> Result<Self> {
996        use crate::backend::VirtualOverlayBackend;
997
998        // overlay=true is incompatible with with_backend: the embedder controls
999        // the VFS and the kernel cannot wrap it without bypassing the embedder's
1000        // semantics. Fail loudly rather than silently ignoring the flag.
1001        if config.overlay {
1002            return Err(anyhow::anyhow!(
1003                "overlay=true is incompatible with Kernel::with_backend: the embedder \
1004                 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1005                 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1006            ));
1007        }
1008
1009        let mut vfs = VfsRouter::new();
1010        let jobs = Arc::new(JobManager::new());
1011
1012        // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1013        // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1014        // kernel-owned memory mount here — embedders own the rest of the VFS.
1015        let vfs_budget: Option<Arc<ByteBudget>> = config
1016            .vfs_budget_bytes
1017            .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1018
1019        vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1020        let blobs_fs = match &vfs_budget {
1021            Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1022            None => MemoryFs::new(),
1023        };
1024        vfs.mount("/v/blobs", blobs_fs);
1025
1026        // /dev/null and friends are software-backed (see DevFs) and must not
1027        // depend on the embedder's backend — a read-only embedder backend
1028        // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1029        // to /dev/null as a filesystem error instead of discarding them.
1030        vfs.mount("/dev", DevFs::new());
1031
1032        // Let caller add custom mounts (e.g., /v/docs, /v/g)
1033        configure_vfs(&mut vfs);
1034
1035        // A custom-backend kernel owns no host mounts — the embedder supplies
1036        // the entire VFS — so any kernel write to a host filesystem via
1037        // `std::fs` (output spill, job output files) bypasses that VFS and its
1038        // read-only guarantees. Forbid host side channels unconditionally.
1039        Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1040            let overlay: Arc<dyn KernelBackend> =
1041                Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1042            ExecContext::with_backend(overlay)
1043        })
1044    }
1045
1046    /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1047    ///
1048    /// The `make_ctx` closure receives the VFS and tools so backends that need
1049    /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1050    /// that already have their own storage can ignore these parameters.
1051    fn assemble(
1052        config: KernelConfig,
1053        mut vfs: VfsRouter,
1054        jobs: Arc<JobManager>,
1055        no_host_filesystem: bool,
1056        vfs_budget: Option<Arc<ByteBudget>>,
1057        configure_tools: impl FnOnce(&mut ToolRegistry),
1058        make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1059    ) -> Result<Self> {
1060        // A kernel with no host filesystem of its own must never write to one
1061        // through a side channel. Two paths bypass the VFS by going straight to
1062        // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1063        // background-job output files (`Job::write_output_file` → host temp).
1064        // Both would punch through the isolation, so force them off:
1065        // in-memory truncation for spill, no host file for job output.
1066        //
1067        // This is true for a `NoLocal` kernel (mounts nothing) and for any
1068        // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1069        // VFS, so the kernel controls no host mounts and any host write is a
1070        // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1071        // when there is no kernel-owned host filesystem to spill to.
1072        let no_host_side_channel =
1073            no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1074
1075        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;
1076
1077        if no_host_side_channel {
1078            output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1079            jobs.set_persist_output_files(false);
1080        }
1081
1082        let mut tools = ToolRegistry::new();
1083        register_builtins(&mut tools);
1084        configure_tools(&mut tools);
1085        let tools = Arc::new(tools);
1086
1087        // Mount BuiltinFs so `ls /v/bin` lists builtins
1088        vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1089
1090        let vfs = Arc::new(vfs);
1091
1092        let runner = PipelineRunner::new(tools.clone());
1093
1094        let (stderr_writer, stderr_receiver) = stderr_stream();
1095
1096        let mut exec_ctx = make_ctx(&vfs, &tools);
1097        exec_ctx.set_cwd(cwd);
1098        exec_ctx.set_job_manager(jobs.clone());
1099        exec_ctx.set_tool_schemas(tools.schemas());
1100        exec_ctx.set_tools(tools.clone());
1101        #[cfg(feature = "os-integration")]
1102        exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1103        exec_ctx.stderr = Some(stderr_writer);
1104        exec_ctx.ignore_config = ignore_config;
1105        exec_ctx.output_limit = output_limit;
1106        exec_ctx.allow_external_commands = allow_external_commands;
1107        exec_ctx.vfs_budget = vfs_budget.clone();
1108        if let Some(store) = nonce_store {
1109            exec_ctx.nonce_store = store;
1110        }
1111
1112        Ok(Self {
1113            name,
1114            scope: RwLock::new({
1115                let mut scope = Scope::new();
1116                scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1117                // HOME is NOT read from the host env here — the kernel is
1118                // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1119                // below (from `std::env::vars()`); a hermetic embedder leaves
1120                // `initial_vars` empty and gets no HOME (tilde stays literal).
1121                // Apply caller-supplied initial variables, all marked exported.
1122                // Frontends (REPL, MCP) populate this from std::env::vars()
1123                // for shell-like UX; embedders that want hermetic behavior
1124                // simply leave it empty.
1125                for (name, value) in initial_vars.clone() {
1126                    scope.set_exported(name, value);
1127                }
1128                scope.set_latch_enabled(latch_enabled);
1129                scope.set_trash_enabled(trash_enabled);
1130                scope
1131            }),
1132            initial_vars,
1133            tools,
1134            user_tools: RwLock::new(HashMap::new()),
1135            vfs,
1136            jobs,
1137            runner,
1138            exec_ctx: RwLock::new(exec_ctx),
1139            skip_validation,
1140            interactive,
1141            allow_external_commands,
1142            vfs_budget,
1143            request_timeout,
1144            kill_grace,
1145            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1146            cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1147            #[cfg(all(unix, feature = "subprocess"))]
1148            terminal_state: None,
1149            self_weak: std::sync::OnceLock::new(),
1150            execute_lock: tokio::sync::Mutex::new(()),
1151            bg_job_id: None,
1152            // Overlay handle is set by Kernel::new after assemble returns;
1153            // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1154            // with_backend always has None (overlay=true is rejected above).
1155            #[cfg(all(feature = "localfs", feature = "overlay"))]
1156            overlay_handle: None,
1157        })
1158    }
1159
1160    /// Get the kernel name.
1161    pub fn name(&self) -> &str {
1162        &self.name
1163    }
1164
1165    /// Wrap this Kernel in an Arc and initialize its self-reference.
1166    ///
1167    /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1168    /// to child contexts, allowing builtins like `timeout` to dispatch inner
1169    /// commands through the full resolution chain (user tools → builtins →
1170    /// .kai scripts → external commands).
1171    pub fn into_arc(self) -> Arc<Self> {
1172        let arc = Arc::new(self);
1173        let _ = arc.self_weak.set(Arc::downgrade(&arc));
1174        arc
1175    }
1176
1177    /// Fork a subsidiary kernel for concurrent execution.
1178    ///
1179    /// The fork is a fully-functional `Kernel` that:
1180    /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1181    ///   user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1182    ///   the fork do NOT propagate back to the parent — matching bash
1183    ///   subshell / background-job semantics.
1184    /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1185    ///   registry, the VFS router, and the job manager. A job registered by
1186    ///   the fork is visible to the parent's `jobs` builtin, and the fork
1187    ///   sees the same VFS mounts.
1188    /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1189    ///   `execute_lock`. It is never the TTY owner, so `interactive` is
1190    ///   `false` and `terminal_state` is `None`.
1191    ///
1192    /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1193    /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1194    /// routes through the fork itself, not the parent — which is essential
1195    /// for concurrency safety.
1196    ///
1197    /// Use this for **detached** background concurrency where the fork should
1198    /// survive parent cancellation: the `&` background-job operator and any
1199    /// other "fire and forget" worker. The fork gets a fresh, independent
1200    /// cancellation token.
1201    ///
1202    /// For foreground concurrency (scatter workers, concurrent pipeline
1203    /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1204    /// into the fork's external children, use [`Self::fork_attached`].
1205    pub async fn fork(&self) -> Arc<Self> {
1206        self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1207            .await
1208    }
1209
1210    /// Fork attached to the parent's cancellation.
1211    ///
1212    /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1213    /// the parent's. When the parent cancels (request timeout, embedder
1214    /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1215    /// turn kills any external children spawned in the fork via the
1216    /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1217    pub async fn fork_attached(&self) -> Arc<Self> {
1218        let child_token = {
1219            #[allow(clippy::expect_used)]
1220            let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1221            parent.child_token()
1222        };
1223        self.fork_inner(child_token, self.bg_job_id).await
1224    }
1225
1226    /// Fork for a background job, stamping the job id so external commands
1227    /// spawned anywhere beneath it record their process groups on that job
1228    /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1229    /// `JobManager::cancel`.
1230    pub async fn fork_for_background(
1231        &self,
1232        cancel: tokio_util::sync::CancellationToken,
1233        job_id: crate::scheduler::JobId,
1234    ) -> Arc<Self> {
1235        self.fork_inner(cancel, Some(job_id)).await
1236    }
1237
1238    /// Shared fork implementation. Caller decides the cancellation token and
1239    /// which background job (if any) this fork runs on behalf of.
1240    async fn fork_inner(
1241        &self,
1242        cancel: tokio_util::sync::CancellationToken,
1243        bg_job_id: Option<crate::scheduler::JobId>,
1244    ) -> Arc<Self> {
1245        let scope_snapshot = self.scope.read().await.clone();
1246        let user_tools_snapshot = self.user_tools.read().await.clone();
1247
1248        // Snapshot exec_ctx by cloning the cloneable fields, then override
1249        // the ones that should not carry over (stderr channel, dispatcher,
1250        // interactive flag, terminal state, cancel — set from `cancel` arg).
1251        let mut fork_ctx = {
1252            let parent_ctx = self.exec_ctx.read().await;
1253            parent_ctx.child_for_pipeline()
1254        };
1255        let (stderr_writer, stderr_receiver) = stderr_stream();
1256        fork_ctx.stderr = Some(stderr_writer);
1257        // Clear dispatcher; dispatch_command will repopulate it to point at
1258        // the fork on the first dispatch call.
1259        fork_ctx.dispatcher = None;
1260        fork_ctx.interactive = false;
1261        fork_ctx.cancel = cancel.clone();
1262        #[cfg(all(unix, feature = "subprocess"))]
1263        {
1264            fork_ctx.terminal_state = None;
1265        }
1266
1267        let fork = Self {
1268            name: format!("{}:fork", self.name),
1269            scope: RwLock::new(scope_snapshot),
1270            initial_vars: self.initial_vars.clone(),
1271            tools: Arc::clone(&self.tools),
1272            user_tools: RwLock::new(user_tools_snapshot),
1273            vfs: Arc::clone(&self.vfs),
1274            jobs: Arc::clone(&self.jobs),
1275            runner: self.runner.clone(),
1276            exec_ctx: RwLock::new(fork_ctx),
1277            skip_validation: self.skip_validation,
1278            // Forks are never the TTY owner — they run in the background.
1279            interactive: false,
1280            allow_external_commands: self.allow_external_commands,
1281            // Arc-clone the budget so the fork draws from the same pool as the
1282            // parent — background jobs and scatter workers count against the same
1283            // cap as foreground writes.
1284            vfs_budget: self.vfs_budget.clone(),
1285            request_timeout: self.request_timeout,
1286            kill_grace: self.kill_grace,
1287            stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1288            cancel_token: std::sync::Mutex::new(cancel),
1289            #[cfg(all(unix, feature = "subprocess"))]
1290            terminal_state: None,
1291            self_weak: std::sync::OnceLock::new(),
1292            execute_lock: tokio::sync::Mutex::new(()),
1293            bg_job_id,
1294            // Arc-clone the overlay handle so forks (background jobs, scatter
1295            // workers, pipeline stages) can reach the same overlay transaction
1296            // via `kaish-vfs status/diff/commit/reset`.
1297            #[cfg(all(feature = "localfs", feature = "overlay"))]
1298            overlay_handle: self.overlay_handle.clone(),
1299        };
1300
1301        fork.into_arc()
1302    }
1303
1304    /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1305    ///
1306    /// Returns `None` if the Kernel was not wrapped, or if all strong references
1307    /// have been dropped (the `Weak` can no longer upgrade).
1308    pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1309        self.self_weak
1310            .get()
1311            .and_then(|weak| weak.upgrade())
1312            .map(|arc| arc as Arc<dyn CommandDispatcher>)
1313    }
1314
1315    /// Initialize terminal state for interactive job control.
1316    ///
1317    /// Call this after kernel creation when running as an interactive REPL
1318    /// and stdin is a TTY. Sets up process groups and signal handling.
1319    #[cfg(all(unix, feature = "subprocess"))]
1320    pub fn init_terminal(&mut self) {
1321        if !self.interactive {
1322            return;
1323        }
1324        match crate::terminal::TerminalState::init() {
1325            Ok(state) => {
1326                let state = Arc::new(state);
1327                self.terminal_state = Some(state.clone());
1328                // Set on exec_ctx so builtins (fg, bg, kill) can access it
1329                self.exec_ctx.get_mut().terminal_state = Some(state);
1330                tracing::debug!("terminal job control initialized");
1331            }
1332            Err(e) => {
1333                tracing::warn!("failed to initialize terminal job control: {}", e);
1334            }
1335        }
1336    }
1337
1338    /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1339    ///
1340    /// The kernel installs the OS trash (`SystemTrash`) automatically when
1341    /// built with the `os-integration` feature. Embedders and tests can swap
1342    /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1343    /// it — with trash enabled but no backend present, `rm` fails loud
1344    /// rather than falling through to permanent delete.
1345    pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1346        self.exec_ctx.get_mut().trash_backend = backend;
1347    }
1348
1349    /// Cancel the current execution.
1350    ///
1351    /// This cancels the current cancellation token, causing any execution
1352    /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1353    /// A fresh token is installed for the next `execute()` call.
1354    pub fn cancel(&self) {
1355        #[allow(clippy::expect_used)]
1356        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1357        token.cancel();
1358    }
1359
1360    /// Check if the current execution has been cancelled.
1361    pub fn is_cancelled(&self) -> bool {
1362        #[allow(clippy::expect_used)]
1363        let token = self.cancel_token.lock().expect("cancel_token poisoned");
1364        token.is_cancelled()
1365    }
1366
1367    /// Reset the cancellation token (called at the start of each execute).
1368    fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1369        #[allow(clippy::expect_used)]
1370        let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1371        if token.is_cancelled() {
1372            *token = tokio_util::sync::CancellationToken::new();
1373        }
1374        token.clone()
1375    }
1376
1377    /// Acquire the per-Kernel execute lock, warning on contention.
1378    ///
1379    /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1380    /// the lock is already held, emit a warning so the silent serialization
1381    /// is observable in logs — if you need real parallelism, fork the kernel.
1382    async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1383        match self.execute_lock.try_lock() {
1384            Ok(guard) => guard,
1385            Err(_) => {
1386                tracing::warn!(
1387                    target: "kaish::kernel::concurrency",
1388                    kernel = %self.name,
1389                    "execute() contended — serializing concurrent caller; \
1390                     use Kernel::fork() for parallelism instead of sharing"
1391                );
1392                self.execute_lock.lock().await
1393            }
1394        }
1395    }
1396
1397    /// Execute kaish source code with default options.
1398    ///
1399    /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1400    /// Returns the result of the last statement executed.
1401    pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1402        self.run_inner(input, ExecuteOptions::default(), None, None).await
1403    }
1404
1405    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1406    /// are **already tokenized**.
1407    ///
1408    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1409    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1410    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1411    /// into a string just to have the lexer split it apart again — a round-trip
1412    /// that is lossy for typed values, since `to_argv()` stringifies
1413    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1414    ///
1415    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1416    /// command substitution, no word splitting — the "single-quoted word"
1417    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1418    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1419    /// does still apply, for consistency with the string door: a leading `~` is
1420    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1421    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1422    /// non-string `Value`
1423    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1424    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1425    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1426    /// sees a stringified value; the typed-passthrough win fully lands only for
1427    /// builtins that read `args.positional` directly — the documented pattern.)
1428    ///
1429    /// This is a *peer*, not a subset: a command string can carry pipelines,
1430    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1431    /// doors converge **late** (at the shared dispatch chain) rather than one
1432    /// wrapping the other. From argv classification onward `execute_argv` reuses
1433    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1434    /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1435    /// transform, and the confirmation latch — so a latched `rm` still emits a
1436    /// nonce and an `ls --json` still applies output formatting. The kernel's
1437    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1438    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1439    ///
1440    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1441    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1442    /// external is interrupted at the deadline with exit code 124, the same as the
1443    /// string door). There is no per-call options surface yet — a future
1444    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1445    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1446    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1447        let _guard = self.acquire_execute_lock().await;
1448        // Fresh cancel surface for this call: `execute_pipeline` reads
1449        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1450        // replaced first. The returned clone is the token the watchdog cancels on
1451        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1452        // cascading SIGTERM/SIGKILL to any external child.
1453        let cancel = self.reset_cancel();
1454
1455        // Honor the kernel-configured request timeout for parity with `execute`.
1456        let timeout = self.request_timeout;
1457        if timeout == Some(Duration::ZERO) {
1458            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1459        }
1460
1461        let pipeline = crate::ast::Pipeline {
1462            commands: vec![crate::ast::Command {
1463                name: name.to_string(),
1464                args: argv_to_args(argv),
1465                redirects: Vec::new(),
1466            }],
1467            background: false,
1468        };
1469        let result = self
1470            .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1471            .await?;
1472        self.update_last_result(&result).await;
1473        Ok(result)
1474    }
1475
1476    /// Fulfill a confirmation latch by replaying its exact captured invocation
1477    /// with the nonce — the highest-fidelity approval path.
1478    ///
1479    /// Inspect a gated result with [`ExecResult::latch_request`]; apply whatever
1480    /// policy (allowlist, model review) over `req.command`/`req.paths`; then call
1481    /// this to approve. It replays `execute_argv(req.tool, req.argv)` with
1482    /// `--confirm=<nonce>` prepended — no re-parsing of the human `hint`, so a
1483    /// path with spaces or glob characters round-trips exactly. Share the nonce
1484    /// store ([`KernelConfig::with_nonce_store`]) to confirm from a *later*
1485    /// kernel call than the one that produced the latch.
1486    ///
1487    /// Errors (exit 2) if the latch carries no captured invocation — a latch
1488    /// produced outside a dispatch seam (a direct `tool.execute` in a unit
1489    /// test). Those are confirmable only by re-running with `--confirm=<nonce>`.
1490    pub async fn confirm(&self, latch: &LatchRequest) -> Result<ExecResult> {
1491        if latch.tool.is_empty() {
1492            return Ok(ExecResult::failure(
1493                2,
1494                "confirm: latch carries no captured invocation to replay — \
1495                 re-run the command with --confirm=<nonce> instead",
1496            ));
1497        }
1498        // Prepend the nonce as a `--confirm=` flag: `to_argv()` trails a `--`
1499        // positional terminator, so appending would let it swallow the flag.
1500        let mut argv: Vec<Value> = Vec::with_capacity(latch.argv.len() + 1);
1501        argv.push(Value::String(format!("--confirm={}", latch.nonce)));
1502        argv.extend(latch.argv.iter().map(|a| Value::String(a.clone())));
1503        self.execute_argv(&latch.tool, &argv).await
1504    }
1505
1506    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1507    /// string door ([`Self::execute_with_options`]) and the argv door
1508    /// ([`Self::execute_argv`]).
1509    ///
1510    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1511    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1512    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1513    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1514    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1515    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1516    /// stale handle would silently suspend nothing). Callers must short-circuit a
1517    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1518    async fn run_under_watchdog<F>(
1519        &self,
1520        timeout: Option<Duration>,
1521        cancel: &tokio_util::sync::CancellationToken,
1522        work: F,
1523    ) -> Result<ExecResult>
1524    where
1525        F: std::future::Future<Output = Result<ExecResult>>,
1526    {
1527        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1528        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1529        {
1530            let mut ec = self.exec_ctx.write().await;
1531            ec.watchdog = watchdog.clone();
1532        }
1533
1534        let result = if let Some(d) = timeout {
1535            #[allow(clippy::expect_used)]
1536            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1537            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1538            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1539            let r = work.await;
1540            timer.abort();
1541            match r {
1542                Ok(mut res) => {
1543                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1544                        res.code = 124;
1545                        if res.err.is_empty() {
1546                            res.err = format!("timeout: timed out after {:?}", d);
1547                        }
1548                    }
1549                    Ok(res)
1550                }
1551                Err(e) => Err(e),
1552            }
1553        } else {
1554            work.await
1555        };
1556
1557        // The timer task is gone (fired or aborted); drop the stale handle.
1558        {
1559            let mut ec = self.exec_ctx.write().await;
1560            ec.watchdog = None;
1561        }
1562        result
1563    }
1564
1565    /// Execute with per-call options. The primary entry point for embedders
1566    /// that don't need per-statement output streaming.
1567    ///
1568    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1569    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1570    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1571    ///
1572    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1573    /// against the kernel's internal token. Either firing cancels and kills
1574    /// external children. The embedder's token is read-only — kernel
1575    /// timeouts do NOT propagate into it. Distinguish via the returned
1576    /// `code`: 124 = timeout, 130 = cancellation.
1577    ///
1578    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1579    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1580    ///
1581    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1582    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1583    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1584    pub async fn execute_with_options(
1585        &self,
1586        input: &str,
1587        opts: ExecuteOptions,
1588    ) -> Result<ExecResult> {
1589        self.run_inner(input, opts, None, None).await
1590    }
1591
1592    /// Same as [`Self::execute_with_options`] but with a per-statement output
1593    /// callback. The callback fires after each top-level statement so the
1594    /// embedder (REPL, MCP streaming) can flush output incrementally.
1595    pub async fn execute_with_options_streaming(
1596        &self,
1597        input: &str,
1598        opts: ExecuteOptions,
1599        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1600    ) -> Result<ExecResult> {
1601        self.run_inner(input, opts, None, Some(on_output)).await
1602    }
1603
1604    /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1605    ///
1606    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read `String`), this never
1607    /// forces the input to be drained before execution: the reader seeds the
1608    /// first top-level command's `pipe_stdin`, and a command that does not read
1609    /// stdin (`echo`) returns without touching it. This is the seam a
1610    /// non-interactive frontend uses to forward an *open* process stdin without
1611    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1612    ///
1613    /// Embedders that already hold a complete buffer should prefer the simpler
1614    /// [`ExecuteOptions::with_stdin`] String path.
1615    pub async fn execute_with_pipe_stdin(
1616        &self,
1617        input: &str,
1618        opts: ExecuteOptions,
1619        pipe_stdin: crate::scheduler::PipeReader,
1620    ) -> Result<ExecResult> {
1621        self.run_inner(input, opts, Some(pipe_stdin), None).await
1622    }
1623
1624    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1625    /// `-c`/script frontend uses this to print output incrementally while
1626    /// feeding a lazy process-stdin pipe.
1627    pub async fn execute_with_pipe_stdin_streaming(
1628        &self,
1629        input: &str,
1630        opts: ExecuteOptions,
1631        pipe_stdin: crate::scheduler::PipeReader,
1632        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1633    ) -> Result<ExecResult> {
1634        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1635    }
1636
1637    /// Execute kaish source code with a transient overlay of exported variables.
1638    ///
1639    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1640    /// should use that method directly:
1641    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1642    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1643    pub async fn execute_with_vars(
1644        &self,
1645        input: &str,
1646        vars: HashMap<String, Value>,
1647    ) -> Result<ExecResult> {
1648        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1649    }
1650
1651    /// Execute kaish source code with a per-statement callback.
1652    ///
1653    /// Deprecated thin wrapper. New code should use
1654    /// [`Self::execute_with_options_streaming`].
1655    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1656    pub async fn execute_streaming(
1657        &self,
1658        input: &str,
1659        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1660    ) -> Result<ExecResult> {
1661        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1662    }
1663
1664    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1665    ///
1666    /// The `#[instrument]` execution span resolves its parent from the *current*
1667    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1668    /// captured when the span is first entered — not when the future is
1669    /// constructed. So a thread-local `attach()` scoped to construction is too
1670    /// early to be seen (the integration test confirms this). `with_context`
1671    /// re-attaches the embedder's context on *every* poll of the inner future,
1672    /// so the context is current at first-enter and survives runtime thread
1673    /// hops. With no embedder trace context, the future runs unwrapped.
1674    async fn run_inner(
1675        &self,
1676        input: &str,
1677        opts: ExecuteOptions,
1678        pipe_stdin: Option<crate::scheduler::PipeReader>,
1679        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1680    ) -> Result<ExecResult> {
1681        use opentelemetry::context::FutureExt;
1682
1683        // Capture the embedder's baggage before `opts` is consumed so it can be
1684        // echoed back onto the result on egress (see `merge_egress_baggage`).
1685        let embedder_baggage = opts.baggage.clone();
1686
1687        let result = match crate::telemetry::extract_parent(&opts) {
1688            Some(parent) => self
1689                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1690                .with_context(parent)
1691                .await,
1692            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1693        };
1694
1695        result.map(|mut r| {
1696            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1697            r
1698        })
1699    }
1700
1701    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1702    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1703    /// cwd override, and timeout race.
1704    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1705    async fn execute_with_options_inner(
1706        &self,
1707        input: &str,
1708        opts: ExecuteOptions,
1709        pipe_stdin: Option<crate::scheduler::PipeReader>,
1710        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1711    ) -> Result<ExecResult> {
1712        let _guard = self.acquire_execute_lock().await;
1713
1714        // Always reset to a fresh internal token; this is the kernel's own
1715        // cancel surface for embedders calling `Kernel::cancel()`. The
1716        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1717        // is NOT written into `self.cancel_token`, because doing so would
1718        // (a) leak the embedder's token past this call's lifetime,
1719        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1720        // (c) extend the token's lifetime via the kernel's strong clone.
1721        let internal = self.reset_cancel();
1722        // Race the embedder token against the kernel's internal token via a
1723        // tracked watcher task. We hold the JoinHandle so we can abort the
1724        // task at function exit — otherwise it would wait forever for either
1725        // token to fire and leak per call.
1726        let (effective_cancel, watcher_handle): (
1727            tokio_util::sync::CancellationToken,
1728            Option<tokio::task::JoinHandle<()>>,
1729        ) = if let Some(ext) = opts.cancel_token {
1730            let combined = tokio_util::sync::CancellationToken::new();
1731            let combined_writer = combined.clone();
1732            let i = internal.clone();
1733            let handle = tokio::spawn(async move {
1734                tokio::select! {
1735                    _ = i.cancelled() => combined_writer.cancel(),
1736                    _ = ext.cancelled() => combined_writer.cancel(),
1737                }
1738            });
1739            (combined, Some(handle))
1740        } else {
1741            (internal, None)
1742        };
1743
1744        // Effective timeout: per-call wins over kernel-config default.
1745        let timeout = opts.timeout.or(self.request_timeout);
1746
1747        // ZERO timeout: return 124 immediately without spawning anything.
1748        if timeout == Some(Duration::ZERO) {
1749            if let Some(h) = watcher_handle {
1750                h.abort();
1751            }
1752            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1753        }
1754
1755        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1756        // an RAII guard so a panic inside `execute_streaming_inner` still
1757        // pops the frame and unexports the temporarily-exported names.
1758        struct VarsFrameGuard<'a> {
1759            kernel: &'a Kernel,
1760            newly_exported: Vec<String>,
1761        }
1762        impl Drop for VarsFrameGuard<'_> {
1763            fn drop(&mut self) {
1764                // Best-effort cleanup using try_write. The execute_lock held
1765                // throughout execute_with_options means there is no concurrent
1766                // foreground caller; forks have their own scope and won't
1767                // block this. blocking_write would deadlock the runtime when
1768                // called from a tokio worker thread, so we explicitly do NOT
1769                // fall back to it — if try_write fails (which we've never
1770                // seen in practice), log loudly and accept the leak rather
1771                // than deadlock the entire kernel.
1772                let Ok(mut scope) = self.kernel.scope.try_write() else {
1773                    tracing::error!(
1774                        "vars frame guard: scope lock unexpectedly busy; \
1775                         skipping pop_frame to avoid runtime deadlock — \
1776                         transient vars may leak"
1777                    );
1778                    return;
1779                };
1780                scope.pop_frame();
1781                for name in self.newly_exported.drain(..) {
1782                    scope.unexport(&name);
1783                }
1784            }
1785        }
1786
1787        // Per-call cwd override: save current cwd, set the new one, restore
1788        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1789        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1790        struct CwdGuard<'a> {
1791            kernel: &'a Kernel,
1792            saved: PathBuf,
1793        }
1794        impl Drop for CwdGuard<'_> {
1795            fn drop(&mut self) {
1796                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1797                    tracing::error!(
1798                        "cwd guard: exec_ctx lock unexpectedly busy; \
1799                         skipping cwd restore — kernel cwd may be wrong for next call"
1800                    );
1801                    return;
1802                };
1803                ec.cwd = std::mem::take(&mut self.saved);
1804            }
1805        }
1806        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1807            let mut ec = self.exec_ctx.write().await;
1808            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1809            drop(ec);
1810            Some(CwdGuard { kernel: self, saved })
1811        } else {
1812            None
1813        };
1814
1815        // Per-call stdin: seed the persistent exec_ctx so the first top-level
1816        // command that reads stdin consumes it (it's `take()`n at dispatch).
1817        // Restore the prior value on Drop — normally `None`, so this also drops
1818        // any residual seed an stdin-less program never consumed, keeping it
1819        // from bleeding into the next call. Same RAII pattern as CwdGuard.
1820        struct StdinGuard<'a> {
1821            kernel: &'a Kernel,
1822            saved: Option<String>,
1823        }
1824        impl Drop for StdinGuard<'_> {
1825            fn drop(&mut self) {
1826                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1827                    tracing::error!(
1828                        "stdin guard: exec_ctx lock unexpectedly busy; \
1829                         skipping stdin restore — stale stdin may leak to next call"
1830                    );
1831                    return;
1832                };
1833                ec.stdin = self.saved.take();
1834            }
1835        }
1836        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1837            let mut ec = self.exec_ctx.write().await;
1838            let saved = ec.stdin.replace(stdin);
1839            drop(ec);
1840            Some(StdinGuard { kernel: self, saved })
1841        } else {
1842            None
1843        };
1844
1845        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1846        // persistent exec_ctx so the first stdin-reading command drains it (it's
1847        // `take()`n at pipeline build). The RAII guard restores the prior value
1848        // on Drop (normally `None`), so an unread reader doesn't bleed into the
1849        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1850        struct PipeStdinGuard<'a> {
1851            kernel: &'a Kernel,
1852            saved: Option<crate::scheduler::PipeReader>,
1853        }
1854        impl Drop for PipeStdinGuard<'_> {
1855            fn drop(&mut self) {
1856                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1857                    tracing::error!(
1858                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1859                         skipping restore — stale pipe stdin may leak to next call"
1860                    );
1861                    return;
1862                };
1863                ec.pipe_stdin = self.saved.take();
1864            }
1865        }
1866        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
1867            let mut ec = self.exec_ctx.write().await;
1868            let saved = ec.pipe_stdin.replace(reader);
1869            drop(ec);
1870            Some(PipeStdinGuard { kernel: self, saved })
1871        } else {
1872            None
1873        };
1874
1875        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
1876            let mut scope = self.scope.write().await;
1877            scope.push_frame();
1878            let mut newly = Vec::with_capacity(opts.vars.len());
1879            for (name, value) in opts.vars {
1880                if !scope.is_exported(&name) {
1881                    newly.push(name.clone());
1882                }
1883                scope.set_exported(name, value);
1884            }
1885            drop(scope);
1886            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
1887        } else {
1888            None
1889        };
1890
1891        // Sync the effective cancel into self.exec_ctx so try_execute_external
1892        // (which reads via self.cancel_token) sees cancellation. We also need
1893        // builtins to see it via ctx.cancel — handled in execute_command.
1894        // For simplicity here we mirror effective_cancel into self.cancel_token
1895        // for the duration of this call, then restore the internal token at
1896        // the end (so a later Kernel::cancel still hits our internal surface).
1897        {
1898            #[allow(clippy::expect_used)]
1899            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1900            *cur = effective_cancel.clone();
1901        }
1902
1903        // Run the script under the movable-deadline watchdog (shared with the
1904        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
1905        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
1906        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
1907        // already handled by the early return above.
1908        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
1909        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
1910            Some(cb) => cb,
1911            None => &mut *noop_cb,
1912        };
1913
1914        let result = self
1915            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
1916            .await;
1917
1918        // Restore self.cancel_token to a fresh, uncancelled token so the
1919        // embedder's view of `Kernel::cancel()` stays predictable on the
1920        // next call (it cancels the kernel's own token, not whatever was
1921        // left over from this call's combined token).
1922        {
1923            #[allow(clippy::expect_used)]
1924            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1925            *cur = tokio_util::sync::CancellationToken::new();
1926        }
1927
1928        // Tear down the embedder-token race watcher (if any). Leaving it
1929        // alive would idle forever waiting for tokens that may never fire.
1930        if let Some(h) = watcher_handle {
1931            h.abort();
1932        }
1933
1934        // VarsFrameGuard drops here on the success path and on early-return
1935        // paths above (error path included). Panic safety preserved.
1936        result
1937    }
1938
1939    /// The actual body of `execute_streaming`, run while holding the execute lock.
1940    ///
1941    /// Split out so internal kernel paths that are already under the lock can
1942    /// call this without deadlocking on re-entry. External callers must go
1943    /// through [`Self::execute_streaming`] so they acquire the lock.
1944    async fn execute_streaming_inner(
1945        &self,
1946        input: &str,
1947        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1948    ) -> Result<ExecResult> {
1949        let program = parse(input).map_err(|errors| {
1950            let msg = errors
1951                .iter()
1952                .map(|e| e.format(input))
1953                .collect::<Vec<_>>()
1954                .join("\n");
1955            anyhow::anyhow!("parse error:\n{}", msg)
1956        })?;
1957
1958        // AST display mode: show AST instead of executing
1959        {
1960            let scope = self.scope.read().await;
1961            if scope.show_ast() {
1962                let output = format!("{:#?}\n", program);
1963                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
1964            }
1965        }
1966
1967        // Pre-execution validation. Most warnings stay trace-only (every
1968        // external command fires an `UndefinedCommand` warning), but a warning
1969        // whose code opts into agent surfacing is collected here and prepended
1970        // to the result's stderr at each return point below.
1971        let mut surfaced_warnings = String::new();
1972        if !self.skip_validation {
1973            let user_tools = self.user_tools.read().await;
1974            let validator = Validator::new(&self.tools, &user_tools);
1975            let issues = validator.validate(&program);
1976
1977            // Collect errors (warnings are logged but don't prevent execution)
1978            let errors: Vec<_> = issues
1979                .iter()
1980                .filter(|i| i.severity == Severity::Error)
1981                .collect();
1982
1983            if !errors.is_empty() {
1984                let error_msg = errors
1985                    .iter()
1986                    .map(|e| e.format(input))
1987                    .collect::<Vec<_>>()
1988                    .join("\n");
1989                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
1990            }
1991
1992            // Log warnings via tracing (trace level to avoid noise); surface the
1993            // opted-in ones to the agent so the guidance is actually seen.
1994            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
1995                tracing::trace!("validation: {}", warning.format(input));
1996                if warning.code.surfaces_to_agent() {
1997                    surfaced_warnings.push_str(&warning.format(input));
1998                    surfaced_warnings.push('\n');
1999                }
2000            }
2001        }
2002
2003        // Surface opted-in validation warnings to the streaming frontend once,
2004        // before any command output. The streaming consumer (`-c`, REPL) prints
2005        // per `on_output` and ignores the returned aggregate err; non-streaming
2006        // callers (`kernel.execute`) use a noop callback and read the aggregate
2007        // `result.err` (prepended at each return below). The two paths are
2008        // disjoint, so this prints the advisory exactly once on each.
2009        if !surfaced_warnings.is_empty() {
2010            let mut advisory = ExecResult::success("");
2011            advisory.err = surfaced_warnings.clone();
2012            on_output(&advisory);
2013        }
2014
2015        let mut result = ExecResult::success("");
2016
2017        // Reset cancellation token for this execution.
2018        let cancel = self.reset_cancel();
2019
2020        for stmt in program.statements {
2021            if matches!(stmt, Stmt::Empty) {
2022                continue;
2023            }
2024
2025            // Cancellation checkpoint
2026            if cancel.is_cancelled() {
2027                result.code = 130;
2028                return Ok(result);
2029            }
2030
2031            let flow = self.execute_stmt_flow(&stmt).await?;
2032
2033            // Drain any stderr written by pipeline stages during this statement.
2034            // This captures stderr from intermediate pipeline stages that would
2035            // otherwise be lost (only the last stage's result is returned).
2036            let drained_stderr = {
2037                let mut receiver = self.stderr_receiver.lock().await;
2038                receiver.drain_lossy()
2039            };
2040
2041            match flow {
2042                ControlFlow::Normal(mut r) => {
2043                    if !drained_stderr.is_empty() {
2044                        if !r.err.is_empty() && !r.err.ends_with('\n') {
2045                            r.err.push('\n');
2046                        }
2047                        // Prepend pipeline stderr before the last stage's stderr
2048                        let combined = format!("{}{}", drained_stderr, r.err);
2049                        r.err = combined;
2050                    }
2051                    on_output(&r);
2052                    // Carry the last statement's structured output for MCP TOON encoding.
2053                    // Must be done here (not in accumulate_result) because accumulate_result
2054                    // is also used in loops where per-iteration output would be wrong.
2055                    let last_output = r.output().cloned();
2056                    accumulate_result(&mut result, &r);
2057                    result.set_output(last_output);
2058                }
2059                ControlFlow::Exit { code } => {
2060                    if !drained_stderr.is_empty() {
2061                        result.err.push_str(&drained_stderr);
2062                    }
2063                    result.code = code;
2064                    if !surfaced_warnings.is_empty() {
2065                        result.err = format!("{surfaced_warnings}{}", result.err);
2066                    }
2067                    return Ok(result);
2068                }
2069                ControlFlow::Return { mut value } => {
2070                    if !drained_stderr.is_empty() {
2071                        value.err = format!("{}{}", drained_stderr, value.err);
2072                    }
2073                    on_output(&value);
2074                    // A top-level `return` stops the script, like `exit` —
2075                    // it must not discard prior statements' accumulated
2076                    // output nor let execution continue past it.
2077                    accumulate_result(&mut result, &value);
2078                    if !surfaced_warnings.is_empty() {
2079                        result.err = format!("{surfaced_warnings}{}", result.err);
2080                    }
2081                    return Ok(result);
2082                }
2083                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2084                    if !drained_stderr.is_empty() {
2085                        r.err = format!("{}{}", drained_stderr, r.err);
2086                    }
2087                    on_output(&r);
2088                    accumulate_result(&mut result, &r);
2089                }
2090            }
2091        }
2092
2093        if !surfaced_warnings.is_empty() {
2094            result.err = format!("{surfaced_warnings}{}", result.err);
2095        }
2096        Ok(result)
2097    }
2098
2099    /// Execute a single statement, returning control flow information.
2100    fn execute_stmt_flow<'a>(
2101        &'a self,
2102        stmt: &'a Stmt,
2103    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2104        use tracing::Instrument;
2105        let span = tracing::debug_span!("execute_stmt_flow", stmt_type = %stmt.kind_name());
2106        Box::pin(async move {
2107        match stmt {
2108            Stmt::Assignment(assign) => {
2109                // Use async evaluator to support command substitution
2110                let value = self.eval_expr_async(&assign.value).await
2111                    .context("failed to evaluate assignment")?;
2112                let mut scope = self.scope.write().await;
2113                if assign.path.segments.len() == 1 {
2114                    // Plain `NAME=value` — no subscript, so `local` applies.
2115                    if assign.local {
2116                        // local: set in innermost (current function) frame
2117                        scope.set(assign.name(), value.clone());
2118                    } else {
2119                        // non-local: update existing or create in root frame
2120                        scope.set_global(assign.name(), value.clone());
2121                    }
2122                } else {
2123                    // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2124                    // mutates the existing root wherever it lives — `local` is
2125                    // meaningless here (see docs/arrays-and-hashes.md, "Assignment
2126                    // lvalues").
2127                    scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2128                        PathError::UndefinedRoot(name) => anyhow::anyhow!(
2129                            "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2130                        ),
2131                        PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2132                    })?;
2133                }
2134                drop(scope);
2135
2136                // Assignments don't produce output (like sh)
2137                Ok(ControlFlow::ok(ExecResult::success("")))
2138            }
2139            Stmt::Command(cmd) => {
2140                // Route single commands through execute_pipeline for a unified path.
2141                // This ensures all commands go through the dispatcher chain.
2142                let pipeline = crate::ast::Pipeline {
2143                    commands: vec![cmd.clone()],
2144                    background: false,
2145                };
2146                let result = self.execute_pipeline(&pipeline).await?;
2147                self.update_last_result(&result).await;
2148
2149                // Check for error exit mode (set -e)
2150                if !result.ok() {
2151                    let scope = self.scope.read().await;
2152                    if scope.error_exit_enabled() {
2153                        return Ok(ControlFlow::exit_code(result.code));
2154                    }
2155                }
2156
2157                Ok(ControlFlow::ok(result))
2158            }
2159            Stmt::Pipeline(pipeline) => {
2160                let result = self.execute_pipeline(pipeline).await?;
2161                self.update_last_result(&result).await;
2162
2163                // Check for error exit mode (set -e)
2164                if !result.ok() {
2165                    let scope = self.scope.read().await;
2166                    if scope.error_exit_enabled() {
2167                        return Ok(ControlFlow::exit_code(result.code));
2168                    }
2169                }
2170
2171                Ok(ControlFlow::ok(result))
2172            }
2173            Stmt::If(if_stmt) => {
2174                // Use async evaluator to support command substitution in conditions
2175                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2176
2177                let branch = if is_truthy(&cond_value) {
2178                    &if_stmt.then_branch
2179                } else {
2180                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2181                };
2182
2183                let mut result = ExecResult::success("");
2184                for stmt in branch {
2185                    let flow = self.execute_stmt_flow(stmt).await?;
2186                    match flow {
2187                        ControlFlow::Normal(r) => {
2188                            accumulate_result(&mut result, &r);
2189                            self.drain_stderr_into(&mut result).await;
2190                        }
2191                        other => {
2192                            self.drain_stderr_into(&mut result).await;
2193                            return Ok(other);
2194                        }
2195                    }
2196                }
2197                Ok(ControlFlow::ok(result))
2198            }
2199            Stmt::For(for_loop) => {
2200                // Evaluate all items and collect values for iteration
2201                // Use async evaluator to support command substitution like $(seq 1 5)
2202                let mut items: Vec<Value> = Vec::new();
2203                for item_expr in &for_loop.items {
2204                    // Glob expansion in for-loop items: `for f in *.txt`
2205                    if let Expr::GlobPattern(pattern) = item_expr {
2206                        let glob_enabled = {
2207                            let scope = self.scope.read().await;
2208                            scope.glob_enabled()
2209                        };
2210                        if glob_enabled {
2211                            let (paths, cwd) = {
2212                                let ctx = self.exec_ctx.read().await;
2213                                let paths = ctx.expand_glob(pattern).await
2214                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2215                                let cwd = ctx.resolve_path(".");
2216                                (paths, cwd)
2217                            };
2218                            if paths.is_empty() {
2219                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2220                            }
2221                            for path in paths {
2222                                let display = if !pattern.starts_with('/') {
2223                                    path.strip_prefix(&cwd)
2224                                        .unwrap_or(&path)
2225                                        .to_string_lossy().into_owned()
2226                                } else {
2227                                    path.to_string_lossy().into_owned()
2228                                };
2229                                items.push(Value::String(display));
2230                            }
2231                            continue;
2232                        }
2233                    }
2234                    // Track whether this item came from $(cmd); that's the
2235                    // only position where multi-line stdout auto-splits per
2236                    // line. Arrays still spread element-by-element; bare
2237                    // $VAR is rejected upstream by validator E012. See
2238                    // docs/LANGUAGE.md.
2239                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2240                    let item = self.eval_expr_async(item_expr).await?;
2241                    match item {
2242                        // JSON arrays iterate over elements (preferred path
2243                        // when builtins emit .data — seq, jq, cut, find, …)
2244                        Value::Json(serde_json::Value::Array(arr)) => {
2245                            for elem in arr {
2246                                // Envelope-free: an element that happens to be
2247                                // envelope-shaped (e.g. from `fromjson`) is
2248                                // external data, not an internal bytes round-trip,
2249                                // so it must NOT be re-decoded to Value::Bytes.
2250                                items.push(json_to_value_no_envelope(elem));
2251                            }
2252                        }
2253                        // Strings from $(cmd): empty → 0 iterations,
2254                        // multi-line → split per line (trimming trailing
2255                        // newlines and per-line trailing \r), single-line
2256                        // → one iteration. Whitespace within a line is
2257                        // NOT split — the "$VAR with spaces just works"
2258                        // promise is preserved because this only fires
2259                        // in CommandSubst position.
2260                        Value::String(s) if from_command_subst => {
2261                            let trimmed = s.trim_end_matches(['\n', '\r']);
2262                            if trimmed.is_empty() {
2263                                continue;
2264                            }
2265                            if trimmed.contains('\n') {
2266                                for line in trimmed.split('\n') {
2267                                    let line = line.trim_end_matches('\r');
2268                                    items.push(Value::String(line.to_string()));
2269                                }
2270                            } else {
2271                                items.push(Value::String(trimmed.to_string()));
2272                            }
2273                        }
2274                        // Binary isn't iterable — fail loud rather than loop
2275                        // once over an opaque byte blob.
2276                        Value::Bytes(_) => {
2277                            anyhow::bail!(
2278                                "for: cannot iterate over binary data — decode it \
2279                                 (base64/xxd) first"
2280                            );
2281                        }
2282                        // Strings not from $(cmd) stay as one value.
2283                        other => items.push(other),
2284                    }
2285                }
2286
2287                let mut result = ExecResult::success("");
2288                {
2289                    let mut scope = self.scope.write().await;
2290                    scope.push_frame();
2291                }
2292
2293                'outer: for item in items {
2294                    // Cancellation checkpoint per iteration
2295                    if self.is_cancelled() {
2296                        let mut scope = self.scope.write().await;
2297                        scope.pop_frame();
2298                        result.code = 130;
2299                        return Ok(ControlFlow::ok(result));
2300                    }
2301                    {
2302                        let mut scope = self.scope.write().await;
2303                        scope.set(&for_loop.variable, item);
2304                    }
2305                    for stmt in &for_loop.body {
2306                        let mut flow = match self.execute_stmt_flow(stmt).await {
2307                            Ok(f) => f,
2308                            Err(e) => {
2309                                let mut scope = self.scope.write().await;
2310                                scope.pop_frame();
2311                                return Err(e);
2312                            }
2313                        };
2314                        self.drain_stderr_into(&mut result).await;
2315                        match &mut flow {
2316                            ControlFlow::Normal(r) => {
2317                                accumulate_result(&mut result, r);
2318                                if !r.ok() {
2319                                    let scope = self.scope.read().await;
2320                                    if scope.error_exit_enabled() {
2321                                        drop(scope);
2322                                        let mut scope = self.scope.write().await;
2323                                        scope.pop_frame();
2324                                        return Ok(ControlFlow::exit_code(r.code));
2325                                    }
2326                                }
2327                            }
2328                            ControlFlow::Break { .. } => {
2329                                if flow.decrement_level() {
2330                                    accumulate_flow_output(&mut result, &flow);
2331                                    break 'outer;
2332                                }
2333                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2334                                let mut scope = self.scope.write().await;
2335                                scope.pop_frame();
2336                                return Ok(flow);
2337                            }
2338                            ControlFlow::Continue { .. } => {
2339                                if flow.decrement_level() {
2340                                    accumulate_flow_output(&mut result, &flow);
2341                                    continue 'outer;
2342                                }
2343                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2344                                let mut scope = self.scope.write().await;
2345                                scope.pop_frame();
2346                                return Ok(flow);
2347                            }
2348                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2349                                let mut scope = self.scope.write().await;
2350                                scope.pop_frame();
2351                                return Ok(flow);
2352                            }
2353                        }
2354                    }
2355                }
2356
2357                {
2358                    let mut scope = self.scope.write().await;
2359                    scope.pop_frame();
2360                }
2361                Ok(ControlFlow::ok(result))
2362            }
2363            Stmt::While(while_loop) => {
2364                let mut result = ExecResult::success("");
2365
2366                'outer: loop {
2367                    // Evaluate condition - use async to support command substitution
2368                    // Cancellation checkpoint per iteration
2369                    if self.is_cancelled() {
2370                        result.code = 130;
2371                        return Ok(ControlFlow::ok(result));
2372                    }
2373
2374                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2375
2376                    if !is_truthy(&cond_value) {
2377                        break;
2378                    }
2379
2380                    // Execute body
2381                    for stmt in &while_loop.body {
2382                        let mut flow = self.execute_stmt_flow(stmt).await?;
2383                        self.drain_stderr_into(&mut result).await;
2384                        match &mut flow {
2385                            ControlFlow::Normal(r) => {
2386                                accumulate_result(&mut result, r);
2387                                if !r.ok() {
2388                                    let scope = self.scope.read().await;
2389                                    if scope.error_exit_enabled() {
2390                                        return Ok(ControlFlow::exit_code(r.code));
2391                                    }
2392                                }
2393                            }
2394                            ControlFlow::Break { .. } => {
2395                                if flow.decrement_level() {
2396                                    accumulate_flow_output(&mut result, &flow);
2397                                    break 'outer;
2398                                }
2399                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2400                                return Ok(flow);
2401                            }
2402                            ControlFlow::Continue { .. } => {
2403                                if flow.decrement_level() {
2404                                    accumulate_flow_output(&mut result, &flow);
2405                                    continue 'outer;
2406                                }
2407                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2408                                return Ok(flow);
2409                            }
2410                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2411                                return Ok(flow);
2412                            }
2413                        }
2414                    }
2415                }
2416
2417                Ok(ControlFlow::ok(result))
2418            }
2419            Stmt::Case(case_stmt) => {
2420                // Evaluate the expression to match against
2421                let match_value = {
2422                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2423                    value_to_string(&value)
2424                };
2425
2426                // Try each branch until we find a match
2427                for branch in &case_stmt.branches {
2428                    let matched = branch.patterns.iter().any(|pattern| {
2429                        glob_match(pattern, &match_value)
2430                    });
2431
2432                    if matched {
2433                        // Execute the branch body
2434                        let mut result = ExecResult::success("");
2435                        for stmt in &branch.body {
2436                            let flow = self.execute_stmt_flow(stmt).await?;
2437                            match flow {
2438                                ControlFlow::Normal(r) => {
2439                                    accumulate_result(&mut result, &r);
2440                                    self.drain_stderr_into(&mut result).await;
2441                                }
2442                                other => {
2443                                    self.drain_stderr_into(&mut result).await;
2444                                    return Ok(other);
2445                                }
2446                            }
2447                        }
2448                        return Ok(ControlFlow::ok(result));
2449                    }
2450                }
2451
2452                // No match - return success with empty output (like sh)
2453                Ok(ControlFlow::ok(ExecResult::success("")))
2454            }
2455            Stmt::Break(levels) => {
2456                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2457            }
2458            Stmt::Continue(levels) => {
2459                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2460            }
2461            Stmt::Return(expr) => {
2462                // return [N] - N becomes the exit code, NOT stdout
2463                // Shell semantics: return sets exit code, doesn't produce output
2464                let result = if let Some(e) = expr {
2465                    let val = self.eval_expr_async(e).await?;
2466                    let code = crate::interpreter::value_to_exit_code(&val)
2467                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2468                    ExecResult::from_parts(code, String::new(), String::new(), None)
2469                } else {
2470                    ExecResult::success("")
2471                };
2472                Ok(ControlFlow::return_value(result))
2473            }
2474            Stmt::Exit(expr) => {
2475                let code = if let Some(e) = expr {
2476                    let val = self.eval_expr_async(e).await?;
2477                    crate::interpreter::value_to_exit_code(&val)
2478                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2479                } else {
2480                    0
2481                };
2482                Ok(ControlFlow::exit_code(code))
2483            }
2484            Stmt::ToolDef(tool_def) => {
2485                let mut user_tools = self.user_tools.write().await;
2486                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2487                Ok(ControlFlow::ok(ExecResult::success("")))
2488            }
2489            Stmt::AndChain { left, right } => {
2490                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2491                // Suppress errexit for the left side — && handles failure itself.
2492                {
2493                    let mut scope = self.scope.write().await;
2494                    scope.suppress_errexit();
2495                }
2496                let left_flow = match self.execute_stmt_flow(left).await {
2497                    Ok(f) => f,
2498                    Err(e) => {
2499                        let mut scope = self.scope.write().await;
2500                        scope.unsuppress_errexit();
2501                        return Err(e);
2502                    }
2503                };
2504                {
2505                    let mut scope = self.scope.write().await;
2506                    scope.unsuppress_errexit();
2507                }
2508                match left_flow {
2509                    ControlFlow::Normal(mut left_result) => {
2510                        self.drain_stderr_into(&mut left_result).await;
2511                        self.update_last_result(&left_result).await;
2512                        if left_result.ok() {
2513                            let right_flow = self.execute_stmt_flow(right).await?;
2514                            match right_flow {
2515                                ControlFlow::Normal(mut right_result) => {
2516                                    self.drain_stderr_into(&mut right_result).await;
2517                                    self.update_last_result(&right_result).await;
2518                                    let mut combined = left_result;
2519                                    accumulate_result(&mut combined, &right_result);
2520                                    Ok(ControlFlow::ok(combined))
2521                                }
2522                                other => Ok(other),
2523                            }
2524                        } else {
2525                            Ok(ControlFlow::ok(left_result))
2526                        }
2527                    }
2528                    _ => Ok(left_flow),
2529                }
2530            }
2531            Stmt::OrChain { left, right } => {
2532                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2533                // Suppress errexit for the left side — || handles failure itself.
2534                {
2535                    let mut scope = self.scope.write().await;
2536                    scope.suppress_errexit();
2537                }
2538                let left_flow = match self.execute_stmt_flow(left).await {
2539                    Ok(f) => f,
2540                    Err(e) => {
2541                        let mut scope = self.scope.write().await;
2542                        scope.unsuppress_errexit();
2543                        return Err(e);
2544                    }
2545                };
2546                {
2547                    let mut scope = self.scope.write().await;
2548                    scope.unsuppress_errexit();
2549                }
2550                match left_flow {
2551                    ControlFlow::Normal(mut left_result) => {
2552                        self.drain_stderr_into(&mut left_result).await;
2553                        self.update_last_result(&left_result).await;
2554                        if !left_result.ok() {
2555                            let right_flow = self.execute_stmt_flow(right).await?;
2556                            match right_flow {
2557                                ControlFlow::Normal(mut right_result) => {
2558                                    self.drain_stderr_into(&mut right_result).await;
2559                                    self.update_last_result(&right_result).await;
2560                                    let mut combined = left_result;
2561                                    accumulate_result(&mut combined, &right_result);
2562                                    Ok(ControlFlow::ok(combined))
2563                                }
2564                                other => Ok(other),
2565                            }
2566                        } else {
2567                            Ok(ControlFlow::ok(left_result))
2568                        }
2569                    }
2570                    _ => Ok(left_flow), // Propagate non-normal flow
2571                }
2572            }
2573            Stmt::Test(test_expr) => {
2574                let is_true = self.eval_test_async(test_expr).await?;
2575                let result = if is_true {
2576                    ExecResult::success("")
2577                } else {
2578                    ExecResult::failure(1, "")
2579                };
2580                // A bare test writes `$?` and honors `set -e` like any command
2581                // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2582                // safe: the chain arms suppress errexit around their left side,
2583                // and `if`/`while` conditions evaluate as expressions, never
2584                // through this statement arm.
2585                self.update_last_result(&result).await;
2586                if !result.ok() {
2587                    let scope = self.scope.read().await;
2588                    if scope.error_exit_enabled() {
2589                        return Ok(ControlFlow::exit_code(result.code));
2590                    }
2591                }
2592                Ok(ControlFlow::ok(result))
2593            }
2594            Stmt::EnvScoped { assignments, body } => {
2595                // Inline env prefix (`NAME=value ... command`): apply the
2596                // assignments as EXPORTED vars in a fresh frame so the command
2597                // — and its subprocess environment — sees them, then unwind so
2598                // they do NOT persist (bash-style command-scoped env). Values
2599                // evaluate left-to-right with earlier ones already in scope, so
2600                // `A=1 B=$A cmd` works.
2601                {
2602                    let mut scope = self.scope.write().await;
2603                    scope.push_frame();
2604                }
2605                let mut prior_export: Vec<(String, bool)> =
2606                    Vec::with_capacity(assignments.len());
2607                let mut setup_err: Option<anyhow::Error> = None;
2608                for assign in assignments {
2609                    match self.eval_expr_async(&assign.value).await {
2610                        Ok(value) => {
2611                            let mut scope = self.scope.write().await;
2612                            prior_export
2613                                .push((assign.name().to_string(), scope.is_exported(assign.name())));
2614                            scope.set_exported(assign.name(), value);
2615                        }
2616                        Err(e) => {
2617                            setup_err = Some(e);
2618                            break;
2619                        }
2620                    }
2621                }
2622
2623                let flow = if setup_err.is_none() {
2624                    self.execute_stmt_flow(body).await
2625                } else {
2626                    Ok(ControlFlow::ok(ExecResult::success("")))
2627                };
2628
2629                // Unwind the env frame and restore export marks unconditionally
2630                // (names that were not exported before must not stay exported).
2631                {
2632                    let mut scope = self.scope.write().await;
2633                    scope.pop_frame();
2634                    for (name, was_exported) in &prior_export {
2635                        if !*was_exported {
2636                            scope.unexport(name);
2637                        }
2638                    }
2639                }
2640
2641                match setup_err {
2642                    Some(e) => Err(e),
2643                    None => flow,
2644                }
2645            }
2646            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2647        }
2648        }.instrument(span))
2649    }
2650
2651    /// Execute a pipeline.
2652    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(background = pipeline.background, command_count = pipeline.commands.len()))]
2653    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2654        if pipeline.commands.is_empty() {
2655            return Ok(ExecResult::success(""));
2656        }
2657
2658        // Handle background execution (`&` operator)
2659        if pipeline.background {
2660            return self.execute_background(pipeline).await;
2661        }
2662
2663        // All commands go through the runner with the Kernel as dispatcher.
2664        // This is the single execution path — no fast path for single commands.
2665        //
2666        // IMPORTANT: We snapshot exec_ctx into a local context and release the
2667        // lock before running. This prevents deadlocks when dispatch_command
2668        // is called from within the pipeline and recursively triggers another
2669        // pipeline (e.g., via user-defined tools).
2670        let (mut ctx, has_pipe_stdin) = {
2671            let ec = self.exec_ctx.read().await;
2672            let scope = self.scope.read().await;
2673            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2674            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2675            // the consume-once block below, so note its presence here.
2676            let has_pipe_stdin = ec.pipe_stdin.is_some();
2677            (ExecContext {
2678                backend: ec.backend.clone(),
2679                scope: scope.clone(),
2680                cwd: ec.cwd.clone(),
2681                prev_cwd: ec.prev_cwd.clone(),
2682                // Seed the first stage's stdin from any frontend-supplied input
2683                // (`ExecuteOptions::stdin`, e.g. `printf … | kaish -c sort`). The
2684                // runner forwards `ctx.stdin` to stage 0 unless a redirect
2685                // (`< file`/heredoc) already set it, so redirect precedence holds.
2686                stdin: ec.stdin.clone(),
2687                stdin_data: ec.stdin_data.clone(),
2688                stdin_data_rx: None,
2689                pipe_stdin: None,
2690                pipe_stdout: None,
2691                stderr: ec.stderr.clone(),
2692                tool_schemas: ec.tool_schemas.clone(),
2693                tools: ec.tools.clone(),
2694                job_manager: ec.job_manager.clone(),
2695                pipeline_position: PipelinePosition::Only,
2696                interactive: self.interactive,
2697                aliases: ec.aliases.clone(),
2698                ignore_config: ec.ignore_config.clone(),
2699                output_limit: ec.output_limit.clone(),
2700                allow_external_commands: self.allow_external_commands,
2701                nonce_store: ec.nonce_store.clone(),
2702                trash_backend: ec.trash_backend.clone(),
2703                #[cfg(all(unix, feature = "subprocess"))]
2704                terminal_state: ec.terminal_state.clone(),
2705                dispatcher: self.dispatcher(),
2706                cancel: {
2707                    #[allow(clippy::expect_used)]
2708                    let token = self.cancel_token.lock().expect("cancel_token poisoned");
2709                    token.clone()
2710                },
2711                output_format: None,
2712                current_invocation: None,
2713                vfs_budget: self.vfs_budget.clone(),
2714                watchdog: ec.watchdog.clone(),
2715                #[cfg(all(feature = "localfs", feature = "overlay"))]
2716                overlay_handle: self.overlay_handle.clone(),
2717            }, has_pipe_stdin)
2718        }; // locks released
2719
2720        // Consume-once: move/clear the seeded stdin sources from the persistent
2721        // exec_ctx now that this pipeline's ctx owns them, so a later statement
2722        // in the same call (`cat ; cat`) does not re-receive them — matching
2723        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2724        // (the ctx above was built with `pipe_stdin: None`).
2725        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2726            let mut ec = self.exec_ctx.write().await;
2727            ctx.pipe_stdin = ec.pipe_stdin.take();
2728            ec.stdin = None;
2729            ec.stdin_data = None;
2730        }
2731
2732        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2733
2734        // Post-hoc spill check (catches builtins and fast external commands)
2735        if ctx.output_limit.is_enabled() {
2736            let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2737        }
2738
2739        // Signal spill with exit 3; agent reads the spill file directly
2740        // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2741        if result.did_spill {
2742            result.original_code = Some(result.code);
2743            result.code = 3;
2744        }
2745
2746        // Sync changes back from context
2747        {
2748            let mut ec = self.exec_ctx.write().await;
2749            ec.cwd = ctx.cwd.clone();
2750            ec.prev_cwd = ctx.prev_cwd.clone();
2751            ec.aliases = ctx.aliases.clone();
2752            ec.ignore_config = ctx.ignore_config.clone();
2753            ec.output_limit = ctx.output_limit.clone();
2754        }
2755        {
2756            let mut scope = self.scope.write().await;
2757            *scope = ctx.scope.clone();
2758        }
2759
2760        Ok(result)
2761    }
2762
2763    /// Execute a pipeline in the background.
2764    ///
2765    /// The command is spawned as a tokio task, registered with the JobManager,
2766    /// and its output is captured via BoundedStreams. The job is observable via
2767    /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2768    ///
2769    /// Returns immediately with a job ID like "[1]".
2770    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2771    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2772        use tokio::sync::oneshot;
2773
2774        // Format the command for display in /v/jobs/{id}/command
2775        let command_str = self.format_pipeline(pipeline);
2776
2777        // Create bounded streams for output capture
2778        let stdout = Arc::new(BoundedStream::default_size());
2779        let stderr = Arc::new(BoundedStream::default_size());
2780
2781        // Create channel for result notification
2782        let (tx, rx) = oneshot::channel();
2783
2784        // Register with JobManager to get job ID and create VFS entries
2785        let job_id = self.jobs.register_with_streams(
2786            command_str.clone(),
2787            rx,
2788            stdout.clone(),
2789            stderr.clone(),
2790        ).await;
2791
2792        // Fork the kernel for this background job. The fork snapshots the
2793        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2794        // while sharing the job manager, VFS, and tool registry. The fork's
2795        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2796        // is available here — something BackendDispatcher couldn't provide.
2797        //
2798        // The fork gets its own cancellation token (recorded on the job so
2799        // `kill %N` can stop the job — including a pure-builtin job with no OS
2800        // process group) and is stamped with the job id so any external
2801        // command it spawns records its process group for `kill -<sig> %N`.
2802        let cancel = tokio_util::sync::CancellationToken::new();
2803        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2804        let fork = self.fork_for_background(cancel, job_id).await;
2805        let runner = self.runner.clone();
2806        let commands = pipeline.commands.clone();
2807
2808        // Snapshot the fork's exec_ctx for the spawned task. We have to do
2809        // this before tokio::spawn because the fork's exec_ctx is behind a
2810        // tokio RwLock and we want the spawned task to own its ctx.
2811        let mut bg_ctx = {
2812            let ec = fork.exec_ctx.read().await;
2813            ec.child_for_pipeline()
2814        };
2815        bg_ctx.scope = fork.scope.read().await.clone();
2816        // The fork's dispatcher points at the fork itself; set it here so
2817        // builtins inside the background task (e.g. timeout) re-dispatch
2818        // through the fork, not the parent.
2819        bg_ctx.dispatcher = fork.dispatcher();
2820
2821        // Spawn the background task. Propagate the embedder's trace context
2822        // across the spawn boundary so the job's spans stay in the same trace.
2823        tokio::spawn(crate::telemetry::bind_current_context(async move {
2824            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2825            // gives us that (Kernel implements CommandDispatcher).
2826            let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2827
2828            // Write output to streams
2829            let text = result.text_out();
2830            if !text.is_empty() {
2831                stdout.write(text.as_bytes()).await;
2832            }
2833            if !result.err.is_empty() {
2834                stderr.write(result.err.as_bytes()).await;
2835            }
2836
2837            // Close streams
2838            stdout.close().await;
2839            stderr.close().await;
2840
2841            // Send result to JobManager (ignore error if receiver dropped)
2842            let _ = tx.send(result);
2843        }));
2844
2845        Ok(ExecResult::success(format!("[{}]", job_id)))
2846    }
2847
2848    /// Format a pipeline as a command string for display.
2849    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
2850        pipeline.commands
2851            .iter()
2852            .map(|cmd| {
2853                let mut parts = vec![cmd.name.clone()];
2854                for arg in &cmd.args {
2855                    match arg {
2856                        Arg::Positional(expr) => {
2857                            parts.push(self.format_expr(expr));
2858                        }
2859                        Arg::Named { key, value } => {
2860                            parts.push(format!("--{}={}", key, self.format_expr(value)));
2861                        }
2862                        Arg::WordAssign { key, value } => {
2863                            parts.push(format!("{}={}", key, self.format_expr(value)));
2864                        }
2865                        Arg::ShortFlag(name) => {
2866                            parts.push(format!("-{}", name));
2867                        }
2868                        Arg::LongFlag(name) => {
2869                            parts.push(format!("--{}", name));
2870                        }
2871                        Arg::DoubleDash => {
2872                            parts.push("--".to_string());
2873                        }
2874                    }
2875                }
2876                parts.join(" ")
2877            })
2878            .collect::<Vec<_>>()
2879            .join(" | ")
2880    }
2881
2882    /// Format an expression as a string for display.
2883    fn format_expr(&self, expr: &Expr) -> String {
2884        match expr {
2885            Expr::Literal(Value::String(s)) => {
2886                if s.contains(' ') || s.contains('"') {
2887                    format!("'{}'", s.replace('\'', "\\'"))
2888                } else {
2889                    s.clone()
2890                }
2891            }
2892            Expr::Literal(Value::Int(i)) => i.to_string(),
2893            Expr::Literal(Value::Float(f)) => f.to_string(),
2894            Expr::Literal(Value::Bool(b)) => b.to_string(),
2895            Expr::Literal(Value::Null) => "null".to_string(),
2896            Expr::VarRef(path) => {
2897                let mut name = String::new();
2898                for (i, seg) in path.segments.iter().enumerate() {
2899                    match seg {
2900                        crate::ast::VarSegment::Field(f) => {
2901                            if i > 0 {
2902                                name.push('.');
2903                            }
2904                            name.push_str(f);
2905                        }
2906                        crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
2907                        crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
2908                        crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
2909                        crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
2910                            "[{}:{}]",
2911                            a.map(|n| n.to_string()).unwrap_or_default(),
2912                            b.map(|n| n.to_string()).unwrap_or_default()
2913                        )),
2914                    }
2915                }
2916                format!("${{{}}}", name)
2917            }
2918            Expr::Interpolated(_) => "\"...\"".to_string(),
2919            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
2920            _ => "...".to_string(),
2921        }
2922    }
2923
2924    /// Execute a single command.
2925    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
2926        self.execute_command_depth(name, args, 0).await
2927    }
2928
2929    #[tracing::instrument(level = "info", skip(self, args, alias_depth), fields(command = %name), err)]
2930    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
2931        // Special built-ins. `SpecialForm::from_name` is the single source of
2932        // truth (shared with `classify_command` via `is_runtime_special_form`),
2933        // and this match on the enum is *exhaustive* — adding a special-form is a
2934        // compile error until both the name mapping and the behavior here are
2935        // updated. A name that is not a special-form falls through to alias /
2936        // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
2937        if let Some(form) = crate::validator::SpecialForm::from_name(name) {
2938            return match form {
2939                crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
2940                crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
2941                crate::validator::SpecialForm::Source => self.execute_source(args).await,
2942            };
2943        }
2944
2945        // Alias expansion (with recursion limit)
2946        if alias_depth < 10 {
2947            let alias_value = {
2948                let ctx = self.exec_ctx.read().await;
2949                ctx.aliases.get(name).cloned()
2950            };
2951            if let Some(alias_val) = alias_value {
2952                // Split alias value into command + args
2953                let parts: Vec<&str> = alias_val.split_whitespace().collect();
2954                if let Some((alias_cmd, alias_args)) = parts.split_first() {
2955                    let mut new_args: Vec<Arg> = alias_args
2956                        .iter()
2957                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
2958                        .collect();
2959                    new_args.extend_from_slice(args);
2960                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
2961                }
2962            }
2963        }
2964
2965        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
2966        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
2967            return match self.tools.get(builtin_name) {
2968                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
2969                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
2970            };
2971        }
2972
2973        // Check user-defined tools first
2974        {
2975            let user_tools = self.user_tools.read().await;
2976            if let Some(tool_def) = user_tools.get(name) {
2977                let tool_def = tool_def.clone();
2978                drop(user_tools);
2979                return self.execute_user_tool(tool_def, args).await;
2980            }
2981        }
2982
2983        // Look up builtin tool
2984        let tool = match self.tools.get(name) {
2985            Some(t) => t,
2986            None => {
2987                // Try executing as .kai script from PATH
2988                if let Some(result) = self.try_execute_script(name, args).await? {
2989                    return Ok(result);
2990                }
2991                // Try executing as external command from PATH
2992                if let Some(result) = self.try_execute_external(name, args).await? {
2993                    return Ok(result);
2994                }
2995
2996                // Try backend-registered tools (embedder engines, etc.)
2997                // Look up tool schema for positional→named mapping.
2998                // Clone backend and drop read lock before awaiting (may involve network I/O).
2999                // Backend tools expect named JSON params, so enable positional mapping.
3000                let backend = self.exec_ctx.read().await.backend.clone();
3001                let tool_schema = backend
3002                    .get_tool(name)
3003                    .await
3004                    .unwrap_or_else(|e| {
3005                        // Schema lookup failing just means positionals won't
3006                        // get name-mapped below — `call_tool` is still
3007                        // attempted. Trace it so the degradation is visible
3008                        // rather than silently swallowed.
3009                        tracing::debug!("backend get_tool error for {name}: {e}");
3010                        None
3011                    })
3012                    .map(|t| {
3013                    let mut s = t.schema;
3014                    // Flat backend/MCP tools expect named JSON params, so map
3015                    // bare positionals onto named params. Subcommand-aware tools
3016                    // route positionals through the subcommand path and declare
3017                    // map_positionals per leaf (kj keeps it false so it re-parses
3018                    // the argv with its own clap) — don't blanket-override them.
3019                    if s.subcommands.is_empty() {
3020                        s.map_positionals = true;
3021                    }
3022                    s
3023                });
3024                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3025                let mut ctx = self.exec_ctx.write().await;
3026                {
3027                    let scope = self.scope.read().await;
3028                    ctx.scope = scope.clone();
3029                }
3030                let backend = ctx.backend.clone();
3031                match backend.call_tool(name, tool_args, &mut *ctx).await {
3032                    Ok(tool_result) => {
3033                        let mut scope = self.scope.write().await;
3034                        *scope = ctx.scope.clone();
3035                        // Preserve every field (data/content_type/baggage/latch,
3036                        // not just stdout text) — this is the embedder seam:
3037                        // `x=$(embedder_tool)` and structured iteration over
3038                        // its result depend on `.data` surviving the crossing
3039                        // back into the kernel.
3040                        return Ok(ExecResult::from(tool_result));
3041                    }
3042                    Err(BackendError::ToolNotFound(_)) => {
3043                        // The backend confirms no such tool exists — fall
3044                        // through to "command not found" below.
3045                    }
3046                    Err(e) => {
3047                        // The tool was found (dispatch reached real
3048                        // execution) but running it failed — a genuine
3049                        // execution error, not "command not found". Surface
3050                        // it loudly instead of masking it as exit-127.
3051                        return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3052                    }
3053                }
3054
3055                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3056            }
3057        };
3058
3059        // Build arguments (async to support command substitution, schema-aware for flag values)
3060        let schema = tool.schema();
3061        let tool_args = self.build_args_async(args, Some(&schema)).await?;
3062
3063        // --help / -h: show the generic whole-tool help, unless either the tool's
3064        // root schema claims that flag OR the tool owns its output. Owned-output
3065        // tools re-parse their own argv and route their own `--help` — including
3066        // leaf/subcommand help — through their internal (clap) parser, so the root
3067        // schema can't express "this leaf claims help" and intercepting here would
3068        // render top-level help and return before `execute()` ever sees the
3069        // request (#51). Pass it through and let the tool render its own help.
3070        let schema_claims = |flag: &str| -> bool {
3071            let bare = flag.trim_start_matches('-');
3072            schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3073        };
3074        let wants_help = !schema.owns_output
3075            && ((tool_args.flags.contains("help") && !schema_claims("help"))
3076                || (tool_args.flags.contains("h") && !schema_claims("-h")));
3077        if wants_help {
3078            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3079            let ctx = self.exec_ctx.read().await;
3080            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3081            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3082        }
3083
3084        // Snapshot exec_ctx into a local context and release the write lock
3085        // before calling tool.execute. Holding the write across tool execution
3086        // would deadlock any builtin that re-dispatches through ctx.dispatcher
3087        // (timeout, scatter) — the inner dispatch_command needs its own
3088        // exec_ctx.write() and would block forever.
3089        let mut ctx = {
3090            let ec = self.exec_ctx.write().await;
3091            let scope = self.scope.read().await;
3092            ExecContext {
3093                backend: ec.backend.clone(),
3094                scope: scope.clone(),
3095                cwd: ec.cwd.clone(),
3096                prev_cwd: ec.prev_cwd.clone(),
3097                stdin: ec.stdin.clone(),
3098                stdin_data: ec.stdin_data.clone(),
3099                stdin_data_rx: None,
3100                pipe_stdin: None, // streaming pipes are per-pipeline; not snapshotted
3101                pipe_stdout: None,
3102                stderr: ec.stderr.clone(),
3103                tool_schemas: ec.tool_schemas.clone(),
3104                tools: ec.tools.clone(),
3105                job_manager: ec.job_manager.clone(),
3106                pipeline_position: ec.pipeline_position,
3107                interactive: self.interactive,
3108                aliases: ec.aliases.clone(),
3109                ignore_config: ec.ignore_config.clone(),
3110                output_limit: ec.output_limit.clone(),
3111                allow_external_commands: self.allow_external_commands,
3112                nonce_store: ec.nonce_store.clone(),
3113                trash_backend: ec.trash_backend.clone(),
3114                #[cfg(all(unix, feature = "subprocess"))]
3115                terminal_state: ec.terminal_state.clone(),
3116                dispatcher: self.dispatcher(),
3117                // Use ec.cancel (set by dispatch_command from the runner's
3118                // ctx.cancel) so any builtin-swapped child token (e.g. timeout's
3119                // child token) reaches the spawned external via wait_or_kill.
3120                // Falls back to the kernel's own token when ec.cancel is the
3121                // default fresh token from a non-dispatch path.
3122                cancel: ec.cancel.clone(),
3123                output_format: None,
3124                current_invocation: None,
3125                vfs_budget: self.vfs_budget.clone(),
3126                watchdog: ec.watchdog.clone(),
3127                #[cfg(all(feature = "localfs", feature = "overlay"))]
3128                overlay_handle: self.overlay_handle.clone(),
3129            }
3130        }; // both locks released — tool.execute can re-dispatch safely
3131
3132        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3133        // semantics): take() so a later dispatch doesn't see stale stdin.
3134        // Done after the snapshot above so we hold the write briefly.
3135        {
3136            let mut ec = self.exec_ctx.write().await;
3137            ctx.stdin = ec.stdin.take();
3138            ctx.stdin_data = ec.stdin_data.take();
3139            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3140            ctx.pipe_stdin = ec.pipe_stdin.take();
3141            ctx.pipe_stdout = ec.pipe_stdout.take();
3142        }
3143
3144        // Honor --json before the builtin runs so its setting survives a clap
3145        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3146        // --json on the floor when `try_parse_from` returns Err early).
3147        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3148        GlobalFlags::apply_from_args(&tool_args, &mut ctx);
3149
3150        // Capture the exact invocation at the dispatch seam so a latch producer
3151        // (`latch_result`/`gate_overwrites`) can stamp it into the LatchRequest
3152        // for a precise `Kernel::confirm` replay — no re-parsing of the human
3153        // `hint`. `to_argv()` is computed before `tool_args` moves into execute.
3154        //
3155        // Captured unconditionally, NOT gated on `latch_enabled`: `kaish-trash
3156        // empty` gates every time (it's inherently destructive, independent of
3157        // `set -o latch`), so a `latch_enabled`-only gate would leave its
3158        // `tool`/`argv` empty and break `confirm`. The cost is a small argv
3159        // clone per command — marginal beside the per-command ExecContext
3160        // snapshot above — and it does NOT reintroduce the deep-`$()` stack
3161        // overflow (that was the inline `LatchRequest` in `ExecResult`, now
3162        // boxed; the capture's temporaries don't survive into the recursive
3163        // `tool.execute` below).
3164        ctx.current_invocation = Some(Box::new((name.to_string(), tool_args.to_argv())));
3165
3166        let result = tool.execute(tool_args, &mut ctx).await;
3167
3168        // Sync mutations back. Tools may have changed scope (set/cd),
3169        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3170        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3171        // hands them back to the pipeline runner — the runner uses
3172        // stage_ctx.pipe_stdout to write the result to the next stage when
3173        // the tool itself didn't take and write to it.
3174        {
3175            let mut scope = self.scope.write().await;
3176            *scope = ctx.scope.clone();
3177        }
3178        {
3179            let mut ec = self.exec_ctx.write().await;
3180            ec.cwd = ctx.cwd;
3181            ec.prev_cwd = ctx.prev_cwd;
3182            ec.aliases = ctx.aliases;
3183            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3184            // mutate the runtime output limit; without this sync the change is
3185            // dropped here and never reaches dispatch_command's read-back, so
3186            // it would not survive past the current statement.
3187            ec.output_limit = ctx.output_limit.clone();
3188            ec.pipe_stdin = ctx.pipe_stdin.take();
3189            ec.pipe_stdout = ctx.pipe_stdout.take();
3190        }
3191
3192        // Builtins parse --json via the GlobalFlags flatten in their clap
3193        // struct and write ctx.output_format. The kernel applies it — unless the
3194        // tool owns its own output (renders --json itself), in which case we
3195        // leave its bytes untouched.
3196        let result = finalize_output(result, ctx.output_format, schema.owns_output);
3197
3198        Ok(result)
3199    }
3200
3201    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3202    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3203    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3204    /// unexpanded rather than leaking the host home directory.
3205    async fn scope_home(&self) -> Option<String> {
3206        match self.scope.read().await.get("HOME") {
3207            Some(Value::String(s)) => Some(s.clone()),
3208            _ => None,
3209        }
3210    }
3211
3212    // (see `push_repeatable_value` below for the repeatable-flag accumulation.)
3213
3214    /// Pull `consumes` positional args after a non-bool flag and stash them
3215    /// on `tool_args.named` under the canonical param name.
3216    ///
3217    /// - `consumes == 1` (non-repeatable) keeps the historical contract: a
3218    ///   single scalar value (last write wins on the rare duplicate).
3219    /// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
3220    ///   inside `named[canonical] = Value::Json(Array(...))`, preserving
3221    ///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
3222    ///   a repeated single-value flag must keep every value, not silently drop
3223    ///   all but the last (a "no silent corruption" violation).
3224    /// - `consumes > 1` accumulates each occurrence as an inner
3225    ///   `serde_json::Value::Array` inside `named[canonical] =
3226    ///   Value::Json(Array(...))`, preserving invocation order. This is the
3227    ///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
3228    ///
3229    /// Errors loudly if the flag is missing required positionals — matches
3230    /// kaish's "no silent fallback" posture and mirrors real jq, which
3231    /// errors on `--arg NAME` with no value.
3232    #[allow(clippy::too_many_arguments)]
3233    async fn consume_flag_positionals(
3234        &self,
3235        args: &[Arg],
3236        flag_name: &str,
3237        canonical: &str,
3238        consumes: usize,
3239        repeatable: bool,
3240        positional_indices: &[usize],
3241        consumed: &mut std::collections::HashSet<usize>,
3242        current_idx: usize,
3243        tool_args: &mut ToolArgs,
3244    ) -> Result<()> {
3245        let home = self.scope_home().await;
3246        let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
3247        for _ in 0..consumes.max(1) {
3248            // A `key=value` (WordAssign) token is consumable only by a
3249            // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
3250            // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
3251            // filter` would reassemble `x=1` into the first slot and steal the
3252            // filter into the second. Multi-value flags take plain positionals.
3253            let allow_word_assign = consumes <= 1;
3254            let next_pos = positional_indices
3255                .iter()
3256                .find(|idx| {
3257                    **idx > current_idx
3258                        && !consumed.contains(idx)
3259                        && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
3260                })
3261                .copied();
3262            match next_pos {
3263                Some(pos_idx) => match &args[pos_idx] {
3264                    Arg::Positional(expr) => {
3265                        let value = self.eval_expr_async(expr).await?;
3266                        let value = apply_tilde_expansion(value, home.as_deref());
3267                        collected.push(value);
3268                        consumed.insert(pos_idx);
3269                    }
3270                    // `-v a=1`: reassemble the `key=value` token as the flag's
3271                    // scalar value (see `positional_indices` construction).
3272                    Arg::WordAssign { key, value } => {
3273                        let val = self.eval_expr_async(value).await?;
3274                        let val = apply_tilde_expansion(val, home.as_deref());
3275                        let val_str = crate::interpreter::value_to_string(&val);
3276                        collected.push(Value::String(format!("{key}={val_str}")));
3277                        consumed.insert(pos_idx);
3278                    }
3279                    _ => {}
3280                },
3281                None => {
3282                    if consumes <= 1 && collected.is_empty() {
3283                        // Back-compat: a flag with no follow-up positional
3284                        // becomes a bare flag. `--path` with nothing after
3285                        // lands in `flags`, same as before this refactor.
3286                        tool_args.flags.insert(flag_name.to_string());
3287                        return Ok(());
3288                    }
3289                    anyhow::bail!(
3290                        "--{flag_name} requires {consumes} argument{}, got {}",
3291                        if consumes == 1 { "" } else { "s" },
3292                        collected.len()
3293                    );
3294                }
3295            }
3296        }
3297
3298        if consumes <= 1 {
3299            if let Some(v) = collected.pop() {
3300                if repeatable {
3301                    push_repeatable_value(tool_args, flag_name, canonical, v)?;
3302                } else {
3303                    tool_args.named.insert(canonical.to_string(), v);
3304                }
3305            }
3306            return Ok(());
3307        }
3308
3309        // Multi-consume: accumulate under named[canonical] as array-of-arrays.
3310        let occ: Vec<serde_json::Value> = collected
3311            .into_iter()
3312            .map(|v| crate::interpreter::value_to_json(&v))
3313            .collect();
3314        let entry = tool_args
3315            .named
3316            .entry(canonical.to_string())
3317            .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
3318        if let Value::Json(serde_json::Value::Array(outer)) = entry {
3319            outer.push(serde_json::Value::Array(occ));
3320        } else {
3321            anyhow::bail!(
3322                "--{flag_name}: named[{canonical}] already holds a non-array value"
3323            );
3324        }
3325        Ok(())
3326    }
3327
3328    /// Build tool arguments from AST args.
3329    ///
3330    /// Uses async evaluation to support command substitution in arguments.
3331    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3332        let mut tool_args = ToolArgs::new();
3333        let home = self.scope_home().await;
3334
3335        // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
3336        // in source order with types preserved — operators (`-f`, `=`, `!`) as
3337        // strings, operands keeping their `Value` — leaving `flags`/`named`
3338        // empty. A position-sensitive command needs the *true* argv: an operand
3339        // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
3340        // hoisted into the unordered flag set the normal binder splits into.
3341        // Globs still expand and `~` still resolves, matching normal positional
3342        // binding — so `test -f *.rs` errors on too many args, not a literal
3343        // pattern stat.
3344        if schema.is_some_and(|s| s.raw_argv) {
3345            for arg in args {
3346                match arg {
3347                    Arg::Positional(expr) => {
3348                        let glob = if let Expr::GlobPattern(p) = expr {
3349                            self.scope.read().await.glob_enabled().then(|| p.clone())
3350                        } else {
3351                            None
3352                        };
3353                        if let Some(pattern) = glob {
3354                            let (paths, cwd) = {
3355                                let ctx = self.exec_ctx.read().await;
3356                                let paths = ctx
3357                                    .expand_glob(&pattern)
3358                                    .await
3359                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3360                                let cwd = ctx.resolve_path(".");
3361                                (paths, cwd)
3362                            };
3363                            if paths.is_empty() {
3364                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3365                            }
3366                            for path in paths {
3367                                let display = if !pattern.starts_with('/') {
3368                                    path.strip_prefix(&cwd)
3369                                        .unwrap_or(&path)
3370                                        .to_string_lossy()
3371                                        .into_owned()
3372                                } else {
3373                                    path.to_string_lossy().into_owned()
3374                                };
3375                                tool_args.positional.push(Value::String(display));
3376                            }
3377                        } else {
3378                            let value = self.eval_expr_async(expr).await?;
3379                            let value = apply_tilde_expansion(value, home.as_deref());
3380                            tool_args.positional.push(value);
3381                        }
3382                    }
3383                    Arg::ShortFlag(name) => {
3384                        tool_args.positional.push(Value::String(format!("-{name}")));
3385                    }
3386                    Arg::LongFlag(name) => {
3387                        tool_args.positional.push(Value::String(format!("--{name}")));
3388                    }
3389                    Arg::Named { key, value } => {
3390                        let val = self.eval_expr_async(value).await?;
3391                        let val = apply_tilde_expansion(val, home.as_deref());
3392                        tool_args.positional.push(Value::String(format!(
3393                            "--{key}={}",
3394                            crate::interpreter::value_to_string(&val)
3395                        )));
3396                    }
3397                    Arg::WordAssign { key, value } => {
3398                        let val = self.eval_expr_async(value).await?;
3399                        let val = apply_tilde_expansion(val, home.as_deref());
3400                        tool_args.positional.push(Value::String(format!(
3401                            "{key}={}",
3402                            crate::interpreter::value_to_string(&val)
3403                        )));
3404                    }
3405                    Arg::DoubleDash => {
3406                        tool_args.positional.push(Value::String("--".to_string()));
3407                    }
3408                }
3409            }
3410            return Ok(tool_args);
3411        }
3412
3413        // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
3414        // schemas; pick the leaf the leading positionals route to and bind
3415        // flags against *its* params. Flat tools return the root. select_leaf
3416        // errors (fail loud) if a computed positional sits where a subcommand
3417        // selector is required.
3418        let leaf = match schema {
3419            Some(s) => Some(select_leaf(s, args)?),
3420            None => None,
3421        };
3422        // Bind against the leaf's params, but MERGE the root schema's params on
3423        // top as "global" flags: a value-flag declared at the tool's top level
3424        // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
3425        // it trails the subcommand path (`kj context retag a b --confirm <n>`).
3426        // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
3427        // merge is a harmless no-op.
3428        let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
3429        if let Some(l) = leaf {
3430            param_lookup.extend(schema_param_lookup(l));
3431        }
3432        // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
3433        // not the leaf — it's a property of the command, not the subcommand.
3434        let accepts_word_assign = schema
3435            .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
3436            .unwrap_or(false);
3437
3438        // Track which positional indices have been consumed as flag values
3439        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
3440        let mut past_double_dash = false;
3441
3442        // Indices a value-flag may consume as its value. Positionals always
3443        // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
3444        // itself treat `key=value` as an assignment (everything but
3445        // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
3446        // `-v`, rather than skipping it and grabbing the next positional (the
3447        // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
3448        // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
3449        let positional_indices: Vec<usize> = args.iter().enumerate()
3450            .filter_map(|(i, a)| {
3451                let consumable = matches!(a, Arg::Positional(_))
3452                    || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
3453                consumable.then_some(i)
3454            })
3455            .collect();
3456
3457        let mut i = 0;
3458        while i < args.len() {
3459            match &args[i] {
3460                Arg::DoubleDash => {
3461                    past_double_dash = true;
3462                }
3463                Arg::Positional(expr) => {
3464                    if !consumed.contains(&i) {
3465                        // Glob expansion: bare glob patterns expand to matching files
3466                        if let Expr::GlobPattern(pattern) = expr {
3467                            let glob_enabled = {
3468                                let scope = self.scope.read().await;
3469                                scope.glob_enabled()
3470                            };
3471                            if glob_enabled {
3472                                let (paths, cwd) = {
3473                                    let ctx = self.exec_ctx.read().await;
3474                                    let paths = ctx.expand_glob(pattern).await
3475                                        .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3476                                    let cwd = ctx.resolve_path(".");
3477                                    (paths, cwd)
3478                                };
3479                                if paths.is_empty() {
3480                                    return Err(anyhow::anyhow!("no matches: {}", pattern));
3481                                }
3482                                for path in paths {
3483                                    let display = if !pattern.starts_with('/') {
3484                                        path.strip_prefix(&cwd)
3485                                            .unwrap_or(&path)
3486                                            .to_string_lossy().into_owned()
3487                                    } else {
3488                                        path.to_string_lossy().into_owned()
3489                                    };
3490                                    tool_args.positional.push(Value::String(display));
3491                                }
3492                                i += 1;
3493                                continue;
3494                            }
3495                        }
3496                        let value = self.eval_expr_async(expr).await?;
3497                        let value = apply_tilde_expansion(value, home.as_deref());
3498                        tool_args.positional.push(value);
3499                    }
3500                }
3501                Arg::Named { key, value } => {
3502                    let val = self.eval_expr_async(value).await?;
3503                    let val = apply_tilde_expansion(val, home.as_deref());
3504                    // A repeatable flag in `--flag=value` form must accumulate too,
3505                    // not overwrite — otherwise `--expression=A --expression=B`
3506                    // would silently keep only B, and mixing with the `-e` space
3507                    // form would clobber the array. Route it through the same
3508                    // accumulator the space form uses.
3509                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
3510                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
3511                    } else {
3512                        tool_args.named.insert(key.clone(), val);
3513                    }
3514                }
3515                Arg::WordAssign { key, value } => {
3516                    // Already pulled in as a preceding value-flag's argument
3517                    // (`awk -v a=1`); don't also emit it as a positional.
3518                    if consumed.contains(&i) {
3519                        i += 1;
3520                        continue;
3521                    }
3522                    let val = self.eval_expr_async(value).await?;
3523                    let val = apply_tilde_expansion(val, home.as_deref());
3524                    if accepts_word_assign {
3525                        tool_args.named.insert(key.clone(), val);
3526                    } else {
3527                        // Stringify "key=value" and pass as a positional.
3528                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
3529                        let val_str = crate::interpreter::value_to_string(&val);
3530                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
3531                    }
3532                }
3533                Arg::ShortFlag(name) => {
3534                    if past_double_dash {
3535                        tool_args.positional.push(Value::String(format!("-{name}")));
3536                    } else if name.len() == 1 {
3537                        let flag_name = name.as_str();
3538                        let lookup = param_lookup.get(flag_name);
3539                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3540
3541                        if is_bool {
3542                            tool_args.flags.insert(flag_name.to_string());
3543                        } else {
3544                            // Non-bool: consume `consumes` positionals as value(s)
3545                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
3546                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3547                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3548                            self.consume_flag_positionals(
3549                                args,
3550                                name,
3551                                canonical,
3552                                consumes,
3553                                repeatable,
3554                                &positional_indices,
3555                                &mut consumed,
3556                                i,
3557                                &mut tool_args,
3558                            )
3559                            .await?;
3560                        }
3561                    } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
3562                        // Multi-char short flag matches a schema param (POSIX style: -name value)
3563                        if is_bool_type(typ) {
3564                            tool_args.flags.insert(canonical.to_string());
3565                        } else {
3566                            self.consume_flag_positionals(
3567                                args,
3568                                name,
3569                                canonical,
3570                                consumes,
3571                                repeatable,
3572                                &positional_indices,
3573                                &mut consumed,
3574                                i,
3575                                &mut tool_args,
3576                            )
3577                            .await?;
3578                        }
3579                    } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
3580                        .get(&name[..1])
3581                        .filter(|(_, typ, ..)| !is_bool_type(typ))
3582                    {
3583                        // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
3584                        // `grep -A1`, `sed -e1d`. The first char is a declared
3585                        // value-taking short flag, so the rest of the token is its
3586                        // value — the coreutils idiom. The lexer's flag char class is
3587                        // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
3588                        // (safe to slice) and the tail is a plain literal.
3589                        bind_glued_short_value(
3590                            &mut tool_args,
3591                            &name[..1],
3592                            canonical,
3593                            consumes,
3594                            repeatable,
3595                            name[1..].to_string(),
3596                        )?;
3597                    } else {
3598                        // Multi-char combined short flags. Bool flags stack
3599                        // (`-la`), but the FIRST value-taking flag reached
3600                        // consumes the rest of the token as its glued value
3601                        // (`-ivC3` → C=3) or, if it is the last char, the next
3602                        // positional (`grep -ivC 3` → C=3). Before this, a
3603                        // trailing value-flag was silently treated as a bool,
3604                        // stranding its argument as a stray positional (arity
3605                        // error). Undeclared/bool chars stay bare flags, so a
3606                        // schemaless tool keeps the old all-boolean behavior.
3607                        // The first char being value-taking is handled by the
3608                        // glued arm above, so it never reaches here. The flag
3609                        // char class is ASCII, so byte indexing is char indexing
3610                        // (no `Vec<char>` allocation needed).
3611                        let bytes = name.as_bytes();
3612                        let mut p = 0;
3613                        while p < bytes.len() {
3614                            let key = &name[p..p + 1];
3615                            match param_lookup.get(key) {
3616                                Some(&(canonical, typ, consumes, repeatable))
3617                                    if !is_bool_type(typ) =>
3618                                {
3619                                    let glued = name[p + 1..].to_string();
3620                                    if glued.is_empty() {
3621                                        // Value flag is the last char: take the
3622                                        // next positional. `consume_flag_positionals`
3623                                        // respects `consumes`.
3624                                        self.consume_flag_positionals(
3625                                            args,
3626                                            key,
3627                                            canonical,
3628                                            consumes,
3629                                            repeatable,
3630                                            &positional_indices,
3631                                            &mut consumed,
3632                                            i,
3633                                            &mut tool_args,
3634                                        )
3635                                        .await?;
3636                                    } else {
3637                                        bind_glued_short_value(
3638                                            &mut tool_args,
3639                                            key,
3640                                            canonical,
3641                                            consumes,
3642                                            repeatable,
3643                                            glued,
3644                                        )?;
3645                                    }
3646                                    break;
3647                                }
3648                                _ => {
3649                                    tool_args.flags.insert(key.to_string());
3650                                    p += 1;
3651                                }
3652                            }
3653                        }
3654                    }
3655                }
3656                Arg::LongFlag(name) => {
3657                    if past_double_dash {
3658                        tool_args.positional.push(Value::String(format!("--{name}")));
3659                    } else {
3660                        let lookup = param_lookup.get(name.as_str());
3661                        // An *undeclared* long flag under a `map_positionals`
3662                        // (backend/MCP) schema that is immediately followed by an
3663                        // unconsumed positional is ambiguous: kaish can't tell the
3664                        // space-form value (`--type explorer`) from a bool flag
3665                        // before a real positional (`--force file.txt`). Defaulting
3666                        // to bool here silently divorces the value and misroutes it
3667                        // — a privilege-escalation-by-typo against deny-by-default
3668                        // embedders (docs/issues.md). Fail loud instead of guessing.
3669                        let ambiguous_value = (lookup.is_none()
3670                            && leaf.is_some_and(|s| s.map_positionals)
3671                            && !consumed.contains(&(i + 1)))
3672                            .then(|| match args.get(i + 1) {
3673                                // Echo a concrete value for a copy-pasteable fix
3674                                // when it's a plain literal; fall back to VALUE.
3675                                Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
3676                                    Some(s.clone())
3677                                }
3678                                Some(Arg::Positional(_)) => Some("VALUE".to_string()),
3679                                _ => None,
3680                            })
3681                            .flatten();
3682                        if let Some(val) = ambiguous_value {
3683                            let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
3684                            anyhow::bail!(
3685                                "{tool}: --{name} is not a declared flag, so the \
3686                                 space-separated value would be silently dropped. \
3687                                 Use --{name}={val}, or have {tool} declare --{name} \
3688                                 in its schema."
3689                            );
3690                        }
3691                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3692
3693                        if is_bool {
3694                            tool_args.flags.insert(name.clone());
3695                        } else {
3696                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
3697                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3698                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3699                            self.consume_flag_positionals(
3700                                args,
3701                                name,
3702                                canonical,
3703                                consumes,
3704                                repeatable,
3705                                &positional_indices,
3706                                &mut consumed,
3707                                i,
3708                                &mut tool_args,
3709                            )
3710                            .await?;
3711                        }
3712                    }
3713                }
3714            }
3715            i += 1;
3716        }
3717
3718        // Map remaining positionals to unfilled non-bool schema params (in order).
3719        // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
3720        // Positionals that appeared after `--` are never mapped (they're raw data).
3721        // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
3722        // Keyed off the routed leaf so a subcommand tool maps against the active
3723        // leaf's params (kj leaves keep map_positionals=false → block skipped).
3724        if let Some(schema) = leaf.filter(|s| s.map_positionals) {
3725            let pre_dash_count = if past_double_dash {
3726                let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
3727                positional_indices.iter()
3728                    .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
3729                    .count()
3730            } else {
3731                tool_args.positional.len()
3732            };
3733
3734            let mut remaining = Vec::new();
3735            let mut positional_iter = tool_args.positional.drain(..).enumerate();
3736
3737            for param in &schema.params {
3738                if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
3739                    continue;
3740                }
3741                if is_bool_type(&param.param_type) {
3742                    continue;
3743                }
3744                loop {
3745                    match positional_iter.next() {
3746                        Some((idx, val)) if idx < pre_dash_count => {
3747                            tool_args.named.insert(param.name.clone(), val);
3748                            break;
3749                        }
3750                        Some((_, val)) => {
3751                            remaining.push(val);
3752                        }
3753                        None => break,
3754                    }
3755                }
3756            }
3757
3758            remaining.extend(positional_iter.map(|(_, v)| v));
3759            tool_args.positional = remaining;
3760        }
3761
3762        Ok(tool_args)
3763    }
3764
3765    /// Build arguments as flat string list for external commands.
3766    ///
3767    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3768    /// this preserves the original flag format as strings for external commands:
3769    /// - `-l` stays as `-l`
3770    /// - `--verbose` stays as `--verbose`
3771    /// - `key=value` stays as `key=value`
3772    ///
3773    /// This is what external commands expect in their argv.
3774    #[cfg(feature = "subprocess")]
3775    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3776        let mut argv = Vec::new();
3777        let home = self.scope_home().await;
3778        for arg in args {
3779            match arg {
3780                Arg::Positional(expr) => {
3781                    // Glob expansion for external commands
3782                    if let Expr::GlobPattern(pattern) = expr {
3783                        let glob_enabled = {
3784                            let scope = self.scope.read().await;
3785                            scope.glob_enabled()
3786                        };
3787                        if glob_enabled {
3788                            let (paths, cwd) = {
3789                                let ctx = self.exec_ctx.read().await;
3790                                let paths = ctx.expand_glob(pattern).await
3791                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3792                                let cwd = ctx.resolve_path(".");
3793                                (paths, cwd)
3794                            };
3795                            if paths.is_empty() {
3796                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3797                            }
3798                            for path in paths {
3799                                let display = if !pattern.starts_with('/') {
3800                                    path.strip_prefix(&cwd)
3801                                        .unwrap_or(&path)
3802                                        .to_string_lossy().into_owned()
3803                                } else {
3804                                    path.to_string_lossy().into_owned()
3805                                };
3806                                argv.push(display);
3807                            }
3808                            continue;
3809                        }
3810                    }
3811                    let value = self.eval_expr_async(expr).await?;
3812                    // Decision D: a bare collection can't cross the external
3813                    // process boundary as an argv element — refuse rather than
3814                    // silently JSON-serializing it. A quoted `"$x"` already
3815                    // reduced to a `Value::String` above (via `Expr::Interpolated`),
3816                    // so only a live, un-interpolated `$x` trips this.
3817                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3818                        return Err(anyhow::anyhow!(msg));
3819                    }
3820                    let value = apply_tilde_expansion(value, home.as_deref());
3821                    // External-command argv is a text sink: a bare `$BIN` binary
3822                    // word goes loud, never the `[binary: N bytes]` placeholder.
3823                    argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3824                }
3825                Arg::Named { key, value } => {
3826                    let val = self.eval_expr_async(value).await?;
3827                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3828                        return Err(anyhow::anyhow!(msg));
3829                    }
3830                    let val = apply_tilde_expansion(val, home.as_deref());
3831                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3832                    argv.push(format!("--{key}={val_str}"));
3833                }
3834                Arg::WordAssign { key, value } => {
3835                    let val = self.eval_expr_async(value).await?;
3836                    if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3837                        return Err(anyhow::anyhow!(msg));
3838                    }
3839                    let val = apply_tilde_expansion(val, home.as_deref());
3840                    let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3841                    argv.push(format!("{key}={val_str}"));
3842                }
3843                Arg::ShortFlag(name) => {
3844                    // Preserve original format: -l, -la (combined flags)
3845                    argv.push(format!("-{}", name));
3846                }
3847                Arg::LongFlag(name) => {
3848                    // Preserve original format: --verbose
3849                    argv.push(format!("--{}", name));
3850                }
3851                Arg::DoubleDash => {
3852                    // Preserve the -- marker
3853                    argv.push("--".to_string());
3854                }
3855            }
3856        }
3857        Ok(argv)
3858    }
3859
3860    /// Async expression evaluator that supports command substitution.
3861    ///
3862    /// This is used for contexts where expressions may contain `$(...)` command
3863    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3864    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3865        Box::pin(async move {
3866        match expr {
3867            Expr::Literal(value) => Ok(value.clone()),
3868            Expr::VarRef(path) => {
3869                let scope = self.scope.read().await;
3870                match scope.resolve_path(path) {
3871                    Ok(v) => Ok(v),
3872                    Err(PathError::UndefinedRoot(_)) => {
3873                        Err(anyhow::anyhow!("undefined variable"))
3874                    }
3875                    Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3876                        Err(anyhow::anyhow!(msg))
3877                    }
3878                }
3879            }
3880            Expr::Interpolated(parts) => {
3881                let mut result = String::new();
3882                for part in parts {
3883                    result.push_str(&self.eval_string_part_async(part).await?);
3884                }
3885                Ok(Value::String(result))
3886            }
3887            Expr::HereDocBody { parts, strip_tabs } => {
3888                // Assemble part-by-part so `<<-` tab stripping applies to the
3889                // literal source, not to tabs from a `$var` value (bash strips
3890                // source-line tabs before parameter expansion).
3891                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3892                for sp in parts {
3893                    match &sp.part {
3894                        StringPart::Literal(s) => asm.push_literal(s),
3895                        other => {
3896                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
3897                        }
3898                    }
3899                }
3900                Ok(Value::String(asm.into_string()))
3901            }
3902            Expr::BinaryOp { left, op, right } => match op {
3903                BinaryOp::And => {
3904                    let left_val = self.eval_expr_async(left).await?;
3905                    if !is_truthy(&left_val) {
3906                        return Ok(left_val);
3907                    }
3908                    self.eval_expr_async(right).await
3909                }
3910                BinaryOp::Or => {
3911                    let left_val = self.eval_expr_async(left).await?;
3912                    if is_truthy(&left_val) {
3913                        return Ok(left_val);
3914                    }
3915                    self.eval_expr_async(right).await
3916                }
3917            },
3918            Expr::CommandSubst(stmts) => {
3919                // Snapshot scope+cwd before running — only output escapes,
3920                // not side effects like `cd` or variable assignments.
3921                let saved_scope = { self.scope.read().await.clone() };
3922                let saved_cwd = {
3923                    let ec = self.exec_ctx.read().await;
3924                    (ec.cwd.clone(), ec.prev_cwd.clone())
3925                };
3926
3927                // Capture result without `?` — restore state unconditionally
3928                let run_result = self.execute_block_capturing(stmts).await;
3929
3930                // Restore scope and cwd regardless of success/failure
3931                {
3932                    let mut scope = self.scope.write().await;
3933                    *scope = saved_scope;
3934                    if let Ok(ref r) = run_result {
3935                        scope.set_last_result(r.clone());
3936                    }
3937                }
3938                {
3939                    let mut ec = self.exec_ctx.write().await;
3940                    ec.cwd = saved_cwd.0;
3941                    ec.prev_cwd = saved_cwd.1;
3942                }
3943
3944                // Now propagate the error
3945                let result = run_result?;
3946
3947                // A binary result is preserved as bytes — never lossy-decoded to
3948                // a string. No trailing-newline trim (every byte is significant).
3949                if let Some(bytes) = result.out_bytes() {
3950                    Ok(Value::Bytes(bytes.to_vec()))
3951                // Prefer structured data (enables `for i in $(cmd)` iteration)
3952                } else if let Some(data) = &result.data {
3953                    Ok(data.clone())
3954                } else if let Some(output) = result.output() {
3955                    // Flat non-text node lists (glob, ls, tree) → iterable array
3956                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3957                        let items: Vec<serde_json::Value> = output.root.iter()
3958                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
3959                            .collect();
3960                        Ok(Value::Json(serde_json::Value::Array(items)))
3961                    } else {
3962                        // Strip trailing newlines only (POSIX command-subst),
3963                        // not all trailing whitespace — spaces/tabs are
3964                        // significant. Use the exact same trim as the quoted
3965                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3966                        // `trim_end_matches('\n')`) so bare and quoted command
3967                        // substitution agree.
3968                        Ok(Value::String(
3969                            result.text_out().trim_end_matches('\n').to_string(),
3970                        ))
3971                    }
3972                } else {
3973                    // Otherwise return stdout as single string (NO implicit splitting)
3974                    Ok(Value::String(
3975                        result.text_out().trim_end_matches('\n').to_string(),
3976                    ))
3977                }
3978            }
3979            Expr::Test(test_expr) => {
3980                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3981            }
3982            Expr::Positional(n) => {
3983                let scope = self.scope.read().await;
3984                match scope.get_positional(*n) {
3985                    Some(s) => Ok(Value::String(s.to_string())),
3986                    None => Ok(Value::String(String::new())),
3987                }
3988            }
3989            Expr::AllArgs => {
3990                let scope = self.scope.read().await;
3991                Ok(Value::String(scope.all_args().join(" ")))
3992            }
3993            Expr::ArgCount => {
3994                let scope = self.scope.read().await;
3995                Ok(Value::Int(scope.arg_count() as i64))
3996            }
3997            Expr::VarLength(path) => {
3998                let scope = self.scope.read().await;
3999                crate::interpreter::resolve_length(&scope, path)
4000                    .map(Value::Int)
4001                    .map_err(|msg| anyhow::anyhow!(msg))
4002            }
4003            Expr::VarWithDefault { path, default } => {
4004                // Resolve inside a scoped guard so the lock is released before the
4005                // recursive default evaluation.
4006                let resolved = {
4007                    let scope = self.scope.read().await;
4008                    crate::interpreter::resolve_default(&scope, path)
4009                        .map_err(|msg| anyhow::anyhow!(msg))?
4010                };
4011                match resolved {
4012                    Some(value) => Ok(value),
4013                    None => self.eval_string_parts_async(default).await.map(Value::String),
4014                }
4015            }
4016            Expr::Arithmetic(expr_str) => {
4017                let scope = self.scope.read().await;
4018                crate::arithmetic::eval_arithmetic(expr_str, &scope)
4019                    .map(Value::Int)
4020                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
4021            }
4022            Expr::Command(cmd) => {
4023                // Execute command and return boolean based on exit code
4024                let result = self.execute_command(&cmd.name, &cmd.args).await?;
4025                Ok(Value::Bool(result.code == 0))
4026            }
4027            Expr::LastExitCode => {
4028                let scope = self.scope.read().await;
4029                Ok(Value::Int(scope.last_result().code))
4030            }
4031            Expr::CurrentPid => {
4032                let scope = self.scope.read().await;
4033                Ok(Value::Int(scope.pid() as i64))
4034            }
4035            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4036            Expr::ListLiteral(elems) => {
4037                // Spread must itself be a list — a scalar/record spread is a
4038                // loud error, never silently coerced or dropped (mirrors the
4039                // sync `Evaluator::eval_list_literal`; wording shared via
4040                // `spread_non_list_message` so the two paths can't diverge).
4041                let mut out = Vec::with_capacity(elems.len());
4042                for elem in elems {
4043                    match elem {
4044                        ListElem::Item(e) => {
4045                            let value = self.eval_expr_async(e).await?;
4046                            out.push(crate::interpreter::value_to_json(&value));
4047                        }
4048                        ListElem::Spread(e) => {
4049                            let value = self.eval_expr_async(e).await?;
4050                            match value {
4051                                Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4052                                other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4053                            }
4054                        }
4055                    }
4056                }
4057                Ok(Value::Json(serde_json::Value::Array(out)))
4058            }
4059            Expr::RecordLiteral(entries) => {
4060                // Insertion order preserved (workspace serde_json has
4061                // `preserve_order`); a duplicate key keeps the last value
4062                // written, matching plain map-insert semantics.
4063                let mut map = serde_json::Map::new();
4064                for entry in entries {
4065                    let key = match &entry.key {
4066                        RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4067                        // `{"$k": v}` resolves like any double-quoted string
4068                        // (used to silently create a literal "$k" key).
4069                        RecordKey::Interpolated(parts) => {
4070                            self.eval_string_parts_async(parts).await?
4071                        }
4072                    };
4073                    let value = self.eval_expr_async(&entry.value).await?;
4074                    map.insert(key, crate::interpreter::value_to_json(&value));
4075                }
4076                Ok(Value::Json(serde_json::Value::Object(map)))
4077            }
4078        }
4079        })
4080    }
4081
4082    /// Async helper to evaluate multiple StringParts into a single string.
4083    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4084        Box::pin(async move {
4085            let mut result = String::new();
4086            for part in parts {
4087                result.push_str(&self.eval_string_part_async(part).await?);
4088            }
4089            Ok(result)
4090        })
4091    }
4092
4093    /// Async helper to evaluate a StringPart.
4094    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4095    /// through the VFS backend instead of using raw `std::path`.
4096    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4097        Box::pin(async move {
4098            match test_expr {
4099                TestExpr::FileTest { op, path } => {
4100                    let path_value = self.eval_expr_async(path).await?;
4101                    // Expand `~` against the session HOME before stat'ing, the
4102                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4103                    // stats the literal `~/x` and is always false.
4104                    let home = self.scope_home().await;
4105                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
4106                    let path_str = value_to_string(&path_value);
4107                    // Resolve against the *session* cwd, not the process cwd, so a
4108                    // relative `[[ -f rel ]]` honors `cd` and agrees with the
4109                    // VFS-aware `test` builtin (GH #101). Backend stats a raw
4110                    // relative path against the process cwd otherwise.
4111                    let (resolved, backend) = {
4112                        let ctx = self.exec_ctx.read().await;
4113                        (ctx.resolve_path(&path_str), ctx.backend.clone())
4114                    };
4115                    let entry = backend.stat(&resolved).await.ok();
4116                    Ok(match op {
4117                        FileTestOp::Exists => entry.is_some(),
4118                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4119                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4120                        FileTestOp::Readable => entry.is_some(),
4121                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4122                            e.permissions.is_none_or(|p| p & 0o222 != 0)
4123                        }),
4124                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4125                            e.permissions.is_some_and(|p| p & 0o111 != 0)
4126                        }),
4127                    })
4128                }
4129                TestExpr::StringTest { op, value } => match op {
4130                    crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4131                        let val = self.eval_expr_async(value).await?;
4132                        // Decision E: a collection operand is a loud Shape error
4133                        // here too — must not diverge from the sync path in
4134                        // interpreter/eval.rs (shared `scalar_test_operand_error`).
4135                        let symbol = match op {
4136                            crate::ast::StringTestOp::IsEmpty => "-z",
4137                            crate::ast::StringTestOp::IsNonEmpty => "-n",
4138                            crate::ast::StringTestOp::IsList
4139                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4140                        };
4141                        if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4142                            anyhow::bail!(msg);
4143                        }
4144                        let s = value_to_string(&val);
4145                        Ok(match op {
4146                            crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4147                            crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4148                            crate::ast::StringTestOp::IsList
4149                            | crate::ast::StringTestOp::IsRecord => unreachable!(),
4150                        })
4151                    }
4152                    // Shape guard: propagates eval errors like -z/-n (a bare
4153                    // `$unset` is an undefined-variable error, not a silent
4154                    // false). A defined-but-wrong-shaped value is false. Must
4155                    // not diverge from the sync path in interpreter/eval.rs.
4156                    crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4157                        let val = self.eval_expr_async(value).await?;
4158                        Ok(op.matches_shape(&val))
4159                    }
4160                },
4161                TestExpr::Comparison { left, op, right } => {
4162                    // Evaluate operands async (handles $(cmd)), then compare sync
4163                    let left_val = self.eval_expr_async(left).await?;
4164                    let right_val = self.eval_expr_async(right).await?;
4165                    let resolved = TestExpr::Comparison {
4166                        left: Box::new(Expr::Literal(left_val)),
4167                        op: *op,
4168                        right: Box::new(Expr::Literal(right_val)),
4169                    };
4170                    let expr = Expr::Test(Box::new(resolved));
4171                    let mut scope = self.scope.write().await;
4172                    let value = eval_expr(&expr, &mut scope)
4173                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4174                    Ok(value_to_bool(&value))
4175                }
4176                TestExpr::And { left, right } => {
4177                    if !self.eval_test_async(left).await? {
4178                        Ok(false)
4179                    } else {
4180                        self.eval_test_async(right).await
4181                    }
4182                }
4183                TestExpr::Or { left, right } => {
4184                    if self.eval_test_async(left).await? {
4185                        Ok(true)
4186                    } else {
4187                        self.eval_test_async(right).await
4188                    }
4189                }
4190                TestExpr::Not { expr } => {
4191                    Ok(!self.eval_test_async(expr).await?)
4192                }
4193                TestExpr::In { left, right } => {
4194                    let left_val = self.eval_expr_async(left).await?;
4195                    let right_val = self.eval_expr_async(right).await?;
4196                    let resolved = TestExpr::In {
4197                        left: Box::new(Expr::Literal(left_val)),
4198                        right: Box::new(Expr::Literal(right_val)),
4199                    };
4200                    let expr = Expr::Test(Box::new(resolved));
4201                    let mut scope = self.scope.write().await;
4202                    let value = eval_expr(&expr, &mut scope)
4203                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4204                    Ok(value_to_bool(&value))
4205                }
4206                TestExpr::NotIn { left, right } => {
4207                    let left_val = self.eval_expr_async(left).await?;
4208                    let right_val = self.eval_expr_async(right).await?;
4209                    let resolved = TestExpr::NotIn {
4210                        left: Box::new(Expr::Literal(left_val)),
4211                        right: Box::new(Expr::Literal(right_val)),
4212                    };
4213                    let expr = Expr::Test(Box::new(resolved));
4214                    let mut scope = self.scope.write().await;
4215                    let value = eval_expr(&expr, &mut scope)
4216                        .map_err(|e| anyhow::anyhow!("{}", e))?;
4217                    Ok(value_to_bool(&value))
4218                }
4219            }
4220        })
4221    }
4222
4223    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4224        Box::pin(async move {
4225            match part {
4226                StringPart::Literal(s) => Ok(s.clone()),
4227                StringPart::Var(path) => {
4228                    let scope = self.scope.read().await;
4229                    match scope.resolve_path(path) {
4230                        // Text sink: binary goes loud, never the placeholder —
4231                        // a `b=$(cat blob)` capture holds real bytes; splicing
4232                        // `[binary: N bytes]` into "$b" would be silent loss.
4233                        Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4234                        // Unset vars expand to empty; loud path errors surface.
4235                        Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4236                        Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4237                            Err(anyhow::anyhow!(msg))
4238                        }
4239                    }
4240                }
4241                StringPart::VarWithDefault { path, default } => {
4242                    let resolved = {
4243                        let scope = self.scope.read().await;
4244                        crate::interpreter::resolve_default(&scope, path)
4245                            .map_err(|msg| anyhow::anyhow!(msg))?
4246                    };
4247                    match resolved {
4248                        Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4249                        None => self.eval_string_parts_async(default).await,
4250                    }
4251                }
4252            StringPart::VarLength(path) => {
4253                let scope = self.scope.read().await;
4254                crate::interpreter::resolve_length(&scope, path)
4255                    .map(|n| n.to_string())
4256                    .map_err(|msg| anyhow::anyhow!(msg))
4257            }
4258            StringPart::Positional(n) => {
4259                let scope = self.scope.read().await;
4260                match scope.get_positional(*n) {
4261                    Some(s) => Ok(s.to_string()),
4262                    None => Ok(String::new()),
4263                }
4264            }
4265            StringPart::AllArgs => {
4266                let scope = self.scope.read().await;
4267                Ok(scope.all_args().join(" "))
4268            }
4269            StringPart::ArgCount => {
4270                let scope = self.scope.read().await;
4271                Ok(scope.arg_count().to_string())
4272            }
4273            StringPart::Arithmetic(expr) => {
4274                let scope = self.scope.read().await;
4275                match crate::arithmetic::eval_arithmetic(expr, &scope) {
4276                    Ok(value) => Ok(value.to_string()),
4277                    Err(_) => Ok(String::new()),
4278                }
4279            }
4280            StringPart::CommandSubst(stmts) => {
4281                // Snapshot scope+cwd — command substitution in strings must
4282                // not leak side effects (e.g., `"dir: $(cd /; pwd)"` must not change cwd).
4283                let saved_scope = { self.scope.read().await.clone() };
4284                let saved_cwd = {
4285                    let ec = self.exec_ctx.read().await;
4286                    (ec.cwd.clone(), ec.prev_cwd.clone())
4287                };
4288
4289                // Capture result without `?` — restore state unconditionally
4290                let run_result = self.execute_block_capturing(stmts).await;
4291
4292                // Restore scope and cwd regardless of success/failure
4293                {
4294                    let mut scope = self.scope.write().await;
4295                    *scope = saved_scope;
4296                    if let Ok(ref r) = run_result {
4297                        scope.set_last_result(r.clone());
4298                    }
4299                }
4300                {
4301                    let mut ec = self.exec_ctx.write().await;
4302                    ec.cwd = saved_cwd.0;
4303                    ec.prev_cwd = saved_cwd.1;
4304                }
4305
4306                // Now propagate the error
4307                let result = run_result?;
4308
4309                // Embedding binary into a string is a text context: fail loud
4310                // rather than splice in U+FFFD garbage.
4311                match result.try_text_out() {
4312                    // Text wins when present — unchanged behavior.
4313                    Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4314                    // `.out` is empty: a builtin/tool that set only structured
4315                    // `.data` must not silently evaporate to "" (SILENT DATA
4316                    // LOSS). Render it the same way a bare `"$x"`
4317                    // collection-valued variable renders — compact JSON for
4318                    // lists/records, plain form for scalars — by reusing
4319                    // `value_to_string` (the exact `StringPart::Var` helper
4320                    // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4321                    // identically. No trailing-newline trim here: that's a
4322                    // text-path artifact, not applicable to a freshly
4323                    // rendered JSON/scalar string.
4324                    Ok(_) => match &result.data {
4325                        Some(data) => Ok(value_to_string(data)),
4326                        None => Ok(String::new()),
4327                    },
4328                    Err(e) => anyhow::bail!(
4329                        "command substitution in a string produced binary data ({e}) — \
4330                         pipe through base64/xxd"
4331                    ),
4332                }
4333            }
4334            StringPart::LastExitCode => {
4335                let scope = self.scope.read().await;
4336                Ok(scope.last_result().code.to_string())
4337            }
4338            StringPart::CurrentPid => {
4339                let scope = self.scope.read().await;
4340                Ok(scope.pid().to_string())
4341            }
4342        }
4343        })
4344    }
4345
4346    /// Update the last result in scope.
4347    async fn update_last_result(&self, result: &ExecResult) {
4348        let mut scope = self.scope.write().await;
4349        scope.set_last_result(result.clone());
4350    }
4351
4352    /// Drain accumulated pipeline stderr into a result.
4353    ///
4354    /// Called after each sub-statement inside control structures (`if`, `for`,
4355    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4356    /// than batching until the entire structure finishes.
4357    async fn drain_stderr_into(&self, result: &mut ExecResult) {
4358        let drained = {
4359            let mut receiver = self.stderr_receiver.lock().await;
4360            receiver.drain_lossy()
4361        };
4362        if !drained.is_empty() {
4363            if !result.err.is_empty() && !result.err.ends_with('\n') {
4364                result.err.push('\n');
4365            }
4366            result.err.push_str(&drained);
4367        }
4368    }
4369
4370    /// Execute a user-defined function with local variable scoping.
4371    ///
4372    /// Functions push a new scope frame for local variables. Variables declared
4373    /// with `local` are scoped to the function; other assignments modify outer
4374    /// scopes (or create in root if new).
4375    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4376        // 1. Build function args from AST args (async to support command substitution)
4377        let tool_args = self.build_args_async(args, None).await?;
4378
4379        // 2. Push a new scope frame for local variables
4380        {
4381            let mut scope = self.scope.write().await;
4382            scope.push_frame();
4383        }
4384
4385        // 3. Save current positional parameters and set new ones for this function
4386        let saved_positional = {
4387            let mut scope = self.scope.write().await;
4388            let saved = scope.save_positional();
4389
4390            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4391            let positional_args: Vec<String> = tool_args.positional
4392                .iter()
4393                .map(value_to_string)
4394                .collect();
4395            scope.set_positional(&def.name, positional_args);
4396
4397            saved
4398        };
4399
4400        // 3. Execute body statements with control flow handling
4401        // Accumulate output across statements (like sh)
4402        // Accumulate stdout as raw bytes so a binary-producing statement in a
4403        // function body survives instead of being lossy-decoded here.
4404        let mut accumulated_out: Vec<u8> = Vec::new();
4405        let mut accumulated_err = String::new();
4406        let mut last_code = 0i64;
4407        let mut last_data: Option<Value> = None;
4408
4409        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4410            match r.out_bytes() {
4411                Some(b) => buf.extend_from_slice(b),
4412                None => buf.extend_from_slice(r.text_out().as_bytes()),
4413            }
4414        }
4415
4416        // Track execution error for propagation after cleanup
4417        let mut exec_error: Option<anyhow::Error> = None;
4418        let mut exit_code: Option<i64> = None;
4419
4420        for stmt in &def.body {
4421            match self.execute_stmt_flow(stmt).await {
4422                Ok(flow) => {
4423                    // Drain pipeline stderr after each sub-statement.
4424                    let drained = {
4425                        let mut receiver = self.stderr_receiver.lock().await;
4426                        receiver.drain_lossy()
4427                    };
4428                    if !drained.is_empty() {
4429                        accumulated_err.push_str(&drained);
4430                    }
4431
4432                    match flow {
4433                        ControlFlow::Normal(r) => {
4434                            push_out(&mut accumulated_out, &r);
4435                            accumulated_err.push_str(&r.err);
4436                            last_code = r.code;
4437                            last_data = r.data;
4438                        }
4439                        ControlFlow::Return { value } => {
4440                            push_out(&mut accumulated_out, &value);
4441                            accumulated_err.push_str(&value.err);
4442                            last_code = value.code;
4443                            last_data = value.data;
4444                            break;
4445                        }
4446                        ControlFlow::Exit { code } => {
4447                            exit_code = Some(code);
4448                            break;
4449                        }
4450                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4451                            push_out(&mut accumulated_out, &r);
4452                            accumulated_err.push_str(&r.err);
4453                            last_code = r.code;
4454                            last_data = r.data;
4455                        }
4456                    }
4457                }
4458                Err(e) => {
4459                    exec_error = Some(e);
4460                    break;
4461                }
4462            }
4463        }
4464
4465        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4466        {
4467            let mut scope = self.scope.write().await;
4468            scope.pop_frame();
4469            scope.set_positional(saved_positional.0, saved_positional.1);
4470        }
4471
4472        // 5. Propagate error or exit after cleanup
4473        if let Some(e) = exec_error {
4474            return Err(e);
4475        }
4476        let code = exit_code.unwrap_or(last_code);
4477        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4478        result.err = accumulated_err;
4479        result.data = last_data;
4480        Ok(result)
4481    }
4482
4483    /// Execute a command-substitution body — a block of statements — and return
4484    /// the combined result. Stdout/stderr accumulate across statements with **no
4485    /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4486    /// and the last statement's exit code and structured `.data` ride through,
4487    /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4488    /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4489    /// cwd) is the caller's responsibility.
4490    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4491        // Accumulate stdout as raw bytes so a binary-producing statement
4492        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4493        // caller can preserve it. The final result is text iff valid UTF-8.
4494        let mut accumulated_out: Vec<u8> = Vec::new();
4495        let mut accumulated_err = String::new();
4496        let mut last_code = 0i64;
4497        let mut last_data: Option<Value> = None;
4498
4499        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4500        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4501            match r.out_bytes() {
4502                Some(b) => buf.extend_from_slice(b),
4503                None => buf.extend_from_slice(r.text_out().as_bytes()),
4504            }
4505        }
4506
4507        for stmt in stmts {
4508            let flow = self.execute_stmt_flow(stmt).await?;
4509
4510            // Drain pipeline stderr after each sub-statement (incremental, like
4511            // the control-structure and function-body executors).
4512            let drained = {
4513                let mut receiver = self.stderr_receiver.lock().await;
4514                receiver.drain_lossy()
4515            };
4516            if !drained.is_empty() {
4517                accumulated_err.push_str(&drained);
4518            }
4519
4520            match flow {
4521                ControlFlow::Normal(r)
4522                | ControlFlow::Break { result: r, .. }
4523                | ControlFlow::Continue { result: r, .. } => {
4524                    push_out(&mut accumulated_out, &r);
4525                    accumulated_err.push_str(&r.err);
4526                    last_code = r.code;
4527                    last_data = r.data;
4528                }
4529                ControlFlow::Return { value } => {
4530                    push_out(&mut accumulated_out, &value);
4531                    accumulated_err.push_str(&value.err);
4532                    last_code = value.code;
4533                    last_data = value.data;
4534                    break;
4535                }
4536                ControlFlow::Exit { code } => {
4537                    last_code = code;
4538                    break;
4539                }
4540            }
4541        }
4542
4543        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4544        result.err = accumulated_err;
4545        result.data = last_data;
4546        Ok(result)
4547    }
4548
4549    /// Execute the `source` / `.` command to include and run a script.
4550    ///
4551    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4552    /// allowing the sourced script to set variables and modify shell state.
4553    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4554        // Get the file path from the first positional argument
4555        let tool_args = self.build_args_async(args, None).await?;
4556        let path = match tool_args.positional.first() {
4557            Some(Value::String(s)) => s.clone(),
4558            Some(v) => value_to_string(v),
4559            None => {
4560                return Ok(ExecResult::failure(1, "source: missing filename"));
4561            }
4562        };
4563
4564        // Resolve path relative to cwd
4565        let full_path = {
4566            let ctx = self.exec_ctx.read().await;
4567            if path.starts_with('/') {
4568                std::path::PathBuf::from(&path)
4569            } else {
4570                ctx.cwd.join(&path)
4571            }
4572        };
4573
4574        // Read file content via backend
4575        let content = {
4576            let ctx = self.exec_ctx.read().await;
4577            match ctx.backend.read(&full_path, None).await {
4578                Ok(bytes) => {
4579                    String::from_utf8(bytes).map_err(|e| {
4580                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4581                    })?
4582                }
4583                Err(e) => {
4584                    return Ok(ExecResult::failure(
4585                        1,
4586                        format!("source: {}: {}", path, e),
4587                    ));
4588                }
4589            }
4590        };
4591
4592        // Parse the content
4593        let program = match crate::parser::parse(&content) {
4594            Ok(p) => p,
4595            Err(errors) => {
4596                let msg = errors
4597                    .iter()
4598                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4599                    .collect::<Vec<_>>()
4600                    .join("\n");
4601                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4602            }
4603        };
4604
4605        // Execute each statement in the CURRENT scope (not isolated), accumulating
4606        // stdout/stderr across statements like `execute_user_tool` — a sourced
4607        // script's earlier statements must not be silently dropped in favor of
4608        // just the last one.
4609        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4610            match r.out_bytes() {
4611                Some(b) => buf.extend_from_slice(b),
4612                None => buf.extend_from_slice(r.text_out().as_bytes()),
4613            }
4614        }
4615
4616        let mut accumulated_out: Vec<u8> = Vec::new();
4617        let mut accumulated_err = String::new();
4618        let mut last_code = 0i64;
4619        let mut last_data: Option<Value> = None;
4620
4621        for stmt in program.statements {
4622            if matches!(stmt, crate::ast::Stmt::Empty) {
4623                continue;
4624            }
4625
4626            match self.execute_stmt_flow(&stmt).await {
4627                Ok(flow) => {
4628                    let drained = {
4629                        let mut receiver = self.stderr_receiver.lock().await;
4630                        receiver.drain_lossy()
4631                    };
4632                    if !drained.is_empty() {
4633                        accumulated_err.push_str(&drained);
4634                    }
4635                    match flow {
4636                        ControlFlow::Normal(r) => {
4637                            push_out(&mut accumulated_out, &r);
4638                            accumulated_err.push_str(&r.err);
4639                            last_code = r.code;
4640                            last_data = r.data.clone();
4641                            self.update_last_result(&r).await;
4642                        }
4643                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4644                            return Err(anyhow::anyhow!(
4645                                "source: {}: unexpected break/continue outside loop",
4646                                path
4647                            ));
4648                        }
4649                        ControlFlow::Return { value } => {
4650                            push_out(&mut accumulated_out, &value);
4651                            accumulated_err.push_str(&value.err);
4652                            let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4653                                .with_code(value.code);
4654                            result.err = accumulated_err;
4655                            result.data = value.data;
4656                            return Ok(result);
4657                        }
4658                        ControlFlow::Exit { code } => {
4659                            let mut result =
4660                                ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4661                            result.err = accumulated_err;
4662                            result.data = last_data;
4663                            return Ok(result);
4664                        }
4665                    }
4666                }
4667                Err(e) => {
4668                    return Err(e.context(format!("source: {}", path)));
4669                }
4670            }
4671        }
4672
4673        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4674        result.err = accumulated_err;
4675        result.data = last_data;
4676        Ok(result)
4677    }
4678
4679    /// Try to execute a script from PATH directories.
4680    ///
4681    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4682    /// (like user-defined tools). Returns None if no script is found.
4683    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4684        // Get PATH from scope (default to "/bin")
4685        let path_value = {
4686            let scope = self.scope.read().await;
4687            scope
4688                .get("PATH")
4689                .map(value_to_string)
4690                .unwrap_or_else(|| "/bin".to_string())
4691        };
4692
4693        // Search PATH directories for script
4694        for dir in path_value.split(':') {
4695            if dir.is_empty() {
4696                continue;
4697            }
4698
4699            // Build script path: {dir}/{name}.kai
4700            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4701
4702            // Check if script exists
4703            let exists = {
4704                let ctx = self.exec_ctx.read().await;
4705                ctx.backend.exists(&script_path).await
4706            };
4707
4708            if !exists {
4709                continue;
4710            }
4711
4712            // Read script content
4713            let content = {
4714                let ctx = self.exec_ctx.read().await;
4715                match ctx.backend.read(&script_path, None).await {
4716                    Ok(bytes) => match String::from_utf8(bytes) {
4717                        Ok(s) => s,
4718                        Err(e) => {
4719                            return Ok(Some(ExecResult::failure(
4720                                1,
4721                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4722                            )));
4723                        }
4724                    },
4725                    Err(e) => {
4726                        return Ok(Some(ExecResult::failure(
4727                            1,
4728                            format!("{}: {}", script_path.display(), e),
4729                        )));
4730                    }
4731                }
4732            };
4733
4734            // Parse the script
4735            let program = match crate::parser::parse(&content) {
4736                Ok(p) => p,
4737                Err(errors) => {
4738                    let msg = errors
4739                        .iter()
4740                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4741                        .collect::<Vec<_>>()
4742                        .join("\n");
4743                    return Ok(Some(ExecResult::failure(1, msg)));
4744                }
4745            };
4746
4747            // Build tool_args from args (async for command substitution support)
4748            let tool_args = self.build_args_async(args, None).await?;
4749
4750            // Create isolated scope (like user tools)
4751            let mut isolated_scope = Scope::new();
4752
4753            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4754            let positional_args: Vec<String> = tool_args.positional
4755                .iter()
4756                .map(value_to_string)
4757                .collect();
4758            isolated_scope.set_positional(name, positional_args);
4759
4760            // Save current scope and swap with isolated scope
4761            let original_scope = {
4762                let mut scope = self.scope.write().await;
4763                std::mem::replace(&mut *scope, isolated_scope)
4764            };
4765
4766            // Execute script statements — accumulate stdout/stderr across
4767            // statements like `execute_user_tool`, rather than keeping only the
4768            // last one's result.
4769            fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4770                match r.out_bytes() {
4771                    Some(b) => buf.extend_from_slice(b),
4772                    None => buf.extend_from_slice(r.text_out().as_bytes()),
4773                }
4774            }
4775
4776            let mut accumulated_out: Vec<u8> = Vec::new();
4777            let mut accumulated_err = String::new();
4778            let mut last_code = 0i64;
4779            let mut last_data: Option<Value> = None;
4780            let mut exec_error: Option<anyhow::Error> = None;
4781            let mut exit_code: Option<i64> = None;
4782
4783            for stmt in program.statements {
4784                if matches!(stmt, crate::ast::Stmt::Empty) {
4785                    continue;
4786                }
4787
4788                match self.execute_stmt_flow(&stmt).await {
4789                    Ok(flow) => {
4790                        let drained = {
4791                            let mut receiver = self.stderr_receiver.lock().await;
4792                            receiver.drain_lossy()
4793                        };
4794                        if !drained.is_empty() {
4795                            accumulated_err.push_str(&drained);
4796                        }
4797                        match flow {
4798                            ControlFlow::Normal(r) => {
4799                                push_out(&mut accumulated_out, &r);
4800                                accumulated_err.push_str(&r.err);
4801                                last_code = r.code;
4802                                last_data = r.data;
4803                            }
4804                            ControlFlow::Return { value } => {
4805                                push_out(&mut accumulated_out, &value);
4806                                accumulated_err.push_str(&value.err);
4807                                last_code = value.code;
4808                                last_data = value.data;
4809                                break;
4810                            }
4811                            ControlFlow::Exit { code } => {
4812                                exit_code = Some(code);
4813                                break;
4814                            }
4815                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4816                                push_out(&mut accumulated_out, &r);
4817                                accumulated_err.push_str(&r.err);
4818                                last_code = r.code;
4819                                last_data = r.data;
4820                            }
4821                        }
4822                    }
4823                    Err(e) => {
4824                        exec_error = Some(e);
4825                        break;
4826                    }
4827                }
4828            }
4829
4830            // Restore original scope unconditionally
4831            {
4832                let mut scope = self.scope.write().await;
4833                *scope = original_scope;
4834            }
4835
4836            // Propagate error or exit after cleanup
4837            if let Some(e) = exec_error {
4838                return Err(e.context(format!("script: {}", script_path.display())));
4839            }
4840            let code = exit_code.unwrap_or(last_code);
4841            let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4842            result.err = accumulated_err;
4843            result.data = last_data;
4844            return Ok(Some(result));
4845        }
4846
4847        // No script found
4848        Ok(None)
4849    }
4850
4851    /// Try to execute an external command from PATH.
4852    ///
4853    /// This is the fallback when no builtin or user-defined tool matches.
4854    /// External commands receive a clean argv (flags preserved in their original format).
4855    ///
4856    /// # Requirements
4857    /// - Command must be found in PATH
4858    /// - Current working directory must be on a real filesystem (not virtual like /v)
4859    ///
4860    /// # Returns
4861    /// - `Ok(Some(result))` if command was found and executed
4862    /// - `Ok(None)` if command was not found in PATH
4863    /// - `Err` on execution errors
4864    #[cfg(not(feature = "subprocess"))]
4865    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4866        Ok(None)
4867    }
4868
4869    /// Try to execute an external command from PATH.
4870    #[cfg(feature = "subprocess")]
4871    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4872    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4873        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4874        // populates from the inbound ctx.cancel on every dispatch. This is
4875        // what makes the `timeout` builtin's swapped child token reach the
4876        // wait_or_kill discipline below — reading `self.cancel_token` would
4877        // give the kernel-wide token and miss the timeout's child cascade.
4878        let cancel = {
4879            let ec = self.exec_ctx.read().await;
4880            ec.cancel.clone()
4881        };
4882        let kill_grace = self.kill_grace;
4883        if !self.allow_external_commands {
4884            return Ok(None);
4885        }
4886
4887        // Get real working directory for relative path resolution and child cwd.
4888        // If the CWD is virtual (no real filesystem path), skip external command
4889        // execution entirely — return None so the dispatch can fall through to
4890        // backend-registered tools.
4891        let real_cwd = {
4892            let ctx = self.exec_ctx.read().await;
4893            match ctx.backend.resolve_real_path(&ctx.cwd) {
4894                Some(p) => p,
4895                None => return Ok(None),
4896            }
4897        };
4898
4899        let executable = if name.contains('/') {
4900            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4901            let resolved = if std::path::Path::new(name).is_absolute() {
4902                std::path::PathBuf::from(name)
4903            } else {
4904                real_cwd.join(name)
4905            };
4906            if !resolved.exists() {
4907                return Ok(Some(ExecResult::failure(
4908                    127,
4909                    format!("{}: No such file or directory", name),
4910                )));
4911            }
4912            if !resolved.is_file() {
4913                return Ok(Some(ExecResult::failure(
4914                    126,
4915                    format!("{}: Is a directory", name),
4916                )));
4917            }
4918            #[cfg(unix)]
4919            {
4920                use std::os::unix::fs::PermissionsExt;
4921                let mode = std::fs::metadata(&resolved)
4922                    .map(|m| m.permissions().mode())
4923                    .unwrap_or(0);
4924                if mode & 0o111 == 0 {
4925                    return Ok(Some(ExecResult::failure(
4926                        126,
4927                        format!("{}: Permission denied", name),
4928                    )));
4929                }
4930            }
4931            resolved.to_string_lossy().into_owned()
4932        } else {
4933            // Get PATH from scope only. The kernel never reads OS env: a
4934            // frontend that wants host PATH seeds it via initial_vars (the REPL
4935            // does, with os_env_vars()). No PATH in scope → nothing resolves.
4936            let path_var = {
4937                let scope = self.scope.read().await;
4938                scope.get("PATH").map(value_to_string).unwrap_or_default()
4939            };
4940
4941            // Resolve command in PATH
4942            match resolve_in_path(name, &path_var) {
4943                Some(path) => path,
4944                None => return Ok(None), // Not found - let caller handle error
4945            }
4946        };
4947
4948        tracing::debug!(executable = %executable, "resolved external command");
4949
4950        // Build flat argv (preserves flag format)
4951        let argv = self.build_args_flat(args).await?;
4952
4953        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4954        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4955        // `String`. Take both out under the lock but do NOT drain here — a pipe
4956        // read can block on its producer (a still-running upstream stage), so
4957        // draining before spawn would serialize the pipeline (deadlocking
4958        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4959        // `set_stdin` clears `pipe_stdin`, so a redirect-set String and a pipe
4960        // are mutually exclusive in practice; prefer the pipe.
4961        let (pipe_stdin, stdin_string) = {
4962            let mut ctx = self.exec_ctx.write().await;
4963            (ctx.pipe_stdin.take(), ctx.take_stdin())
4964        };
4965        let has_stdin = pipe_stdin.is_some() || stdin_string.is_some();
4966
4967        // Build and spawn the command
4968        use tokio::process::Command;
4969
4970        let mut cmd = Command::new(&executable);
4971        cmd.args(&argv);
4972        cmd.current_dir(&real_cwd);
4973
4974        // Hermetic env: child sees only kaish's exported vars, not the kaish
4975        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4976        // populate it via KernelConfig::initial_vars at construction.
4977        cmd.env_clear();
4978        {
4979            let scope = self.scope.read().await;
4980            let exported = scope.exported_vars();
4981            // A structured value can't cross the process boundary; refuse rather
4982            // than silently JSON-serialize it into the child's environment.
4983            if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4984                return Err(anyhow::anyhow!(msg));
4985            }
4986            for (var_name, value) in exported {
4987                cmd.env(var_name, value_to_string(&value));
4988            }
4989        }
4990
4991        // Handle stdin
4992        cmd.stdin(if has_stdin {
4993            std::process::Stdio::piped()
4994        } else if self.interactive {
4995            std::process::Stdio::inherit()
4996        } else {
4997            std::process::Stdio::null()
4998        });
4999
5000        // In interactive mode, standalone or last-in-pipeline commands inherit
5001        // the terminal's stdout/stderr so output streams in real-time.
5002        // First/middle commands must capture stdout for the pipe — same as bash.
5003        let pipeline_position = {
5004            let ctx = self.exec_ctx.read().await;
5005            ctx.pipeline_position
5006        };
5007        let inherit_output = self.interactive
5008            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5009
5010        if inherit_output {
5011            cmd.stdout(std::process::Stdio::inherit());
5012            cmd.stderr(std::process::Stdio::inherit());
5013        } else {
5014            cmd.stdout(std::process::Stdio::piped());
5015            cmd.stderr(std::process::Stdio::piped());
5016        }
5017
5018        // On Unix, always put the child in its own process group so cancellation
5019        // can `killpg` the whole tree (the child plus any grandchildren).
5020        // Restoring default tty-related signal handlers stays gated on
5021        // job-control mode — those only matter when the child has a controlling
5022        // terminal.
5023        #[cfg(unix)]
5024        {
5025            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5026            // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
5027            #[allow(unsafe_code)]
5028            unsafe {
5029                cmd.pre_exec(move || {
5030                    // Own process group — for kill scope.
5031                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5032                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5033                    if restore_jc_signals {
5034                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5035                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
5036                        sa.sa_sigaction = SIG_DFL;
5037                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5038                            return Err(std::io::Error::last_os_error());
5039                        }
5040                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5041                            return Err(std::io::Error::last_os_error());
5042                        }
5043                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5044                            return Err(std::io::Error::last_os_error());
5045                        }
5046                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5047                            return Err(std::io::Error::last_os_error());
5048                        }
5049                    }
5050                    Ok(())
5051                });
5052            }
5053        }
5054
5055        // Backstop for kill on drop in case our explicit kill path is bypassed
5056        // (panic, early return, etc) on the **capture** wait path. We do NOT
5057        // set this on the JC inherit path: that uses sync `waitpid` outside
5058        // tokio's view of the child, so on drop tokio would try to kill an
5059        // already-reaped (possibly-reused) PID. The JC path has its own
5060        // cancel handling via the side-task watcher.
5061        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5062        if !in_jc_inherit_path {
5063            cmd.kill_on_drop(true);
5064        }
5065
5066        // Spawn the process. Capture a `KillTarget` immediately so cancel/
5067        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5068        // to this process's generation, immune to PID reuse if the OS reaps
5069        // the child before our kill syscalls fire.
5070        let mut child = match cmd.spawn() {
5071            Ok(child) => child,
5072            Err(e) => {
5073                return Ok(Some(ExecResult::failure(
5074                    127,
5075                    format!("{}: {}", name, e),
5076                )));
5077            }
5078        };
5079        let kill_target = crate::pidfd::KillTarget::from_child(&child);
5080
5081        // If this external runs on behalf of a background job, record its
5082        // process group on the job so `kill -<sig> %N` can signal the real
5083        // process directly (STOP/CONT/USR1/…, not just terminate). The child
5084        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5085        if let Some(job_id) = self.bg_job_id
5086            && let Some(pid) = child.id()
5087        {
5088            self.jobs.add_pgid(job_id, pid).await;
5089        }
5090
5091        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5092        // detached task (bounded memory, no pre-drain) so an upstream stage and
5093        // this child run concurrently — and a child that never reads stdin (or
5094        // is killed) just breaks the copy, which stops. A buffered `String` is
5095        // written inline and stdin dropped to signal EOF. Bytes are copied
5096        // verbatim, so binary stdin survives.
5097        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5098            child.stdin.take().map(|mut child_stdin| {
5099                tokio::spawn(async move {
5100                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5101                    let mut buf = [0u8; 8192];
5102                    loop {
5103                        match pipe_in.read(&mut buf).await {
5104                            Ok(0) => break, // EOF
5105                            Ok(n) => {
5106                                if child_stdin.write_all(&buf[..n]).await.is_err() {
5107                                    break; // child closed stdin
5108                                }
5109                            }
5110                            Err(_) => break,
5111                        }
5112                    }
5113                    // Dropping child_stdin signals EOF to the child.
5114                })
5115            })
5116        } else if let Some(data) = stdin_string {
5117            // Write the buffered String from a detached task too — NOT inline.
5118            // An inline write blocks once the stdin pipe fills, and the output
5119            // drain hasn't spawned yet, so a child that emits a lot before
5120            // consuming all its input (every pipe buffer full) deadlocks. A
5121            // write error here is normal, not a failure: a child that closes
5122            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5123            // signals EOF.
5124            child.stdin.take().map(|mut child_stdin| {
5125                tokio::spawn(async move {
5126                    use tokio::io::AsyncWriteExt;
5127                    let _ = child_stdin.write_all(data.as_bytes()).await;
5128                })
5129            })
5130        } else {
5131            None
5132        };
5133
5134        // Abort the stdin-copy task on EVERY exit path (the capture path, both
5135        // interactive `inherit_output` returns, and any early error return).
5136        // Once the child is reaped the copy has nothing left to deliver; if it
5137        // were left parked on `pipe_in.read()` it would leak and hold the
5138        // upstream pipe reader open. A drop guard is the single place that
5139        // covers all returns — explicit per-return aborts were error-prone (an
5140        // earlier version missed the two inherit_output returns).
5141        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5142        impl Drop for AbortStdinCopyOnDrop {
5143            fn drop(&mut self) {
5144                if let Some(t) = self.0.take() {
5145                    t.abort();
5146                }
5147            }
5148        }
5149        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5150
5151        if inherit_output {
5152            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5153            #[cfg(unix)]
5154            if let Some(ref term) = self.terminal_state {
5155                let child_id = child.id().unwrap_or(0);
5156                let pid = nix::unistd::Pid::from_raw(child_id as i32);
5157                let pgid = pid; // child is its own pgid leader
5158
5159                // Give the terminal to the child's process group
5160                if let Err(e) = term.give_terminal_to(pgid) {
5161                    tracing::warn!("failed to give terminal to child: {}", e);
5162                }
5163
5164                let term_clone = term.clone();
5165                let cmd_name = name.to_string();
5166                let cmd_display = format!("{} {}", name, argv.join(" "));
5167                let jobs = self.jobs.clone();
5168
5169                // Side task that watches for cancellation while the blocking
5170                // waitpid runs. On cancel, it SIGTERMs the process group, waits
5171                // the grace period, then SIGKILLs. The blocking waitpid returns
5172                // when the child dies. AbortOnDrop guard cancels the watcher
5173                // on the success path so it doesn't keep running after wait
5174                // returns naturally.
5175                //
5176                // `wait_complete` shrinks the PID-reuse race: the watcher
5177                // checks it before each kill syscall and bails out if
5178                // wait_for_foreground has already reaped the child. This
5179                // doesn't fully eliminate the race (atomic load + kill is
5180                // not atomic with the OS reap+reuse), but narrows the window
5181                // to nanoseconds — enough to be ignorable in practice.
5182                let wait_complete = std::sync::Arc::new(
5183                    std::sync::atomic::AtomicBool::new(false)
5184                );
5185                let cancel_watcher = {
5186                    let cancel = cancel.clone();
5187                    let wc = wait_complete.clone();
5188                    // Ownership transfer: the JC path's sync wait inside
5189                    // block_in_place owns the child's reaping, so the
5190                    // cancel_watcher drives the kill side via KillTarget
5191                    // (pidfd-bound on Linux). When kill_target is None
5192                    // (older kernel + open failure, or non-Linux), falls
5193                    // through to the older PID-based path the closure
5194                    // captures from `pid`.
5195                    let target = kill_target.as_ref().map(|t| {
5196                        // Re-borrow the components we need into Owned-ish form
5197                        // so the spawned task is 'static. We can't move
5198                        // KillTarget directly because try_execute_external
5199                        // still uses it after the spawn — but on the JC path
5200                        // there is no further use after the watcher spawn,
5201                        // so a clone-of-pid + owned None pidfd is safe.
5202                        // Simpler: signal via the existing target by cloning
5203                        // a fresh pidfd; the original keeps its handle.
5204                        // Pidfd is just an OwnedFd — not Clone — so do it
5205                        // by re-opening from the pid. Fall back if reopen
5206                        // fails (race already reaped → best-effort kill).
5207                        crate::pidfd::KillTarget::from_pid(t.pid())
5208                    });
5209                    tokio::spawn(async move {
5210                        cancel.cancelled().await;
5211                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5212                        use nix::sys::signal::Signal;
5213                        if let Some(t) = &target {
5214                            t.signal(Signal::SIGTERM);
5215                            t.signal_pg(Signal::SIGTERM);
5216                        } else {
5217                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5218                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5219                        }
5220                        if kill_grace > Duration::ZERO {
5221                            tokio::time::sleep(kill_grace).await;
5222                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5223                        }
5224                        if let Some(t) = &target {
5225                            t.signal(Signal::SIGKILL);
5226                            t.signal_pg(Signal::SIGKILL);
5227                        } else {
5228                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5229                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5230                        }
5231                    })
5232                };
5233                struct AbortOnDrop(tokio::task::JoinHandle<()>);
5234                impl Drop for AbortOnDrop {
5235                    fn drop(&mut self) {
5236                        self.0.abort();
5237                    }
5238                }
5239                let _watcher_guard = AbortOnDrop(cancel_watcher);
5240
5241                let wait_complete_setter = wait_complete.clone();
5242                let code = tokio::task::block_in_place(move || {
5243                    let result = term_clone.wait_for_foreground(pid);
5244                    // Mark wait done before the watcher might fire.
5245                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5246
5247                    // Always reclaim the terminal
5248                    if let Err(e) = term_clone.reclaim_terminal() {
5249                        tracing::warn!("failed to reclaim terminal: {}", e);
5250                    }
5251
5252                    match result {
5253                        crate::terminal::WaitResult::Exited(code) => code as i64,
5254                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5255                        crate::terminal::WaitResult::Stopped(_sig) => {
5256                            // Register as a stopped job
5257                            let rt = tokio::runtime::Handle::current();
5258                            let job_id = rt.block_on(jobs.register_stopped(
5259                                cmd_display,
5260                                child_id,
5261                                child_id, // pgid = pid for group leader
5262                            ));
5263                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5264                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5265                        }
5266                    }
5267                });
5268
5269                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5270            }
5271
5272            // Non-job-control path with inherited stdio.
5273            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5274                Ok(s) => s,
5275                Err(e) => {
5276                    return Ok(Some(ExecResult::failure(
5277                        1,
5278                        format!("{}: failed to wait: {}", name, e),
5279                    )));
5280                }
5281            };
5282
5283            let code = status.code().unwrap_or_else(|| {
5284                #[cfg(unix)]
5285                {
5286                    use std::os::unix::process::ExitStatusExt;
5287                    128 + status.signal().unwrap_or(0)
5288                }
5289                #[cfg(not(unix))]
5290                {
5291                    -1
5292                }
5293            }) as i64;
5294
5295            // stdout/stderr already went to the terminal
5296            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5297        } else {
5298            // Capture output via bounded streams
5299            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5300            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5301
5302            let stdout_pipe = child.stdout.take();
5303            let stderr_pipe = child.stderr.take();
5304
5305            let stdout_clone = stdout_stream.clone();
5306            let stderr_clone = stderr_stream.clone();
5307
5308            let stdout_task = stdout_pipe.map(|pipe| {
5309                tokio::spawn(async move {
5310                    drain_to_stream(pipe, stdout_clone).await;
5311                })
5312            });
5313
5314            let stderr_task = stderr_pipe.map(|pipe| {
5315                tokio::spawn(async move {
5316                    drain_to_stream(pipe, stderr_clone).await;
5317                })
5318            });
5319
5320            let cancelled_before_wait = cancel.is_cancelled();
5321            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5322                Ok(s) => s,
5323                Err(e) => {
5324                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5325                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5326                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5327                    return Ok(Some(ExecResult::failure(
5328                        1,
5329                        format!("{}: failed to wait: {}", name, e),
5330                    )));
5331                }
5332            };
5333
5334            // On cancel, abort the drain tasks (the child's pipes are gone;
5335            // late output is lost but predictable death beats partial capture).
5336            // On normal exit, await drains so we don't lose buffered output.
5337            if cancelled_before_wait || cancel.is_cancelled() {
5338                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5339                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5340            } else {
5341                if let Some(task) = stdout_task {
5342                    // Ignore join error — the drain task logs its own errors
5343                    let _ = task.await;
5344                }
5345                if let Some(task) = stderr_task {
5346                    let _ = task.await;
5347                }
5348            }
5349
5350            let code = status.code().unwrap_or_else(|| {
5351                #[cfg(unix)]
5352                {
5353                    use std::os::unix::process::ExitStatusExt;
5354                    128 + status.signal().unwrap_or(0)
5355                }
5356                #[cfg(not(unix))]
5357                {
5358                    -1
5359                }
5360            }) as i64;
5361
5362            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5363            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5364            // intact. stderr stays text. See docs/binary-data.md.
5365            let stdout = stdout_stream.read().await;
5366            let stderr = stderr_stream.read_string().await;
5367            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5368            result.err = stderr;
5369            Ok(Some(result))
5370        }
5371    }
5372
5373    // --- Variable Access ---
5374
5375    /// Get a variable value.
5376    pub async fn get_var(&self, name: &str) -> Option<Value> {
5377        let scope = self.scope.read().await;
5378        scope.get(name).cloned()
5379    }
5380
5381    /// Check if error-exit mode is enabled (for testing).
5382    #[cfg(test)]
5383    pub async fn error_exit_enabled(&self) -> bool {
5384        let scope = self.scope.read().await;
5385        scope.error_exit_enabled()
5386    }
5387
5388    /// Set a variable value.
5389    pub async fn set_var(&self, name: &str, value: Value) {
5390        let mut scope = self.scope.write().await;
5391        scope.set(name.to_string(), value);
5392    }
5393
5394    /// Set positional parameters ($0 script name and $1-$9 args).
5395    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5396        let mut scope = self.scope.write().await;
5397        scope.set_positional(script_name, args);
5398    }
5399
5400    /// List all variables.
5401    pub async fn list_vars(&self) -> Vec<(String, Value)> {
5402        let scope = self.scope.read().await;
5403        scope.all()
5404    }
5405
5406    /// List exported variables (name, value), sorted by name. These are the
5407    /// vars a child process would see (see `dispatch`'s hermetic env build).
5408    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5409        let scope = self.scope.read().await;
5410        scope.exported_vars()
5411    }
5412
5413    // --- CWD ---
5414
5415    /// Get current working directory.
5416    pub async fn cwd(&self) -> PathBuf {
5417        self.exec_ctx.read().await.cwd.clone()
5418    }
5419
5420    /// Set current working directory.
5421    pub async fn set_cwd(&self, path: PathBuf) {
5422        let mut ctx = self.exec_ctx.write().await;
5423        ctx.set_cwd(path);
5424    }
5425
5426    /// Set the working directory only if `path` resolves to a directory in the
5427    /// kernel's backend — the same namespace `cd` validates against. Unlike a
5428    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5429    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5430    /// disappeared. Returns whether the cwd was changed.
5431    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5432        // Clone the backend Arc out before the stat so we never hold the
5433        // exec_ctx lock across the await.
5434        let backend = self.exec_ctx.read().await.backend.clone();
5435        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5436        if is_dir {
5437            self.exec_ctx.write().await.set_cwd(path);
5438        }
5439        is_dir
5440    }
5441
5442    // --- Last Result ---
5443
5444    /// Get the last result ($?).
5445    pub async fn last_result(&self) -> ExecResult {
5446        let scope = self.scope.read().await;
5447        scope.last_result().clone()
5448    }
5449
5450    // --- Tools ---
5451
5452    /// Check if a user-defined function exists.
5453    pub async fn has_function(&self, name: &str) -> bool {
5454        self.user_tools.read().await.contains_key(name)
5455    }
5456
5457    /// Get available tool schemas.
5458    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5459        self.tools.schemas()
5460    }
5461
5462    /// Classify how the kernel will resolve a command name.
5463    ///
5464    /// This is the supported, single source of truth for command resolution that
5465    /// embedders should call instead of re-deriving the rules. Walk a parsed
5466    /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5467    /// this per command name to bucket each into builtin / user-function /
5468    /// special-form / dynamic / external — for example a consent gate that blocks
5469    /// a script until external commands are approved.
5470    ///
5471    /// The classification mirrors the interpreter's real resolution order
5472    /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5473    /// short-circuit first, then **aliases are expanded** (bounded recursion,
5474    /// re-checking special-forms each step, exactly as execution does), then user
5475    /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5476    /// name that is a variable or command-substitution expansion (`$cmd`,
5477    /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5478    /// be resolved statically.
5479    ///
5480    /// Aliases are resolved against the kernel's current alias table, so an
5481    /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5482    /// thing it would actually run. The safe direction of any residual imprecision
5483    /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5484    /// `.kai`/backend-tool resolution are reported `External` even though some of
5485    /// those resolve in-process, so a consent gate over-gates rather than letting
5486    /// a `PATH` escape slip through.
5487    pub async fn classify_command(&self, name: &str) -> CommandKind {
5488        // Resolve the command head the way `execute_command_depth` does: a
5489        // special-form short-circuits before any alias lookup, otherwise expand
5490        // aliases (bounded, recursive) and re-check from the top. A dynamic name
5491        // can't be resolved at all.
5492        let mut name = name.to_string();
5493        let mut alias_depth = 0u8;
5494        loop {
5495            if !crate::validator::is_static_command_name(&name) {
5496                return CommandKind::Dynamic;
5497            }
5498            if crate::validator::is_runtime_special_form(&name) {
5499                return CommandKind::Special;
5500            }
5501            if alias_depth >= 10 {
5502                break;
5503            }
5504            let alias_value = {
5505                let ctx = self.exec_ctx.read().await;
5506                ctx.aliases.get(&name).cloned()
5507            };
5508            // Expand to the alias's head command. An empty alias value (no head)
5509            // is ignored by execution, so resolution continues with this name.
5510            match alias_value
5511                .as_deref()
5512                .and_then(|v| v.split_whitespace().next())
5513            {
5514                Some(head) => {
5515                    name = head.to_string();
5516                    alias_depth += 1;
5517                }
5518                None => break,
5519            }
5520        }
5521
5522        let is_user_tool = self.user_tools.read().await.contains_key(&name);
5523        let is_builtin = self.tools.contains(&name);
5524        crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5525    }
5526
5527    // --- Jobs ---
5528
5529    /// Get job manager.
5530    pub fn jobs(&self) -> Arc<JobManager> {
5531        self.jobs.clone()
5532    }
5533
5534    // --- VFS ---
5535
5536    /// Get VFS router.
5537    pub fn vfs(&self) -> Arc<VfsRouter> {
5538        self.vfs.clone()
5539    }
5540
5541    // --- State ---
5542
5543    /// Reset kernel to initial state.
5544    ///
5545    /// Clears in-memory variables and resets cwd to root. History is not
5546    /// cleared (it persists across resets). The kernel's `$$` identity, the
5547    /// confirmation latch / trash-on-delete configuration, and any
5548    /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
5549    /// are re-applied to the fresh scope rather than silently reverting to
5550    /// defaults — an embedder that opted into `with_latch(true)` must not
5551    /// find the gate quietly disabled after a `reset()` between requests.
5552    pub async fn reset(&self) -> Result<()> {
5553        {
5554            let mut scope = self.scope.write().await;
5555            let pid = scope.pid();
5556            let latch_enabled = scope.latch_enabled();
5557            let trash_enabled = scope.trash_enabled();
5558            let mut fresh = Scope::new();
5559            fresh.set_pid(pid);
5560            for (name, value) in self.initial_vars.clone() {
5561                fresh.set_exported(name, value);
5562            }
5563            fresh.set_latch_enabled(latch_enabled);
5564            fresh.set_trash_enabled(trash_enabled);
5565            *scope = fresh;
5566        }
5567        {
5568            let mut ctx = self.exec_ctx.write().await;
5569            ctx.cwd = PathBuf::from("/");
5570        }
5571        Ok(())
5572    }
5573
5574    /// Shutdown the kernel.
5575    pub async fn shutdown(self) -> Result<()> {
5576        // Wait for all background jobs
5577        self.jobs.wait_all().await;
5578        Ok(())
5579    }
5580
5581    /// Dispatch a single command using the full resolution chain.
5582    ///
5583    /// This is the core of `CommandDispatcher` — it syncs state between the
5584    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5585    /// then delegates to `execute_command` for the actual dispatch.
5586    ///
5587    /// State flow:
5588    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5589    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5590    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5591    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5592        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5593        // its inner command via ctx.dispatcher) routes through THIS kernel,
5594        // not a stale parent. Critical for forks: the fork's builtins must
5595        // use the fork's dispatcher, not the parent's.
5596        if let Some(d) = self.dispatcher() {
5597            ctx.dispatcher = Some(d);
5598        }
5599
5600        // 1. Sync ctx → self internals
5601        {
5602            let mut scope = self.scope.write().await;
5603            *scope = ctx.scope.clone();
5604        }
5605        {
5606            let mut ec = self.exec_ctx.write().await;
5607            ec.cwd = ctx.cwd.clone();
5608            ec.prev_cwd = ctx.prev_cwd.clone();
5609            ec.stdin = ctx.stdin.take();
5610            ec.stdin_data = ctx.stdin_data.take();
5611            // The structured-data sideband receiver (set by the concurrent
5612            // pipeline runner on the stage ctx) must reach the tool's snapshot
5613            // too — same reason as the pipe endpoints below. Without this a
5614            // pipeline consumer never sees the producer's `.data`.
5615            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5616            // Streaming pipe endpoints and kernel stderr must flow to the
5617            // tool via self.exec_ctx — execute_command reads that, not the
5618            // passed-in ctx. Without moving these, concurrent pipeline
5619            // stages dispatched via a fork get pipe_stdin = None and
5620            // silently read nothing.
5621            ec.pipe_stdin = ctx.pipe_stdin.take();
5622            ec.pipe_stdout = ctx.pipe_stdout.take();
5623            if let Some(stderr) = ctx.stderr.clone() {
5624                ec.stderr = Some(stderr);
5625            }
5626            ec.aliases = ctx.aliases.clone();
5627            ec.ignore_config = ctx.ignore_config.clone();
5628            ec.output_limit = ctx.output_limit.clone();
5629            ec.pipeline_position = ctx.pipeline_position;
5630            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5631            // swap ctx.cancel to a derived child token before re-dispatching;
5632            // execute_command's snapshot reads ec.cancel (kept aligned by
5633            // this sync), so try_execute_external sees the right token.
5634            ec.cancel = ctx.cancel.clone();
5635            // Same alignment for the watchdog: a fork dispatching through its
5636            // own kernel must hand the shared script clock to the snapshot so
5637            // patient holds in forked stages suspend the right timer.
5638            ec.watchdog = ctx.watchdog.clone();
5639        }
5640
5641        // 2. Execute via the full dispatch chain
5642        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5643
5644        // 3. Sync self → ctx
5645        {
5646            let scope = self.scope.read().await;
5647            ctx.scope = scope.clone();
5648        }
5649        {
5650            let mut ec = self.exec_ctx.write().await;
5651            ctx.cwd = ec.cwd.clone();
5652            ctx.prev_cwd = ec.prev_cwd.clone();
5653            ctx.aliases = ec.aliases.clone();
5654            ctx.ignore_config = ec.ignore_config.clone();
5655            ctx.output_limit = ec.output_limit.clone();
5656            // Return any pipe endpoints that the tool didn't consume.
5657            // `take()` here keeps the fork's exec_ctx in a clean state for
5658            // the next dispatch — these are per-command and shouldn't leak
5659            // between calls.
5660            ctx.pipe_stdin = ec.pipe_stdin.take();
5661            ctx.pipe_stdout = ec.pipe_stdout.take();
5662        }
5663
5664        Ok(result)
5665    }
5666}
5667
5668#[async_trait]
5669impl CommandDispatcher for Kernel {
5670    /// Dispatch a command through the Kernel's full resolution chain.
5671    ///
5672    /// This is the single path for all command execution when called from
5673    /// the pipeline runner. It provides the full dispatch chain:
5674    /// user tools → builtins → .kai scripts → external commands → backend tools.
5675    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5676        self.dispatch_command(cmd, ctx).await
5677    }
5678
5679    /// Evaluate an expression through the kernel's async chain, including
5680    /// command substitution. Delegates to `eval_expr_async`, which snapshots
5681    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
5682    /// only command output escapes. The `ctx` is unused here because the
5683    /// kernel evaluates against its own session state (a fork carries the
5684    /// pipeline stage's snapshot); var refs resolve against that scope.
5685    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
5686        self.eval_expr_async(expr).await
5687    }
5688
5689    /// Produce a forked dispatcher with independent mutable state (detached).
5690    ///
5691    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
5692    /// recursing into the trait method we're defining) and coerces the
5693    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
5694    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
5695        let fork: Arc<Kernel> = Kernel::fork(self).await;
5696        fork
5697    }
5698
5699    /// Produce a forked dispatcher with cancellation cascading from this kernel.
5700    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
5701        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
5702        fork
5703    }
5704}
5705
5706/// Apply the requested output format to a builtin's result, unless the tool
5707/// owns its own output.
5708///
5709/// `format` is `ctx.output_format` (set from `--json`). When `owns_output` is
5710/// true the tool already rendered its bytes (bespoke JSON envelope), so the
5711/// kernel leaves the result untouched rather than re-formatting its
5712/// `OutputData`. Otherwise the kernel renders the typed `OutputData` uniformly.
5713fn finalize_output(
5714    result: ExecResult,
5715    format: Option<crate::interpreter::OutputFormat>,
5716    owns_output: bool,
5717) -> ExecResult {
5718    match format {
5719        Some(_) if owns_output => result,
5720        Some(format) => apply_output_format(result, format),
5721        None => result,
5722    }
5723}
5724
5725/// Accumulate output from one result into another.
5726///
5727/// Appends stdout and stderr verbatim and updates the exit code to match the
5728/// new result. Used to preserve output from multiple statements, loop
5729/// iterations, and command chains. No separator is inserted between outputs —
5730/// each command's output concatenates raw, matching bash (`printf a; printf b`
5731/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
5732/// when a command emits its own, as `echo` does).
5733fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
5734    // Materialize lazy OutputData into .out before accumulating.
5735    // Without this, the first command's output stays in .output while
5736    // the second's text gets appended to .out, losing the first.
5737    accumulated.materialize();
5738    match new.out_bytes() {
5739        // A binary result must not be lossy-decoded by text_out(): concatenate
5740        // raw bytes so the combined output stays binary (this is the path every
5741        // top-level statement's result flows through). See docs/binary-data.md.
5742        Some(new_bytes) => {
5743            let mut combined: Vec<u8> = match accumulated.out_bytes() {
5744                Some(b) => b.to_vec(),
5745                None => accumulated.text_out().into_owned().into_bytes(),
5746            };
5747            combined.extend_from_slice(new_bytes);
5748            accumulated.set_out_bytes(combined);
5749        }
5750        None => accumulated.push_out(&new.text_out()),
5751    }
5752    accumulated.err.push_str(&new.err);
5753    accumulated.code = new.code;
5754    accumulated.data = new.data.clone();
5755    accumulated.did_spill = new.did_spill;
5756    accumulated.original_code = new.original_code;
5757    accumulated.content_type = new.content_type.clone();
5758    accumulated.baggage.clone_from(&new.baggage);
5759    // A latch gate (exit-2 + nonce) is the last statement's result; carry its
5760    // control-plane field through accumulation or the confirmation is lost.
5761    accumulated.latch = new.latch.clone();
5762}
5763
5764/// Fold a loop's accumulated output into a break/continue signal that is
5765/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
5766/// (with `N > 1`) would otherwise be discarded when the signal replaces the
5767/// loop's result on its way up. The loop's output comes first (it ran before
5768/// the signal was raised), then the signal's already-carried output.
5769fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
5770    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5771        let mut merged = loop_output;
5772        accumulate_result(&mut merged, result);
5773        *result = merged;
5774    }
5775}
5776
5777/// Accumulate the output a break/continue signal carried (from inner loops it
5778/// propagated through) into the loop that finally handles it, so it survives
5779/// into that loop's result.
5780fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
5781    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5782        accumulate_result(accumulated, result);
5783    }
5784}
5785
5786/// Check if a value is truthy.
5787fn is_truthy(value: &Value) -> bool {
5788    match value {
5789        Value::Null => false,
5790        Value::Bool(b) => *b,
5791        Value::Int(i) => *i != 0,
5792        Value::Float(f) => *f != 0.0,
5793        Value::String(s) => !s.is_empty(),
5794        Value::Json(json) => match json {
5795            serde_json::Value::Null => false,
5796            serde_json::Value::Array(arr) => !arr.is_empty(),
5797            serde_json::Value::Object(obj) => !obj.is_empty(),
5798            serde_json::Value::Bool(b) => *b,
5799            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
5800            serde_json::Value::String(s) => !s.is_empty(),
5801        },
5802        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
5803    }
5804}
5805
5806/// Apply tilde expansion to a value.
5807///
5808/// Only string values starting with `~` are expanded. `home` is the session
5809/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
5810/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
5811fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
5812    match value {
5813        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
5814        _ => value,
5815    }
5816}
5817
5818/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
5819/// how the lexer tokenizes the equivalent minimally-quoted command string —
5820/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
5821/// (`build_args_async`) verbatim instead of carrying a parallel one that could
5822/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
5823/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
5824///
5825/// Classification matches the lexer's word classes:
5826/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
5827///   the binder's `past_double_dash` arms, exactly as for the string door).
5828/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
5829/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
5830///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
5831///   they fall through to a positional, not a flag).
5832/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
5833///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
5834///   `key=value` positional, per the command's word-assign allowlist).
5835/// - everything else → a literal [`Arg::Positional`].
5836///
5837/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
5838/// positional — it can never be a flag — and rides through as-is. That is the
5839/// typed passthrough the string-native door cannot offer.
5840pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
5841    argv.iter().map(classify_argv_token).collect()
5842}
5843
5844fn classify_argv_token(token: &Value) -> Arg {
5845    let Value::String(s) = token else {
5846        return Arg::Positional(Expr::Literal(token.clone()));
5847    };
5848
5849    if s == "--" {
5850        return Arg::DoubleDash;
5851    }
5852
5853    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
5854    // long-flag words (the string door tokenizes them differently and often
5855    // errors), so they fall through to a literal positional rather than a
5856    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
5857    if let Some(rest) = s.strip_prefix("--") {
5858        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
5859            return match rest.split_once('=') {
5860                Some((key, val)) => Arg::Named {
5861                    key: key.to_string(),
5862                    value: Expr::Literal(Value::String(val.to_string())),
5863                },
5864                None => Arg::LongFlag(rest.to_string()),
5865            };
5866        }
5867    } else if let Some(rest) = s.strip_prefix('-') {
5868        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
5869        // token carrying any other char — notably `=` (`-k=v` is a parse error in
5870        // the string door) — or a leading digit (`-1` lexes as a number) is not a
5871        // short-flag word, so it falls through to a literal positional instead of
5872        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
5873        if is_short_flag_body(rest) {
5874            return Arg::ShortFlag(rest.to_string());
5875        }
5876    }
5877
5878    if let Some((key, val)) = s.split_once('=') {
5879        if is_shell_identifier(key) {
5880            return Arg::WordAssign {
5881                key: key.to_string(),
5882                value: Expr::Literal(Value::String(val.to_string())),
5883            };
5884        }
5885    }
5886
5887    Arg::Positional(Expr::Literal(Value::String(s.clone())))
5888}
5889
5890/// A short-flag word: a leading ASCII letter, then only ASCII
5891/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
5892/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
5893/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
5894/// (`=` is the assignment operator — a parse error in the string door), and
5895/// any non-ASCII tail (never produced by the lexer, and not safe for the
5896/// combined-short-flag binder's byte-index slicing) do not, so they fall
5897/// through to a literal positional instead of a malformed `ShortFlag`.
5898fn is_short_flag_body(s: &str) -> bool {
5899    s.starts_with(|c: char| c.is_ascii_alphabetic())
5900        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
5901}
5902
5903/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
5904fn is_shell_identifier(s: &str) -> bool {
5905    let mut chars = s.chars();
5906    match chars.next() {
5907        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
5908        _ => return false,
5909    }
5910    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
5911}
5912
5913/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
5914/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
5915/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
5916/// must keep every value, not silently drop all but the last. Used by every flag
5917/// surface that can carry the same flag twice — the space form
5918/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
5919/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
5920/// ordered array.
5921pub(crate) fn push_repeatable_value(
5922    tool_args: &mut ToolArgs,
5923    flag_name: &str,
5924    canonical: &str,
5925    v: Value,
5926) -> anyhow::Result<()> {
5927    let occ = crate::interpreter::value_to_json(&v);
5928    let entry = tool_args
5929        .named
5930        .entry(canonical.to_string())
5931        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5932    if let Value::Json(serde_json::Value::Array(items)) = entry {
5933        items.push(occ);
5934        Ok(())
5935    } else {
5936        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
5937    }
5938}
5939
5940/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
5941/// is one token, so it carries a single value: a repeatable flag accumulates
5942/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
5943/// first-char glued arm and the combined-bundle arm so the two can't drift on
5944/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
5945/// is a loud error, not a silent single-value bind.
5946pub(crate) fn bind_glued_short_value(
5947    tool_args: &mut ToolArgs,
5948    flag_name: &str,
5949    canonical: &str,
5950    consumes: usize,
5951    repeatable: bool,
5952    value: String,
5953) -> anyhow::Result<()> {
5954    if consumes > 1 {
5955        anyhow::bail!(
5956            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
5957        );
5958    }
5959    if repeatable {
5960        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
5961    } else {
5962        tool_args
5963            .named
5964            .insert(canonical.to_string(), Value::String(value));
5965        Ok(())
5966    }
5967}
5968
5969/// Wait for a child to exit, killing it if `cancel` fires first.
5970///
5971/// `target` carries a Linux pidfd (when available) for race-free direct-child
5972/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
5973/// parameter is ignored and we use tokio's cross-platform `start_kill`.
5974#[cfg(all(unix, feature = "subprocess"))]
5975pub(crate) async fn wait_or_kill(
5976    child: &mut tokio::process::Child,
5977    target: Option<&crate::pidfd::KillTarget>,
5978    cancel: &tokio_util::sync::CancellationToken,
5979    grace: Duration,
5980) -> std::io::Result<std::process::ExitStatus> {
5981    tokio::select! {
5982        biased;
5983        status = child.wait() => status,
5984        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
5985    }
5986}
5987
5988#[cfg(all(not(unix), feature = "subprocess"))]
5989pub(crate) async fn wait_or_kill(
5990    child: &mut tokio::process::Child,
5991    _target: Option<&()>,
5992    cancel: &tokio_util::sync::CancellationToken,
5993    _grace: Duration,
5994) -> std::io::Result<std::process::ExitStatus> {
5995    tokio::select! {
5996        biased;
5997        status = child.wait() => status,
5998        _ = cancel.cancelled() => {
5999            let _ = child.start_kill();
6000            child.wait().await
6001        }
6002    }
6003}
6004
6005/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6006///
6007/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6008/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6009/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6010#[cfg(all(unix, feature = "subprocess"))]
6011pub(crate) async fn kill_with_grace(
6012    child: &mut tokio::process::Child,
6013    target: Option<&crate::pidfd::KillTarget>,
6014    grace: Duration,
6015) -> std::io::Result<std::process::ExitStatus> {
6016    use nix::sys::signal::Signal;
6017
6018    if let Some(t) = target {
6019        t.signal(Signal::SIGTERM);
6020        t.signal_pg(Signal::SIGTERM);
6021        if grace > Duration::ZERO
6022            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6023        {
6024            return status;
6025        }
6026        t.signal(Signal::SIGKILL);
6027        t.signal_pg(Signal::SIGKILL);
6028    }
6029    child.wait().await
6030}
6031
6032#[cfg(test)]
6033#[allow(clippy::unwrap_used, clippy::expect_used)]
6034mod argv_classify_tests {
6035    use super::*;
6036
6037    /// A normalized, comparable view of one `Arg` representing its *logical
6038    /// argument* (what the command observably receives), not its exact AST shape:
6039    ///
6040    /// - Value-bearing arms compare by *stringified* value, so the parser's
6041    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6042    ///   (`String("-1")`) count as the same argument.
6043    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6044    ///   For every command except the `export`/`alias` allowlist, a bareword
6045    ///   `key=value` is stringified straight back to a `"key=value"` positional
6046    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6047    ///   converge observably even when they disagree on the AST tag — e.g. the
6048    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6049    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
6050    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6051    ///   separately by `classifies_each_word_class`.
6052    ///
6053    /// Returns `None` for shapes we deliberately don't compare:
6054    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6055    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6056    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
6057    ///   string. That divergence is *intentional* — `execute_argv` preserves a
6058    ///   literal numeric string (pass `Value::Int` for a number), the string door
6059    ///   can only guess — so the property skips it rather than demanding the
6060    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
6061    ///   by `classifies_each_word_class`.
6062    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6063        // Only a *string*-valued literal is comparable; a coerced number is not.
6064        let lit = |e: &Expr| match e {
6065            Expr::Literal(Value::String(s)) => Some(s.clone()),
6066            _ => None,
6067        };
6068        Some(match arg {
6069            Arg::DoubleDash => ("dash", String::new(), String::new()),
6070            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6071            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6072            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6073            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6074            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6075        })
6076    }
6077
6078    /// Classify a single string token the way `execute_argv` would.
6079    fn classify(token: &str) -> Arg {
6080        classify_argv_token(&Value::String(token.to_string()))
6081    }
6082
6083    #[test]
6084    fn classifies_each_word_class() {
6085        assert_eq!(classify("--"), Arg::DoubleDash);
6086        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6087        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6088        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6089        assert_eq!(
6090            classify("--key=value"),
6091            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6092        );
6093        assert_eq!(
6094            classify("NAME=val"),
6095            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6096        );
6097        // Digits after the first flag char are ordinary (kept verbatim).
6098        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6099        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6100        // Leading-digit dash is a number to the lexer, not a flag → positional.
6101        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6102        // Numeric strings keep their literal text — `execute_argv` does NOT
6103        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6104        // who wants a number passes `Value::Int`; a string stays the string.
6105        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6106        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6107        // A lone dash (stdin convention) is a positional, not a flag.
6108        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6109        // Non-identifier LHS is not an assignment.
6110        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6111        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6112    }
6113
6114    #[test]
6115    fn typed_values_pass_through_as_literal_positionals() {
6116        // The whole point of the `&[Value]` signature: a non-string value is a
6117        // literal positional carrying the *exact* value, never stringified and
6118        // never flag-interpreted.
6119        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6120        assert_eq!(
6121            classify_argv_token(&bytes),
6122            Arg::Positional(Expr::Literal(bytes.clone()))
6123        );
6124        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6125        assert_eq!(
6126            classify_argv_token(&json),
6127            Arg::Positional(Expr::Literal(json.clone()))
6128        );
6129        // An integer token that *looks* like a flag is still a positional value
6130        // (only strings are inspected for a leading dash).
6131        assert_eq!(
6132            classify_argv_token(&Value::Int(-9)),
6133            Arg::Positional(Expr::Literal(Value::Int(-9)))
6134        );
6135    }
6136
6137    #[test]
6138    fn double_dash_only_matches_exactly() {
6139        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6140        // (the lexer splits it `--` + `-`); as a single argv token it's literal.
6141        assert_eq!(classify("--"), Arg::DoubleDash);
6142        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6143        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6144    }
6145
6146    #[test]
6147    fn malformed_flag_words_fall_back_to_literal_positionals() {
6148        // A token that isn't a well-formed flag word must NOT be silently misbound
6149        // into the arg binder (house rule: loud/visible over silent-wrong). Each
6150        // of these is a parse error or different tokenization in the string door,
6151        // so the argv door keeps them as literal positionals.
6152        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6153        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6154        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6155        assert_eq!(classify("-k=v"), pos("-k=v"));
6156        assert_eq!(classify("-="), pos("-="));
6157        // Empty long-flag key.
6158        assert_eq!(classify("--=v"), pos("--=v"));
6159        // `--` followed by a non-letter is not a long flag.
6160        assert_eq!(classify("--1"), pos("--1"));
6161        // A bare dash and a number-dash are positionals (covered above too).
6162        assert_eq!(classify("-"), pos("-"));
6163        assert_eq!(classify("-9"), pos("-9"));
6164        // A non-ASCII tail is not part of the lexer's short-flag char class
6165        // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6166        // absorbs) — classifying it as `ShortFlag` would hand the combined
6167        // short-flag binder a byte string it (correctly, for real ASCII flag
6168        // words) slices by *byte* index, panicking on a multi-byte char
6169        // boundary. Fall back to a literal positional instead.
6170        assert_eq!(classify("-lé"), pos("-lé"));
6171        assert_eq!(classify("-é"), pos("-é"));
6172    }
6173
6174    #[tokio::test]
6175    async fn non_ascii_short_flag_bundle_does_not_panic() {
6176        // Regression: `execute_argv`'s combined-short-flag loop assumed the
6177        // flag body was ASCII (safe to byte-slice) because the lexer's
6178        // grammar guarantees that on the *string* door. The argv door's
6179        // classifier let a non-ASCII tail through as `ShortFlag`, so
6180        // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6181        let kernel = Kernel::transient().expect("failed to create kernel");
6182        let result = kernel
6183            .execute_argv("ls", &[Value::String("-lé".into())])
6184            .await
6185            .expect("execute_argv must not panic on a non-ASCII short-flag token");
6186        // Not a well-formed flag word, so it's a literal positional — `ls`
6187        // then reports it as a missing path rather than mangling flags.
6188        assert_ne!(result.code, 0);
6189    }
6190
6191    proptest::proptest! {
6192        /// The core correctness claim: the classifier mirrors the lexer/parser
6193        /// on metacharacter-free tokens. For any such single token, the `Arg`
6194        /// the classifier produces matches the one the real parser produces for
6195        /// the equivalent one-word command — so `execute_argv` reusing the
6196        /// string door's binder is sound. (First proptest in the workspace.)
6197        #[test]
6198        fn classifier_matches_parser_on_clean_tokens(
6199            // No digits: this property tests the *classification* boundary
6200            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6201            // positional), not numeric coercion. The lexer coerces digit runs to
6202            // `Int`/`Float` and drops the literal text (even inside a colon-merged
6203            // word: `00:` → `0:`); the classifier intentionally preserves the raw
6204            // string. Those numeric edges are pinned exactly by the unit tests.
6205            token in "[a-zA-Z_=./@:+-]{1,8}"
6206        ) {
6207            let parsed = match parse(&format!("cmd {token}")) {
6208                Ok(p) => p,
6209                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6210            };
6211            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6212                return Ok(());
6213            };
6214            // Only compare when the token lexed as exactly one argument.
6215            let [arg] = cmd.args.as_slice() else { return Ok(()); };
6216
6217            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6218                return Ok(()); // a non-literal parsed Expr we don't model — skip
6219            };
6220            proptest::prop_assert_eq!(
6221                ours, theirs,
6222                "classifier diverged from parser on token {:?}", token
6223            );
6224        }
6225    }
6226}
6227
6228#[cfg(all(test, feature = "subprocess"))]
6229#[allow(clippy::expect_used)]
6230mod tests {
6231    use super::*;
6232
6233    #[tokio::test]
6234    async fn test_kernel_transient() {
6235        let kernel = Kernel::transient().expect("failed to create kernel");
6236        assert_eq!(kernel.name(), "transient");
6237    }
6238
6239    #[tokio::test]
6240    async fn test_kernel_execute_echo() {
6241        let kernel = Kernel::transient().expect("failed to create kernel");
6242        let result = kernel.execute("echo hello").await.expect("execution failed");
6243        assert!(result.ok());
6244        assert_eq!(result.text_out().trim(), "hello");
6245    }
6246
6247    #[tokio::test]
6248    async fn test_multiple_statements_accumulate_output() {
6249        let kernel = Kernel::transient().expect("failed to create kernel");
6250        let result = kernel
6251            .execute("echo one\necho two\necho three")
6252            .await
6253            .expect("execution failed");
6254        assert!(result.ok());
6255        // Should have all three outputs separated by newlines
6256        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6257        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6258        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6259    }
6260
6261    #[tokio::test]
6262    async fn test_and_chain_accumulates_output() {
6263        let kernel = Kernel::transient().expect("failed to create kernel");
6264        let result = kernel
6265            .execute("echo first && echo second")
6266            .await
6267            .expect("execution failed");
6268        assert!(result.ok());
6269        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6270        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6271    }
6272
6273    #[tokio::test]
6274    async fn test_for_loop_accumulates_output() {
6275        let kernel = Kernel::transient().expect("failed to create kernel");
6276        let result = kernel
6277            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6278            .await
6279            .expect("execution failed");
6280        assert!(result.ok());
6281        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6282        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6283        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6284    }
6285
6286    #[tokio::test]
6287    async fn test_while_loop_accumulates_output() {
6288        let kernel = Kernel::transient().expect("failed to create kernel");
6289        let result = kernel
6290            .execute(r#"
6291                N=3
6292                while [[ ${N} -gt 0 ]]; do
6293                    echo "N=${N}"
6294                    N=$((N - 1))
6295                done
6296            "#)
6297            .await
6298            .expect("execution failed");
6299        assert!(result.ok());
6300        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
6301        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
6302        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
6303    }
6304
6305    #[tokio::test]
6306    async fn test_kernel_set_var() {
6307        let kernel = Kernel::transient().expect("failed to create kernel");
6308
6309        kernel.execute("X=42").await.expect("set failed");
6310
6311        let value = kernel.get_var("X").await;
6312        assert_eq!(value, Some(Value::Int(42)));
6313    }
6314
6315    #[tokio::test]
6316    async fn test_kernel_var_expansion() {
6317        let kernel = Kernel::transient().expect("failed to create kernel");
6318
6319        kernel.execute("NAME=\"world\"").await.expect("set failed");
6320        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
6321
6322        assert!(result.ok());
6323        assert_eq!(result.text_out().trim(), "hello world");
6324    }
6325
6326    #[tokio::test]
6327    async fn test_kernel_last_result() {
6328        let kernel = Kernel::transient().expect("failed to create kernel");
6329
6330        kernel.execute("echo test").await.expect("echo failed");
6331
6332        let last = kernel.last_result().await;
6333        assert!(last.ok());
6334        assert_eq!(last.text_out().trim(), "test");
6335    }
6336
6337    #[tokio::test]
6338    async fn test_kernel_tool_not_found() {
6339        let kernel = Kernel::transient().expect("failed to create kernel");
6340
6341        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
6342        assert!(!result.ok());
6343        assert_eq!(result.code, 127);
6344        assert!(result.err.contains("command not found"));
6345    }
6346
6347    #[tokio::test]
6348    async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
6349        // The embedder seam: a backend-registered tool (kaijutsu, an MCP
6350        // engine, …) returns a `ToolResult` with structured `data` — this
6351        // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
6352        // and `for r in $(embedder_tool)` see the typed value, not just
6353        // stdout text.
6354        use crate::backend::testing::MockBackend;
6355        use crate::backend::ToolResult;
6356        let (mock, _calls) = MockBackend::new();
6357        let backend = mock.with_tool_result(|_name| {
6358            let mut baggage = std::collections::BTreeMap::new();
6359            baggage.insert("trace_id".to_string(), "abc123".to_string());
6360            Ok(ToolResult {
6361                code: 0,
6362                stdout: String::new(),
6363                stderr: String::new(),
6364                data: Some(serde_json::json!({"key": "value"})),
6365                output: None,
6366                content_type: Some("application/json".to_string()),
6367                baggage,
6368                latch: None,
6369            })
6370        });
6371        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6372        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6373            .expect("with_backend kernel");
6374
6375        let result = kernel
6376            .execute("embedder_tool")
6377            .await
6378            .expect("execution failed");
6379        assert!(result.ok(), "backend tool call should succeed: {result:?}");
6380        assert_eq!(
6381            result.data,
6382            Some(Value::Json(serde_json::json!({"key": "value"}))),
6383            "backend tool's structured data must survive into ExecResult, not be dropped"
6384        );
6385        assert_eq!(
6386            result.content_type.as_deref(),
6387            Some("application/json"),
6388            "backend tool's content_type must survive into ExecResult"
6389        );
6390        assert_eq!(
6391            result.baggage.get("trace_id").map(String::as_str),
6392            Some("abc123"),
6393            "backend tool's baggage must survive into ExecResult"
6394        );
6395    }
6396
6397    #[tokio::test]
6398    async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
6399        // A backend tool that IS found but fails during execution (`Io`,
6400        // `PermissionDenied`, …) must surface its real error, not get
6401        // misreported as exit-127 "command not found" — that masks a genuine
6402        // failure as a lookup miss.
6403        use crate::backend::testing::MockBackend;
6404        let (mock, _calls) = MockBackend::new();
6405        let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
6406        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6407        let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6408            .expect("with_backend kernel");
6409
6410        let result = kernel
6411            .execute("embedder_tool")
6412            .await
6413            .expect("execution failed");
6414        assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
6415        assert!(!result.ok());
6416        assert!(
6417            result.err.contains("disk exploded"),
6418            "the real backend error must be visible, not masked: {result:?}"
6419        );
6420    }
6421
6422    #[tokio::test]
6423    async fn test_external_command_true() {
6424        // Use REPL config for passthrough filesystem access
6425        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6426
6427        // /bin/true should be available on any Unix system
6428        let result = kernel.execute("true").await.expect("execution failed");
6429        // This should use the builtin true, which returns 0
6430        assert!(result.ok(), "true should succeed: {:?}", result);
6431    }
6432
6433    #[tokio::test]
6434    async fn test_external_command_basic() {
6435        // Use REPL config for passthrough filesystem access
6436        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6437
6438        // Test with /bin/echo which is external
6439        // Note: kaish has a builtin echo, so this will use the builtin
6440        // Let's test with a command that's not a builtin
6441        // Actually, let's just test that PATH resolution works by checking the PATH var
6442        let path_var = std::env::var("PATH").unwrap_or_default();
6443        eprintln!("System PATH: {}", path_var);
6444
6445        // Set PATH in kernel to ensure it's available
6446        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
6447
6448        // Now try an external command like /usr/bin/env
6449        // But env is also a builtin... let's try uname
6450        let result = kernel.execute("uname").await.expect("execution failed");
6451        eprintln!("uname result: {:?}", result);
6452        // uname should succeed if external commands work
6453        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
6454    }
6455
6456    #[tokio::test]
6457    async fn test_kernel_reset() {
6458        let kernel = Kernel::transient().expect("failed to create kernel");
6459
6460        kernel.execute("X=1").await.expect("set failed");
6461        assert!(kernel.get_var("X").await.is_some());
6462
6463        kernel.reset().await.expect("reset failed");
6464        assert!(kernel.get_var("X").await.is_none());
6465    }
6466
6467    #[tokio::test]
6468    async fn test_kernel_reset_preserves_latch_and_trash_config() {
6469        // An embedder configuring `with_latch(true)` must not have the
6470        // confirmation gate silently disabled by a later `reset()` — that
6471        // would let a destructive command through with no nonce and no
6472        // error, exactly the "silent fallback" the latch exists to prevent.
6473        let kernel = Kernel::new(
6474            KernelConfig::transient()
6475                .with_latch(true)
6476                .with_skip_validation(true),
6477        )
6478        .expect("failed to create kernel");
6479
6480        // Write and rm relative to `/` (reset()'s post-reset cwd) so the file
6481        // is reachable identically before and after reset.
6482        kernel.execute("cd /; echo hi > latch-probe.txt").await.expect("setup write failed");
6483
6484        let before = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6485        assert_eq!(before.code, 2, "latch should require confirmation before reset: {before:?}");
6486
6487        kernel.reset().await.expect("reset failed");
6488
6489        // reset() only clears scope/cwd (to `/`), not the VFS — the
6490        // un-deleted probe file (the latch blocked the delete above) is
6491        // still there.
6492        let after = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6493        assert_eq!(
6494            after.code, 2,
6495            "latch must still require confirmation after reset, not silently disable: {after:?}"
6496        );
6497    }
6498
6499    #[tokio::test]
6500    async fn test_kernel_reset_preserves_pid_and_initial_vars() {
6501        let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
6502            .expect("failed to create kernel");
6503
6504        let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6505        assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
6506
6507        kernel.reset().await.expect("reset failed");
6508
6509        let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6510        assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
6511        assert_eq!(
6512            kernel.get_var("HOME").await,
6513            Some(Value::String("/home/probe".into())),
6514            "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
6515        );
6516    }
6517
6518    #[tokio::test]
6519    async fn test_kernel_cwd() {
6520        let kernel = Kernel::transient().expect("failed to create kernel");
6521
6522        // Transient kernel uses sandboxed mode with cwd=$HOME
6523        let cwd = kernel.cwd().await;
6524        let home = std::env::var("HOME")
6525            .map(PathBuf::from)
6526            .unwrap_or_else(|_| PathBuf::from("/"));
6527        assert_eq!(cwd, home);
6528
6529        kernel.set_cwd(PathBuf::from("/tmp")).await;
6530        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
6531    }
6532
6533    #[tokio::test]
6534    async fn test_kernel_list_vars() {
6535        let kernel = Kernel::transient().expect("failed to create kernel");
6536
6537        kernel.execute("A=1").await.ok();
6538        kernel.execute("B=2").await.ok();
6539
6540        let vars = kernel.list_vars().await;
6541        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
6542        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
6543    }
6544
6545    #[tokio::test]
6546    async fn test_is_truthy() {
6547        assert!(!is_truthy(&Value::Null));
6548        assert!(!is_truthy(&Value::Bool(false)));
6549        assert!(is_truthy(&Value::Bool(true)));
6550        assert!(!is_truthy(&Value::Int(0)));
6551        assert!(is_truthy(&Value::Int(1)));
6552        assert!(!is_truthy(&Value::String("".into())));
6553        assert!(is_truthy(&Value::String("x".into())));
6554    }
6555
6556    #[tokio::test]
6557    async fn test_jq_in_pipeline() {
6558        let kernel = Kernel::transient().expect("failed to create kernel");
6559        // kaish uses double quotes only; escape inner quotes
6560        let result = kernel
6561            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
6562            .await
6563            .expect("execution failed");
6564        assert!(result.ok(), "jq pipeline failed: {}", result.err);
6565        assert_eq!(result.text_out().trim(), "Alice");
6566    }
6567
6568    #[tokio::test]
6569    async fn test_user_defined_tool() {
6570        let kernel = Kernel::transient().expect("failed to create kernel");
6571
6572        // Define a function
6573        kernel
6574            .execute(r#"greet() { echo "Hello, $1!" }"#)
6575            .await
6576            .expect("function definition failed");
6577
6578        // Call the function
6579        let result = kernel
6580            .execute(r#"greet "World""#)
6581            .await
6582            .expect("function call failed");
6583
6584        assert!(result.ok(), "greet failed: {}", result.err);
6585        assert_eq!(result.text_out().trim(), "Hello, World!");
6586    }
6587
6588    #[tokio::test]
6589    async fn test_user_tool_positional_args() {
6590        let kernel = Kernel::transient().expect("failed to create kernel");
6591
6592        // Define a function with positional param
6593        kernel
6594            .execute(r#"greet() { echo "Hi $1" }"#)
6595            .await
6596            .expect("function definition failed");
6597
6598        // Call with positional argument
6599        let result = kernel
6600            .execute(r#"greet "Amy""#)
6601            .await
6602            .expect("function call failed");
6603
6604        assert!(result.ok(), "greet failed: {}", result.err);
6605        assert_eq!(result.text_out().trim(), "Hi Amy");
6606    }
6607
6608    #[tokio::test]
6609    async fn test_function_shared_scope() {
6610        let kernel = Kernel::transient().expect("failed to create kernel");
6611
6612        // Set a variable in parent scope
6613        kernel
6614            .execute(r#"SECRET="hidden""#)
6615            .await
6616            .expect("set failed");
6617
6618        // Define a function that accesses and modifies parent variable
6619        kernel
6620            .execute(r#"access_parent() {
6621                echo "${SECRET}"
6622                SECRET="modified"
6623            }"#)
6624            .await
6625            .expect("function definition failed");
6626
6627        // Call the function - it SHOULD see SECRET (shared scope like sh)
6628        let result = kernel.execute("access_parent").await.expect("function call failed");
6629
6630        // Function should have access to parent scope
6631        assert!(
6632            result.text_out().contains("hidden"),
6633            "Function should access parent scope, got: {}",
6634            result.text_out()
6635        );
6636
6637        // Function should have modified the parent variable
6638        let secret = kernel.get_var("SECRET").await;
6639        assert_eq!(
6640            secret,
6641            Some(Value::String("modified".into())),
6642            "Function should modify parent scope"
6643        );
6644    }
6645
6646    #[tokio::test]
6647    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
6648    async fn test_exec_builtin() {
6649        let kernel = Kernel::transient().expect("failed to create kernel");
6650        // argv is now a space-separated string or JSON array string
6651        let result = kernel
6652            .execute(r#"exec command="/bin/echo" argv="hello world""#)
6653            .await
6654            .expect("exec failed");
6655
6656        assert!(result.ok(), "exec failed: {}", result.err);
6657        assert_eq!(result.text_out().trim(), "hello world");
6658    }
6659
6660    #[tokio::test]
6661    async fn test_while_false_never_runs() {
6662        let kernel = Kernel::transient().expect("failed to create kernel");
6663
6664        // A while loop with false condition should never run
6665        let result = kernel
6666            .execute(r#"
6667                while false; do
6668                    echo "should not run"
6669                done
6670            "#)
6671            .await
6672            .expect("while false failed");
6673
6674        assert!(result.ok());
6675        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
6676    }
6677
6678    #[tokio::test]
6679    async fn test_while_string_comparison() {
6680        let kernel = Kernel::transient().expect("failed to create kernel");
6681
6682        // Set a flag
6683        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
6684
6685        // Use string comparison as condition (shell-compatible [[ ]] syntax)
6686        // Note: Put echo last so we can check the output
6687        let result = kernel
6688            .execute(r#"
6689                while [[ ${FLAG} == "go" ]]; do
6690                    FLAG="stop"
6691                    echo "running"
6692                done
6693            "#)
6694            .await
6695            .expect("while with string cmp failed");
6696
6697        assert!(result.ok());
6698        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
6699
6700        // Verify flag was changed
6701        let flag = kernel.get_var("FLAG").await;
6702        assert_eq!(flag, Some(Value::String("stop".into())));
6703    }
6704
6705    #[tokio::test]
6706    async fn test_while_numeric_comparison() {
6707        let kernel = Kernel::transient().expect("failed to create kernel");
6708
6709        // Test > comparison (shell-compatible [[ ]] with -gt)
6710        kernel.execute("N=5").await.expect("set failed");
6711
6712        // Note: Put echo last so we can check the output
6713        let result = kernel
6714            .execute(r#"
6715                while [[ ${N} -gt 3 ]]; do
6716                    N=3
6717                    echo "N was greater"
6718                done
6719            "#)
6720            .await
6721            .expect("while with > failed");
6722
6723        assert!(result.ok());
6724        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
6725    }
6726
6727    #[tokio::test]
6728    async fn test_break_in_while_loop() {
6729        let kernel = Kernel::transient().expect("failed to create kernel");
6730
6731        let result = kernel
6732            .execute(r#"
6733                I=0
6734                while true; do
6735                    I=1
6736                    echo "before break"
6737                    break
6738                    echo "after break"
6739                done
6740            "#)
6741            .await
6742            .expect("while with break failed");
6743
6744        assert!(result.ok());
6745        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
6746        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
6747
6748        // Verify we exited the loop
6749        let i = kernel.get_var("I").await;
6750        assert_eq!(i, Some(Value::Int(1)));
6751    }
6752
6753    #[tokio::test]
6754    async fn test_continue_in_while_loop() {
6755        let kernel = Kernel::transient().expect("failed to create kernel");
6756
6757        // Test continue in a while loop where variables persist
6758        // We use string state transition: "start" -> "middle" -> "end"
6759        // continue on "middle" should skip to next iteration
6760        // Shell-compatible: use [[ ]] for comparisons
6761        let result = kernel
6762            .execute(r#"
6763                STATE="start"
6764                AFTER_CONTINUE="no"
6765                while [[ ${STATE} != "done" ]]; do
6766                    if [[ ${STATE} == "start" ]]; then
6767                        STATE="middle"
6768                        continue
6769                        AFTER_CONTINUE="yes"
6770                    fi
6771                    if [[ ${STATE} == "middle" ]]; then
6772                        STATE="done"
6773                    fi
6774                done
6775            "#)
6776            .await
6777            .expect("while with continue failed");
6778
6779        assert!(result.ok());
6780
6781        // STATE should be "done" (we completed the loop)
6782        let state = kernel.get_var("STATE").await;
6783        assert_eq!(state, Some(Value::String("done".into())));
6784
6785        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
6786        let after = kernel.get_var("AFTER_CONTINUE").await;
6787        assert_eq!(after, Some(Value::String("no".into())));
6788    }
6789
6790    #[tokio::test]
6791    async fn test_break_with_level() {
6792        let kernel = Kernel::transient().expect("failed to create kernel");
6793
6794        // Nested loop with break 2 to exit both loops
6795        // We verify by checking OUTER value:
6796        // - If break 2 works, OUTER stays at 1 (set before for loop)
6797        // - If break 2 fails, OUTER becomes 2 (set after for loop)
6798        let result = kernel
6799            .execute(r#"
6800                OUTER=0
6801                while true; do
6802                    OUTER=1
6803                    for X in "1 2"; do
6804                        break 2
6805                    done
6806                    OUTER=2
6807                done
6808            "#)
6809            .await
6810            .expect("nested break failed");
6811
6812        assert!(result.ok());
6813
6814        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
6815        let outer = kernel.get_var("OUTER").await;
6816        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
6817    }
6818
6819    #[tokio::test]
6820    async fn test_return_from_tool() {
6821        let kernel = Kernel::transient().expect("failed to create kernel");
6822
6823        // Define a function that returns early
6824        kernel
6825            .execute(r#"early_return() {
6826                if [[ $1 == 1 ]]; then
6827                    return 42
6828                fi
6829                echo "not returned"
6830            }"#)
6831            .await
6832            .expect("function definition failed");
6833
6834        // Call with arg=1 should return with exit code 42
6835        // (POSIX shell behavior: return N sets exit code, doesn't output N)
6836        let result = kernel
6837            .execute("early_return 1")
6838            .await
6839            .expect("function call failed");
6840
6841        // Exit code should be 42 (non-zero, so not ok())
6842        assert_eq!(result.code, 42);
6843        // Output should be empty (we returned before echo)
6844        assert!(result.text_out().is_empty());
6845    }
6846
6847    #[tokio::test]
6848    async fn test_return_without_value() {
6849        let kernel = Kernel::transient().expect("failed to create kernel");
6850
6851        // Define a function that returns without a value
6852        kernel
6853            .execute(r#"early_exit() {
6854                if [[ $1 == "stop" ]]; then
6855                    return
6856                fi
6857                echo "continued"
6858            }"#)
6859            .await
6860            .expect("function definition failed");
6861
6862        // Call with arg="stop" should return early
6863        let result = kernel
6864            .execute(r#"early_exit "stop""#)
6865            .await
6866            .expect("function call failed");
6867
6868        assert!(result.ok());
6869        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
6870    }
6871
6872    #[tokio::test]
6873    async fn test_exit_stops_execution() {
6874        let kernel = Kernel::transient().expect("failed to create kernel");
6875
6876        // exit should stop further execution
6877        kernel
6878            .execute(r#"
6879                BEFORE="yes"
6880                exit 0
6881                AFTER="yes"
6882            "#)
6883            .await
6884            .expect("execution failed");
6885
6886        // BEFORE should be set, AFTER should not
6887        let before = kernel.get_var("BEFORE").await;
6888        assert_eq!(before, Some(Value::String("yes".into())));
6889
6890        let after = kernel.get_var("AFTER").await;
6891        assert!(after.is_none(), "AFTER should not be set after exit");
6892    }
6893
6894    #[tokio::test]
6895    async fn test_exit_with_code() {
6896        let kernel = Kernel::transient().expect("failed to create kernel");
6897
6898        // exit with code should propagate the exit code
6899        let result = kernel
6900            .execute("exit 42")
6901            .await
6902            .expect("exit failed");
6903
6904        assert_eq!(result.code, 42);
6905        assert!(result.text_out().is_empty(), "exit should not produce stdout");
6906    }
6907
6908    #[tokio::test]
6909    async fn test_set_e_stops_on_failure() {
6910        let kernel = Kernel::transient().expect("failed to create kernel");
6911
6912        // Enable error-exit mode
6913        kernel.execute("set -e").await.expect("set -e failed");
6914
6915        // Run a sequence where the middle command fails
6916        kernel
6917            .execute(r#"
6918                STEP1="done"
6919                false
6920                STEP2="done"
6921            "#)
6922            .await
6923            .expect("execution failed");
6924
6925        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
6926        let step1 = kernel.get_var("STEP1").await;
6927        assert_eq!(step1, Some(Value::String("done".into())));
6928
6929        let step2 = kernel.get_var("STEP2").await;
6930        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
6931    }
6932
6933    #[tokio::test]
6934    async fn test_set_plus_e_disables_error_exit() {
6935        let kernel = Kernel::transient().expect("failed to create kernel");
6936
6937        // Enable then disable error-exit mode
6938        kernel.execute("set -e").await.expect("set -e failed");
6939        kernel.execute("set +e").await.expect("set +e failed");
6940
6941        // Now failure should NOT stop execution
6942        kernel
6943            .execute(r#"
6944                STEP1="done"
6945                false
6946                STEP2="done"
6947            "#)
6948            .await
6949            .expect("execution failed");
6950
6951        // Both should be set since +e disables error exit
6952        let step1 = kernel.get_var("STEP1").await;
6953        assert_eq!(step1, Some(Value::String("done".into())));
6954
6955        let step2 = kernel.get_var("STEP2").await;
6956        assert_eq!(step2, Some(Value::String("done".into())));
6957    }
6958
6959    #[tokio::test]
6960    async fn test_set_ignores_unknown_options() {
6961        let kernel = Kernel::transient().expect("failed to create kernel");
6962
6963        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
6964        let result = kernel
6965            .execute("set -e -u -o pipefail")
6966            .await
6967            .expect("set with unknown options failed");
6968
6969        assert!(result.ok(), "set should succeed with unknown options");
6970
6971        // -e should still be enabled
6972        kernel
6973            .execute(r#"
6974                BEFORE="yes"
6975                false
6976                AFTER="yes"
6977            "#)
6978            .await
6979            .ok();
6980
6981        let after = kernel.get_var("AFTER").await;
6982        assert!(after.is_none(), "-e should be enabled despite unknown options");
6983    }
6984
6985    #[tokio::test]
6986    async fn test_set_no_args_shows_settings() {
6987        let kernel = Kernel::transient().expect("failed to create kernel");
6988
6989        // Enable -e
6990        kernel.execute("set -e").await.expect("set -e failed");
6991
6992        // Call set with no args to see settings
6993        let result = kernel.execute("set").await.expect("set failed");
6994
6995        assert!(result.ok());
6996        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
6997    }
6998
6999    #[tokio::test]
7000    async fn test_set_e_in_pipeline() {
7001        let kernel = Kernel::transient().expect("failed to create kernel");
7002
7003        kernel.execute("set -e").await.expect("set -e failed");
7004
7005        // Pipeline failure should trigger exit
7006        kernel
7007            .execute(r#"
7008                BEFORE="yes"
7009                false | cat
7010                AFTER="yes"
7011            "#)
7012            .await
7013            .ok();
7014
7015        let before = kernel.get_var("BEFORE").await;
7016        assert_eq!(before, Some(Value::String("yes".into())));
7017
7018        // AFTER should not be set if pipeline failure triggers exit
7019        // Note: The exit code of a pipeline is the exit code of the last command
7020        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7021        // To test pipeline failure, we need the last command to fail.
7022    }
7023
7024    #[tokio::test]
7025    async fn test_set_e_with_and_chain() {
7026        let kernel = Kernel::transient().expect("failed to create kernel");
7027
7028        kernel.execute("set -e").await.expect("set -e failed");
7029
7030        // Commands in && chain should not trigger -e on the first failure
7031        // because && explicitly handles the error
7032        kernel
7033            .execute(r#"
7034                RESULT="initial"
7035                false && RESULT="chained"
7036                RESULT="continued"
7037            "#)
7038            .await
7039            .ok();
7040
7041        // In bash, commands in && don't trigger -e. The chain handles the failure.
7042        // Our implementation may differ - let's verify current behavior.
7043        let result = kernel.get_var("RESULT").await;
7044        // If we follow bash semantics, RESULT should be "continued"
7045        // If we trigger -e on the false, RESULT stays "initial"
7046        assert!(result.is_some(), "RESULT should be set");
7047    }
7048
7049    #[tokio::test]
7050    async fn test_set_e_exits_in_for_loop() {
7051        let kernel = Kernel::transient().expect("failed to create kernel");
7052
7053        kernel.execute("set -e").await.expect("set -e failed");
7054
7055        kernel
7056            .execute(r#"
7057                REACHED="no"
7058                for x in 1 2 3; do
7059                    false
7060                    REACHED="yes"
7061                done
7062            "#)
7063            .await
7064            .ok();
7065
7066        // With set -e, false should trigger exit; REACHED should remain "no"
7067        let reached = kernel.get_var("REACHED").await;
7068        assert_eq!(reached, Some(Value::String("no".into())),
7069            "set -e should exit on failure in for loop body");
7070    }
7071
7072    #[tokio::test]
7073    async fn test_for_loop_continues_without_set_e() {
7074        let kernel = Kernel::transient().expect("failed to create kernel");
7075
7076        // Without set -e, for loop should continue normally
7077        kernel
7078            .execute(r#"
7079                COUNT=0
7080                for x in 1 2 3; do
7081                    false
7082                    COUNT=$((COUNT + 1))
7083                done
7084            "#)
7085            .await
7086            .ok();
7087
7088        let count = kernel.get_var("COUNT").await;
7089        // Arithmetic produces Int values; accept either Int or String representation
7090        let count_val = match &count {
7091            Some(Value::Int(n)) => *n,
7092            Some(Value::String(s)) => s.parse().unwrap_or(-1),
7093            _ => -1,
7094        };
7095        assert_eq!(count_val, 3,
7096            "without set -e, loop should complete all iterations (got {:?})", count);
7097    }
7098
7099    // ═══════════════════════════════════════════════════════════════════════════
7100    // Source Tests
7101    // ═══════════════════════════════════════════════════════════════════════════
7102
7103    #[tokio::test]
7104    async fn test_source_sets_variables() {
7105        let kernel = Kernel::transient().expect("failed to create kernel");
7106
7107        // Write a script to the VFS
7108        kernel
7109            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7110            .await
7111            .expect("write failed");
7112
7113        // Source the script
7114        let result = kernel
7115            .execute(r#"source "/test.kai""#)
7116            .await
7117            .expect("source failed");
7118
7119        assert!(result.ok(), "source should succeed");
7120
7121        // Variable should be set in current scope
7122        let foo = kernel.get_var("FOO").await;
7123        assert_eq!(foo, Some(Value::String("bar".into())));
7124    }
7125
7126    #[tokio::test]
7127    async fn test_source_with_dot_alias() {
7128        let kernel = Kernel::transient().expect("failed to create kernel");
7129
7130        // Write a script to the VFS
7131        kernel
7132            .execute(r#"write "/vars.kai" 'X=42'"#)
7133            .await
7134            .expect("write failed");
7135
7136        // Source using . alias
7137        let result = kernel
7138            .execute(r#". "/vars.kai""#)
7139            .await
7140            .expect(". failed");
7141
7142        assert!(result.ok(), ". should succeed");
7143
7144        // Variable should be set in current scope
7145        let x = kernel.get_var("X").await;
7146        assert_eq!(x, Some(Value::Int(42)));
7147    }
7148
7149    #[tokio::test]
7150    async fn test_source_not_found() {
7151        let kernel = Kernel::transient().expect("failed to create kernel");
7152
7153        // Try to source a non-existent file
7154        let result = kernel
7155            .execute(r#"source "/nonexistent.kai""#)
7156            .await
7157            .expect("source should not fail with error");
7158
7159        assert!(!result.ok(), "source of non-existent file should fail");
7160        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7161    }
7162
7163    #[tokio::test]
7164    async fn test_source_missing_filename() {
7165        let kernel = Kernel::transient().expect("failed to create kernel");
7166
7167        // Call source with no arguments
7168        let result = kernel
7169            .execute("source")
7170            .await
7171            .expect("source should not fail with error");
7172
7173        assert!(!result.ok(), "source without filename should fail");
7174        assert!(result.err.contains("missing filename"), "error should mention missing filename");
7175    }
7176
7177    #[tokio::test]
7178    async fn test_source_executes_multiple_statements() {
7179        let kernel = Kernel::transient().expect("failed to create kernel");
7180
7181        // Write a script with multiple statements
7182        kernel
7183            .execute(r#"write "/multi.kai" 'A=1
7184B=2
7185C=3'"#)
7186            .await
7187            .expect("write failed");
7188
7189        // Source it
7190        kernel
7191            .execute(r#"source "/multi.kai""#)
7192            .await
7193            .expect("source failed");
7194
7195        // All variables should be set
7196        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7197        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7198        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7199    }
7200
7201    #[tokio::test]
7202    async fn test_source_can_define_functions() {
7203        let kernel = Kernel::transient().expect("failed to create kernel");
7204
7205        // Write a script that defines a function
7206        kernel
7207            .execute(r#"write "/functions.kai" 'greet() {
7208    echo "Hello, $1!"
7209}'"#)
7210            .await
7211            .expect("write failed");
7212
7213        // Source it
7214        kernel
7215            .execute(r#"source "/functions.kai""#)
7216            .await
7217            .expect("source failed");
7218
7219        // Use the defined function
7220        let result = kernel
7221            .execute(r#"greet "World""#)
7222            .await
7223            .expect("greet failed");
7224
7225        assert!(result.ok());
7226        assert!(result.text_out().contains("Hello, World!"));
7227    }
7228
7229    #[tokio::test]
7230    async fn test_source_inherits_error_exit() {
7231        let kernel = Kernel::transient().expect("failed to create kernel");
7232
7233        // Enable error exit
7234        kernel.execute("set -e").await.expect("set -e failed");
7235
7236        // Write a script that has a failure
7237        kernel
7238            .execute(r#"write "/fail.kai" 'BEFORE="yes"
7239false
7240AFTER="yes"'"#)
7241            .await
7242            .expect("write failed");
7243
7244        // Source it (should exit on false due to set -e)
7245        kernel
7246            .execute(r#"source "/fail.kai""#)
7247            .await
7248            .ok();
7249
7250        // BEFORE should be set, AFTER should NOT be set due to error exit
7251        let before = kernel.get_var("BEFORE").await;
7252        assert_eq!(before, Some(Value::String("yes".into())));
7253
7254        // Note: This test depends on whether error exit is checked within source
7255        // Currently our implementation checks per-statement in the main kernel
7256    }
7257
7258    // ═══════════════════════════════════════════════════════════════════════════
7259    // set -e with && / || chains
7260    // ═══════════════════════════════════════════════════════════════════════════
7261
7262    #[tokio::test]
7263    async fn test_set_e_and_chain_left_fails() {
7264        // set -e; false && echo hi; REACHED=1 → REACHED should be set
7265        let kernel = Kernel::transient().expect("failed to create kernel");
7266        kernel.execute("set -e").await.expect("set -e failed");
7267
7268        kernel
7269            .execute("false && echo hi; REACHED=1")
7270            .await
7271            .expect("execution failed");
7272
7273        let reached = kernel.get_var("REACHED").await;
7274        assert_eq!(
7275            reached,
7276            Some(Value::Int(1)),
7277            "set -e should not trigger on left side of &&"
7278        );
7279    }
7280
7281    #[tokio::test]
7282    async fn test_set_e_and_chain_right_fails() {
7283        // set -e; true && false; REACHED=1 → REACHED should NOT be set
7284        let kernel = Kernel::transient().expect("failed to create kernel");
7285        kernel.execute("set -e").await.expect("set -e failed");
7286
7287        kernel
7288            .execute("true && false; REACHED=1")
7289            .await
7290            .expect("execution failed");
7291
7292        let reached = kernel.get_var("REACHED").await;
7293        assert!(
7294            reached.is_none(),
7295            "set -e should trigger when right side of && fails"
7296        );
7297    }
7298
7299    #[tokio::test]
7300    async fn test_set_e_or_chain_recovers() {
7301        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7302        let kernel = Kernel::transient().expect("failed to create kernel");
7303        kernel.execute("set -e").await.expect("set -e failed");
7304
7305        kernel
7306            .execute("false || echo recovered; REACHED=1")
7307            .await
7308            .expect("execution failed");
7309
7310        let reached = kernel.get_var("REACHED").await;
7311        assert_eq!(
7312            reached,
7313            Some(Value::Int(1)),
7314            "set -e should not trigger when || recovers the failure"
7315        );
7316    }
7317
7318    #[tokio::test]
7319    async fn test_set_e_or_chain_both_fail() {
7320        // set -e; false || false; REACHED=1 → REACHED should NOT be set
7321        let kernel = Kernel::transient().expect("failed to create kernel");
7322        kernel.execute("set -e").await.expect("set -e failed");
7323
7324        kernel
7325            .execute("false || false; REACHED=1")
7326            .await
7327            .expect("execution failed");
7328
7329        let reached = kernel.get_var("REACHED").await;
7330        assert!(
7331            reached.is_none(),
7332            "set -e should trigger when || chain ultimately fails"
7333        );
7334    }
7335
7336    // ═══════════════════════════════════════════════════════════════════════════
7337    // Cancellation Tests
7338    // ═══════════════════════════════════════════════════════════════════════════
7339
7340    /// Helper: schedule a cancel after a delay from a background thread.
7341    /// Uses std::thread because cancel() is sync and Kernel is not Send.
7342    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
7343        let k = Arc::clone(kernel);
7344        std::thread::spawn(move || {
7345            std::thread::sleep(delay);
7346            k.cancel();
7347        });
7348    }
7349
7350    #[tokio::test]
7351    async fn test_cancel_interrupts_for_loop() {
7352        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7353
7354        // Schedule cancel after a short delay from a background OS thread
7355        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7356
7357        let result = kernel
7358            .execute("for i in $(seq 1 100000); do X=$i; done")
7359            .await
7360            .expect("execute failed");
7361
7362        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
7363
7364        // The loop variable should be set to something < 100000
7365        let x = kernel.get_var("X").await;
7366        if let Some(Value::Int(n)) = x {
7367            assert!(n < 100000, "loop should have been interrupted before finishing, got X={n}");
7368        }
7369    }
7370
7371    #[tokio::test]
7372    async fn test_cancel_interrupts_while_loop() {
7373        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7374        kernel.execute("COUNT=0").await.expect("init failed");
7375
7376        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7377
7378        let result = kernel
7379            .execute("while true; do COUNT=$((COUNT + 1)); done")
7380            .await
7381            .expect("execute failed");
7382
7383        assert_eq!(result.code, 130);
7384
7385        let count = kernel.get_var("COUNT").await;
7386        if let Some(Value::Int(n)) = count {
7387            assert!(n > 0, "loop should have run at least once");
7388        }
7389    }
7390
7391    #[tokio::test]
7392    async fn test_reset_after_cancel() {
7393        // After cancellation, the next execute() should work normally
7394        let kernel = Kernel::transient().expect("failed to create kernel");
7395        kernel.cancel(); // cancel with nothing running
7396
7397        let result = kernel.execute("echo hello").await.expect("execute failed");
7398        assert!(result.ok(), "execute after cancel should succeed");
7399        assert_eq!(result.text_out().trim(), "hello");
7400    }
7401
7402    #[tokio::test]
7403    async fn test_cancel_interrupts_statement_sequence() {
7404        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7405
7406        // Schedule cancel after the first statement runs but before sleep finishes
7407        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
7408
7409        let result = kernel
7410            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
7411            .await
7412            .expect("execute failed");
7413
7414        assert_eq!(result.code, 130);
7415
7416        // STEP should be 1 (set before sleep), not 2 or 3
7417        let step = kernel.get_var("STEP").await;
7418        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
7419    }
7420
7421    // ═══════════════════════════════════════════════════════════════════════════
7422    // Case Statement Tests
7423    // ═══════════════════════════════════════════════════════════════════════════
7424
7425    #[tokio::test]
7426    async fn test_case_simple_match() {
7427        let kernel = Kernel::transient().expect("failed to create kernel");
7428
7429        let result = kernel
7430            .execute(r#"
7431                case "hello" in
7432                    hello) echo "matched hello" ;;
7433                    world) echo "matched world" ;;
7434                esac
7435            "#)
7436            .await
7437            .expect("case failed");
7438
7439        assert!(result.ok());
7440        assert_eq!(result.text_out().trim(), "matched hello");
7441    }
7442
7443    #[tokio::test]
7444    async fn test_case_wildcard_match() {
7445        let kernel = Kernel::transient().expect("failed to create kernel");
7446
7447        let result = kernel
7448            .execute(r#"
7449                case "main.rs" in
7450                    *.py) echo "Python" ;;
7451                    *.rs) echo "Rust" ;;
7452                    *) echo "Unknown" ;;
7453                esac
7454            "#)
7455            .await
7456            .expect("case failed");
7457
7458        assert!(result.ok());
7459        assert_eq!(result.text_out().trim(), "Rust");
7460    }
7461
7462    #[tokio::test]
7463    async fn test_case_default_match() {
7464        let kernel = Kernel::transient().expect("failed to create kernel");
7465
7466        let result = kernel
7467            .execute(r#"
7468                case "unknown.xyz" in
7469                    *.py) echo "Python" ;;
7470                    *.rs) echo "Rust" ;;
7471                    *) echo "Default" ;;
7472                esac
7473            "#)
7474            .await
7475            .expect("case failed");
7476
7477        assert!(result.ok());
7478        assert_eq!(result.text_out().trim(), "Default");
7479    }
7480
7481    #[tokio::test]
7482    async fn test_case_no_match() {
7483        let kernel = Kernel::transient().expect("failed to create kernel");
7484
7485        // Case with no default branch and no match
7486        let result = kernel
7487            .execute(r#"
7488                case "nope" in
7489                    "yes") echo "yes" ;;
7490                    "no") echo "no" ;;
7491                esac
7492            "#)
7493            .await
7494            .expect("case failed");
7495
7496        assert!(result.ok());
7497        assert!(result.text_out().is_empty(), "no match should produce empty output");
7498    }
7499
7500    #[tokio::test]
7501    async fn test_case_with_variable() {
7502        let kernel = Kernel::transient().expect("failed to create kernel");
7503
7504        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
7505
7506        let result = kernel
7507            .execute(r#"
7508                case ${LANG} in
7509                    python) echo "snake" ;;
7510                    rust) echo "crab" ;;
7511                    go) echo "gopher" ;;
7512                esac
7513            "#)
7514            .await
7515            .expect("case failed");
7516
7517        assert!(result.ok());
7518        assert_eq!(result.text_out().trim(), "crab");
7519    }
7520
7521    #[tokio::test]
7522    async fn test_case_multiple_patterns() {
7523        let kernel = Kernel::transient().expect("failed to create kernel");
7524
7525        let result = kernel
7526            .execute(r#"
7527                case "yes" in
7528                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
7529                    "n"|"no"|"N"|"NO") echo "negative" ;;
7530                esac
7531            "#)
7532            .await
7533            .expect("case failed");
7534
7535        assert!(result.ok());
7536        assert_eq!(result.text_out().trim(), "affirmative");
7537    }
7538
7539    #[tokio::test]
7540    async fn test_case_glob_question_mark() {
7541        let kernel = Kernel::transient().expect("failed to create kernel");
7542
7543        let result = kernel
7544            .execute(r#"
7545                case "test1" in
7546                    test?) echo "matched test?" ;;
7547                    *) echo "default" ;;
7548                esac
7549            "#)
7550            .await
7551            .expect("case failed");
7552
7553        assert!(result.ok());
7554        assert_eq!(result.text_out().trim(), "matched test?");
7555    }
7556
7557    #[tokio::test]
7558    async fn test_case_char_class() {
7559        let kernel = Kernel::transient().expect("failed to create kernel");
7560
7561        let result = kernel
7562            .execute(r#"
7563                case "Yes" in
7564                    [Yy]*) echo "yes-like" ;;
7565                    [Nn]*) echo "no-like" ;;
7566                esac
7567            "#)
7568            .await
7569            .expect("case failed");
7570
7571        assert!(result.ok());
7572        assert_eq!(result.text_out().trim(), "yes-like");
7573    }
7574
7575    // ═══════════════════════════════════════════════════════════════════════════
7576    // Cat Stdin Tests
7577    // ═══════════════════════════════════════════════════════════════════════════
7578
7579    #[tokio::test]
7580    async fn test_cat_from_pipeline() {
7581        let kernel = Kernel::transient().expect("failed to create kernel");
7582
7583        let result = kernel
7584            .execute(r#"echo "piped text" | cat"#)
7585            .await
7586            .expect("cat pipeline failed");
7587
7588        assert!(result.ok(), "cat failed: {}", result.err);
7589        assert_eq!(result.text_out().trim(), "piped text");
7590    }
7591
7592    #[tokio::test]
7593    async fn test_cat_from_pipeline_multiline() {
7594        let kernel = Kernel::transient().expect("failed to create kernel");
7595
7596        let result = kernel
7597            .execute(r#"echo "line1\nline2" | cat -n"#)
7598            .await
7599            .expect("cat pipeline failed");
7600
7601        assert!(result.ok(), "cat failed: {}", result.err);
7602        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
7603    }
7604
7605    // ═══════════════════════════════════════════════════════════════════════════
7606    // Heredoc Tests
7607    // ═══════════════════════════════════════════════════════════════════════════
7608
7609    #[tokio::test]
7610    async fn test_heredoc_basic() {
7611        let kernel = Kernel::transient().expect("failed to create kernel");
7612
7613        let result = kernel
7614            .execute("cat <<EOF\nhello\nEOF")
7615            .await
7616            .expect("heredoc failed");
7617
7618        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7619        assert_eq!(result.text_out().trim(), "hello");
7620    }
7621
7622    #[tokio::test]
7623    async fn test_arithmetic_in_string() {
7624        let kernel = Kernel::transient().expect("failed to create kernel");
7625
7626        let result = kernel
7627            .execute(r#"echo "result: $((1 + 2))""#)
7628            .await
7629            .expect("arithmetic in string failed");
7630
7631        assert!(result.ok(), "echo failed: {}", result.err);
7632        assert_eq!(result.text_out().trim(), "result: 3");
7633    }
7634
7635    #[tokio::test]
7636    async fn test_heredoc_multiline() {
7637        let kernel = Kernel::transient().expect("failed to create kernel");
7638
7639        let result = kernel
7640            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
7641            .await
7642            .expect("heredoc failed");
7643
7644        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7645        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
7646        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
7647        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
7648    }
7649
7650    #[tokio::test]
7651    async fn test_heredoc_variable_expansion() {
7652        // Bug N: unquoted heredoc should expand variables
7653        let kernel = Kernel::transient().expect("failed to create kernel");
7654
7655        kernel.execute("GREETING=hello").await.expect("set var");
7656
7657        let result = kernel
7658            .execute("cat <<EOF\n$GREETING world\nEOF")
7659            .await
7660            .expect("heredoc expansion failed");
7661
7662        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
7663        assert_eq!(result.text_out().trim(), "hello world");
7664    }
7665
7666    #[tokio::test]
7667    async fn test_heredoc_quoted_no_expansion() {
7668        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
7669        let kernel = Kernel::transient().expect("failed to create kernel");
7670
7671        kernel.execute("GREETING=hello").await.expect("set var");
7672
7673        let result = kernel
7674            .execute("cat <<'EOF'\n$GREETING world\nEOF")
7675            .await
7676            .expect("quoted heredoc failed");
7677
7678        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
7679        assert_eq!(result.text_out().trim(), "$GREETING world");
7680    }
7681
7682    #[tokio::test]
7683    async fn test_heredoc_default_value_expansion() {
7684        // Bug N: ${VAR:-default} should expand in unquoted heredocs
7685        let kernel = Kernel::transient().expect("failed to create kernel");
7686
7687        let result = kernel
7688            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
7689            .await
7690            .expect("heredoc default expansion failed");
7691
7692        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
7693        assert_eq!(result.text_out().trim(), "fallback");
7694    }
7695
7696    // ═══════════════════════════════════════════════════════════════════════════
7697    // Read Builtin Tests
7698    // ═══════════════════════════════════════════════════════════════════════════
7699
7700    #[tokio::test]
7701    async fn test_read_from_pipeline() {
7702        let kernel = Kernel::transient().expect("failed to create kernel");
7703
7704        // Pipe input to read
7705        let result = kernel
7706            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
7707            .await
7708            .expect("read pipeline failed");
7709
7710        assert!(result.ok(), "read failed: {}", result.err);
7711        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
7712    }
7713
7714    #[tokio::test]
7715    async fn test_read_multiple_vars_from_pipeline() {
7716        let kernel = Kernel::transient().expect("failed to create kernel");
7717
7718        let result = kernel
7719            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
7720            .await
7721            .expect("read pipeline failed");
7722
7723        assert!(result.ok(), "read failed: {}", result.err);
7724        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
7725    }
7726
7727    // ═══════════════════════════════════════════════════════════════════════════
7728    // Shell-Style Function Tests
7729    // ═══════════════════════════════════════════════════════════════════════════
7730
7731    #[tokio::test]
7732    async fn test_posix_function_with_positional_params() {
7733        let kernel = Kernel::transient().expect("failed to create kernel");
7734
7735        // Define POSIX-style function
7736        kernel
7737            .execute(r#"greet() { echo "Hello, $1!" }"#)
7738            .await
7739            .expect("function definition failed");
7740
7741        // Call the function
7742        let result = kernel
7743            .execute(r#"greet "Amy""#)
7744            .await
7745            .expect("function call failed");
7746
7747        assert!(result.ok(), "greet failed: {}", result.err);
7748        assert_eq!(result.text_out().trim(), "Hello, Amy!");
7749    }
7750
7751    #[tokio::test]
7752    async fn test_posix_function_multiple_args() {
7753        let kernel = Kernel::transient().expect("failed to create kernel");
7754
7755        // Define function using $1 and $2
7756        kernel
7757            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
7758            .await
7759            .expect("function definition failed");
7760
7761        // Call the function
7762        let result = kernel
7763            .execute(r#"add_greeting "Hello" "World""#)
7764            .await
7765            .expect("function call failed");
7766
7767        assert!(result.ok(), "function failed: {}", result.err);
7768        assert_eq!(result.text_out().trim(), "Hello World!");
7769    }
7770
7771    #[tokio::test]
7772    async fn test_bash_function_with_positional_params() {
7773        let kernel = Kernel::transient().expect("failed to create kernel");
7774
7775        // Define bash-style function (function keyword, no parens)
7776        kernel
7777            .execute(r#"function greet { echo "Hi $1" }"#)
7778            .await
7779            .expect("function definition failed");
7780
7781        // Call the function
7782        let result = kernel
7783            .execute(r#"greet "Bob""#)
7784            .await
7785            .expect("function call failed");
7786
7787        assert!(result.ok(), "greet failed: {}", result.err);
7788        assert_eq!(result.text_out().trim(), "Hi Bob");
7789    }
7790
7791    #[tokio::test]
7792    async fn test_shell_function_with_all_args() {
7793        let kernel = Kernel::transient().expect("failed to create kernel");
7794
7795        // Define function using $@ (all args)
7796        kernel
7797            .execute(r#"echo_all() { echo "args: $@" }"#)
7798            .await
7799            .expect("function definition failed");
7800
7801        // Call with multiple args
7802        let result = kernel
7803            .execute(r#"echo_all "a" "b" "c""#)
7804            .await
7805            .expect("function call failed");
7806
7807        assert!(result.ok(), "function failed: {}", result.err);
7808        assert_eq!(result.text_out().trim(), "args: a b c");
7809    }
7810
7811    #[tokio::test]
7812    async fn test_shell_function_with_arg_count() {
7813        let kernel = Kernel::transient().expect("failed to create kernel");
7814
7815        // Define function using $# (arg count)
7816        kernel
7817            .execute(r#"count_args() { echo "count: $#" }"#)
7818            .await
7819            .expect("function definition failed");
7820
7821        // Call with three args
7822        let result = kernel
7823            .execute(r#"count_args "x" "y" "z""#)
7824            .await
7825            .expect("function call failed");
7826
7827        assert!(result.ok(), "function failed: {}", result.err);
7828        assert_eq!(result.text_out().trim(), "count: 3");
7829    }
7830
7831    #[tokio::test]
7832    async fn test_shell_function_shared_scope() {
7833        let kernel = Kernel::transient().expect("failed to create kernel");
7834
7835        // Set a variable in parent scope
7836        kernel
7837            .execute(r#"PARENT_VAR="visible""#)
7838            .await
7839            .expect("set failed");
7840
7841        // Define shell function that reads and writes parent variable
7842        kernel
7843            .execute(r#"modify_parent() {
7844                echo "saw: ${PARENT_VAR}"
7845                PARENT_VAR="changed by function"
7846            }"#)
7847            .await
7848            .expect("function definition failed");
7849
7850        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
7851        let result = kernel.execute("modify_parent").await.expect("function failed");
7852
7853        assert!(
7854            result.text_out().contains("visible"),
7855            "Shell function should access parent scope, got: {}",
7856            result.text_out()
7857        );
7858
7859        // Parent variable should be modified
7860        let var = kernel.get_var("PARENT_VAR").await;
7861        assert_eq!(
7862            var,
7863            Some(Value::String("changed by function".into())),
7864            "Shell function should modify parent scope"
7865        );
7866    }
7867
7868    // ═══════════════════════════════════════════════════════════════════════════
7869    // Script Execution via PATH Tests
7870    // ═══════════════════════════════════════════════════════════════════════════
7871
7872    #[tokio::test]
7873    async fn test_script_execution_from_path() {
7874        let kernel = Kernel::transient().expect("failed to create kernel");
7875
7876        // Create /bin directory and script
7877        kernel.execute(r#"mkdir "/bin""#).await.ok();
7878        kernel
7879            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
7880            .await
7881            .expect("write script failed");
7882
7883        // Set PATH to /bin
7884        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
7885
7886        // Call script by name (without .kai extension)
7887        let result = kernel
7888            .execute("hello")
7889            .await
7890            .expect("script execution failed");
7891
7892        assert!(result.ok(), "script failed: {}", result.err);
7893        assert_eq!(result.text_out().trim(), "Hello from script!");
7894    }
7895
7896    #[tokio::test]
7897    async fn test_script_with_args() {
7898        let kernel = Kernel::transient().expect("failed to create kernel");
7899
7900        // Create script that uses positional params
7901        kernel.execute(r#"mkdir "/bin""#).await.ok();
7902        kernel
7903            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
7904            .await
7905            .expect("write script failed");
7906
7907        // Set PATH
7908        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
7909
7910        // Call script with arg
7911        let result = kernel
7912            .execute(r#"greet "World""#)
7913            .await
7914            .expect("script execution failed");
7915
7916        assert!(result.ok(), "script failed: {}", result.err);
7917        assert_eq!(result.text_out().trim(), "Hello, World!");
7918    }
7919
7920    #[tokio::test]
7921    async fn test_script_not_found() {
7922        let kernel = Kernel::transient().expect("failed to create kernel");
7923
7924        // Set empty PATH
7925        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
7926
7927        // Call non-existent script
7928        let result = kernel
7929            .execute("noscript")
7930            .await
7931            .expect("execution failed");
7932
7933        assert!(!result.ok(), "should fail with command not found");
7934        assert_eq!(result.code, 127);
7935        assert!(result.err.contains("command not found"));
7936    }
7937
7938    #[tokio::test]
7939    async fn test_script_path_search_order() {
7940        let kernel = Kernel::transient().expect("failed to create kernel");
7941
7942        // Create two directories with same-named script
7943        // Note: using "myscript" not "test" to avoid conflict with test builtin
7944        kernel.execute(r#"mkdir "/first""#).await.ok();
7945        kernel.execute(r#"mkdir "/second""#).await.ok();
7946        kernel
7947            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
7948            .await
7949            .expect("write failed");
7950        kernel
7951            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
7952            .await
7953            .expect("write failed");
7954
7955        // Set PATH with first before second
7956        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
7957
7958        // Should find first one
7959        let result = kernel
7960            .execute("myscript")
7961            .await
7962            .expect("script execution failed");
7963
7964        assert!(result.ok(), "script failed: {}", result.err);
7965        assert_eq!(result.text_out().trim(), "from first");
7966    }
7967
7968    // ═══════════════════════════════════════════════════════════════════════════
7969    // Special Variable Tests ($?, $$, unset vars)
7970    // ═══════════════════════════════════════════════════════════════════════════
7971
7972    #[tokio::test]
7973    async fn test_last_exit_code_success() {
7974        let kernel = Kernel::transient().expect("failed to create kernel");
7975
7976        // true exits with 0
7977        let result = kernel.execute("true; echo $?").await.expect("execution failed");
7978        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
7979    }
7980
7981    #[tokio::test]
7982    async fn test_last_exit_code_failure() {
7983        let kernel = Kernel::transient().expect("failed to create kernel");
7984
7985        // false exits with 1
7986        let result = kernel.execute("false; echo $?").await.expect("execution failed");
7987        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
7988    }
7989
7990    #[tokio::test]
7991    async fn test_current_pid() {
7992        let kernel = Kernel::transient().expect("failed to create kernel");
7993
7994        let result = kernel.execute("echo $$").await.expect("execution failed");
7995        // PID should be a positive number
7996        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
7997        assert!(pid > 0, "PID should be positive");
7998    }
7999
8000    #[tokio::test]
8001    async fn test_unset_variable_expands_to_empty() {
8002        let kernel = Kernel::transient().expect("failed to create kernel");
8003
8004        // Unset variable in interpolation should be empty
8005        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8006        assert_eq!(result.text_out().trim(), "prefix::suffix");
8007    }
8008
8009    #[tokio::test]
8010    async fn test_eq_ne_operators() {
8011        let kernel = Kernel::transient().expect("failed to create kernel");
8012
8013        // Test -eq operator
8014        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8015        assert_eq!(result.text_out().trim(), "eq works");
8016
8017        // Test -ne operator
8018        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8019        assert_eq!(result.text_out().trim(), "ne works");
8020
8021        // Test -eq with different values
8022        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8023        assert_eq!(result.text_out().trim(), "correct");
8024    }
8025
8026    #[tokio::test]
8027    async fn test_escaped_dollar_in_string() {
8028        let kernel = Kernel::transient().expect("failed to create kernel");
8029
8030        // \$ should produce literal $
8031        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8032        assert_eq!(result.text_out().trim(), "$100");
8033    }
8034
8035    #[tokio::test]
8036    async fn test_special_vars_in_interpolation() {
8037        let kernel = Kernel::transient().expect("failed to create kernel");
8038
8039        // Test $? in string interpolation
8040        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8041        assert_eq!(result.text_out().trim(), "exit: 0");
8042
8043        // Test $$ in string interpolation
8044        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8045        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8046        let text = result.text_out();
8047        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8048        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8049    }
8050
8051    // ═══════════════════════════════════════════════════════════════════════════
8052    // Command Substitution Tests
8053    // ═══════════════════════════════════════════════════════════════════════════
8054
8055    #[tokio::test]
8056    async fn test_command_subst_assignment() {
8057        let kernel = Kernel::transient().expect("failed to create kernel");
8058
8059        // Command substitution in assignment
8060        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8061        assert_eq!(result.text_out().trim(), "hello");
8062    }
8063
8064    #[tokio::test]
8065    async fn test_command_subst_with_args() {
8066        let kernel = Kernel::transient().expect("failed to create kernel");
8067
8068        // Command substitution with string argument
8069        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8070        assert_eq!(result.text_out().trim(), "a b c");
8071    }
8072
8073    #[tokio::test]
8074    async fn test_command_subst_nested_vars() {
8075        let kernel = Kernel::transient().expect("failed to create kernel");
8076
8077        // Variables inside command substitution
8078        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8079        assert_eq!(result.text_out().trim(), "hello world");
8080    }
8081
8082    #[tokio::test]
8083    async fn test_background_job_basic() {
8084        use std::time::Duration;
8085
8086        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8087
8088        // Run a simple background command
8089        let result = kernel.execute("echo hello &").await.expect("execution failed");
8090        assert!(result.ok(), "background command should succeed: {}", result.err);
8091        assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8092
8093        // Give the job time to complete
8094        tokio::time::sleep(Duration::from_millis(100)).await;
8095
8096        // Check job status
8097        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8098        assert!(status.ok(), "status should succeed: {}", status.err);
8099        assert!(
8100            status.text_out().contains("done:") || status.text_out().contains("running"),
8101            "should have valid status: {}",
8102            status.text_out()
8103        );
8104
8105        // Check stdout
8106        let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
8107        assert!(stdout.ok());
8108        assert!(stdout.text_out().contains("hello"));
8109    }
8110
8111    #[tokio::test]
8112    async fn test_heredoc_piped_to_command() {
8113        // Bug 4: heredoc content should pipe through to next command
8114        let kernel = Kernel::transient().expect("kernel");
8115        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8116        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8117        assert_eq!(result.text_out().trim(), "hello world");
8118    }
8119
8120    /// A transient kernel paired with a real, auto-cleaning tempdir. The
8121    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8122    /// tests need actual files on disk. Hold the returned `TempDir` for the
8123    /// test's lifetime: it removes the directory tree on drop — including on
8124    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8125    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8126    /// as a string for interpolation into scripts.
8127    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8128        let kernel = Kernel::transient().expect("kernel");
8129        let tmp = tempfile::tempdir().expect("tempdir");
8130        let dir = tmp.path().display().to_string();
8131        (kernel, tmp, dir)
8132    }
8133
8134    #[tokio::test]
8135    async fn test_for_loop_glob_iterates() {
8136        // Bug 1: for F in $(glob ...) should iterate per file, not once
8137        let (kernel, _tmp, dir) = transient_with_tempdir();
8138        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8139        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8140        let result = kernel.execute(&format!(r#"
8141            N=0
8142            for F in $(glob "{dir}/*.txt"); do
8143                N=$((N + 1))
8144            done
8145            echo $N
8146        "#)).await.unwrap();
8147        assert!(result.ok(), "for glob failed: {}", result.err);
8148        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8149    }
8150
8151    #[tokio::test]
8152    async fn test_bare_glob_expansion_echo() {
8153        let (kernel, _tmp, dir) = transient_with_tempdir();
8154        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8155        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8156        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8157        kernel.execute(&format!("cd {dir}")).await.unwrap();
8158        let result = kernel.execute("echo *.txt").await.unwrap();
8159        assert!(result.ok(), "echo *.txt failed: {}", result.err);
8160        let out = result.text_out();
8161        let out = out.trim();
8162        // Should contain both .txt files (order may vary)
8163        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8164        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8165        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8166    }
8167
8168    #[tokio::test]
8169    async fn test_bare_glob_no_matches_errors() {
8170        let (kernel, _tmp, dir) = transient_with_tempdir();
8171        kernel.execute(&format!("cd {dir}")).await.unwrap();
8172        let result = kernel.execute("echo *.nonexistent").await;
8173        match &result {
8174            Ok(exec) => {
8175                // No-match glob should produce a non-zero exit code
8176                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8177                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8178            }
8179            Err(e) => {
8180                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8181            }
8182        }
8183    }
8184
8185    #[tokio::test]
8186    async fn test_bare_glob_disabled_with_set() {
8187        let (kernel, _tmp, dir) = transient_with_tempdir();
8188        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8189        kernel.execute(&format!("cd {dir}")).await.unwrap();
8190        // Disable glob expansion
8191        kernel.execute("set +o glob").await.unwrap();
8192        let result = kernel.execute("echo *.txt").await.unwrap();
8193        // With glob disabled, *.txt should be passed as literal string
8194        assert!(result.ok(), "echo should succeed: {}", result.err);
8195        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8196    }
8197
8198    #[tokio::test]
8199    async fn test_bare_glob_quoted_not_expanded() {
8200        let (kernel, _tmp, dir) = transient_with_tempdir();
8201        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8202        kernel.execute(&format!("cd {dir}")).await.unwrap();
8203        // Quoted globs should NOT expand
8204        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8205        assert!(result.ok(), "echo should succeed: {}", result.err);
8206        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8207    }
8208
8209    #[tokio::test]
8210    async fn test_bare_glob_for_loop() {
8211        let (kernel, _tmp, dir) = transient_with_tempdir();
8212        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8213        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8214        kernel.execute(&format!("cd {dir}")).await.unwrap();
8215        let result = kernel.execute(r#"
8216            N=0
8217            for f in *.txt; do
8218                N=$((N + 1))
8219            done
8220            echo $N
8221        "#).await.unwrap();
8222        assert!(result.ok(), "for loop failed: {}", result.err);
8223        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8224    }
8225
8226    #[tokio::test]
8227    async fn test_glob_in_assignment_is_literal() {
8228        let kernel = Kernel::transient().expect("kernel");
8229        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8230        assert!(result.ok());
8231        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8232    }
8233
8234    #[tokio::test]
8235    async fn test_glob_in_test_expr_is_literal() {
8236        let kernel = Kernel::transient().expect("kernel");
8237        let result = kernel.execute(r#"
8238            if [[ *.txt == "*.txt" ]]; then
8239                echo "match"
8240            else
8241                echo "no"
8242            fi
8243        "#).await.unwrap();
8244        assert!(result.ok());
8245        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8246    }
8247
8248    #[tokio::test]
8249    async fn test_command_subst_echo_not_iterable() {
8250        // Regression guard: $(echo "a b c") must remain a single string
8251        let kernel = Kernel::transient().expect("kernel");
8252        let result = kernel.execute(r#"
8253            N=0
8254            for X in $(echo "a b c"); do N=$((N + 1)); done
8255            echo $N
8256        "#).await.unwrap();
8257        assert!(result.ok());
8258        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8259    }
8260
8261    // -- accumulate_result / newline tests --
8262
8263    #[test]
8264    fn test_accumulate_preserves_own_newlines() {
8265        // Outputs concatenate verbatim — a command's own trailing newline is
8266        // kept, none is invented.
8267        let mut acc = ExecResult::success("line1\n");
8268        let new = ExecResult::success("line2\n");
8269        accumulate_result(&mut acc, &new);
8270        assert_eq!(&*acc.text_out(), "line1\nline2\n");
8271        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8272    }
8273
8274    #[test]
8275    fn test_accumulate_inserts_no_separator() {
8276        // No artificial separator: `printf a; printf b` style concatenates to
8277        // `ab`, matching bash (regression for the 2026-06-09 finding).
8278        let mut acc = ExecResult::success("line1");
8279        let new = ExecResult::success("line2");
8280        accumulate_result(&mut acc, &new);
8281        assert_eq!(&*acc.text_out(), "line1line2");
8282    }
8283
8284    #[test]
8285    fn test_accumulate_empty_into_nonempty() {
8286        let mut acc = ExecResult::success("");
8287        let new = ExecResult::success("hello\n");
8288        accumulate_result(&mut acc, &new);
8289        assert_eq!(&*acc.text_out(), "hello\n");
8290    }
8291
8292    #[test]
8293    fn test_accumulate_nonempty_into_empty() {
8294        let mut acc = ExecResult::success("hello\n");
8295        let new = ExecResult::success("");
8296        accumulate_result(&mut acc, &new);
8297        assert_eq!(&*acc.text_out(), "hello\n");
8298    }
8299
8300    #[test]
8301    fn test_accumulate_stderr_no_double_newlines() {
8302        let mut acc = ExecResult::failure(1, "err1\n");
8303        let new = ExecResult::failure(1, "err2\n");
8304        accumulate_result(&mut acc, &new);
8305        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
8306    }
8307
8308    #[tokio::test]
8309    async fn test_multiple_echo_no_blank_lines() {
8310        let kernel = Kernel::transient().expect("kernel");
8311        let result = kernel
8312            .execute("echo one\necho two\necho three")
8313            .await
8314            .expect("execution failed");
8315        assert!(result.ok());
8316        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
8317    }
8318
8319    #[tokio::test]
8320    async fn test_for_loop_no_blank_lines() {
8321        let kernel = Kernel::transient().expect("kernel");
8322        let result = kernel
8323            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8324            .await
8325            .expect("execution failed");
8326        assert!(result.ok());
8327        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
8328    }
8329
8330    #[tokio::test]
8331    async fn test_for_command_subst_no_blank_lines() {
8332        let kernel = Kernel::transient().expect("kernel");
8333        let result = kernel
8334            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
8335            .await
8336            .expect("execution failed");
8337        assert!(result.ok());
8338        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
8339    }
8340
8341    // ------------------------------------------------------------------
8342    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
8343    // ------------------------------------------------------------------
8344
8345    /// Helper: a throwaway schema with one `--pair` param declared as
8346    /// consuming two positionals per occurrence. Modelled after what
8347    /// jq_native will declare for `--arg` / `--argjson`.
8348    fn multi_consume_schema() -> crate::tools::ToolSchema {
8349        use crate::tools::{ParamSchema, ToolSchema};
8350        ToolSchema::new("test", "multi-consume smoke")
8351            .param(
8352                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
8353                    .consumes(2),
8354            )
8355    }
8356
8357    fn pos(s: &str) -> Arg {
8358        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
8359    }
8360
8361    #[tokio::test]
8362    async fn build_args_multi_consume_single_occurrence() {
8363        let kernel = Kernel::transient().expect("kernel");
8364        let schema = multi_consume_schema();
8365        // Simulates:  test --pair NAME VALUE filter
8366        let args = vec![
8367            Arg::LongFlag("pair".into()),
8368            pos("NAME"),
8369            pos("VALUE"),
8370            pos("filter"),
8371        ];
8372        let built = kernel
8373            .build_args_async(&args, Some(&schema))
8374            .await
8375            .expect("build_args should succeed");
8376
8377        // `--pair` + its two positionals are consumed into named["pair"],
8378        // which becomes an outer array of one inner 2-element array.
8379        let pair = built.named.get("pair").expect("named[pair] missing");
8380        match pair {
8381            Value::Json(serde_json::Value::Array(occurrences)) => {
8382                assert_eq!(occurrences.len(), 1, "expected one occurrence");
8383                match &occurrences[0] {
8384                    serde_json::Value::Array(values) => {
8385                        assert_eq!(values.len(), 2, "pair must have 2 values");
8386                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
8387                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
8388                    }
8389                    other => panic!("expected inner array, got {other:?}"),
8390                }
8391            }
8392            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
8393        }
8394
8395        // The un-consumed positional ("filter") remains in `positional`.
8396        assert_eq!(built.positional.len(), 1);
8397        assert_eq!(built.positional[0], Value::String("filter".into()));
8398    }
8399    #[tokio::test]
8400    async fn build_args_multi_consume_two_occurrences_accumulate() {
8401        let kernel = Kernel::transient().expect("kernel");
8402        let schema = multi_consume_schema();
8403        // Simulates:  test --pair A 1 --pair B 2 filter
8404        let args = vec![
8405            Arg::LongFlag("pair".into()),
8406            pos("A"),
8407            pos("1"),
8408            Arg::LongFlag("pair".into()),
8409            pos("B"),
8410            pos("2"),
8411            pos("filter"),
8412        ];
8413        let built = kernel
8414            .build_args_async(&args, Some(&schema))
8415            .await
8416            .expect("build_args should succeed");
8417
8418        let pair = built.named.get("pair").expect("named[pair] missing");
8419        match pair {
8420            Value::Json(serde_json::Value::Array(occurrences)) => {
8421                assert_eq!(occurrences.len(), 2, "expected two occurrences");
8422                // Preserved in invocation order.
8423                match &occurrences[0] {
8424                    serde_json::Value::Array(values) => {
8425                        assert_eq!(values[0], serde_json::Value::String("A".into()));
8426                        assert_eq!(values[1], serde_json::Value::String("1".into()));
8427                    }
8428                    other => panic!("expected inner array, got {other:?}"),
8429                }
8430                match &occurrences[1] {
8431                    serde_json::Value::Array(values) => {
8432                        assert_eq!(values[0], serde_json::Value::String("B".into()));
8433                        assert_eq!(values[1], serde_json::Value::String("2".into()));
8434                    }
8435                    other => panic!("expected inner array, got {other:?}"),
8436                }
8437            }
8438            other => panic!("expected Json(Array(...)), got {other:?}"),
8439        }
8440    }
8441
8442    // ── undeclared space-form flag under map_positionals (kj --type val) ──
8443    //
8444    // A backend/MCP tool whose schema does NOT declare a flag must not let
8445    // `--flag value` (space form) silently divorce the value: that was a
8446    // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
8447    // kaish fails loud rather than guessing.
8448
8449    use crate::tools::{ParamSchema, ToolSchema};
8450
8451    /// Backend-style schema (map_positionals) declaring only a `name`
8452    /// positional — `--type` is intentionally undeclared.
8453    fn kj_like_schema() -> ToolSchema {
8454        ToolSchema::new("kj", "incomplete backend schema")
8455            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8456            .with_positional_mapping()
8457    }
8458
8459    #[tokio::test]
8460    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
8461        let kernel = Kernel::transient().expect("kernel");
8462        let schema = kj_like_schema();
8463        // kj context create exp --type explorer
8464        let args = vec![
8465            pos("context"),
8466            pos("create"),
8467            pos("exp"),
8468            Arg::LongFlag("type".into()),
8469            pos("explorer"),
8470        ];
8471        let err = kernel
8472            .build_args_async(&args, Some(&schema))
8473            .await
8474            .expect_err("undeclared --type with a space value must fail loud");
8475        let msg = err.to_string();
8476        assert!(msg.contains("--type"), "message should name the flag: {msg}");
8477        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
8478        assert!(msg.contains("kj"), "message should name the tool: {msg}");
8479    }
8480
8481    #[tokio::test]
8482    async fn build_args_declared_space_flag_still_binds() {
8483        let kernel = Kernel::transient().expect("kernel");
8484        // Same tool, but now the schema DECLARES --type as a string param.
8485        let schema = ToolSchema::new("kj", "complete schema")
8486            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8487            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
8488            .with_positional_mapping();
8489        let args = vec![
8490            pos("exp"),
8491            Arg::LongFlag("type".into()),
8492            pos("explorer"),
8493        ];
8494        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8495        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8496    }
8497
8498    #[tokio::test]
8499    async fn build_args_equals_form_binds_for_undeclared_flag() {
8500        let kernel = Kernel::transient().expect("kernel");
8501        let schema = kj_like_schema();
8502        // The unambiguous `=` form must keep working even when undeclared.
8503        let args = vec![
8504            pos("exp"),
8505            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
8506        ];
8507        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8508        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8509    }
8510
8511    #[tokio::test]
8512    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
8513        let kernel = Kernel::transient().expect("kernel");
8514        let schema = kj_like_schema();
8515        // No positional follows --force → unambiguously a bare flag.
8516        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
8517        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8518        assert!(built.flags.contains("force"));
8519    }
8520
8521    #[tokio::test]
8522    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
8523        let kernel = Kernel::transient().expect("kernel");
8524        let schema = kj_like_schema();
8525        // --verbose is followed by a flag, not a positional → not ambiguous.
8526        let args = vec![
8527            Arg::LongFlag("verbose".into()),
8528            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
8529        ];
8530        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8531        assert!(built.flags.contains("verbose"));
8532    }
8533
8534    #[tokio::test]
8535    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
8536        let kernel = Kernel::transient().expect("kernel");
8537        // Builtins set map_positionals=false; the ambiguity guard must not
8538        // fire there (clap validates their flags separately).
8539        let schema = ToolSchema::new("frobnicate", "builtin-style")
8540            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
8541        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
8542        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8543        assert!(built.flags.contains("frob"));
8544    }
8545
8546    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
8547    //
8548    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
8549    // params, not the root's. The subcommand-path positionals stay positional
8550    // (kj re-parses them with its own clap), and a value flag declared only on
8551    // a deep leaf still binds in space form.
8552
8553    /// kj → context (alias ctx) → create{--type value, --force bool}.
8554    /// map_positionals defaults false on every node (builtin/kj style).
8555    fn kj_tree_schema() -> ToolSchema {
8556        ToolSchema::new("kj", "subcommand tool").subcommand(
8557            ToolSchema::new("context", "context ops")
8558                .with_command_aliases(["ctx"])
8559                .subcommand(
8560                    ToolSchema::new("create", "create context")
8561                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
8562                        .param(ParamSchema::new("force", "bool")),
8563                ),
8564        )
8565    }
8566
8567    #[tokio::test]
8568    async fn build_args_binds_deep_leaf_value_flag_space_form() {
8569        let kernel = Kernel::transient().expect("kernel");
8570        let schema = kj_tree_schema();
8571        // kj context create --type explorer
8572        let args = vec![
8573            pos("context"),
8574            pos("create"),
8575            Arg::LongFlag("type".into()),
8576            pos("explorer"),
8577        ];
8578        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8579        // --type (declared only on the create leaf) binds in space form.
8580        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8581        // The subcommand path survives as positionals for kj to re-parse.
8582        let positionals: Vec<&str> = built
8583            .positional
8584            .iter()
8585            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8586            .collect();
8587        assert_eq!(positionals, vec!["context", "create"]);
8588    }
8589
8590    #[tokio::test]
8591    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
8592        let kernel = Kernel::transient().expect("kernel");
8593        let schema = kj_tree_schema();
8594        // kj context create --force somearg  → --force is a leaf bool flag,
8595        // it must NOT consume `somearg`.
8596        let args = vec![
8597            pos("context"),
8598            pos("create"),
8599            Arg::LongFlag("force".into()),
8600            pos("somearg"),
8601        ];
8602        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8603        assert!(built.flags.contains("force"), "force should be a bare flag");
8604        let positionals: Vec<&str> = built
8605            .positional
8606            .iter()
8607            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8608            .collect();
8609        assert_eq!(positionals, vec!["context", "create", "somearg"]);
8610    }
8611
8612    #[tokio::test]
8613    async fn build_args_alias_routed_leaf_binds_value_flag() {
8614        let kernel = Kernel::transient().expect("kernel");
8615        let schema = kj_tree_schema();
8616        // kj ctx create -t explorer  → command alias + short flag alias.
8617        let args = vec![
8618            pos("ctx"),
8619            pos("create"),
8620            Arg::ShortFlag("t".into()),
8621            pos("explorer"),
8622        ];
8623        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8624        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8625    }
8626
8627    #[tokio::test]
8628    async fn build_args_computed_subcommand_selector_fails_loud() {
8629        let kernel = Kernel::transient().expect("kernel");
8630        let schema = kj_tree_schema();
8631        // kj $(echo context) — routing can't see the value; fail loud.
8632        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
8633            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
8634        )]))];
8635        let err = kernel
8636            .build_args_async(&args, Some(&schema))
8637            .await
8638            .expect_err("computed subcommand selector must error");
8639        assert!(
8640            err.to_string().contains("subcommand name is required"),
8641            "got: {err}"
8642        );
8643    }
8644
8645    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
8646
8647    #[test]
8648    fn finalize_output_renders_when_kernel_owns_it() {
8649        use crate::interpreter::{OutputData, OutputFormat};
8650        let r = ExecResult::with_output(OutputData::text("RAW"));
8651        let out = finalize_output(r, Some(OutputFormat::Json), false);
8652        // Kernel renders the typed OutputData → JSON; text is no longer bare.
8653        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
8654    }
8655
8656    #[test]
8657    fn finalize_output_skips_when_tool_owns_output() {
8658        use crate::interpreter::{OutputData, OutputFormat};
8659        let r = ExecResult::with_output(OutputData::text("RAW"));
8660        let out = finalize_output(r, Some(OutputFormat::Json), true);
8661        // owns_output: the tool already rendered; kernel leaves bytes untouched.
8662        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
8663    }
8664
8665    #[test]
8666    fn finalize_output_no_format_is_noop() {
8667        use crate::interpreter::OutputData;
8668        let r = ExecResult::with_output(OutputData::text("RAW"));
8669        let out = finalize_output(r, None, false);
8670        assert_eq!(out.text_out(), "RAW");
8671    }
8672
8673    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
8674
8675    #[tokio::test]
8676    async fn test_initial_vars_set_and_exported() {
8677        let config = KernelConfig::transient()
8678            .with_var("INIT_FOO", Value::String("bar".into()));
8679        let kernel = Kernel::new(config).expect("failed to create kernel");
8680
8681        assert_eq!(
8682            kernel.get_var("INIT_FOO").await,
8683            Some(Value::String("bar".into()))
8684        );
8685        assert!(
8686            kernel.scope.read().await.is_exported("INIT_FOO"),
8687            "initial_vars entries must be marked exported"
8688        );
8689    }
8690
8691    #[tokio::test]
8692    async fn test_execute_with_vars_overlay_visible() {
8693        let kernel = Kernel::transient().expect("failed to create kernel");
8694        let mut overlay = HashMap::new();
8695        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
8696
8697        let result = kernel
8698            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
8699            .await
8700            .expect("execute failed");
8701
8702        assert!(result.ok());
8703        assert_eq!(result.text_out().trim(), "yes");
8704    }
8705
8706    #[tokio::test]
8707    async fn test_execute_with_vars_overlay_cleanup() {
8708        let kernel = Kernel::transient().expect("failed to create kernel");
8709        let mut overlay = HashMap::new();
8710        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
8711
8712        kernel
8713            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
8714            .await
8715            .expect("execute failed");
8716
8717        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
8718        assert!(
8719            !kernel.scope.read().await.is_exported("EPHEMERAL"),
8720            "overlay-only export must be cleared on return"
8721        );
8722    }
8723
8724    #[tokio::test]
8725    async fn test_execute_with_vars_does_not_clobber_existing_export() {
8726        let kernel = Kernel::transient().expect("failed to create kernel");
8727        kernel
8728            .execute("export OUTER=outer")
8729            .await
8730            .expect("export failed");
8731
8732        let mut overlay = HashMap::new();
8733        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
8734        let result = kernel
8735            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
8736            .await
8737            .expect("execute failed");
8738        assert_eq!(result.text_out().trim(), "inner");
8739
8740        assert_eq!(
8741            kernel.get_var("OUTER").await,
8742            Some(Value::String("outer".into())),
8743            "outer value must reappear after pop"
8744        );
8745        assert!(
8746            kernel.scope.read().await.is_exported("OUTER"),
8747            "outer export must survive overlay"
8748        );
8749    }
8750
8751    #[tokio::test]
8752    async fn test_execute_with_vars_inner_assignment_is_local() {
8753        let kernel = Kernel::transient().expect("failed to create kernel");
8754        let mut overlay = HashMap::new();
8755        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
8756
8757        // Variable assignment inside a single statement uses set() (innermost
8758        // frame), not set_global() — this matches bash function-local semantics.
8759        // We explicitly use `local FOO=...` style by relying on the pushed
8760        // frame; the assignment in the script body modifies the same frame.
8761        let result = kernel
8762            .execute_with_options(
8763                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
8764                ExecuteOptions::new().with_vars(overlay),
8765            )
8766            .await
8767            .expect("execute failed");
8768        assert!(result.ok());
8769
8770        // After the call the frame is popped, so LOCAL_FOO is gone regardless
8771        // of how the script reassigned it.
8772        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
8773    }
8774
8775    #[tokio::test]
8776    async fn test_external_command_sees_exported_var() {
8777        let kernel = Kernel::transient().expect("failed to create kernel");
8778        // PATH must be in scope to resolve the external `printenv` — the kernel
8779        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
8780        // what a frontend does through initial_vars.
8781        let path = std::env::var("PATH").unwrap_or_default();
8782        let result = kernel
8783            .execute(&format!(
8784                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
8785            ))
8786            .await
8787            .expect("execute failed");
8788
8789        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
8790        assert_eq!(result.text_out().trim(), "bar");
8791    }
8792
8793    #[tokio::test]
8794    async fn test_external_command_does_not_see_unexported_var() {
8795        let kernel = Kernel::transient().expect("failed to create kernel");
8796
8797        // Set without exporting; printenv must not see it (exit code != 0,
8798        // empty stdout per printenv semantics).
8799        let result = kernel
8800            .execute("EXT_BAR=hidden; printenv EXT_BAR")
8801            .await
8802            .expect("execute failed");
8803
8804        assert!(!result.ok(), "printenv should fail when var is unexported");
8805        assert!(
8806            result.text_out().trim().is_empty(),
8807            "no stdout when var is missing, got: {}",
8808            result.text_out()
8809        );
8810    }
8811
8812    #[tokio::test]
8813    async fn test_external_command_does_not_see_os_env() {
8814        // The kernel is hermetic: it never reads std::env::vars() and only
8815        // exports what it has been told to export. Cargo always sets PATH for
8816        // tests, so PATH is reliably present in the OS env — but a transient
8817        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
8818        // inside the kernel must fail.
8819        assert!(
8820            std::env::var_os("PATH").is_some(),
8821            "test precondition: cargo should set PATH"
8822        );
8823
8824        let kernel = Kernel::transient().expect("failed to create kernel");
8825        let result = kernel
8826            .execute("printenv PATH")
8827            .await
8828            .expect("execute failed");
8829
8830        assert!(
8831            !result.ok(),
8832            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
8833            result.text_out()
8834        );
8835        assert!(
8836            result.text_out().trim().is_empty(),
8837            "no PATH in subprocess env, got stdout={:?}",
8838            result.text_out()
8839        );
8840    }
8841
8842    #[tokio::test]
8843    async fn test_execute_with_vars_overlay_reaches_subprocess() {
8844        let kernel = Kernel::transient().expect("failed to create kernel");
8845        let mut overlay = HashMap::new();
8846        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
8847        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
8848        overlay.insert(
8849            "PATH".to_string(),
8850            Value::String(std::env::var("PATH").unwrap_or_default()),
8851        );
8852
8853        let result = kernel
8854            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
8855            .await
8856            .expect("execute failed");
8857
8858        assert!(
8859            result.ok(),
8860            "printenv should succeed: code={} stdout={:?} stderr={:?}",
8861            result.code,
8862            result.text_out(),
8863            result.err
8864        );
8865        assert_eq!(result.text_out().trim(), "subproc");
8866    }
8867
8868    #[tokio::test]
8869    async fn test_classify_command_builtin() {
8870        let kernel = Kernel::transient().expect("failed to create kernel");
8871        assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
8872        assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
8873    }
8874
8875    #[tokio::test]
8876    async fn test_classify_command_special_forms() {
8877        let kernel = Kernel::transient().expect("failed to create kernel");
8878        for name in ["true", "false", "source", "."] {
8879            assert_eq!(
8880                kernel.classify_command(name).await,
8881                CommandKind::Special,
8882                "{name} should be a special-form",
8883            );
8884        }
8885    }
8886
8887    #[tokio::test]
8888    async fn test_classify_command_dynamic() {
8889        let kernel = Kernel::transient().expect("failed to create kernel");
8890        assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
8891        assert_eq!(
8892            kernel.classify_command("$(pick)").await,
8893            CommandKind::Dynamic
8894        );
8895    }
8896
8897    #[tokio::test]
8898    async fn test_classify_command_external() {
8899        let kernel = Kernel::transient().expect("failed to create kernel");
8900        // Not a builtin, user function, or special-form → escapes to PATH.
8901        assert_eq!(
8902            kernel.classify_command("definitely_not_a_kaish_builtin").await,
8903            CommandKind::External
8904        );
8905        // `readonly` is *not* a kaish special-form despite the validator's
8906        // warning heuristic — at runtime it resolves to an external command, so
8907        // a consent gate must see it as External (regression guard against the
8908        // validator/runtime divergence).
8909        assert_eq!(
8910            kernel.classify_command("readonly").await,
8911            CommandKind::External
8912        );
8913        assert!(kernel.classify_command("readonly").await.escapes_kernel());
8914    }
8915
8916    #[tokio::test]
8917    async fn test_classify_command_user_tool_shadows_builtin() {
8918        let kernel = Kernel::transient().expect("failed to create kernel");
8919        kernel
8920            .execute(r#"greet() { echo "hi" }"#)
8921            .await
8922            .expect("function definition failed");
8923        assert_eq!(
8924            kernel.classify_command("greet").await,
8925            CommandKind::UserTool
8926        );
8927
8928        // A user function named after a builtin classifies as UserTool, matching
8929        // the interpreter's user-tools-first resolution.
8930        kernel
8931            .execute(r#"cat() { echo "shadowed" }"#)
8932            .await
8933            .expect("function definition failed");
8934        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
8935    }
8936
8937    #[tokio::test]
8938    async fn test_classify_command_alias_to_external_is_external() {
8939        let kernel = Kernel::transient().expect("failed to create kernel");
8940        // An alias whose head is an external binary must NOT report as the
8941        // builtin it shadows — execution expands the alias, so a consent gate
8942        // would otherwise be told an external command is internal.
8943        kernel
8944            .execute("alias cat='/usr/bin/whatever'")
8945            .await
8946            .expect("alias failed");
8947        assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
8948        assert!(kernel.classify_command("cat").await.escapes_kernel());
8949    }
8950
8951    #[tokio::test]
8952    async fn test_classify_command_alias_to_builtin() {
8953        let kernel = Kernel::transient().expect("failed to create kernel");
8954        kernel.execute("alias g=grep").await.expect("alias failed");
8955        assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
8956    }
8957
8958    #[tokio::test]
8959    async fn test_classify_command_alias_to_special_form() {
8960        let kernel = Kernel::transient().expect("failed to create kernel");
8961        kernel.execute("alias t=true").await.expect("alias failed");
8962        assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
8963    }
8964
8965    #[tokio::test]
8966    async fn test_classify_command_braced_var_is_dynamic() {
8967        let kernel = Kernel::transient().expect("failed to create kernel");
8968        // The string API can be handed a `${VAR}` head; it must not be mistaken
8969        // for an external named literally "${VAR}".
8970        assert_eq!(
8971            kernel.classify_command("${CMD}").await,
8972            CommandKind::Dynamic
8973        );
8974    }
8975
8976    /// Drift guard: `classify_command` must agree with what the executor
8977    /// (`execute_command_depth`) actually resolves. The classifier duplicates the
8978    /// interpreter's resolution rules (special-form set, user-tools-before-builtins
8979    /// precedence, alias expansion); without this test those copies could diverge
8980    /// silently — the exact failure class `classify_command` exists to prevent,
8981    /// just moved inside the kernel. Each case asserts the classification AND
8982    /// observes the real resolution, so a future change to one side without the
8983    /// other fails here.
8984    #[tokio::test]
8985    async fn classify_command_matches_executor() {
8986        let kernel = Kernel::transient().expect("failed to create kernel");
8987
8988        // (1) Special-forms. `SpecialForm::from_name` is the single source of
8989        // truth: classify reports Special via it, and the executor matches the
8990        // enum exhaustively, so const↔behavior parity is compile-enforced (a new
8991        // form won't build until both sides handle it). This test pins the other
8992        // half — that each form classifies Special AND actually short-circuits at
8993        // runtime rather than escaping to `PATH`. Every form is executed (not just
8994        // `true`/`false`): an external miss in this PATH-less kernel would be exit
8995        // 127, so a non-127 result that matches the form's own behavior proves the
8996        // short-circuit fired.
8997        for name in ["true", "false", "source", "."] {
8998            assert_eq!(
8999                kernel.classify_command(name).await,
9000                CommandKind::Special,
9001                "{name} should classify Special",
9002            );
9003        }
9004        assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9005        assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9006        // `source`/`.` short-circuit to execute_source, which (no filename) fails
9007        // with its own message — exit 1, never the 127 of an unresolved external.
9008        for name in ["source", "."] {
9009            let r = kernel.execute(name).await.expect("run source form");
9010            assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9011            assert!(
9012                r.err.contains("source: missing filename"),
9013                "{name} did not route to execute_source: {:?}",
9014                r.err,
9015            );
9016        }
9017
9018        // (2) Builtin: classify Builtin AND the executor runs the builtin.
9019        assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9020        let r = kernel.execute("echo hi").await.expect("run echo");
9021        assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9022
9023        // (3) User function shadows a builtin: classify UserTool AND the executor
9024        // runs the function body, not the `cat` builtin.
9025        kernel
9026            .execute(r#"cat() { echo SHADOWED }"#)
9027            .await
9028            .expect("define cat()");
9029        assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9030        let r = kernel.execute("cat").await.expect("run shadowed cat");
9031        assert_eq!(
9032            r.text_out().trim(),
9033            "SHADOWED",
9034            "executor ran the builtin instead of the shadowing function",
9035        );
9036
9037        // (4) Alias whose head is external: classify External AND the executor
9038        // resolves through the alias to a missing external (not a builtin).
9039        kernel
9040            .execute("alias x='/nonexistent/binary'")
9041            .await
9042            .expect("define alias x");
9043        assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9044        let r = kernel.execute("x").await.expect("run alias x");
9045        assert!(
9046            !r.ok(),
9047            "alias to a missing external should fail, not resolve internally",
9048        );
9049    }
9050}