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