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    /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1388    /// are **already tokenized**.
1389    ///
1390    /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1391    /// that already holds OS/structured argv (a busybox-style multicall binary, a
1392    /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1393    /// into a string just to have the lexer split it apart again — a round-trip
1394    /// that is lossy for typed values, since `to_argv()` stringifies
1395    /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1396    ///
1397    /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1398    /// command substitution, no word splitting — the "single-quoted word"
1399    /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1400    /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1401    /// does still apply, for consistency with the string door: a leading `~` is
1402    /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1403    /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1404    /// non-string `Value`
1405    /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1406    /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1407    /// clap arg model means a builtin that re-parses its own `to_argv()` still
1408    /// sees a stringified value; the typed-passthrough win fully lands only for
1409    /// builtins that read `args.positional` directly — the documented pattern.)
1410    ///
1411    /// This is a *peer*, not a subset: a command string can carry pipelines,
1412    /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1413    /// doors converge **late** (at the shared dispatch chain) rather than one
1414    /// wrapping the other. From argv classification onward `execute_argv` reuses
1415    /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1416    /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1417    /// transform, and the confirmation latch — so a latched `rm` still emits a
1418    /// nonce and an `ls --json` still applies output formatting. The kernel's
1419    /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1420    /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1421    ///
1422    /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1423    /// and the kernel's configured `request_timeout` applies (a hung builtin or
1424    /// external is interrupted at the deadline with exit code 124, the same as the
1425    /// string door). There is no per-call options surface yet — a future
1426    /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1427    #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1428    pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1429        let _guard = self.acquire_execute_lock().await;
1430        // Fresh cancel surface for this call: `execute_pipeline` reads
1431        // `self.cancel_token`, so a stale cancelled token from a prior call must be
1432        // replaced first. The returned clone is the token the watchdog cancels on
1433        // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1434        // cascading SIGTERM/SIGKILL to any external child.
1435        let cancel = self.reset_cancel();
1436
1437        // Honor the kernel-configured request timeout for parity with `execute`.
1438        let timeout = self.request_timeout;
1439        if timeout == Some(Duration::ZERO) {
1440            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1441        }
1442
1443        let pipeline = crate::ast::Pipeline {
1444            commands: vec![crate::ast::Command {
1445                name: name.to_string(),
1446                args: argv_to_args(argv),
1447                redirects: Vec::new(),
1448            }],
1449            background: false,
1450        };
1451        let result = self
1452            .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1453            .await?;
1454        self.update_last_result(&result).await;
1455        Ok(result)
1456    }
1457
1458    /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1459    /// string door ([`Self::execute_with_options`]) and the argv door
1460    /// ([`Self::execute_argv`]).
1461    ///
1462    /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1463    /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1464    /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1465    /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1466    /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1467    /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1468    /// stale handle would silently suspend nothing). Callers must short-circuit a
1469    /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1470    async fn run_under_watchdog<F>(
1471        &self,
1472        timeout: Option<Duration>,
1473        cancel: &tokio_util::sync::CancellationToken,
1474        work: F,
1475    ) -> Result<ExecResult>
1476    where
1477        F: std::future::Future<Output = Result<ExecResult>>,
1478    {
1479        // Assigned unconditionally (clearing any stale handle); None without a timeout.
1480        let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1481        {
1482            let mut ec = self.exec_ctx.write().await;
1483            ec.watchdog = watchdog.clone();
1484        }
1485
1486        let result = if let Some(d) = timeout {
1487            #[allow(clippy::expect_used)]
1488            let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1489            let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1490            let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1491            let r = work.await;
1492            timer.abort();
1493            match r {
1494                Ok(mut res) => {
1495                    if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1496                        res.code = 124;
1497                        if res.err.is_empty() {
1498                            res.err = format!("timeout: timed out after {:?}", d);
1499                        }
1500                    }
1501                    Ok(res)
1502                }
1503                Err(e) => Err(e),
1504            }
1505        } else {
1506            work.await
1507        };
1508
1509        // The timer task is gone (fired or aborted); drop the stale handle.
1510        {
1511            let mut ec = self.exec_ctx.write().await;
1512            ec.watchdog = None;
1513        }
1514        result
1515    }
1516
1517    /// Execute with per-call options. The primary entry point for embedders
1518    /// that don't need per-statement output streaming.
1519    ///
1520    /// `opts` carries timeout, transient vars overlay, optional cwd override,
1521    /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1522    /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1523    ///
1524    /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1525    /// against the kernel's internal token. Either firing cancels and kills
1526    /// external children. The embedder's token is read-only — kernel
1527    /// timeouts do NOT propagate into it. Distinguish via the returned
1528    /// `code`: 124 = timeout, 130 = cancellation.
1529    ///
1530    /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1531    /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1532    ///
1533    /// Concurrent callers on the same Kernel serialize on the kernel-wide
1534    /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1535    /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1536    pub async fn execute_with_options(
1537        &self,
1538        input: &str,
1539        opts: ExecuteOptions,
1540    ) -> Result<ExecResult> {
1541        self.run_inner(input, opts, None, None).await
1542    }
1543
1544    /// Same as [`Self::execute_with_options`] but with a per-statement output
1545    /// callback. The callback fires after each top-level statement so the
1546    /// embedder (REPL, MCP streaming) can flush output incrementally.
1547    pub async fn execute_with_options_streaming(
1548        &self,
1549        input: &str,
1550        opts: ExecuteOptions,
1551        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1552    ) -> Result<ExecResult> {
1553        self.run_inner(input, opts, None, Some(on_output)).await
1554    }
1555
1556    /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1557    ///
1558    /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read `String`), this never
1559    /// forces the input to be drained before execution: the reader seeds the
1560    /// first top-level command's `pipe_stdin`, and a command that does not read
1561    /// stdin (`echo`) returns without touching it. This is the seam a
1562    /// non-interactive frontend uses to forward an *open* process stdin without
1563    /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1564    ///
1565    /// Embedders that already hold a complete buffer should prefer the simpler
1566    /// [`ExecuteOptions::with_stdin`] String path.
1567    pub async fn execute_with_pipe_stdin(
1568        &self,
1569        input: &str,
1570        opts: ExecuteOptions,
1571        pipe_stdin: crate::scheduler::PipeReader,
1572    ) -> Result<ExecResult> {
1573        self.run_inner(input, opts, Some(pipe_stdin), None).await
1574    }
1575
1576    /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1577    /// `-c`/script frontend uses this to print output incrementally while
1578    /// feeding a lazy process-stdin pipe.
1579    pub async fn execute_with_pipe_stdin_streaming(
1580        &self,
1581        input: &str,
1582        opts: ExecuteOptions,
1583        pipe_stdin: crate::scheduler::PipeReader,
1584        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1585    ) -> Result<ExecResult> {
1586        self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1587    }
1588
1589    /// Execute kaish source code with a transient overlay of exported variables.
1590    ///
1591    /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1592    /// should use that method directly:
1593    /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1594    #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1595    pub async fn execute_with_vars(
1596        &self,
1597        input: &str,
1598        vars: HashMap<String, Value>,
1599    ) -> Result<ExecResult> {
1600        self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1601    }
1602
1603    /// Execute kaish source code with a per-statement callback.
1604    ///
1605    /// Deprecated thin wrapper. New code should use
1606    /// [`Self::execute_with_options_streaming`].
1607    #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1608    pub async fn execute_streaming(
1609        &self,
1610        input: &str,
1611        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1612    ) -> Result<ExecResult> {
1613        self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1614    }
1615
1616    /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1617    ///
1618    /// The `#[instrument]` execution span resolves its parent from the *current*
1619    /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1620    /// captured when the span is first entered — not when the future is
1621    /// constructed. So a thread-local `attach()` scoped to construction is too
1622    /// early to be seen (the integration test confirms this). `with_context`
1623    /// re-attaches the embedder's context on *every* poll of the inner future,
1624    /// so the context is current at first-enter and survives runtime thread
1625    /// hops. With no embedder trace context, the future runs unwrapped.
1626    async fn run_inner(
1627        &self,
1628        input: &str,
1629        opts: ExecuteOptions,
1630        pipe_stdin: Option<crate::scheduler::PipeReader>,
1631        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1632    ) -> Result<ExecResult> {
1633        use opentelemetry::context::FutureExt;
1634
1635        // Capture the embedder's baggage before `opts` is consumed so it can be
1636        // echoed back onto the result on egress (see `merge_egress_baggage`).
1637        let embedder_baggage = opts.baggage.clone();
1638
1639        let result = match crate::telemetry::extract_parent(&opts) {
1640            Some(parent) => self
1641                .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1642                .with_context(parent)
1643                .await,
1644            None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1645        };
1646
1647        result.map(|mut r| {
1648            crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1649            r
1650        })
1651    }
1652
1653    /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1654    /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1655    /// cwd override, and timeout race.
1656    #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1657    async fn execute_with_options_inner(
1658        &self,
1659        input: &str,
1660        opts: ExecuteOptions,
1661        pipe_stdin: Option<crate::scheduler::PipeReader>,
1662        on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1663    ) -> Result<ExecResult> {
1664        let _guard = self.acquire_execute_lock().await;
1665
1666        // Always reset to a fresh internal token; this is the kernel's own
1667        // cancel surface for embedders calling `Kernel::cancel()`. The
1668        // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1669        // is NOT written into `self.cancel_token`, because doing so would
1670        // (a) leak the embedder's token past this call's lifetime,
1671        // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1672        // (c) extend the token's lifetime via the kernel's strong clone.
1673        let internal = self.reset_cancel();
1674        // Race the embedder token against the kernel's internal token via a
1675        // tracked watcher task. We hold the JoinHandle so we can abort the
1676        // task at function exit — otherwise it would wait forever for either
1677        // token to fire and leak per call.
1678        let (effective_cancel, watcher_handle): (
1679            tokio_util::sync::CancellationToken,
1680            Option<tokio::task::JoinHandle<()>>,
1681        ) = if let Some(ext) = opts.cancel_token {
1682            let combined = tokio_util::sync::CancellationToken::new();
1683            let combined_writer = combined.clone();
1684            let i = internal.clone();
1685            let handle = tokio::spawn(async move {
1686                tokio::select! {
1687                    _ = i.cancelled() => combined_writer.cancel(),
1688                    _ = ext.cancelled() => combined_writer.cancel(),
1689                }
1690            });
1691            (combined, Some(handle))
1692        } else {
1693            (internal, None)
1694        };
1695
1696        // Effective timeout: per-call wins over kernel-config default.
1697        let timeout = opts.timeout.or(self.request_timeout);
1698
1699        // ZERO timeout: return 124 immediately without spawning anything.
1700        if timeout == Some(Duration::ZERO) {
1701            if let Some(h) = watcher_handle {
1702                h.abort();
1703            }
1704            return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1705        }
1706
1707        // Apply per-call vars overlay (push frame + set_exported), wrapped in
1708        // an RAII guard so a panic inside `execute_streaming_inner` still
1709        // pops the frame and unexports the temporarily-exported names.
1710        struct VarsFrameGuard<'a> {
1711            kernel: &'a Kernel,
1712            newly_exported: Vec<String>,
1713        }
1714        impl Drop for VarsFrameGuard<'_> {
1715            fn drop(&mut self) {
1716                // Best-effort cleanup using try_write. The execute_lock held
1717                // throughout execute_with_options means there is no concurrent
1718                // foreground caller; forks have their own scope and won't
1719                // block this. blocking_write would deadlock the runtime when
1720                // called from a tokio worker thread, so we explicitly do NOT
1721                // fall back to it — if try_write fails (which we've never
1722                // seen in practice), log loudly and accept the leak rather
1723                // than deadlock the entire kernel.
1724                let Ok(mut scope) = self.kernel.scope.try_write() else {
1725                    tracing::error!(
1726                        "vars frame guard: scope lock unexpectedly busy; \
1727                         skipping pop_frame to avoid runtime deadlock — \
1728                         transient vars may leak"
1729                    );
1730                    return;
1731                };
1732                scope.pop_frame();
1733                for name in self.newly_exported.drain(..) {
1734                    scope.unexport(&name);
1735                }
1736            }
1737        }
1738
1739        // Per-call cwd override: save current cwd, set the new one, restore
1740        // on Drop so the kernel's persistent cwd doesn't leak between calls.
1741        // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1742        struct CwdGuard<'a> {
1743            kernel: &'a Kernel,
1744            saved: PathBuf,
1745        }
1746        impl Drop for CwdGuard<'_> {
1747            fn drop(&mut self) {
1748                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1749                    tracing::error!(
1750                        "cwd guard: exec_ctx lock unexpectedly busy; \
1751                         skipping cwd restore — kernel cwd may be wrong for next call"
1752                    );
1753                    return;
1754                };
1755                ec.cwd = std::mem::take(&mut self.saved);
1756            }
1757        }
1758        let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1759            let mut ec = self.exec_ctx.write().await;
1760            let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1761            drop(ec);
1762            Some(CwdGuard { kernel: self, saved })
1763        } else {
1764            None
1765        };
1766
1767        // Per-call stdin: seed the persistent exec_ctx so the first top-level
1768        // command that reads stdin consumes it (it's `take()`n at dispatch).
1769        // Restore the prior value on Drop — normally `None`, so this also drops
1770        // any residual seed an stdin-less program never consumed, keeping it
1771        // from bleeding into the next call. Same RAII pattern as CwdGuard.
1772        struct StdinGuard<'a> {
1773            kernel: &'a Kernel,
1774            saved: Option<String>,
1775        }
1776        impl Drop for StdinGuard<'_> {
1777            fn drop(&mut self) {
1778                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1779                    tracing::error!(
1780                        "stdin guard: exec_ctx lock unexpectedly busy; \
1781                         skipping stdin restore — stale stdin may leak to next call"
1782                    );
1783                    return;
1784                };
1785                ec.stdin = self.saved.take();
1786            }
1787        }
1788        let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1789            let mut ec = self.exec_ctx.write().await;
1790            let saved = ec.stdin.replace(stdin);
1791            drop(ec);
1792            Some(StdinGuard { kernel: self, saved })
1793        } else {
1794            None
1795        };
1796
1797        // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1798        // persistent exec_ctx so the first stdin-reading command drains it (it's
1799        // `take()`n at pipeline build). The RAII guard restores the prior value
1800        // on Drop (normally `None`), so an unread reader doesn't bleed into the
1801        // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1802        struct PipeStdinGuard<'a> {
1803            kernel: &'a Kernel,
1804            saved: Option<crate::scheduler::PipeReader>,
1805        }
1806        impl Drop for PipeStdinGuard<'_> {
1807            fn drop(&mut self) {
1808                let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1809                    tracing::error!(
1810                        "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1811                         skipping restore — stale pipe stdin may leak to next call"
1812                    );
1813                    return;
1814                };
1815                ec.pipe_stdin = self.saved.take();
1816            }
1817        }
1818        let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
1819            let mut ec = self.exec_ctx.write().await;
1820            let saved = ec.pipe_stdin.replace(reader);
1821            drop(ec);
1822            Some(PipeStdinGuard { kernel: self, saved })
1823        } else {
1824            None
1825        };
1826
1827        let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
1828            let mut scope = self.scope.write().await;
1829            scope.push_frame();
1830            let mut newly = Vec::with_capacity(opts.vars.len());
1831            for (name, value) in opts.vars {
1832                if !scope.is_exported(&name) {
1833                    newly.push(name.clone());
1834                }
1835                scope.set_exported(name, value);
1836            }
1837            drop(scope);
1838            Some(VarsFrameGuard { kernel: self, newly_exported: newly })
1839        } else {
1840            None
1841        };
1842
1843        // Sync the effective cancel into self.exec_ctx so try_execute_external
1844        // (which reads via self.cancel_token) sees cancellation. We also need
1845        // builtins to see it via ctx.cancel — handled in execute_command.
1846        // For simplicity here we mirror effective_cancel into self.cancel_token
1847        // for the duration of this call, then restore the internal token at
1848        // the end (so a later Kernel::cancel still hits our internal surface).
1849        {
1850            #[allow(clippy::expect_used)]
1851            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1852            *cur = effective_cancel.clone();
1853        }
1854
1855        // Run the script under the movable-deadline watchdog (shared with the
1856        // argv door). The watchdog task cancels `effective_cancel` on an elapsed
1857        // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
1858        // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
1859        // already handled by the early return above.
1860        let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
1861        let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
1862            Some(cb) => cb,
1863            None => &mut *noop_cb,
1864        };
1865
1866        let result = self
1867            .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
1868            .await;
1869
1870        // Restore self.cancel_token to a fresh, uncancelled token so the
1871        // embedder's view of `Kernel::cancel()` stays predictable on the
1872        // next call (it cancels the kernel's own token, not whatever was
1873        // left over from this call's combined token).
1874        {
1875            #[allow(clippy::expect_used)]
1876            let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1877            *cur = tokio_util::sync::CancellationToken::new();
1878        }
1879
1880        // Tear down the embedder-token race watcher (if any). Leaving it
1881        // alive would idle forever waiting for tokens that may never fire.
1882        if let Some(h) = watcher_handle {
1883            h.abort();
1884        }
1885
1886        // VarsFrameGuard drops here on the success path and on early-return
1887        // paths above (error path included). Panic safety preserved.
1888        result
1889    }
1890
1891    /// The actual body of `execute_streaming`, run while holding the execute lock.
1892    ///
1893    /// Split out so internal kernel paths that are already under the lock can
1894    /// call this without deadlocking on re-entry. External callers must go
1895    /// through [`Self::execute_streaming`] so they acquire the lock.
1896    async fn execute_streaming_inner(
1897        &self,
1898        input: &str,
1899        on_output: &mut (dyn FnMut(&ExecResult) + Send),
1900    ) -> Result<ExecResult> {
1901        let program = parse(input).map_err(|errors| {
1902            let msg = errors
1903                .iter()
1904                .map(|e| e.format(input))
1905                .collect::<Vec<_>>()
1906                .join("\n");
1907            anyhow::anyhow!("parse error:\n{}", msg)
1908        })?;
1909
1910        // AST display mode: show AST instead of executing
1911        {
1912            let scope = self.scope.read().await;
1913            if scope.show_ast() {
1914                let output = format!("{:#?}\n", program);
1915                return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
1916            }
1917        }
1918
1919        // Pre-execution validation. Most warnings stay trace-only (every
1920        // external command fires an `UndefinedCommand` warning), but a warning
1921        // whose code opts into agent surfacing is collected here and prepended
1922        // to the result's stderr at each return point below.
1923        let mut surfaced_warnings = String::new();
1924        if !self.skip_validation {
1925            let user_tools = self.user_tools.read().await;
1926            let validator = Validator::new(&self.tools, &user_tools);
1927            let issues = validator.validate(&program);
1928
1929            // Collect errors (warnings are logged but don't prevent execution)
1930            let errors: Vec<_> = issues
1931                .iter()
1932                .filter(|i| i.severity == Severity::Error)
1933                .collect();
1934
1935            if !errors.is_empty() {
1936                let error_msg = errors
1937                    .iter()
1938                    .map(|e| e.format(input))
1939                    .collect::<Vec<_>>()
1940                    .join("\n");
1941                return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
1942            }
1943
1944            // Log warnings via tracing (trace level to avoid noise); surface the
1945            // opted-in ones to the agent so the guidance is actually seen.
1946            for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
1947                tracing::trace!("validation: {}", warning.format(input));
1948                if warning.code.surfaces_to_agent() {
1949                    surfaced_warnings.push_str(&warning.format(input));
1950                    surfaced_warnings.push('\n');
1951                }
1952            }
1953        }
1954
1955        // Surface opted-in validation warnings to the streaming frontend once,
1956        // before any command output. The streaming consumer (`-c`, REPL) prints
1957        // per `on_output` and ignores the returned aggregate err; non-streaming
1958        // callers (`kernel.execute`) use a noop callback and read the aggregate
1959        // `result.err` (prepended at each return below). The two paths are
1960        // disjoint, so this prints the advisory exactly once on each.
1961        if !surfaced_warnings.is_empty() {
1962            let mut advisory = ExecResult::success("");
1963            advisory.err = surfaced_warnings.clone();
1964            on_output(&advisory);
1965        }
1966
1967        let mut result = ExecResult::success("");
1968
1969        // Reset cancellation token for this execution.
1970        let cancel = self.reset_cancel();
1971
1972        for stmt in program.statements {
1973            if matches!(stmt, Stmt::Empty) {
1974                continue;
1975            }
1976
1977            // Cancellation checkpoint
1978            if cancel.is_cancelled() {
1979                result.code = 130;
1980                return Ok(result);
1981            }
1982
1983            let flow = self.execute_stmt_flow(&stmt).await?;
1984
1985            // Drain any stderr written by pipeline stages during this statement.
1986            // This captures stderr from intermediate pipeline stages that would
1987            // otherwise be lost (only the last stage's result is returned).
1988            let drained_stderr = {
1989                let mut receiver = self.stderr_receiver.lock().await;
1990                receiver.drain_lossy()
1991            };
1992
1993            match flow {
1994                ControlFlow::Normal(mut r) => {
1995                    if !drained_stderr.is_empty() {
1996                        if !r.err.is_empty() && !r.err.ends_with('\n') {
1997                            r.err.push('\n');
1998                        }
1999                        // Prepend pipeline stderr before the last stage's stderr
2000                        let combined = format!("{}{}", drained_stderr, r.err);
2001                        r.err = combined;
2002                    }
2003                    on_output(&r);
2004                    // Carry the last statement's structured output for MCP TOON encoding.
2005                    // Must be done here (not in accumulate_result) because accumulate_result
2006                    // is also used in loops where per-iteration output would be wrong.
2007                    let last_output = r.output().cloned();
2008                    accumulate_result(&mut result, &r);
2009                    result.set_output(last_output);
2010                }
2011                ControlFlow::Exit { code } => {
2012                    if !drained_stderr.is_empty() {
2013                        result.err.push_str(&drained_stderr);
2014                    }
2015                    result.code = code;
2016                    if !surfaced_warnings.is_empty() {
2017                        result.err = format!("{surfaced_warnings}{}", result.err);
2018                    }
2019                    return Ok(result);
2020                }
2021                ControlFlow::Return { mut value } => {
2022                    if !drained_stderr.is_empty() {
2023                        value.err = format!("{}{}", drained_stderr, value.err);
2024                    }
2025                    on_output(&value);
2026                    result = value;
2027                }
2028                ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2029                    if !drained_stderr.is_empty() {
2030                        r.err = format!("{}{}", drained_stderr, r.err);
2031                    }
2032                    on_output(&r);
2033                    result = r;
2034                }
2035            }
2036        }
2037
2038        if !surfaced_warnings.is_empty() {
2039            result.err = format!("{surfaced_warnings}{}", result.err);
2040        }
2041        Ok(result)
2042    }
2043
2044    /// Execute a single statement, returning control flow information.
2045    fn execute_stmt_flow<'a>(
2046        &'a self,
2047        stmt: &'a Stmt,
2048    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2049        use tracing::Instrument;
2050        let span = tracing::debug_span!("execute_stmt_flow", stmt_type = %stmt.kind_name());
2051        Box::pin(async move {
2052        match stmt {
2053            Stmt::Assignment(assign) => {
2054                // Use async evaluator to support command substitution
2055                let value = self.eval_expr_async(&assign.value).await
2056                    .context("failed to evaluate assignment")?;
2057                let mut scope = self.scope.write().await;
2058                if assign.local {
2059                    // local: set in innermost (current function) frame
2060                    scope.set(&assign.name, value.clone());
2061                } else {
2062                    // non-local: update existing or create in root frame
2063                    scope.set_global(&assign.name, value.clone());
2064                }
2065                drop(scope);
2066
2067                // Assignments don't produce output (like sh)
2068                Ok(ControlFlow::ok(ExecResult::success("")))
2069            }
2070            Stmt::Command(cmd) => {
2071                // Route single commands through execute_pipeline for a unified path.
2072                // This ensures all commands go through the dispatcher chain.
2073                let pipeline = crate::ast::Pipeline {
2074                    commands: vec![cmd.clone()],
2075                    background: false,
2076                };
2077                let result = self.execute_pipeline(&pipeline).await?;
2078                self.update_last_result(&result).await;
2079
2080                // Check for error exit mode (set -e)
2081                if !result.ok() {
2082                    let scope = self.scope.read().await;
2083                    if scope.error_exit_enabled() {
2084                        return Ok(ControlFlow::exit_code(result.code));
2085                    }
2086                }
2087
2088                Ok(ControlFlow::ok(result))
2089            }
2090            Stmt::Pipeline(pipeline) => {
2091                let result = self.execute_pipeline(pipeline).await?;
2092                self.update_last_result(&result).await;
2093
2094                // Check for error exit mode (set -e)
2095                if !result.ok() {
2096                    let scope = self.scope.read().await;
2097                    if scope.error_exit_enabled() {
2098                        return Ok(ControlFlow::exit_code(result.code));
2099                    }
2100                }
2101
2102                Ok(ControlFlow::ok(result))
2103            }
2104            Stmt::If(if_stmt) => {
2105                // Use async evaluator to support command substitution in conditions
2106                let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2107
2108                let branch = if is_truthy(&cond_value) {
2109                    &if_stmt.then_branch
2110                } else {
2111                    if_stmt.else_branch.as_deref().unwrap_or(&[])
2112                };
2113
2114                let mut result = ExecResult::success("");
2115                for stmt in branch {
2116                    let flow = self.execute_stmt_flow(stmt).await?;
2117                    match flow {
2118                        ControlFlow::Normal(r) => {
2119                            accumulate_result(&mut result, &r);
2120                            self.drain_stderr_into(&mut result).await;
2121                        }
2122                        other => {
2123                            self.drain_stderr_into(&mut result).await;
2124                            return Ok(other);
2125                        }
2126                    }
2127                }
2128                Ok(ControlFlow::ok(result))
2129            }
2130            Stmt::For(for_loop) => {
2131                // Evaluate all items and collect values for iteration
2132                // Use async evaluator to support command substitution like $(seq 1 5)
2133                let mut items: Vec<Value> = Vec::new();
2134                for item_expr in &for_loop.items {
2135                    // Glob expansion in for-loop items: `for f in *.txt`
2136                    if let Expr::GlobPattern(pattern) = item_expr {
2137                        let glob_enabled = {
2138                            let scope = self.scope.read().await;
2139                            scope.glob_enabled()
2140                        };
2141                        if glob_enabled {
2142                            let (paths, cwd) = {
2143                                let ctx = self.exec_ctx.read().await;
2144                                let paths = ctx.expand_glob(pattern).await
2145                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2146                                let cwd = ctx.resolve_path(".");
2147                                (paths, cwd)
2148                            };
2149                            if paths.is_empty() {
2150                                return Err(anyhow::anyhow!("no matches: {}", pattern));
2151                            }
2152                            for path in paths {
2153                                let display = if !pattern.starts_with('/') {
2154                                    path.strip_prefix(&cwd)
2155                                        .unwrap_or(&path)
2156                                        .to_string_lossy().into_owned()
2157                                } else {
2158                                    path.to_string_lossy().into_owned()
2159                                };
2160                                items.push(Value::String(display));
2161                            }
2162                            continue;
2163                        }
2164                    }
2165                    // Track whether this item came from $(cmd); that's the
2166                    // only position where multi-line stdout auto-splits per
2167                    // line. Arrays still spread element-by-element; bare
2168                    // $VAR is rejected upstream by validator E012. See
2169                    // docs/LANGUAGE.md.
2170                    let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2171                    let item = self.eval_expr_async(item_expr).await?;
2172                    match item {
2173                        // JSON arrays iterate over elements (preferred path
2174                        // when builtins emit .data — seq, jq, cut, find, …)
2175                        Value::Json(serde_json::Value::Array(arr)) => {
2176                            for elem in arr {
2177                                items.push(json_to_value(elem));
2178                            }
2179                        }
2180                        // Strings from $(cmd): empty → 0 iterations,
2181                        // multi-line → split per line (trimming trailing
2182                        // newlines and per-line trailing \r), single-line
2183                        // → one iteration. Whitespace within a line is
2184                        // NOT split — the "$VAR with spaces just works"
2185                        // promise is preserved because this only fires
2186                        // in CommandSubst position.
2187                        Value::String(s) if from_command_subst => {
2188                            let trimmed = s.trim_end_matches(['\n', '\r']);
2189                            if trimmed.is_empty() {
2190                                continue;
2191                            }
2192                            if trimmed.contains('\n') {
2193                                for line in trimmed.split('\n') {
2194                                    let line = line.trim_end_matches('\r');
2195                                    items.push(Value::String(line.to_string()));
2196                                }
2197                            } else {
2198                                items.push(Value::String(trimmed.to_string()));
2199                            }
2200                        }
2201                        // Binary isn't iterable — fail loud rather than loop
2202                        // once over an opaque byte blob.
2203                        Value::Bytes(_) => {
2204                            anyhow::bail!(
2205                                "for: cannot iterate over binary data — decode it \
2206                                 (base64/xxd) first"
2207                            );
2208                        }
2209                        // Strings not from $(cmd) stay as one value.
2210                        other => items.push(other),
2211                    }
2212                }
2213
2214                let mut result = ExecResult::success("");
2215                {
2216                    let mut scope = self.scope.write().await;
2217                    scope.push_frame();
2218                }
2219
2220                'outer: for item in items {
2221                    // Cancellation checkpoint per iteration
2222                    if self.is_cancelled() {
2223                        let mut scope = self.scope.write().await;
2224                        scope.pop_frame();
2225                        result.code = 130;
2226                        return Ok(ControlFlow::ok(result));
2227                    }
2228                    {
2229                        let mut scope = self.scope.write().await;
2230                        scope.set(&for_loop.variable, item);
2231                    }
2232                    for stmt in &for_loop.body {
2233                        let mut flow = match self.execute_stmt_flow(stmt).await {
2234                            Ok(f) => f,
2235                            Err(e) => {
2236                                let mut scope = self.scope.write().await;
2237                                scope.pop_frame();
2238                                return Err(e);
2239                            }
2240                        };
2241                        self.drain_stderr_into(&mut result).await;
2242                        match &mut flow {
2243                            ControlFlow::Normal(r) => {
2244                                accumulate_result(&mut result, r);
2245                                if !r.ok() {
2246                                    let scope = self.scope.read().await;
2247                                    if scope.error_exit_enabled() {
2248                                        drop(scope);
2249                                        let mut scope = self.scope.write().await;
2250                                        scope.pop_frame();
2251                                        return Ok(ControlFlow::exit_code(r.code));
2252                                    }
2253                                }
2254                            }
2255                            ControlFlow::Break { .. } => {
2256                                if flow.decrement_level() {
2257                                    accumulate_flow_output(&mut result, &flow);
2258                                    break 'outer;
2259                                }
2260                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2261                                let mut scope = self.scope.write().await;
2262                                scope.pop_frame();
2263                                return Ok(flow);
2264                            }
2265                            ControlFlow::Continue { .. } => {
2266                                if flow.decrement_level() {
2267                                    accumulate_flow_output(&mut result, &flow);
2268                                    continue 'outer;
2269                                }
2270                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2271                                let mut scope = self.scope.write().await;
2272                                scope.pop_frame();
2273                                return Ok(flow);
2274                            }
2275                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2276                                let mut scope = self.scope.write().await;
2277                                scope.pop_frame();
2278                                return Ok(flow);
2279                            }
2280                        }
2281                    }
2282                }
2283
2284                {
2285                    let mut scope = self.scope.write().await;
2286                    scope.pop_frame();
2287                }
2288                Ok(ControlFlow::ok(result))
2289            }
2290            Stmt::While(while_loop) => {
2291                let mut result = ExecResult::success("");
2292
2293                'outer: loop {
2294                    // Evaluate condition - use async to support command substitution
2295                    // Cancellation checkpoint per iteration
2296                    if self.is_cancelled() {
2297                        result.code = 130;
2298                        return Ok(ControlFlow::ok(result));
2299                    }
2300
2301                    let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2302
2303                    if !is_truthy(&cond_value) {
2304                        break;
2305                    }
2306
2307                    // Execute body
2308                    for stmt in &while_loop.body {
2309                        let mut flow = self.execute_stmt_flow(stmt).await?;
2310                        self.drain_stderr_into(&mut result).await;
2311                        match &mut flow {
2312                            ControlFlow::Normal(r) => {
2313                                accumulate_result(&mut result, r);
2314                                if !r.ok() {
2315                                    let scope = self.scope.read().await;
2316                                    if scope.error_exit_enabled() {
2317                                        return Ok(ControlFlow::exit_code(r.code));
2318                                    }
2319                                }
2320                            }
2321                            ControlFlow::Break { .. } => {
2322                                if flow.decrement_level() {
2323                                    accumulate_flow_output(&mut result, &flow);
2324                                    break 'outer;
2325                                }
2326                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2327                                return Ok(flow);
2328                            }
2329                            ControlFlow::Continue { .. } => {
2330                                if flow.decrement_level() {
2331                                    accumulate_flow_output(&mut result, &flow);
2332                                    continue 'outer;
2333                                }
2334                                fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2335                                return Ok(flow);
2336                            }
2337                            ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2338                                return Ok(flow);
2339                            }
2340                        }
2341                    }
2342                }
2343
2344                Ok(ControlFlow::ok(result))
2345            }
2346            Stmt::Case(case_stmt) => {
2347                // Evaluate the expression to match against
2348                let match_value = {
2349                    let value = self.eval_expr_async(&case_stmt.expr).await?;
2350                    value_to_string(&value)
2351                };
2352
2353                // Try each branch until we find a match
2354                for branch in &case_stmt.branches {
2355                    let matched = branch.patterns.iter().any(|pattern| {
2356                        glob_match(pattern, &match_value)
2357                    });
2358
2359                    if matched {
2360                        // Execute the branch body
2361                        let mut result = ExecResult::success("");
2362                        for stmt in &branch.body {
2363                            let flow = self.execute_stmt_flow(stmt).await?;
2364                            match flow {
2365                                ControlFlow::Normal(r) => {
2366                                    accumulate_result(&mut result, &r);
2367                                    self.drain_stderr_into(&mut result).await;
2368                                }
2369                                other => {
2370                                    self.drain_stderr_into(&mut result).await;
2371                                    return Ok(other);
2372                                }
2373                            }
2374                        }
2375                        return Ok(ControlFlow::ok(result));
2376                    }
2377                }
2378
2379                // No match - return success with empty output (like sh)
2380                Ok(ControlFlow::ok(ExecResult::success("")))
2381            }
2382            Stmt::Break(levels) => {
2383                Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2384            }
2385            Stmt::Continue(levels) => {
2386                Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2387            }
2388            Stmt::Return(expr) => {
2389                // return [N] - N becomes the exit code, NOT stdout
2390                // Shell semantics: return sets exit code, doesn't produce output
2391                let result = if let Some(e) = expr {
2392                    let val = self.eval_expr_async(e).await?;
2393                    let code = crate::interpreter::value_to_exit_code(&val)
2394                        .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2395                    ExecResult::from_parts(code, String::new(), String::new(), None)
2396                } else {
2397                    ExecResult::success("")
2398                };
2399                Ok(ControlFlow::return_value(result))
2400            }
2401            Stmt::Exit(expr) => {
2402                let code = if let Some(e) = expr {
2403                    let val = self.eval_expr_async(e).await?;
2404                    crate::interpreter::value_to_exit_code(&val)
2405                        .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2406                } else {
2407                    0
2408                };
2409                Ok(ControlFlow::exit_code(code))
2410            }
2411            Stmt::ToolDef(tool_def) => {
2412                let mut user_tools = self.user_tools.write().await;
2413                user_tools.insert(tool_def.name.clone(), tool_def.clone());
2414                Ok(ControlFlow::ok(ExecResult::success("")))
2415            }
2416            Stmt::AndChain { left, right } => {
2417                // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2418                // Suppress errexit for the left side — && handles failure itself.
2419                {
2420                    let mut scope = self.scope.write().await;
2421                    scope.suppress_errexit();
2422                }
2423                let left_flow = match self.execute_stmt_flow(left).await {
2424                    Ok(f) => f,
2425                    Err(e) => {
2426                        let mut scope = self.scope.write().await;
2427                        scope.unsuppress_errexit();
2428                        return Err(e);
2429                    }
2430                };
2431                {
2432                    let mut scope = self.scope.write().await;
2433                    scope.unsuppress_errexit();
2434                }
2435                match left_flow {
2436                    ControlFlow::Normal(mut left_result) => {
2437                        self.drain_stderr_into(&mut left_result).await;
2438                        self.update_last_result(&left_result).await;
2439                        if left_result.ok() {
2440                            let right_flow = self.execute_stmt_flow(right).await?;
2441                            match right_flow {
2442                                ControlFlow::Normal(mut right_result) => {
2443                                    self.drain_stderr_into(&mut right_result).await;
2444                                    self.update_last_result(&right_result).await;
2445                                    let mut combined = left_result;
2446                                    accumulate_result(&mut combined, &right_result);
2447                                    Ok(ControlFlow::ok(combined))
2448                                }
2449                                other => Ok(other),
2450                            }
2451                        } else {
2452                            Ok(ControlFlow::ok(left_result))
2453                        }
2454                    }
2455                    _ => Ok(left_flow),
2456                }
2457            }
2458            Stmt::OrChain { left, right } => {
2459                // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2460                // Suppress errexit for the left side — || handles failure itself.
2461                {
2462                    let mut scope = self.scope.write().await;
2463                    scope.suppress_errexit();
2464                }
2465                let left_flow = match self.execute_stmt_flow(left).await {
2466                    Ok(f) => f,
2467                    Err(e) => {
2468                        let mut scope = self.scope.write().await;
2469                        scope.unsuppress_errexit();
2470                        return Err(e);
2471                    }
2472                };
2473                {
2474                    let mut scope = self.scope.write().await;
2475                    scope.unsuppress_errexit();
2476                }
2477                match left_flow {
2478                    ControlFlow::Normal(mut left_result) => {
2479                        self.drain_stderr_into(&mut left_result).await;
2480                        self.update_last_result(&left_result).await;
2481                        if !left_result.ok() {
2482                            let right_flow = self.execute_stmt_flow(right).await?;
2483                            match right_flow {
2484                                ControlFlow::Normal(mut right_result) => {
2485                                    self.drain_stderr_into(&mut right_result).await;
2486                                    self.update_last_result(&right_result).await;
2487                                    let mut combined = left_result;
2488                                    accumulate_result(&mut combined, &right_result);
2489                                    Ok(ControlFlow::ok(combined))
2490                                }
2491                                other => Ok(other),
2492                            }
2493                        } else {
2494                            Ok(ControlFlow::ok(left_result))
2495                        }
2496                    }
2497                    _ => Ok(left_flow), // Propagate non-normal flow
2498                }
2499            }
2500            Stmt::Test(test_expr) => {
2501                let is_true = self.eval_test_async(test_expr).await?;
2502                if is_true {
2503                    Ok(ControlFlow::ok(ExecResult::success("")))
2504                } else {
2505                    Ok(ControlFlow::ok(ExecResult::failure(1, "")))
2506                }
2507            }
2508            Stmt::EnvScoped { assignments, body } => {
2509                // Inline env prefix (`NAME=value ... command`): apply the
2510                // assignments as EXPORTED vars in a fresh frame so the command
2511                // — and its subprocess environment — sees them, then unwind so
2512                // they do NOT persist (bash-style command-scoped env). Values
2513                // evaluate left-to-right with earlier ones already in scope, so
2514                // `A=1 B=$A cmd` works.
2515                {
2516                    let mut scope = self.scope.write().await;
2517                    scope.push_frame();
2518                }
2519                let mut prior_export: Vec<(String, bool)> =
2520                    Vec::with_capacity(assignments.len());
2521                let mut setup_err: Option<anyhow::Error> = None;
2522                for assign in assignments {
2523                    match self.eval_expr_async(&assign.value).await {
2524                        Ok(value) => {
2525                            let mut scope = self.scope.write().await;
2526                            prior_export
2527                                .push((assign.name.clone(), scope.is_exported(&assign.name)));
2528                            scope.set_exported(&assign.name, value);
2529                        }
2530                        Err(e) => {
2531                            setup_err = Some(e);
2532                            break;
2533                        }
2534                    }
2535                }
2536
2537                let flow = if setup_err.is_none() {
2538                    self.execute_stmt_flow(body).await
2539                } else {
2540                    Ok(ControlFlow::ok(ExecResult::success("")))
2541                };
2542
2543                // Unwind the env frame and restore export marks unconditionally
2544                // (names that were not exported before must not stay exported).
2545                {
2546                    let mut scope = self.scope.write().await;
2547                    scope.pop_frame();
2548                    for (name, was_exported) in &prior_export {
2549                        if !*was_exported {
2550                            scope.unexport(name);
2551                        }
2552                    }
2553                }
2554
2555                match setup_err {
2556                    Some(e) => Err(e),
2557                    None => flow,
2558                }
2559            }
2560            Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2561        }
2562        }.instrument(span))
2563    }
2564
2565    /// Execute a pipeline.
2566    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(background = pipeline.background, command_count = pipeline.commands.len()))]
2567    async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2568        if pipeline.commands.is_empty() {
2569            return Ok(ExecResult::success(""));
2570        }
2571
2572        // Handle background execution (`&` operator)
2573        if pipeline.background {
2574            return self.execute_background(pipeline).await;
2575        }
2576
2577        // All commands go through the runner with the Kernel as dispatcher.
2578        // This is the single execution path — no fast path for single commands.
2579        //
2580        // IMPORTANT: We snapshot exec_ctx into a local context and release the
2581        // lock before running. This prevents deadlocks when dispatch_command
2582        // is called from within the pipeline and recursively triggers another
2583        // pipeline (e.g., via user-defined tools).
2584        let (mut ctx, has_pipe_stdin) = {
2585            let ec = self.exec_ctx.read().await;
2586            let scope = self.scope.read().await;
2587            // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2588            // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2589            // the consume-once block below, so note its presence here.
2590            let has_pipe_stdin = ec.pipe_stdin.is_some();
2591            (ExecContext {
2592                backend: ec.backend.clone(),
2593                scope: scope.clone(),
2594                cwd: ec.cwd.clone(),
2595                prev_cwd: ec.prev_cwd.clone(),
2596                // Seed the first stage's stdin from any frontend-supplied input
2597                // (`ExecuteOptions::stdin`, e.g. `printf … | kaish -c sort`). The
2598                // runner forwards `ctx.stdin` to stage 0 unless a redirect
2599                // (`< file`/heredoc) already set it, so redirect precedence holds.
2600                stdin: ec.stdin.clone(),
2601                stdin_data: ec.stdin_data.clone(),
2602                stdin_data_rx: None,
2603                pipe_stdin: None,
2604                pipe_stdout: None,
2605                stderr: ec.stderr.clone(),
2606                tool_schemas: ec.tool_schemas.clone(),
2607                tools: ec.tools.clone(),
2608                job_manager: ec.job_manager.clone(),
2609                pipeline_position: PipelinePosition::Only,
2610                interactive: self.interactive,
2611                aliases: ec.aliases.clone(),
2612                ignore_config: ec.ignore_config.clone(),
2613                output_limit: ec.output_limit.clone(),
2614                allow_external_commands: self.allow_external_commands,
2615                nonce_store: ec.nonce_store.clone(),
2616                trash_backend: ec.trash_backend.clone(),
2617                #[cfg(all(unix, feature = "subprocess"))]
2618                terminal_state: ec.terminal_state.clone(),
2619                dispatcher: self.dispatcher(),
2620                cancel: {
2621                    #[allow(clippy::expect_used)]
2622                    let token = self.cancel_token.lock().expect("cancel_token poisoned");
2623                    token.clone()
2624                },
2625                output_format: None,
2626                vfs_budget: self.vfs_budget.clone(),
2627                watchdog: ec.watchdog.clone(),
2628                #[cfg(all(feature = "localfs", feature = "overlay"))]
2629                overlay_handle: self.overlay_handle.clone(),
2630            }, has_pipe_stdin)
2631        }; // locks released
2632
2633        // Consume-once: move/clear the seeded stdin sources from the persistent
2634        // exec_ctx now that this pipeline's ctx owns them, so a later statement
2635        // in the same call (`cat ; cat`) does not re-receive them — matching
2636        // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2637        // (the ctx above was built with `pipe_stdin: None`).
2638        if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2639            let mut ec = self.exec_ctx.write().await;
2640            ctx.pipe_stdin = ec.pipe_stdin.take();
2641            ec.stdin = None;
2642            ec.stdin_data = None;
2643        }
2644
2645        let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2646
2647        // Post-hoc spill check (catches builtins and fast external commands)
2648        if ctx.output_limit.is_enabled() {
2649            let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2650        }
2651
2652        // Signal spill with exit 3; agent reads the spill file directly
2653        // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2654        if result.did_spill {
2655            result.original_code = Some(result.code);
2656            result.code = 3;
2657        }
2658
2659        // Sync changes back from context
2660        {
2661            let mut ec = self.exec_ctx.write().await;
2662            ec.cwd = ctx.cwd.clone();
2663            ec.prev_cwd = ctx.prev_cwd.clone();
2664            ec.aliases = ctx.aliases.clone();
2665            ec.ignore_config = ctx.ignore_config.clone();
2666            ec.output_limit = ctx.output_limit.clone();
2667        }
2668        {
2669            let mut scope = self.scope.write().await;
2670            *scope = ctx.scope.clone();
2671        }
2672
2673        Ok(result)
2674    }
2675
2676    /// Execute a pipeline in the background.
2677    ///
2678    /// The command is spawned as a tokio task, registered with the JobManager,
2679    /// and its output is captured via BoundedStreams. The job is observable via
2680    /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2681    ///
2682    /// Returns immediately with a job ID like "[1]".
2683    #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2684    async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2685        use tokio::sync::oneshot;
2686
2687        // Format the command for display in /v/jobs/{id}/command
2688        let command_str = self.format_pipeline(pipeline);
2689
2690        // Create bounded streams for output capture
2691        let stdout = Arc::new(BoundedStream::default_size());
2692        let stderr = Arc::new(BoundedStream::default_size());
2693
2694        // Create channel for result notification
2695        let (tx, rx) = oneshot::channel();
2696
2697        // Register with JobManager to get job ID and create VFS entries
2698        let job_id = self.jobs.register_with_streams(
2699            command_str.clone(),
2700            rx,
2701            stdout.clone(),
2702            stderr.clone(),
2703        ).await;
2704
2705        // Fork the kernel for this background job. The fork snapshots the
2706        // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2707        // while sharing the job manager, VFS, and tool registry. The fork's
2708        // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2709        // is available here — something BackendDispatcher couldn't provide.
2710        //
2711        // The fork gets its own cancellation token (recorded on the job so
2712        // `kill %N` can stop the job — including a pure-builtin job with no OS
2713        // process group) and is stamped with the job id so any external
2714        // command it spawns records its process group for `kill -<sig> %N`.
2715        let cancel = tokio_util::sync::CancellationToken::new();
2716        self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2717        let fork = self.fork_for_background(cancel, job_id).await;
2718        let runner = self.runner.clone();
2719        let commands = pipeline.commands.clone();
2720
2721        // Snapshot the fork's exec_ctx for the spawned task. We have to do
2722        // this before tokio::spawn because the fork's exec_ctx is behind a
2723        // tokio RwLock and we want the spawned task to own its ctx.
2724        let mut bg_ctx = {
2725            let ec = fork.exec_ctx.read().await;
2726            ec.child_for_pipeline()
2727        };
2728        bg_ctx.scope = fork.scope.read().await.clone();
2729        // The fork's dispatcher points at the fork itself; set it here so
2730        // builtins inside the background task (e.g. timeout) re-dispatch
2731        // through the fork, not the parent.
2732        bg_ctx.dispatcher = fork.dispatcher();
2733
2734        // Spawn the background task. Propagate the embedder's trace context
2735        // across the spawn boundary so the job's spans stay in the same trace.
2736        tokio::spawn(crate::telemetry::bind_current_context(async move {
2737            // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2738            // gives us that (Kernel implements CommandDispatcher).
2739            let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2740
2741            // Write output to streams
2742            let text = result.text_out();
2743            if !text.is_empty() {
2744                stdout.write(text.as_bytes()).await;
2745            }
2746            if !result.err.is_empty() {
2747                stderr.write(result.err.as_bytes()).await;
2748            }
2749
2750            // Close streams
2751            stdout.close().await;
2752            stderr.close().await;
2753
2754            // Send result to JobManager (ignore error if receiver dropped)
2755            let _ = tx.send(result);
2756        }));
2757
2758        Ok(ExecResult::success(format!("[{}]", job_id)))
2759    }
2760
2761    /// Format a pipeline as a command string for display.
2762    fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
2763        pipeline.commands
2764            .iter()
2765            .map(|cmd| {
2766                let mut parts = vec![cmd.name.clone()];
2767                for arg in &cmd.args {
2768                    match arg {
2769                        Arg::Positional(expr) => {
2770                            parts.push(self.format_expr(expr));
2771                        }
2772                        Arg::Named { key, value } => {
2773                            parts.push(format!("--{}={}", key, self.format_expr(value)));
2774                        }
2775                        Arg::WordAssign { key, value } => {
2776                            parts.push(format!("{}={}", key, self.format_expr(value)));
2777                        }
2778                        Arg::ShortFlag(name) => {
2779                            parts.push(format!("-{}", name));
2780                        }
2781                        Arg::LongFlag(name) => {
2782                            parts.push(format!("--{}", name));
2783                        }
2784                        Arg::DoubleDash => {
2785                            parts.push("--".to_string());
2786                        }
2787                    }
2788                }
2789                parts.join(" ")
2790            })
2791            .collect::<Vec<_>>()
2792            .join(" | ")
2793    }
2794
2795    /// Format an expression as a string for display.
2796    fn format_expr(&self, expr: &Expr) -> String {
2797        match expr {
2798            Expr::Literal(Value::String(s)) => {
2799                if s.contains(' ') || s.contains('"') {
2800                    format!("'{}'", s.replace('\'', "\\'"))
2801                } else {
2802                    s.clone()
2803                }
2804            }
2805            Expr::Literal(Value::Int(i)) => i.to_string(),
2806            Expr::Literal(Value::Float(f)) => f.to_string(),
2807            Expr::Literal(Value::Bool(b)) => b.to_string(),
2808            Expr::Literal(Value::Null) => "null".to_string(),
2809            Expr::VarRef(path) => {
2810                let name = path.segments.iter()
2811                    .map(|seg| match seg {
2812                        crate::ast::VarSegment::Field(f) => f.clone(),
2813                    })
2814                    .collect::<Vec<_>>()
2815                    .join(".");
2816                format!("${{{}}}", name)
2817            }
2818            Expr::Interpolated(_) => "\"...\"".to_string(),
2819            Expr::HereDocBody { .. } => "<<heredoc".to_string(),
2820            _ => "...".to_string(),
2821        }
2822    }
2823
2824    /// Execute a single command.
2825    async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
2826        self.execute_command_depth(name, args, 0).await
2827    }
2828
2829    #[tracing::instrument(level = "info", skip(self, args, alias_depth), fields(command = %name), err)]
2830    async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
2831        // Special built-ins
2832        match name {
2833            "true" => return Ok(ExecResult::success("")),
2834            "false" => return Ok(ExecResult::failure(1, "")),
2835            "source" | "." => return self.execute_source(args).await,
2836            _ => {}
2837        }
2838
2839        // Alias expansion (with recursion limit)
2840        if alias_depth < 10 {
2841            let alias_value = {
2842                let ctx = self.exec_ctx.read().await;
2843                ctx.aliases.get(name).cloned()
2844            };
2845            if let Some(alias_val) = alias_value {
2846                // Split alias value into command + args
2847                let parts: Vec<&str> = alias_val.split_whitespace().collect();
2848                if let Some((alias_cmd, alias_args)) = parts.split_first() {
2849                    let mut new_args: Vec<Arg> = alias_args
2850                        .iter()
2851                        .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
2852                        .collect();
2853                    new_args.extend_from_slice(args);
2854                    return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
2855                }
2856            }
2857        }
2858
2859        // Handle /v/bin/ prefix — dispatch to builtins via virtual path
2860        if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
2861            return match self.tools.get(builtin_name) {
2862                Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
2863                None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
2864            };
2865        }
2866
2867        // Check user-defined tools first
2868        {
2869            let user_tools = self.user_tools.read().await;
2870            if let Some(tool_def) = user_tools.get(name) {
2871                let tool_def = tool_def.clone();
2872                drop(user_tools);
2873                return self.execute_user_tool(tool_def, args).await;
2874            }
2875        }
2876
2877        // Look up builtin tool
2878        let tool = match self.tools.get(name) {
2879            Some(t) => t,
2880            None => {
2881                // Try executing as .kai script from PATH
2882                if let Some(result) = self.try_execute_script(name, args).await? {
2883                    return Ok(result);
2884                }
2885                // Try executing as external command from PATH
2886                if let Some(result) = self.try_execute_external(name, args).await? {
2887                    return Ok(result);
2888                }
2889
2890                // Try backend-registered tools (embedder engines, etc.)
2891                // Look up tool schema for positional→named mapping.
2892                // Clone backend and drop read lock before awaiting (may involve network I/O).
2893                // Backend tools expect named JSON params, so enable positional mapping.
2894                let backend = self.exec_ctx.read().await.backend.clone();
2895                let tool_schema = backend.get_tool(name).await.ok().flatten().map(|t| {
2896                    let mut s = t.schema;
2897                    // Flat backend/MCP tools expect named JSON params, so map
2898                    // bare positionals onto named params. Subcommand-aware tools
2899                    // route positionals through the subcommand path and declare
2900                    // map_positionals per leaf (kj keeps it false so it re-parses
2901                    // the argv with its own clap) — don't blanket-override them.
2902                    if s.subcommands.is_empty() {
2903                        s.map_positionals = true;
2904                    }
2905                    s
2906                });
2907                let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
2908                let mut ctx = self.exec_ctx.write().await;
2909                {
2910                    let scope = self.scope.read().await;
2911                    ctx.scope = scope.clone();
2912                }
2913                let backend = ctx.backend.clone();
2914                match backend.call_tool(name, tool_args, &mut *ctx).await {
2915                    Ok(tool_result) => {
2916                        let mut scope = self.scope.write().await;
2917                        *scope = ctx.scope.clone();
2918                        let mut exec = ExecResult::from_output(
2919                            tool_result.code as i64, tool_result.stdout, tool_result.stderr,
2920                        );
2921                        exec.set_output(tool_result.output);
2922                        return Ok(exec);
2923                    }
2924                    Err(BackendError::ToolNotFound(_)) => {
2925                        // Fall through to "command not found"
2926                    }
2927                    Err(e) => {
2928                        // Backend dispatch is last-resort lookup — if it fails
2929                        // for any reason, the command simply doesn't exist.
2930                        tracing::debug!("backend error for {name}: {e}");
2931                    }
2932                }
2933
2934                return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
2935            }
2936        };
2937
2938        // Build arguments (async to support command substitution, schema-aware for flag values)
2939        let schema = tool.schema();
2940        let tool_args = self.build_args_async(args, Some(&schema)).await?;
2941
2942        // --help / -h: show help unless the tool's schema claims that flag
2943        let schema_claims = |flag: &str| -> bool {
2944            let bare = flag.trim_start_matches('-');
2945            schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
2946        };
2947        let wants_help =
2948            (tool_args.flags.contains("help") && !schema_claims("help"))
2949            || (tool_args.flags.contains("h") && !schema_claims("-h"));
2950        if wants_help {
2951            let help_topic = crate::help::HelpTopic::Tool(name.to_string());
2952            let ctx = self.exec_ctx.read().await;
2953            let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
2954            return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
2955        }
2956
2957        // Snapshot exec_ctx into a local context and release the write lock
2958        // before calling tool.execute. Holding the write across tool execution
2959        // would deadlock any builtin that re-dispatches through ctx.dispatcher
2960        // (timeout, scatter) — the inner dispatch_command needs its own
2961        // exec_ctx.write() and would block forever.
2962        let mut ctx = {
2963            let ec = self.exec_ctx.write().await;
2964            let scope = self.scope.read().await;
2965            ExecContext {
2966                backend: ec.backend.clone(),
2967                scope: scope.clone(),
2968                cwd: ec.cwd.clone(),
2969                prev_cwd: ec.prev_cwd.clone(),
2970                stdin: ec.stdin.clone(),
2971                stdin_data: ec.stdin_data.clone(),
2972                stdin_data_rx: None,
2973                pipe_stdin: None, // streaming pipes are per-pipeline; not snapshotted
2974                pipe_stdout: None,
2975                stderr: ec.stderr.clone(),
2976                tool_schemas: ec.tool_schemas.clone(),
2977                tools: ec.tools.clone(),
2978                job_manager: ec.job_manager.clone(),
2979                pipeline_position: ec.pipeline_position,
2980                interactive: self.interactive,
2981                aliases: ec.aliases.clone(),
2982                ignore_config: ec.ignore_config.clone(),
2983                output_limit: ec.output_limit.clone(),
2984                allow_external_commands: self.allow_external_commands,
2985                nonce_store: ec.nonce_store.clone(),
2986                trash_backend: ec.trash_backend.clone(),
2987                #[cfg(all(unix, feature = "subprocess"))]
2988                terminal_state: ec.terminal_state.clone(),
2989                dispatcher: self.dispatcher(),
2990                // Use ec.cancel (set by dispatch_command from the runner's
2991                // ctx.cancel) so any builtin-swapped child token (e.g. timeout's
2992                // child token) reaches the spawned external via wait_or_kill.
2993                // Falls back to the kernel's own token when ec.cancel is the
2994                // default fresh token from a non-dispatch path.
2995                cancel: ec.cancel.clone(),
2996                output_format: None,
2997                vfs_budget: self.vfs_budget.clone(),
2998                watchdog: ec.watchdog.clone(),
2999                #[cfg(all(feature = "localfs", feature = "overlay"))]
3000                overlay_handle: self.overlay_handle.clone(),
3001            }
3002        }; // both locks released — tool.execute can re-dispatch safely
3003
3004        // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3005        // semantics): take() so a later dispatch doesn't see stale stdin.
3006        // Done after the snapshot above so we hold the write briefly.
3007        {
3008            let mut ec = self.exec_ctx.write().await;
3009            ctx.stdin = ec.stdin.take();
3010            ctx.stdin_data = ec.stdin_data.take();
3011            ctx.stdin_data_rx = ec.stdin_data_rx.take();
3012            ctx.pipe_stdin = ec.pipe_stdin.take();
3013            ctx.pipe_stdout = ec.pipe_stdout.take();
3014        }
3015
3016        // Honor --json before the builtin runs so its setting survives a clap
3017        // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3018        // --json on the floor when `try_parse_from` returns Err early).
3019        // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3020        GlobalFlags::apply_from_args(&tool_args, &mut ctx);
3021
3022        let result = tool.execute(tool_args, &mut ctx).await;
3023
3024        // Sync mutations back. Tools may have changed scope (set/cd),
3025        // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3026        // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3027        // hands them back to the pipeline runner — the runner uses
3028        // stage_ctx.pipe_stdout to write the result to the next stage when
3029        // the tool itself didn't take and write to it.
3030        {
3031            let mut scope = self.scope.write().await;
3032            *scope = ctx.scope.clone();
3033        }
3034        {
3035            let mut ec = self.exec_ctx.write().await;
3036            ec.cwd = ctx.cwd;
3037            ec.prev_cwd = ctx.prev_cwd;
3038            ec.aliases = ctx.aliases;
3039            // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3040            // mutate the runtime output limit; without this sync the change is
3041            // dropped here and never reaches dispatch_command's read-back, so
3042            // it would not survive past the current statement.
3043            ec.output_limit = ctx.output_limit.clone();
3044            ec.pipe_stdin = ctx.pipe_stdin.take();
3045            ec.pipe_stdout = ctx.pipe_stdout.take();
3046        }
3047
3048        // Builtins parse --json via the GlobalFlags flatten in their clap
3049        // struct and write ctx.output_format. The kernel applies it — unless the
3050        // tool owns its own output (renders --json itself), in which case we
3051        // leave its bytes untouched.
3052        let result = finalize_output(result, ctx.output_format, schema.owns_output);
3053
3054        Ok(result)
3055    }
3056
3057    /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3058    /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3059    /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3060    /// unexpanded rather than leaking the host home directory.
3061    async fn scope_home(&self) -> Option<String> {
3062        match self.scope.read().await.get("HOME") {
3063            Some(Value::String(s)) => Some(s.clone()),
3064            _ => None,
3065        }
3066    }
3067
3068    // (see `push_repeatable_value` below for the repeatable-flag accumulation.)
3069
3070    /// Pull `consumes` positional args after a non-bool flag and stash them
3071    /// on `tool_args.named` under the canonical param name.
3072    ///
3073    /// - `consumes == 1` (non-repeatable) keeps the historical contract: a
3074    ///   single scalar value (last write wins on the rare duplicate).
3075    /// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
3076    ///   inside `named[canonical] = Value::Json(Array(...))`, preserving
3077    ///   invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
3078    ///   a repeated single-value flag must keep every value, not silently drop
3079    ///   all but the last (a "no silent corruption" violation).
3080    /// - `consumes > 1` accumulates each occurrence as an inner
3081    ///   `serde_json::Value::Array` inside `named[canonical] =
3082    ///   Value::Json(Array(...))`, preserving invocation order. This is the
3083    ///   shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
3084    ///
3085    /// Errors loudly if the flag is missing required positionals — matches
3086    /// kaish's "no silent fallback" posture and mirrors real jq, which
3087    /// errors on `--arg NAME` with no value.
3088    #[allow(clippy::too_many_arguments)]
3089    async fn consume_flag_positionals(
3090        &self,
3091        args: &[Arg],
3092        flag_name: &str,
3093        canonical: &str,
3094        consumes: usize,
3095        repeatable: bool,
3096        positional_indices: &[usize],
3097        consumed: &mut std::collections::HashSet<usize>,
3098        current_idx: usize,
3099        tool_args: &mut ToolArgs,
3100    ) -> Result<()> {
3101        let home = self.scope_home().await;
3102        let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
3103        for _ in 0..consumes.max(1) {
3104            // A `key=value` (WordAssign) token is consumable only by a
3105            // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
3106            // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
3107            // filter` would reassemble `x=1` into the first slot and steal the
3108            // filter into the second. Multi-value flags take plain positionals.
3109            let allow_word_assign = consumes <= 1;
3110            let next_pos = positional_indices
3111                .iter()
3112                .find(|idx| {
3113                    **idx > current_idx
3114                        && !consumed.contains(idx)
3115                        && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
3116                })
3117                .copied();
3118            match next_pos {
3119                Some(pos_idx) => match &args[pos_idx] {
3120                    Arg::Positional(expr) => {
3121                        let value = self.eval_expr_async(expr).await?;
3122                        let value = apply_tilde_expansion(value, home.as_deref());
3123                        collected.push(value);
3124                        consumed.insert(pos_idx);
3125                    }
3126                    // `-v a=1`: reassemble the `key=value` token as the flag's
3127                    // scalar value (see `positional_indices` construction).
3128                    Arg::WordAssign { key, value } => {
3129                        let val = self.eval_expr_async(value).await?;
3130                        let val = apply_tilde_expansion(val, home.as_deref());
3131                        let val_str = crate::interpreter::value_to_string(&val);
3132                        collected.push(Value::String(format!("{key}={val_str}")));
3133                        consumed.insert(pos_idx);
3134                    }
3135                    _ => {}
3136                },
3137                None => {
3138                    if consumes <= 1 && collected.is_empty() {
3139                        // Back-compat: a flag with no follow-up positional
3140                        // becomes a bare flag. `--path` with nothing after
3141                        // lands in `flags`, same as before this refactor.
3142                        tool_args.flags.insert(flag_name.to_string());
3143                        return Ok(());
3144                    }
3145                    anyhow::bail!(
3146                        "--{flag_name} requires {consumes} argument{}, got {}",
3147                        if consumes == 1 { "" } else { "s" },
3148                        collected.len()
3149                    );
3150                }
3151            }
3152        }
3153
3154        if consumes <= 1 {
3155            if let Some(v) = collected.pop() {
3156                if repeatable {
3157                    push_repeatable_value(tool_args, flag_name, canonical, v)?;
3158                } else {
3159                    tool_args.named.insert(canonical.to_string(), v);
3160                }
3161            }
3162            return Ok(());
3163        }
3164
3165        // Multi-consume: accumulate under named[canonical] as array-of-arrays.
3166        let occ: Vec<serde_json::Value> = collected
3167            .into_iter()
3168            .map(|v| crate::interpreter::value_to_json(&v))
3169            .collect();
3170        let entry = tool_args
3171            .named
3172            .entry(canonical.to_string())
3173            .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
3174        if let Value::Json(serde_json::Value::Array(outer)) = entry {
3175            outer.push(serde_json::Value::Array(occ));
3176        } else {
3177            anyhow::bail!(
3178                "--{flag_name}: named[{canonical}] already holds a non-array value"
3179            );
3180        }
3181        Ok(())
3182    }
3183
3184    /// Build tool arguments from AST args.
3185    ///
3186    /// Uses async evaluation to support command substitution in arguments.
3187    async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3188        let mut tool_args = ToolArgs::new();
3189        let home = self.scope_home().await;
3190        // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
3191        // schemas; pick the leaf the leading positionals route to and bind
3192        // flags against *its* params. Flat tools return the root. select_leaf
3193        // errors (fail loud) if a computed positional sits where a subcommand
3194        // selector is required.
3195        let leaf = match schema {
3196            Some(s) => Some(select_leaf(s, args)?),
3197            None => None,
3198        };
3199        // Bind against the leaf's params, but MERGE the root schema's params on
3200        // top as "global" flags: a value-flag declared at the tool's top level
3201        // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
3202        // it trails the subcommand path (`kj context retag a b --confirm <n>`).
3203        // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
3204        // merge is a harmless no-op.
3205        let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
3206        if let Some(l) = leaf {
3207            param_lookup.extend(schema_param_lookup(l));
3208        }
3209        // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
3210        // not the leaf — it's a property of the command, not the subcommand.
3211        let accepts_word_assign = schema
3212            .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
3213            .unwrap_or(false);
3214
3215        // Track which positional indices have been consumed as flag values
3216        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
3217        let mut past_double_dash = false;
3218
3219        // Indices a value-flag may consume as its value. Positionals always
3220        // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
3221        // itself treat `key=value` as an assignment (everything but
3222        // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
3223        // `-v`, rather than skipping it and grabbing the next positional (the
3224        // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
3225        // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
3226        let positional_indices: Vec<usize> = args.iter().enumerate()
3227            .filter_map(|(i, a)| {
3228                let consumable = matches!(a, Arg::Positional(_))
3229                    || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
3230                consumable.then_some(i)
3231            })
3232            .collect();
3233
3234        let mut i = 0;
3235        while i < args.len() {
3236            match &args[i] {
3237                Arg::DoubleDash => {
3238                    past_double_dash = true;
3239                }
3240                Arg::Positional(expr) => {
3241                    if !consumed.contains(&i) {
3242                        // Glob expansion: bare glob patterns expand to matching files
3243                        if let Expr::GlobPattern(pattern) = expr {
3244                            let glob_enabled = {
3245                                let scope = self.scope.read().await;
3246                                scope.glob_enabled()
3247                            };
3248                            if glob_enabled {
3249                                let (paths, cwd) = {
3250                                    let ctx = self.exec_ctx.read().await;
3251                                    let paths = ctx.expand_glob(pattern).await
3252                                        .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3253                                    let cwd = ctx.resolve_path(".");
3254                                    (paths, cwd)
3255                                };
3256                                if paths.is_empty() {
3257                                    return Err(anyhow::anyhow!("no matches: {}", pattern));
3258                                }
3259                                for path in paths {
3260                                    let display = if !pattern.starts_with('/') {
3261                                        path.strip_prefix(&cwd)
3262                                            .unwrap_or(&path)
3263                                            .to_string_lossy().into_owned()
3264                                    } else {
3265                                        path.to_string_lossy().into_owned()
3266                                    };
3267                                    tool_args.positional.push(Value::String(display));
3268                                }
3269                                i += 1;
3270                                continue;
3271                            }
3272                        }
3273                        let value = self.eval_expr_async(expr).await?;
3274                        let value = apply_tilde_expansion(value, home.as_deref());
3275                        tool_args.positional.push(value);
3276                    }
3277                }
3278                Arg::Named { key, value } => {
3279                    let val = self.eval_expr_async(value).await?;
3280                    let val = apply_tilde_expansion(val, home.as_deref());
3281                    // A repeatable flag in `--flag=value` form must accumulate too,
3282                    // not overwrite — otherwise `--expression=A --expression=B`
3283                    // would silently keep only B, and mixing with the `-e` space
3284                    // form would clobber the array. Route it through the same
3285                    // accumulator the space form uses.
3286                    if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
3287                        push_repeatable_value(&mut tool_args, key, canonical, val)?;
3288                    } else {
3289                        tool_args.named.insert(key.clone(), val);
3290                    }
3291                }
3292                Arg::WordAssign { key, value } => {
3293                    // Already pulled in as a preceding value-flag's argument
3294                    // (`awk -v a=1`); don't also emit it as a positional.
3295                    if consumed.contains(&i) {
3296                        i += 1;
3297                        continue;
3298                    }
3299                    let val = self.eval_expr_async(value).await?;
3300                    let val = apply_tilde_expansion(val, home.as_deref());
3301                    if accepts_word_assign {
3302                        tool_args.named.insert(key.clone(), val);
3303                    } else {
3304                        // Stringify "key=value" and pass as a positional.
3305                        // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
3306                        let val_str = crate::interpreter::value_to_string(&val);
3307                        tool_args.positional.push(Value::String(format!("{key}={val_str}")));
3308                    }
3309                }
3310                Arg::ShortFlag(name) => {
3311                    if past_double_dash {
3312                        tool_args.positional.push(Value::String(format!("-{name}")));
3313                    } else if name.len() == 1 {
3314                        let flag_name = name.as_str();
3315                        let lookup = param_lookup.get(flag_name);
3316                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3317
3318                        if is_bool {
3319                            tool_args.flags.insert(flag_name.to_string());
3320                        } else {
3321                            // Non-bool: consume `consumes` positionals as value(s)
3322                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
3323                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3324                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3325                            self.consume_flag_positionals(
3326                                args,
3327                                name,
3328                                canonical,
3329                                consumes,
3330                                repeatable,
3331                                &positional_indices,
3332                                &mut consumed,
3333                                i,
3334                                &mut tool_args,
3335                            )
3336                            .await?;
3337                        }
3338                    } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
3339                        // Multi-char short flag matches a schema param (POSIX style: -name value)
3340                        if is_bool_type(typ) {
3341                            tool_args.flags.insert(canonical.to_string());
3342                        } else {
3343                            self.consume_flag_positionals(
3344                                args,
3345                                name,
3346                                canonical,
3347                                consumes,
3348                                repeatable,
3349                                &positional_indices,
3350                                &mut consumed,
3351                                i,
3352                                &mut tool_args,
3353                            )
3354                            .await?;
3355                        }
3356                    } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
3357                        .get(&name[..1])
3358                        .filter(|(_, typ, ..)| !is_bool_type(typ))
3359                    {
3360                        // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
3361                        // `grep -A1`, `sed -e1d`. The first char is a declared
3362                        // value-taking short flag, so the rest of the token is its
3363                        // value — the coreutils idiom. The lexer's flag char class is
3364                        // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
3365                        // (safe to slice) and the tail is a plain literal.
3366                        bind_glued_short_value(
3367                            &mut tool_args,
3368                            &name[..1],
3369                            canonical,
3370                            consumes,
3371                            repeatable,
3372                            name[1..].to_string(),
3373                        )?;
3374                    } else {
3375                        // Multi-char combined short flags. Bool flags stack
3376                        // (`-la`), but the FIRST value-taking flag reached
3377                        // consumes the rest of the token as its glued value
3378                        // (`-ivC3` → C=3) or, if it is the last char, the next
3379                        // positional (`grep -ivC 3` → C=3). Before this, a
3380                        // trailing value-flag was silently treated as a bool,
3381                        // stranding its argument as a stray positional (arity
3382                        // error). Undeclared/bool chars stay bare flags, so a
3383                        // schemaless tool keeps the old all-boolean behavior.
3384                        // The first char being value-taking is handled by the
3385                        // glued arm above, so it never reaches here. The flag
3386                        // char class is ASCII, so byte indexing is char indexing
3387                        // (no `Vec<char>` allocation needed).
3388                        let bytes = name.as_bytes();
3389                        let mut p = 0;
3390                        while p < bytes.len() {
3391                            let key = &name[p..p + 1];
3392                            match param_lookup.get(key) {
3393                                Some(&(canonical, typ, consumes, repeatable))
3394                                    if !is_bool_type(typ) =>
3395                                {
3396                                    let glued = name[p + 1..].to_string();
3397                                    if glued.is_empty() {
3398                                        // Value flag is the last char: take the
3399                                        // next positional. `consume_flag_positionals`
3400                                        // respects `consumes`.
3401                                        self.consume_flag_positionals(
3402                                            args,
3403                                            key,
3404                                            canonical,
3405                                            consumes,
3406                                            repeatable,
3407                                            &positional_indices,
3408                                            &mut consumed,
3409                                            i,
3410                                            &mut tool_args,
3411                                        )
3412                                        .await?;
3413                                    } else {
3414                                        bind_glued_short_value(
3415                                            &mut tool_args,
3416                                            key,
3417                                            canonical,
3418                                            consumes,
3419                                            repeatable,
3420                                            glued,
3421                                        )?;
3422                                    }
3423                                    break;
3424                                }
3425                                _ => {
3426                                    tool_args.flags.insert(key.to_string());
3427                                    p += 1;
3428                                }
3429                            }
3430                        }
3431                    }
3432                }
3433                Arg::LongFlag(name) => {
3434                    if past_double_dash {
3435                        tool_args.positional.push(Value::String(format!("--{name}")));
3436                    } else {
3437                        let lookup = param_lookup.get(name.as_str());
3438                        // An *undeclared* long flag under a `map_positionals`
3439                        // (backend/MCP) schema that is immediately followed by an
3440                        // unconsumed positional is ambiguous: kaish can't tell the
3441                        // space-form value (`--type explorer`) from a bool flag
3442                        // before a real positional (`--force file.txt`). Defaulting
3443                        // to bool here silently divorces the value and misroutes it
3444                        // — a privilege-escalation-by-typo against deny-by-default
3445                        // embedders (docs/issues.md). Fail loud instead of guessing.
3446                        let ambiguous_value = (lookup.is_none()
3447                            && leaf.is_some_and(|s| s.map_positionals)
3448                            && !consumed.contains(&(i + 1)))
3449                            .then(|| match args.get(i + 1) {
3450                                // Echo a concrete value for a copy-pasteable fix
3451                                // when it's a plain literal; fall back to VALUE.
3452                                Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
3453                                    Some(s.clone())
3454                                }
3455                                Some(Arg::Positional(_)) => Some("VALUE".to_string()),
3456                                _ => None,
3457                            })
3458                            .flatten();
3459                        if let Some(val) = ambiguous_value {
3460                            let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
3461                            anyhow::bail!(
3462                                "{tool}: --{name} is not a declared flag, so the \
3463                                 space-separated value would be silently dropped. \
3464                                 Use --{name}={val}, or have {tool} declare --{name} \
3465                                 in its schema."
3466                            );
3467                        }
3468                        let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3469
3470                        if is_bool {
3471                            tool_args.flags.insert(name.clone());
3472                        } else {
3473                            let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
3474                            let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3475                            let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3476                            self.consume_flag_positionals(
3477                                args,
3478                                name,
3479                                canonical,
3480                                consumes,
3481                                repeatable,
3482                                &positional_indices,
3483                                &mut consumed,
3484                                i,
3485                                &mut tool_args,
3486                            )
3487                            .await?;
3488                        }
3489                    }
3490                }
3491            }
3492            i += 1;
3493        }
3494
3495        // Map remaining positionals to unfilled non-bool schema params (in order).
3496        // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
3497        // Positionals that appeared after `--` are never mapped (they're raw data).
3498        // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
3499        // Keyed off the routed leaf so a subcommand tool maps against the active
3500        // leaf's params (kj leaves keep map_positionals=false → block skipped).
3501        if let Some(schema) = leaf.filter(|s| s.map_positionals) {
3502            let pre_dash_count = if past_double_dash {
3503                let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
3504                positional_indices.iter()
3505                    .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
3506                    .count()
3507            } else {
3508                tool_args.positional.len()
3509            };
3510
3511            let mut remaining = Vec::new();
3512            let mut positional_iter = tool_args.positional.drain(..).enumerate();
3513
3514            for param in &schema.params {
3515                if tool_args.named.contains_key(&param.name) || tool_args.flags.contains(&param.name) {
3516                    continue;
3517                }
3518                if is_bool_type(&param.param_type) {
3519                    continue;
3520                }
3521                loop {
3522                    match positional_iter.next() {
3523                        Some((idx, val)) if idx < pre_dash_count => {
3524                            tool_args.named.insert(param.name.clone(), val);
3525                            break;
3526                        }
3527                        Some((_, val)) => {
3528                            remaining.push(val);
3529                        }
3530                        None => break,
3531                    }
3532                }
3533            }
3534
3535            remaining.extend(positional_iter.map(|(_, v)| v));
3536            tool_args.positional = remaining;
3537        }
3538
3539        Ok(tool_args)
3540    }
3541
3542    /// Build arguments as flat string list for external commands.
3543    ///
3544    /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3545    /// this preserves the original flag format as strings for external commands:
3546    /// - `-l` stays as `-l`
3547    /// - `--verbose` stays as `--verbose`
3548    /// - `key=value` stays as `key=value`
3549    ///
3550    /// This is what external commands expect in their argv.
3551    #[cfg(feature = "subprocess")]
3552    async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3553        let mut argv = Vec::new();
3554        let home = self.scope_home().await;
3555        for arg in args {
3556            match arg {
3557                Arg::Positional(expr) => {
3558                    // Glob expansion for external commands
3559                    if let Expr::GlobPattern(pattern) = expr {
3560                        let glob_enabled = {
3561                            let scope = self.scope.read().await;
3562                            scope.glob_enabled()
3563                        };
3564                        if glob_enabled {
3565                            let (paths, cwd) = {
3566                                let ctx = self.exec_ctx.read().await;
3567                                let paths = ctx.expand_glob(pattern).await
3568                                    .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3569                                let cwd = ctx.resolve_path(".");
3570                                (paths, cwd)
3571                            };
3572                            if paths.is_empty() {
3573                                return Err(anyhow::anyhow!("no matches: {}", pattern));
3574                            }
3575                            for path in paths {
3576                                let display = if !pattern.starts_with('/') {
3577                                    path.strip_prefix(&cwd)
3578                                        .unwrap_or(&path)
3579                                        .to_string_lossy().into_owned()
3580                                } else {
3581                                    path.to_string_lossy().into_owned()
3582                                };
3583                                argv.push(display);
3584                            }
3585                            continue;
3586                        }
3587                    }
3588                    let value = self.eval_expr_async(expr).await?;
3589                    let value = apply_tilde_expansion(value, home.as_deref());
3590                    argv.push(value_to_string(&value));
3591                }
3592                Arg::Named { key, value } => {
3593                    let val = self.eval_expr_async(value).await?;
3594                    let val = apply_tilde_expansion(val, home.as_deref());
3595                    argv.push(format!("--{}={}", key, value_to_string(&val)));
3596                }
3597                Arg::WordAssign { key, value } => {
3598                    let val = self.eval_expr_async(value).await?;
3599                    let val = apply_tilde_expansion(val, home.as_deref());
3600                    argv.push(format!("{}={}", key, value_to_string(&val)));
3601                }
3602                Arg::ShortFlag(name) => {
3603                    // Preserve original format: -l, -la (combined flags)
3604                    argv.push(format!("-{}", name));
3605                }
3606                Arg::LongFlag(name) => {
3607                    // Preserve original format: --verbose
3608                    argv.push(format!("--{}", name));
3609                }
3610                Arg::DoubleDash => {
3611                    // Preserve the -- marker
3612                    argv.push("--".to_string());
3613                }
3614            }
3615        }
3616        Ok(argv)
3617    }
3618
3619    /// Async expression evaluator that supports command substitution.
3620    ///
3621    /// This is used for contexts where expressions may contain `$(...)` command
3622    /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3623    fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3624        Box::pin(async move {
3625        match expr {
3626            Expr::Literal(value) => Ok(value.clone()),
3627            Expr::VarRef(path) => {
3628                let scope = self.scope.read().await;
3629                scope.resolve_path(path)
3630                    .ok_or_else(|| anyhow::anyhow!("undefined variable"))
3631            }
3632            Expr::Interpolated(parts) => {
3633                let mut result = String::new();
3634                for part in parts {
3635                    result.push_str(&self.eval_string_part_async(part).await?);
3636                }
3637                Ok(Value::String(result))
3638            }
3639            Expr::HereDocBody { parts, strip_tabs } => {
3640                // Assemble part-by-part so `<<-` tab stripping applies to the
3641                // literal source, not to tabs from a `$var` value (bash strips
3642                // source-line tabs before parameter expansion).
3643                let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3644                for sp in parts {
3645                    match &sp.part {
3646                        StringPart::Literal(s) => asm.push_literal(s),
3647                        other => {
3648                            asm.push_interpolated(&self.eval_string_part_async(other).await?)
3649                        }
3650                    }
3651                }
3652                Ok(Value::String(asm.into_string()))
3653            }
3654            Expr::BinaryOp { left, op, right } => match op {
3655                BinaryOp::And => {
3656                    let left_val = self.eval_expr_async(left).await?;
3657                    if !is_truthy(&left_val) {
3658                        return Ok(left_val);
3659                    }
3660                    self.eval_expr_async(right).await
3661                }
3662                BinaryOp::Or => {
3663                    let left_val = self.eval_expr_async(left).await?;
3664                    if is_truthy(&left_val) {
3665                        return Ok(left_val);
3666                    }
3667                    self.eval_expr_async(right).await
3668                }
3669            },
3670            Expr::CommandSubst(stmts) => {
3671                // Snapshot scope+cwd before running — only output escapes,
3672                // not side effects like `cd` or variable assignments.
3673                let saved_scope = { self.scope.read().await.clone() };
3674                let saved_cwd = {
3675                    let ec = self.exec_ctx.read().await;
3676                    (ec.cwd.clone(), ec.prev_cwd.clone())
3677                };
3678
3679                // Capture result without `?` — restore state unconditionally
3680                let run_result = self.execute_block_capturing(stmts).await;
3681
3682                // Restore scope and cwd regardless of success/failure
3683                {
3684                    let mut scope = self.scope.write().await;
3685                    *scope = saved_scope;
3686                    if let Ok(ref r) = run_result {
3687                        scope.set_last_result(r.clone());
3688                    }
3689                }
3690                {
3691                    let mut ec = self.exec_ctx.write().await;
3692                    ec.cwd = saved_cwd.0;
3693                    ec.prev_cwd = saved_cwd.1;
3694                }
3695
3696                // Now propagate the error
3697                let result = run_result?;
3698
3699                // A binary result is preserved as bytes — never lossy-decoded to
3700                // a string. No trailing-newline trim (every byte is significant).
3701                if let Some(bytes) = result.out_bytes() {
3702                    Ok(Value::Bytes(bytes.to_vec()))
3703                // Prefer structured data (enables `for i in $(cmd)` iteration)
3704                } else if let Some(data) = &result.data {
3705                    Ok(data.clone())
3706                } else if let Some(output) = result.output() {
3707                    // Flat non-text node lists (glob, ls, tree) → iterable array
3708                    if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3709                        let items: Vec<serde_json::Value> = output.root.iter()
3710                            .map(|n| serde_json::Value::String(n.display_name().to_string()))
3711                            .collect();
3712                        Ok(Value::Json(serde_json::Value::Array(items)))
3713                    } else {
3714                        // Strip trailing newlines only (POSIX command-subst),
3715                        // not all trailing whitespace — spaces/tabs are
3716                        // significant. Use the exact same trim as the quoted
3717                        // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3718                        // `trim_end_matches('\n')`) so bare and quoted command
3719                        // substitution agree.
3720                        Ok(Value::String(
3721                            result.text_out().trim_end_matches('\n').to_string(),
3722                        ))
3723                    }
3724                } else {
3725                    // Otherwise return stdout as single string (NO implicit splitting)
3726                    Ok(Value::String(
3727                        result.text_out().trim_end_matches('\n').to_string(),
3728                    ))
3729                }
3730            }
3731            Expr::Test(test_expr) => {
3732                Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3733            }
3734            Expr::Positional(n) => {
3735                let scope = self.scope.read().await;
3736                match scope.get_positional(*n) {
3737                    Some(s) => Ok(Value::String(s.to_string())),
3738                    None => Ok(Value::String(String::new())),
3739                }
3740            }
3741            Expr::AllArgs => {
3742                let scope = self.scope.read().await;
3743                Ok(Value::String(scope.all_args().join(" ")))
3744            }
3745            Expr::ArgCount => {
3746                let scope = self.scope.read().await;
3747                Ok(Value::Int(scope.arg_count() as i64))
3748            }
3749            Expr::VarLength(name) => {
3750                let scope = self.scope.read().await;
3751                match scope.get(name) {
3752                    Some(value) => Ok(Value::Int(value_to_string(value).len() as i64)),
3753                    None => Ok(Value::Int(0)),
3754                }
3755            }
3756            Expr::VarWithDefault { name, default } => {
3757                let scope = self.scope.read().await;
3758                let use_default = match scope.get(name) {
3759                    Some(value) => value_to_string(value).is_empty(),
3760                    None => true,
3761                };
3762                drop(scope); // Release the lock before recursive evaluation
3763                if use_default {
3764                    // Evaluate the default parts (supports nested expansions)
3765                    self.eval_string_parts_async(default).await.map(Value::String)
3766                } else {
3767                    let scope = self.scope.read().await;
3768                    scope.get(name).cloned().ok_or_else(|| anyhow::anyhow!("variable '{}' not found", name))
3769                }
3770            }
3771            Expr::Arithmetic(expr_str) => {
3772                let scope = self.scope.read().await;
3773                crate::arithmetic::eval_arithmetic(expr_str, &scope)
3774                    .map(Value::Int)
3775                    .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3776            }
3777            Expr::Command(cmd) => {
3778                // Execute command and return boolean based on exit code
3779                let result = self.execute_command(&cmd.name, &cmd.args).await?;
3780                Ok(Value::Bool(result.code == 0))
3781            }
3782            Expr::LastExitCode => {
3783                let scope = self.scope.read().await;
3784                Ok(Value::Int(scope.last_result().code))
3785            }
3786            Expr::CurrentPid => {
3787                let scope = self.scope.read().await;
3788                Ok(Value::Int(scope.pid() as i64))
3789            }
3790            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3791        }
3792        })
3793    }
3794
3795    /// Async helper to evaluate multiple StringParts into a single string.
3796    fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3797        Box::pin(async move {
3798            let mut result = String::new();
3799            for part in parts {
3800                result.push_str(&self.eval_string_part_async(part).await?);
3801            }
3802            Ok(result)
3803        })
3804    }
3805
3806    /// Async helper to evaluate a StringPart.
3807    /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3808    /// through the VFS backend instead of using raw `std::path`.
3809    fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3810        Box::pin(async move {
3811            match test_expr {
3812                TestExpr::FileTest { op, path } => {
3813                    let path_value = self.eval_expr_async(path).await?;
3814                    // Expand `~` against the session HOME before stat'ing, the
3815                    // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3816                    // stats the literal `~/x` and is always false.
3817                    let home = self.scope_home().await;
3818                    let path_value = apply_tilde_expansion(path_value, home.as_deref());
3819                    let path_str = value_to_string(&path_value);
3820                    let backend = self.exec_ctx.read().await.backend.clone();
3821                    let entry = backend.stat(std::path::Path::new(&path_str)).await.ok();
3822                    Ok(match op {
3823                        FileTestOp::Exists => entry.is_some(),
3824                        FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3825                        FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3826                        FileTestOp::Readable => entry.is_some(),
3827                        FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3828                            e.permissions.is_none_or(|p| p & 0o222 != 0)
3829                        }),
3830                        FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3831                            e.permissions.is_some_and(|p| p & 0o111 != 0)
3832                        }),
3833                    })
3834                }
3835                TestExpr::StringTest { op, value } => {
3836                    let val = self.eval_expr_async(value).await?;
3837                    let s = value_to_string(&val);
3838                    Ok(match op {
3839                        crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3840                        crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3841                    })
3842                }
3843                TestExpr::Comparison { left, op, right } => {
3844                    // Evaluate operands async (handles $(cmd)), then compare sync
3845                    let left_val = self.eval_expr_async(left).await?;
3846                    let right_val = self.eval_expr_async(right).await?;
3847                    let resolved = TestExpr::Comparison {
3848                        left: Box::new(Expr::Literal(left_val)),
3849                        op: *op,
3850                        right: Box::new(Expr::Literal(right_val)),
3851                    };
3852                    let expr = Expr::Test(Box::new(resolved));
3853                    let mut scope = self.scope.write().await;
3854                    let value = eval_expr(&expr, &mut scope)
3855                        .map_err(|e| anyhow::anyhow!("{}", e))?;
3856                    Ok(value_to_bool(&value))
3857                }
3858                TestExpr::And { left, right } => {
3859                    if !self.eval_test_async(left).await? {
3860                        Ok(false)
3861                    } else {
3862                        self.eval_test_async(right).await
3863                    }
3864                }
3865                TestExpr::Or { left, right } => {
3866                    if self.eval_test_async(left).await? {
3867                        Ok(true)
3868                    } else {
3869                        self.eval_test_async(right).await
3870                    }
3871                }
3872                TestExpr::Not { expr } => {
3873                    Ok(!self.eval_test_async(expr).await?)
3874                }
3875            }
3876        })
3877    }
3878
3879    fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3880        Box::pin(async move {
3881            match part {
3882                StringPart::Literal(s) => Ok(s.clone()),
3883                StringPart::Var(path) => {
3884                    let scope = self.scope.read().await;
3885                    match scope.resolve_path(path) {
3886                        Some(value) => Ok(value_to_string(&value)),
3887                        None => Ok(String::new()), // Unset vars expand to empty
3888                    }
3889                }
3890                StringPart::VarWithDefault { name, default } => {
3891                    let scope = self.scope.read().await;
3892                    let use_default = match scope.get(name) {
3893                        Some(value) => value_to_string(value).is_empty(),
3894                        None => true,
3895                    };
3896                    drop(scope); // Release lock before recursive evaluation
3897                    if use_default {
3898                        // Evaluate the default parts (supports nested expansions)
3899                        self.eval_string_parts_async(default).await
3900                    } else {
3901                        let scope = self.scope.read().await;
3902                        Ok(value_to_string(scope.get(name).ok_or_else(|| anyhow::anyhow!("variable '{}' not found", name))?))
3903                    }
3904                }
3905            StringPart::VarLength(name) => {
3906                let scope = self.scope.read().await;
3907                match scope.get(name) {
3908                    Some(value) => Ok(value_to_string(value).len().to_string()),
3909                    None => Ok("0".to_string()),
3910                }
3911            }
3912            StringPart::Positional(n) => {
3913                let scope = self.scope.read().await;
3914                match scope.get_positional(*n) {
3915                    Some(s) => Ok(s.to_string()),
3916                    None => Ok(String::new()),
3917                }
3918            }
3919            StringPart::AllArgs => {
3920                let scope = self.scope.read().await;
3921                Ok(scope.all_args().join(" "))
3922            }
3923            StringPart::ArgCount => {
3924                let scope = self.scope.read().await;
3925                Ok(scope.arg_count().to_string())
3926            }
3927            StringPart::Arithmetic(expr) => {
3928                let scope = self.scope.read().await;
3929                match crate::arithmetic::eval_arithmetic(expr, &scope) {
3930                    Ok(value) => Ok(value.to_string()),
3931                    Err(_) => Ok(String::new()),
3932                }
3933            }
3934            StringPart::CommandSubst(stmts) => {
3935                // Snapshot scope+cwd — command substitution in strings must
3936                // not leak side effects (e.g., `"dir: $(cd /; pwd)"` must not change cwd).
3937                let saved_scope = { self.scope.read().await.clone() };
3938                let saved_cwd = {
3939                    let ec = self.exec_ctx.read().await;
3940                    (ec.cwd.clone(), ec.prev_cwd.clone())
3941                };
3942
3943                // Capture result without `?` — restore state unconditionally
3944                let run_result = self.execute_block_capturing(stmts).await;
3945
3946                // Restore scope and cwd regardless of success/failure
3947                {
3948                    let mut scope = self.scope.write().await;
3949                    *scope = saved_scope;
3950                    if let Ok(ref r) = run_result {
3951                        scope.set_last_result(r.clone());
3952                    }
3953                }
3954                {
3955                    let mut ec = self.exec_ctx.write().await;
3956                    ec.cwd = saved_cwd.0;
3957                    ec.prev_cwd = saved_cwd.1;
3958                }
3959
3960                // Now propagate the error
3961                let result = run_result?;
3962
3963                // Embedding binary into a string is a text context: fail loud
3964                // rather than splice in U+FFFD garbage.
3965                match result.try_text_out() {
3966                    Ok(s) => Ok(s.trim_end_matches('\n').to_string()),
3967                    Err(e) => anyhow::bail!(
3968                        "command substitution in a string produced binary data ({e}) — \
3969                         pipe through base64/xxd"
3970                    ),
3971                }
3972            }
3973            StringPart::LastExitCode => {
3974                let scope = self.scope.read().await;
3975                Ok(scope.last_result().code.to_string())
3976            }
3977            StringPart::CurrentPid => {
3978                let scope = self.scope.read().await;
3979                Ok(scope.pid().to_string())
3980            }
3981        }
3982        })
3983    }
3984
3985    /// Update the last result in scope.
3986    async fn update_last_result(&self, result: &ExecResult) {
3987        let mut scope = self.scope.write().await;
3988        scope.set_last_result(result.clone());
3989    }
3990
3991    /// Drain accumulated pipeline stderr into a result.
3992    ///
3993    /// Called after each sub-statement inside control structures (`if`, `for`,
3994    /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
3995    /// than batching until the entire structure finishes.
3996    async fn drain_stderr_into(&self, result: &mut ExecResult) {
3997        let drained = {
3998            let mut receiver = self.stderr_receiver.lock().await;
3999            receiver.drain_lossy()
4000        };
4001        if !drained.is_empty() {
4002            if !result.err.is_empty() && !result.err.ends_with('\n') {
4003                result.err.push('\n');
4004            }
4005            result.err.push_str(&drained);
4006        }
4007    }
4008
4009    /// Execute a user-defined function with local variable scoping.
4010    ///
4011    /// Functions push a new scope frame for local variables. Variables declared
4012    /// with `local` are scoped to the function; other assignments modify outer
4013    /// scopes (or create in root if new).
4014    async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4015        // 1. Build function args from AST args (async to support command substitution)
4016        let tool_args = self.build_args_async(args, None).await?;
4017
4018        // 2. Push a new scope frame for local variables
4019        {
4020            let mut scope = self.scope.write().await;
4021            scope.push_frame();
4022        }
4023
4024        // 3. Save current positional parameters and set new ones for this function
4025        let saved_positional = {
4026            let mut scope = self.scope.write().await;
4027            let saved = scope.save_positional();
4028
4029            // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4030            let positional_args: Vec<String> = tool_args.positional
4031                .iter()
4032                .map(value_to_string)
4033                .collect();
4034            scope.set_positional(&def.name, positional_args);
4035
4036            saved
4037        };
4038
4039        // 3. Execute body statements with control flow handling
4040        // Accumulate output across statements (like sh)
4041        // Accumulate stdout as raw bytes so a binary-producing statement in a
4042        // function body survives instead of being lossy-decoded here.
4043        let mut accumulated_out: Vec<u8> = Vec::new();
4044        let mut accumulated_err = String::new();
4045        let mut last_code = 0i64;
4046        let mut last_data: Option<Value> = None;
4047
4048        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4049            match r.out_bytes() {
4050                Some(b) => buf.extend_from_slice(b),
4051                None => buf.extend_from_slice(r.text_out().as_bytes()),
4052            }
4053        }
4054
4055        // Track execution error for propagation after cleanup
4056        let mut exec_error: Option<anyhow::Error> = None;
4057        let mut exit_code: Option<i64> = None;
4058
4059        for stmt in &def.body {
4060            match self.execute_stmt_flow(stmt).await {
4061                Ok(flow) => {
4062                    // Drain pipeline stderr after each sub-statement.
4063                    let drained = {
4064                        let mut receiver = self.stderr_receiver.lock().await;
4065                        receiver.drain_lossy()
4066                    };
4067                    if !drained.is_empty() {
4068                        accumulated_err.push_str(&drained);
4069                    }
4070
4071                    match flow {
4072                        ControlFlow::Normal(r) => {
4073                            push_out(&mut accumulated_out, &r);
4074                            accumulated_err.push_str(&r.err);
4075                            last_code = r.code;
4076                            last_data = r.data;
4077                        }
4078                        ControlFlow::Return { value } => {
4079                            push_out(&mut accumulated_out, &value);
4080                            accumulated_err.push_str(&value.err);
4081                            last_code = value.code;
4082                            last_data = value.data;
4083                            break;
4084                        }
4085                        ControlFlow::Exit { code } => {
4086                            exit_code = Some(code);
4087                            break;
4088                        }
4089                        ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4090                            push_out(&mut accumulated_out, &r);
4091                            accumulated_err.push_str(&r.err);
4092                            last_code = r.code;
4093                            last_data = r.data;
4094                        }
4095                    }
4096                }
4097                Err(e) => {
4098                    exec_error = Some(e);
4099                    break;
4100                }
4101            }
4102        }
4103
4104        // 4. Pop scope frame and restore original positional parameters (unconditionally)
4105        {
4106            let mut scope = self.scope.write().await;
4107            scope.pop_frame();
4108            scope.set_positional(saved_positional.0, saved_positional.1);
4109        }
4110
4111        // 5. Propagate error or exit after cleanup
4112        if let Some(e) = exec_error {
4113            return Err(e);
4114        }
4115        let code = exit_code.unwrap_or(last_code);
4116        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4117        result.err = accumulated_err;
4118        result.data = last_data;
4119        Ok(result)
4120    }
4121
4122    /// Execute a command-substitution body — a block of statements — and return
4123    /// the combined result. Stdout/stderr accumulate across statements with **no
4124    /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4125    /// and the last statement's exit code and structured `.data` ride through,
4126    /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4127    /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4128    /// cwd) is the caller's responsibility.
4129    async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4130        // Accumulate stdout as raw bytes so a binary-producing statement
4131        // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4132        // caller can preserve it. The final result is text iff valid UTF-8.
4133        let mut accumulated_out: Vec<u8> = Vec::new();
4134        let mut accumulated_err = String::new();
4135        let mut last_code = 0i64;
4136        let mut last_data: Option<Value> = None;
4137
4138        // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4139        fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4140            match r.out_bytes() {
4141                Some(b) => buf.extend_from_slice(b),
4142                None => buf.extend_from_slice(r.text_out().as_bytes()),
4143            }
4144        }
4145
4146        for stmt in stmts {
4147            let flow = self.execute_stmt_flow(stmt).await?;
4148
4149            // Drain pipeline stderr after each sub-statement (incremental, like
4150            // the control-structure and function-body executors).
4151            let drained = {
4152                let mut receiver = self.stderr_receiver.lock().await;
4153                receiver.drain_lossy()
4154            };
4155            if !drained.is_empty() {
4156                accumulated_err.push_str(&drained);
4157            }
4158
4159            match flow {
4160                ControlFlow::Normal(r)
4161                | ControlFlow::Break { result: r, .. }
4162                | ControlFlow::Continue { result: r, .. } => {
4163                    push_out(&mut accumulated_out, &r);
4164                    accumulated_err.push_str(&r.err);
4165                    last_code = r.code;
4166                    last_data = r.data;
4167                }
4168                ControlFlow::Return { value } => {
4169                    push_out(&mut accumulated_out, &value);
4170                    accumulated_err.push_str(&value.err);
4171                    last_code = value.code;
4172                    last_data = value.data;
4173                    break;
4174                }
4175                ControlFlow::Exit { code } => {
4176                    last_code = code;
4177                    break;
4178                }
4179            }
4180        }
4181
4182        let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4183        result.err = accumulated_err;
4184        result.data = last_data;
4185        Ok(result)
4186    }
4187
4188    /// Execute the `source` / `.` command to include and run a script.
4189    ///
4190    /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4191    /// allowing the sourced script to set variables and modify shell state.
4192    async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4193        // Get the file path from the first positional argument
4194        let tool_args = self.build_args_async(args, None).await?;
4195        let path = match tool_args.positional.first() {
4196            Some(Value::String(s)) => s.clone(),
4197            Some(v) => value_to_string(v),
4198            None => {
4199                return Ok(ExecResult::failure(1, "source: missing filename"));
4200            }
4201        };
4202
4203        // Resolve path relative to cwd
4204        let full_path = {
4205            let ctx = self.exec_ctx.read().await;
4206            if path.starts_with('/') {
4207                std::path::PathBuf::from(&path)
4208            } else {
4209                ctx.cwd.join(&path)
4210            }
4211        };
4212
4213        // Read file content via backend
4214        let content = {
4215            let ctx = self.exec_ctx.read().await;
4216            match ctx.backend.read(&full_path, None).await {
4217                Ok(bytes) => {
4218                    String::from_utf8(bytes).map_err(|e| {
4219                        anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4220                    })?
4221                }
4222                Err(e) => {
4223                    return Ok(ExecResult::failure(
4224                        1,
4225                        format!("source: {}: {}", path, e),
4226                    ));
4227                }
4228            }
4229        };
4230
4231        // Parse the content
4232        let program = match crate::parser::parse(&content) {
4233            Ok(p) => p,
4234            Err(errors) => {
4235                let msg = errors
4236                    .iter()
4237                    .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4238                    .collect::<Vec<_>>()
4239                    .join("\n");
4240                return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4241            }
4242        };
4243
4244        // Execute each statement in the CURRENT scope (not isolated)
4245        let mut result = ExecResult::success("");
4246        for stmt in program.statements {
4247            if matches!(stmt, crate::ast::Stmt::Empty) {
4248                continue;
4249            }
4250
4251            match self.execute_stmt_flow(&stmt).await {
4252                Ok(flow) => {
4253                    self.drain_stderr_into(&mut result).await;
4254                    match flow {
4255                        ControlFlow::Normal(r) => {
4256                            result = r.clone();
4257                            self.update_last_result(&r).await;
4258                        }
4259                        ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4260                            return Err(anyhow::anyhow!(
4261                                "source: {}: unexpected break/continue outside loop",
4262                                path
4263                            ));
4264                        }
4265                        ControlFlow::Return { value } => {
4266                            return Ok(value);
4267                        }
4268                        ControlFlow::Exit { code } => {
4269                            result.code = code;
4270                            return Ok(result);
4271                        }
4272                    }
4273                }
4274                Err(e) => {
4275                    return Err(e.context(format!("source: {}", path)));
4276                }
4277            }
4278        }
4279
4280        Ok(result)
4281    }
4282
4283    /// Try to execute a script from PATH directories.
4284    ///
4285    /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4286    /// (like user-defined tools). Returns None if no script is found.
4287    async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4288        // Get PATH from scope (default to "/bin")
4289        let path_value = {
4290            let scope = self.scope.read().await;
4291            scope
4292                .get("PATH")
4293                .map(value_to_string)
4294                .unwrap_or_else(|| "/bin".to_string())
4295        };
4296
4297        // Search PATH directories for script
4298        for dir in path_value.split(':') {
4299            if dir.is_empty() {
4300                continue;
4301            }
4302
4303            // Build script path: {dir}/{name}.kai
4304            let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4305
4306            // Check if script exists
4307            let exists = {
4308                let ctx = self.exec_ctx.read().await;
4309                ctx.backend.exists(&script_path).await
4310            };
4311
4312            if !exists {
4313                continue;
4314            }
4315
4316            // Read script content
4317            let content = {
4318                let ctx = self.exec_ctx.read().await;
4319                match ctx.backend.read(&script_path, None).await {
4320                    Ok(bytes) => match String::from_utf8(bytes) {
4321                        Ok(s) => s,
4322                        Err(e) => {
4323                            return Ok(Some(ExecResult::failure(
4324                                1,
4325                                format!("{}: invalid UTF-8: {}", script_path.display(), e),
4326                            )));
4327                        }
4328                    },
4329                    Err(e) => {
4330                        return Ok(Some(ExecResult::failure(
4331                            1,
4332                            format!("{}: {}", script_path.display(), e),
4333                        )));
4334                    }
4335                }
4336            };
4337
4338            // Parse the script
4339            let program = match crate::parser::parse(&content) {
4340                Ok(p) => p,
4341                Err(errors) => {
4342                    let msg = errors
4343                        .iter()
4344                        .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4345                        .collect::<Vec<_>>()
4346                        .join("\n");
4347                    return Ok(Some(ExecResult::failure(1, msg)));
4348                }
4349            };
4350
4351            // Build tool_args from args (async for command substitution support)
4352            let tool_args = self.build_args_async(args, None).await?;
4353
4354            // Create isolated scope (like user tools)
4355            let mut isolated_scope = Scope::new();
4356
4357            // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4358            let positional_args: Vec<String> = tool_args.positional
4359                .iter()
4360                .map(value_to_string)
4361                .collect();
4362            isolated_scope.set_positional(name, positional_args);
4363
4364            // Save current scope and swap with isolated scope
4365            let original_scope = {
4366                let mut scope = self.scope.write().await;
4367                std::mem::replace(&mut *scope, isolated_scope)
4368            };
4369
4370            // Execute script statements — track outcome for cleanup
4371            let mut result = ExecResult::success("");
4372            let mut exec_error: Option<anyhow::Error> = None;
4373            let mut exit_code: Option<i64> = None;
4374
4375            for stmt in program.statements {
4376                if matches!(stmt, crate::ast::Stmt::Empty) {
4377                    continue;
4378                }
4379
4380                match self.execute_stmt_flow(&stmt).await {
4381                    Ok(flow) => {
4382                        match flow {
4383                            ControlFlow::Normal(r) => result = r,
4384                            ControlFlow::Return { value } => {
4385                                result = value;
4386                                break;
4387                            }
4388                            ControlFlow::Exit { code } => {
4389                                exit_code = Some(code);
4390                                break;
4391                            }
4392                            ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4393                                result = r;
4394                            }
4395                        }
4396                    }
4397                    Err(e) => {
4398                        exec_error = Some(e);
4399                        break;
4400                    }
4401                }
4402            }
4403
4404            // Restore original scope unconditionally
4405            {
4406                let mut scope = self.scope.write().await;
4407                *scope = original_scope;
4408            }
4409
4410            // Propagate error or exit after cleanup
4411            if let Some(e) = exec_error {
4412                return Err(e.context(format!("script: {}", script_path.display())));
4413            }
4414            if let Some(code) = exit_code {
4415                result.code = code;
4416                return Ok(Some(result));
4417            }
4418
4419            return Ok(Some(result));
4420        }
4421
4422        // No script found
4423        Ok(None)
4424    }
4425
4426    /// Try to execute an external command from PATH.
4427    ///
4428    /// This is the fallback when no builtin or user-defined tool matches.
4429    /// External commands receive a clean argv (flags preserved in their original format).
4430    ///
4431    /// # Requirements
4432    /// - Command must be found in PATH
4433    /// - Current working directory must be on a real filesystem (not virtual like /v)
4434    ///
4435    /// # Returns
4436    /// - `Ok(Some(result))` if command was found and executed
4437    /// - `Ok(None)` if command was not found in PATH
4438    /// - `Err` on execution errors
4439    #[cfg(not(feature = "subprocess"))]
4440    async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4441        Ok(None)
4442    }
4443
4444    /// Try to execute an external command from PATH.
4445    #[cfg(feature = "subprocess")]
4446    #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4447    async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4448        // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4449        // populates from the inbound ctx.cancel on every dispatch. This is
4450        // what makes the `timeout` builtin's swapped child token reach the
4451        // wait_or_kill discipline below — reading `self.cancel_token` would
4452        // give the kernel-wide token and miss the timeout's child cascade.
4453        let cancel = {
4454            let ec = self.exec_ctx.read().await;
4455            ec.cancel.clone()
4456        };
4457        let kill_grace = self.kill_grace;
4458        if !self.allow_external_commands {
4459            return Ok(None);
4460        }
4461
4462        // Get real working directory for relative path resolution and child cwd.
4463        // If the CWD is virtual (no real filesystem path), skip external command
4464        // execution entirely — return None so the dispatch can fall through to
4465        // backend-registered tools.
4466        let real_cwd = {
4467            let ctx = self.exec_ctx.read().await;
4468            match ctx.backend.resolve_real_path(&ctx.cwd) {
4469                Some(p) => p,
4470                None => return Ok(None),
4471            }
4472        };
4473
4474        let executable = if name.contains('/') {
4475            // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4476            let resolved = if std::path::Path::new(name).is_absolute() {
4477                std::path::PathBuf::from(name)
4478            } else {
4479                real_cwd.join(name)
4480            };
4481            if !resolved.exists() {
4482                return Ok(Some(ExecResult::failure(
4483                    127,
4484                    format!("{}: No such file or directory", name),
4485                )));
4486            }
4487            if !resolved.is_file() {
4488                return Ok(Some(ExecResult::failure(
4489                    126,
4490                    format!("{}: Is a directory", name),
4491                )));
4492            }
4493            #[cfg(unix)]
4494            {
4495                use std::os::unix::fs::PermissionsExt;
4496                let mode = std::fs::metadata(&resolved)
4497                    .map(|m| m.permissions().mode())
4498                    .unwrap_or(0);
4499                if mode & 0o111 == 0 {
4500                    return Ok(Some(ExecResult::failure(
4501                        126,
4502                        format!("{}: Permission denied", name),
4503                    )));
4504                }
4505            }
4506            resolved.to_string_lossy().into_owned()
4507        } else {
4508            // Get PATH from scope only. The kernel never reads OS env: a
4509            // frontend that wants host PATH seeds it via initial_vars (the REPL
4510            // does, with os_env_vars()). No PATH in scope → nothing resolves.
4511            let path_var = {
4512                let scope = self.scope.read().await;
4513                scope.get("PATH").map(value_to_string).unwrap_or_default()
4514            };
4515
4516            // Resolve command in PATH
4517            match resolve_in_path(name, &path_var) {
4518                Some(path) => path,
4519                None => return Ok(None), // Not found - let caller handle error
4520            }
4521        };
4522
4523        tracing::debug!(executable = %executable, "resolved external command");
4524
4525        // Build flat argv (preserves flag format)
4526        let argv = self.build_args_flat(args).await?;
4527
4528        // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4529        // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4530        // `String`. Take both out under the lock but do NOT drain here — a pipe
4531        // read can block on its producer (a still-running upstream stage), so
4532        // draining before spawn would serialize the pipeline (deadlocking
4533        // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4534        // `set_stdin` clears `pipe_stdin`, so a redirect-set String and a pipe
4535        // are mutually exclusive in practice; prefer the pipe.
4536        let (pipe_stdin, stdin_string) = {
4537            let mut ctx = self.exec_ctx.write().await;
4538            (ctx.pipe_stdin.take(), ctx.take_stdin())
4539        };
4540        let has_stdin = pipe_stdin.is_some() || stdin_string.is_some();
4541
4542        // Build and spawn the command
4543        use tokio::process::Command;
4544
4545        let mut cmd = Command::new(&executable);
4546        cmd.args(&argv);
4547        cmd.current_dir(&real_cwd);
4548
4549        // Hermetic env: child sees only kaish's exported vars, not the kaish
4550        // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4551        // populate it via KernelConfig::initial_vars at construction.
4552        cmd.env_clear();
4553        {
4554            let scope = self.scope.read().await;
4555            for (var_name, value) in scope.exported_vars() {
4556                cmd.env(var_name, value_to_string(&value));
4557            }
4558        }
4559
4560        // Handle stdin
4561        cmd.stdin(if has_stdin {
4562            std::process::Stdio::piped()
4563        } else if self.interactive {
4564            std::process::Stdio::inherit()
4565        } else {
4566            std::process::Stdio::null()
4567        });
4568
4569        // In interactive mode, standalone or last-in-pipeline commands inherit
4570        // the terminal's stdout/stderr so output streams in real-time.
4571        // First/middle commands must capture stdout for the pipe — same as bash.
4572        let pipeline_position = {
4573            let ctx = self.exec_ctx.read().await;
4574            ctx.pipeline_position
4575        };
4576        let inherit_output = self.interactive
4577            && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4578
4579        if inherit_output {
4580            cmd.stdout(std::process::Stdio::inherit());
4581            cmd.stderr(std::process::Stdio::inherit());
4582        } else {
4583            cmd.stdout(std::process::Stdio::piped());
4584            cmd.stderr(std::process::Stdio::piped());
4585        }
4586
4587        // On Unix, always put the child in its own process group so cancellation
4588        // can `killpg` the whole tree (the child plus any grandchildren).
4589        // Restoring default tty-related signal handlers stays gated on
4590        // job-control mode — those only matter when the child has a controlling
4591        // terminal.
4592        #[cfg(unix)]
4593        {
4594            let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4595            // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
4596            #[allow(unsafe_code)]
4597            unsafe {
4598                cmd.pre_exec(move || {
4599                    // Own process group — for kill scope.
4600                    nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4601                        .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4602                    if restore_jc_signals {
4603                        use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4604                        let mut sa: nix::libc::sigaction = std::mem::zeroed();
4605                        sa.sa_sigaction = SIG_DFL;
4606                        if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4607                            return Err(std::io::Error::last_os_error());
4608                        }
4609                        if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4610                            return Err(std::io::Error::last_os_error());
4611                        }
4612                        if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4613                            return Err(std::io::Error::last_os_error());
4614                        }
4615                        if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4616                            return Err(std::io::Error::last_os_error());
4617                        }
4618                    }
4619                    Ok(())
4620                });
4621            }
4622        }
4623
4624        // Backstop for kill on drop in case our explicit kill path is bypassed
4625        // (panic, early return, etc) on the **capture** wait path. We do NOT
4626        // set this on the JC inherit path: that uses sync `waitpid` outside
4627        // tokio's view of the child, so on drop tokio would try to kill an
4628        // already-reaped (possibly-reused) PID. The JC path has its own
4629        // cancel handling via the side-task watcher.
4630        let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4631        if !in_jc_inherit_path {
4632            cmd.kill_on_drop(true);
4633        }
4634
4635        // Spawn the process. Capture a `KillTarget` immediately so cancel/
4636        // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4637        // to this process's generation, immune to PID reuse if the OS reaps
4638        // the child before our kill syscalls fire.
4639        let mut child = match cmd.spawn() {
4640            Ok(child) => child,
4641            Err(e) => {
4642                return Ok(Some(ExecResult::failure(
4643                    127,
4644                    format!("{}: {}", name, e),
4645                )));
4646            }
4647        };
4648        let kill_target = crate::pidfd::KillTarget::from_child(&child);
4649
4650        // If this external runs on behalf of a background job, record its
4651        // process group on the job so `kill -<sig> %N` can signal the real
4652        // process directly (STOP/CONT/USR1/…, not just terminate). The child
4653        // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4654        if let Some(job_id) = self.bg_job_id
4655            && let Some(pid) = child.id()
4656        {
4657            self.jobs.add_pgid(job_id, pid).await;
4658        }
4659
4660        // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4661        // detached task (bounded memory, no pre-drain) so an upstream stage and
4662        // this child run concurrently — and a child that never reads stdin (or
4663        // is killed) just breaks the copy, which stops. A buffered `String` is
4664        // written inline and stdin dropped to signal EOF. Bytes are copied
4665        // verbatim, so binary stdin survives.
4666        let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4667            child.stdin.take().map(|mut child_stdin| {
4668                tokio::spawn(async move {
4669                    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4670                    let mut buf = [0u8; 8192];
4671                    loop {
4672                        match pipe_in.read(&mut buf).await {
4673                            Ok(0) => break, // EOF
4674                            Ok(n) => {
4675                                if child_stdin.write_all(&buf[..n]).await.is_err() {
4676                                    break; // child closed stdin
4677                                }
4678                            }
4679                            Err(_) => break,
4680                        }
4681                    }
4682                    // Dropping child_stdin signals EOF to the child.
4683                })
4684            })
4685        } else if let Some(data) = stdin_string {
4686            // Write the buffered String from a detached task too — NOT inline.
4687            // An inline write blocks once the stdin pipe fills, and the output
4688            // drain hasn't spawned yet, so a child that emits a lot before
4689            // consuming all its input (every pipe buffer full) deadlocks. A
4690            // write error here is normal, not a failure: a child that closes
4691            // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
4692            // signals EOF.
4693            child.stdin.take().map(|mut child_stdin| {
4694                tokio::spawn(async move {
4695                    use tokio::io::AsyncWriteExt;
4696                    let _ = child_stdin.write_all(data.as_bytes()).await;
4697                })
4698            })
4699        } else {
4700            None
4701        };
4702
4703        // Abort the stdin-copy task on EVERY exit path (the capture path, both
4704        // interactive `inherit_output` returns, and any early error return).
4705        // Once the child is reaped the copy has nothing left to deliver; if it
4706        // were left parked on `pipe_in.read()` it would leak and hold the
4707        // upstream pipe reader open. A drop guard is the single place that
4708        // covers all returns — explicit per-return aborts were error-prone (an
4709        // earlier version missed the two inherit_output returns).
4710        struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
4711        impl Drop for AbortStdinCopyOnDrop {
4712            fn drop(&mut self) {
4713                if let Some(t) = self.0.take() {
4714                    t.abort();
4715                }
4716            }
4717        }
4718        let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
4719
4720        if inherit_output {
4721            // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
4722            #[cfg(unix)]
4723            if let Some(ref term) = self.terminal_state {
4724                let child_id = child.id().unwrap_or(0);
4725                let pid = nix::unistd::Pid::from_raw(child_id as i32);
4726                let pgid = pid; // child is its own pgid leader
4727
4728                // Give the terminal to the child's process group
4729                if let Err(e) = term.give_terminal_to(pgid) {
4730                    tracing::warn!("failed to give terminal to child: {}", e);
4731                }
4732
4733                let term_clone = term.clone();
4734                let cmd_name = name.to_string();
4735                let cmd_display = format!("{} {}", name, argv.join(" "));
4736                let jobs = self.jobs.clone();
4737
4738                // Side task that watches for cancellation while the blocking
4739                // waitpid runs. On cancel, it SIGTERMs the process group, waits
4740                // the grace period, then SIGKILLs. The blocking waitpid returns
4741                // when the child dies. AbortOnDrop guard cancels the watcher
4742                // on the success path so it doesn't keep running after wait
4743                // returns naturally.
4744                //
4745                // `wait_complete` shrinks the PID-reuse race: the watcher
4746                // checks it before each kill syscall and bails out if
4747                // wait_for_foreground has already reaped the child. This
4748                // doesn't fully eliminate the race (atomic load + kill is
4749                // not atomic with the OS reap+reuse), but narrows the window
4750                // to nanoseconds — enough to be ignorable in practice.
4751                let wait_complete = std::sync::Arc::new(
4752                    std::sync::atomic::AtomicBool::new(false)
4753                );
4754                let cancel_watcher = {
4755                    let cancel = cancel.clone();
4756                    let wc = wait_complete.clone();
4757                    // Ownership transfer: the JC path's sync wait inside
4758                    // block_in_place owns the child's reaping, so the
4759                    // cancel_watcher drives the kill side via KillTarget
4760                    // (pidfd-bound on Linux). When kill_target is None
4761                    // (older kernel + open failure, or non-Linux), falls
4762                    // through to the older PID-based path the closure
4763                    // captures from `pid`.
4764                    let target = kill_target.as_ref().map(|t| {
4765                        // Re-borrow the components we need into Owned-ish form
4766                        // so the spawned task is 'static. We can't move
4767                        // KillTarget directly because try_execute_external
4768                        // still uses it after the spawn — but on the JC path
4769                        // there is no further use after the watcher spawn,
4770                        // so a clone-of-pid + owned None pidfd is safe.
4771                        // Simpler: signal via the existing target by cloning
4772                        // a fresh pidfd; the original keeps its handle.
4773                        // Pidfd is just an OwnedFd — not Clone — so do it
4774                        // by re-opening from the pid. Fall back if reopen
4775                        // fails (race already reaped → best-effort kill).
4776                        crate::pidfd::KillTarget::from_pid(t.pid())
4777                    });
4778                    tokio::spawn(async move {
4779                        cancel.cancelled().await;
4780                        if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4781                        use nix::sys::signal::Signal;
4782                        if let Some(t) = &target {
4783                            t.signal(Signal::SIGTERM);
4784                            t.signal_pg(Signal::SIGTERM);
4785                        } else {
4786                            let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
4787                            let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
4788                        }
4789                        if kill_grace > Duration::ZERO {
4790                            tokio::time::sleep(kill_grace).await;
4791                            if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4792                        }
4793                        if let Some(t) = &target {
4794                            t.signal(Signal::SIGKILL);
4795                            t.signal_pg(Signal::SIGKILL);
4796                        } else {
4797                            let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
4798                            let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
4799                        }
4800                    })
4801                };
4802                struct AbortOnDrop(tokio::task::JoinHandle<()>);
4803                impl Drop for AbortOnDrop {
4804                    fn drop(&mut self) {
4805                        self.0.abort();
4806                    }
4807                }
4808                let _watcher_guard = AbortOnDrop(cancel_watcher);
4809
4810                let wait_complete_setter = wait_complete.clone();
4811                let code = tokio::task::block_in_place(move || {
4812                    let result = term_clone.wait_for_foreground(pid);
4813                    // Mark wait done before the watcher might fire.
4814                    wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
4815
4816                    // Always reclaim the terminal
4817                    if let Err(e) = term_clone.reclaim_terminal() {
4818                        tracing::warn!("failed to reclaim terminal: {}", e);
4819                    }
4820
4821                    match result {
4822                        crate::terminal::WaitResult::Exited(code) => code as i64,
4823                        crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
4824                        crate::terminal::WaitResult::Stopped(_sig) => {
4825                            // Register as a stopped job
4826                            let rt = tokio::runtime::Handle::current();
4827                            let job_id = rt.block_on(jobs.register_stopped(
4828                                cmd_display,
4829                                child_id,
4830                                child_id, // pgid = pid for group leader
4831                            ));
4832                            eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
4833                            148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
4834                        }
4835                    }
4836                });
4837
4838                return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
4839            }
4840
4841            // Non-job-control path with inherited stdio.
4842            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
4843                Ok(s) => s,
4844                Err(e) => {
4845                    return Ok(Some(ExecResult::failure(
4846                        1,
4847                        format!("{}: failed to wait: {}", name, e),
4848                    )));
4849                }
4850            };
4851
4852            let code = status.code().unwrap_or_else(|| {
4853                #[cfg(unix)]
4854                {
4855                    use std::os::unix::process::ExitStatusExt;
4856                    128 + status.signal().unwrap_or(0)
4857                }
4858                #[cfg(not(unix))]
4859                {
4860                    -1
4861                }
4862            }) as i64;
4863
4864            // stdout/stderr already went to the terminal
4865            Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
4866        } else {
4867            // Capture output via bounded streams
4868            let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
4869            let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
4870
4871            let stdout_pipe = child.stdout.take();
4872            let stderr_pipe = child.stderr.take();
4873
4874            let stdout_clone = stdout_stream.clone();
4875            let stderr_clone = stderr_stream.clone();
4876
4877            let stdout_task = stdout_pipe.map(|pipe| {
4878                tokio::spawn(async move {
4879                    drain_to_stream(pipe, stdout_clone).await;
4880                })
4881            });
4882
4883            let stderr_task = stderr_pipe.map(|pipe| {
4884                tokio::spawn(async move {
4885                    drain_to_stream(pipe, stderr_clone).await;
4886                })
4887            });
4888
4889            let cancelled_before_wait = cancel.is_cancelled();
4890            let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
4891                Ok(s) => s,
4892                Err(e) => {
4893                    // stdin-copy task is aborted by `_stdin_copy_guard` on return.
4894                    if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
4895                    if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
4896                    return Ok(Some(ExecResult::failure(
4897                        1,
4898                        format!("{}: failed to wait: {}", name, e),
4899                    )));
4900                }
4901            };
4902
4903            // On cancel, abort the drain tasks (the child's pipes are gone;
4904            // late output is lost but predictable death beats partial capture).
4905            // On normal exit, await drains so we don't lose buffered output.
4906            if cancelled_before_wait || cancel.is_cancelled() {
4907                if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
4908                if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
4909            } else {
4910                if let Some(task) = stdout_task {
4911                    // Ignore join error — the drain task logs its own errors
4912                    let _ = task.await;
4913                }
4914                if let Some(task) = stderr_task {
4915                    let _ = task.await;
4916                }
4917            }
4918
4919            let code = status.code().unwrap_or_else(|| {
4920                #[cfg(unix)]
4921                {
4922                    use std::os::unix::process::ExitStatusExt;
4923                    128 + status.signal().unwrap_or(0)
4924                }
4925                #[cfg(not(unix))]
4926                {
4927                    -1
4928                }
4929            }) as i64;
4930
4931            // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
4932            // result, so `curl url`, `curl url > file.bin`, etc. keep binary
4933            // intact. stderr stays text. See docs/binary-data.md.
4934            let stdout = stdout_stream.read().await;
4935            let stderr = stderr_stream.read_string().await;
4936            let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
4937            result.err = stderr;
4938            Ok(Some(result))
4939        }
4940    }
4941
4942    // --- Variable Access ---
4943
4944    /// Get a variable value.
4945    pub async fn get_var(&self, name: &str) -> Option<Value> {
4946        let scope = self.scope.read().await;
4947        scope.get(name).cloned()
4948    }
4949
4950    /// Check if error-exit mode is enabled (for testing).
4951    #[cfg(test)]
4952    pub async fn error_exit_enabled(&self) -> bool {
4953        let scope = self.scope.read().await;
4954        scope.error_exit_enabled()
4955    }
4956
4957    /// Set a variable value.
4958    pub async fn set_var(&self, name: &str, value: Value) {
4959        let mut scope = self.scope.write().await;
4960        scope.set(name.to_string(), value);
4961    }
4962
4963    /// Set positional parameters ($0 script name and $1-$9 args).
4964    pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
4965        let mut scope = self.scope.write().await;
4966        scope.set_positional(script_name, args);
4967    }
4968
4969    /// List all variables.
4970    pub async fn list_vars(&self) -> Vec<(String, Value)> {
4971        let scope = self.scope.read().await;
4972        scope.all()
4973    }
4974
4975    /// List exported variables (name, value), sorted by name. These are the
4976    /// vars a child process would see (see `dispatch`'s hermetic env build).
4977    pub async fn exported_vars(&self) -> Vec<(String, Value)> {
4978        let scope = self.scope.read().await;
4979        scope.exported_vars()
4980    }
4981
4982    // --- CWD ---
4983
4984    /// Get current working directory.
4985    pub async fn cwd(&self) -> PathBuf {
4986        self.exec_ctx.read().await.cwd.clone()
4987    }
4988
4989    /// Set current working directory.
4990    pub async fn set_cwd(&self, path: PathBuf) {
4991        let mut ctx = self.exec_ctx.write().await;
4992        ctx.set_cwd(path);
4993    }
4994
4995    /// Set the working directory only if `path` resolves to a directory in the
4996    /// kernel's backend — the same namespace `cd` validates against. Unlike a
4997    /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
4998    /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
4999    /// disappeared. Returns whether the cwd was changed.
5000    pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5001        // Clone the backend Arc out before the stat so we never hold the
5002        // exec_ctx lock across the await.
5003        let backend = self.exec_ctx.read().await.backend.clone();
5004        let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5005        if is_dir {
5006            self.exec_ctx.write().await.set_cwd(path);
5007        }
5008        is_dir
5009    }
5010
5011    // --- Last Result ---
5012
5013    /// Get the last result ($?).
5014    pub async fn last_result(&self) -> ExecResult {
5015        let scope = self.scope.read().await;
5016        scope.last_result().clone()
5017    }
5018
5019    // --- Tools ---
5020
5021    /// Check if a user-defined function exists.
5022    pub async fn has_function(&self, name: &str) -> bool {
5023        self.user_tools.read().await.contains_key(name)
5024    }
5025
5026    /// Get available tool schemas.
5027    pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5028        self.tools.schemas()
5029    }
5030
5031    // --- Jobs ---
5032
5033    /// Get job manager.
5034    pub fn jobs(&self) -> Arc<JobManager> {
5035        self.jobs.clone()
5036    }
5037
5038    // --- VFS ---
5039
5040    /// Get VFS router.
5041    pub fn vfs(&self) -> Arc<VfsRouter> {
5042        self.vfs.clone()
5043    }
5044
5045    // --- State ---
5046
5047    /// Reset kernel to initial state.
5048    ///
5049    /// Clears in-memory variables and resets cwd to root.
5050    /// History is not cleared (it persists across resets).
5051    pub async fn reset(&self) -> Result<()> {
5052        {
5053            let mut scope = self.scope.write().await;
5054            *scope = Scope::new();
5055        }
5056        {
5057            let mut ctx = self.exec_ctx.write().await;
5058            ctx.cwd = PathBuf::from("/");
5059        }
5060        Ok(())
5061    }
5062
5063    /// Shutdown the kernel.
5064    pub async fn shutdown(self) -> Result<()> {
5065        // Wait for all background jobs
5066        self.jobs.wait_all().await;
5067        Ok(())
5068    }
5069
5070    /// Dispatch a single command using the full resolution chain.
5071    ///
5072    /// This is the core of `CommandDispatcher` — it syncs state between the
5073    /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5074    /// then delegates to `execute_command` for the actual dispatch.
5075    ///
5076    /// State flow:
5077    /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5078    /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5079    /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5080    async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5081        // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5082        // its inner command via ctx.dispatcher) routes through THIS kernel,
5083        // not a stale parent. Critical for forks: the fork's builtins must
5084        // use the fork's dispatcher, not the parent's.
5085        if let Some(d) = self.dispatcher() {
5086            ctx.dispatcher = Some(d);
5087        }
5088
5089        // 1. Sync ctx → self internals
5090        {
5091            let mut scope = self.scope.write().await;
5092            *scope = ctx.scope.clone();
5093        }
5094        {
5095            let mut ec = self.exec_ctx.write().await;
5096            ec.cwd = ctx.cwd.clone();
5097            ec.prev_cwd = ctx.prev_cwd.clone();
5098            ec.stdin = ctx.stdin.take();
5099            ec.stdin_data = ctx.stdin_data.take();
5100            // The structured-data sideband receiver (set by the concurrent
5101            // pipeline runner on the stage ctx) must reach the tool's snapshot
5102            // too — same reason as the pipe endpoints below. Without this a
5103            // pipeline consumer never sees the producer's `.data`.
5104            ec.stdin_data_rx = ctx.stdin_data_rx.take();
5105            // Streaming pipe endpoints and kernel stderr must flow to the
5106            // tool via self.exec_ctx — execute_command reads that, not the
5107            // passed-in ctx. Without moving these, concurrent pipeline
5108            // stages dispatched via a fork get pipe_stdin = None and
5109            // silently read nothing.
5110            ec.pipe_stdin = ctx.pipe_stdin.take();
5111            ec.pipe_stdout = ctx.pipe_stdout.take();
5112            if let Some(stderr) = ctx.stderr.clone() {
5113                ec.stderr = Some(stderr);
5114            }
5115            ec.aliases = ctx.aliases.clone();
5116            ec.ignore_config = ctx.ignore_config.clone();
5117            ec.output_limit = ctx.output_limit.clone();
5118            ec.pipeline_position = ctx.pipeline_position;
5119            // Sync the cancel token from ctx → ec. Builtins like `timeout`
5120            // swap ctx.cancel to a derived child token before re-dispatching;
5121            // execute_command's snapshot reads ec.cancel (kept aligned by
5122            // this sync), so try_execute_external sees the right token.
5123            ec.cancel = ctx.cancel.clone();
5124            // Same alignment for the watchdog: a fork dispatching through its
5125            // own kernel must hand the shared script clock to the snapshot so
5126            // patient holds in forked stages suspend the right timer.
5127            ec.watchdog = ctx.watchdog.clone();
5128        }
5129
5130        // 2. Execute via the full dispatch chain
5131        let result = self.execute_command(&cmd.name, &cmd.args).await?;
5132
5133        // 3. Sync self → ctx
5134        {
5135            let scope = self.scope.read().await;
5136            ctx.scope = scope.clone();
5137        }
5138        {
5139            let mut ec = self.exec_ctx.write().await;
5140            ctx.cwd = ec.cwd.clone();
5141            ctx.prev_cwd = ec.prev_cwd.clone();
5142            ctx.aliases = ec.aliases.clone();
5143            ctx.ignore_config = ec.ignore_config.clone();
5144            ctx.output_limit = ec.output_limit.clone();
5145            // Return any pipe endpoints that the tool didn't consume.
5146            // `take()` here keeps the fork's exec_ctx in a clean state for
5147            // the next dispatch — these are per-command and shouldn't leak
5148            // between calls.
5149            ctx.pipe_stdin = ec.pipe_stdin.take();
5150            ctx.pipe_stdout = ec.pipe_stdout.take();
5151        }
5152
5153        Ok(result)
5154    }
5155}
5156
5157#[async_trait]
5158impl CommandDispatcher for Kernel {
5159    /// Dispatch a command through the Kernel's full resolution chain.
5160    ///
5161    /// This is the single path for all command execution when called from
5162    /// the pipeline runner. It provides the full dispatch chain:
5163    /// user tools → builtins → .kai scripts → external commands → backend tools.
5164    async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5165        self.dispatch_command(cmd, ctx).await
5166    }
5167
5168    /// Evaluate an expression through the kernel's async chain, including
5169    /// command substitution. Delegates to `eval_expr_async`, which snapshots
5170    /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
5171    /// only command output escapes. The `ctx` is unused here because the
5172    /// kernel evaluates against its own session state (a fork carries the
5173    /// pipeline stage's snapshot); var refs resolve against that scope.
5174    async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
5175        self.eval_expr_async(expr).await
5176    }
5177
5178    /// Produce a forked dispatcher with independent mutable state (detached).
5179    ///
5180    /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
5181    /// recursing into the trait method we're defining) and coerces the
5182    /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
5183    async fn fork(&self) -> Arc<dyn CommandDispatcher> {
5184        let fork: Arc<Kernel> = Kernel::fork(self).await;
5185        fork
5186    }
5187
5188    /// Produce a forked dispatcher with cancellation cascading from this kernel.
5189    async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
5190        let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
5191        fork
5192    }
5193}
5194
5195/// Apply the requested output format to a builtin's result, unless the tool
5196/// owns its own output.
5197///
5198/// `format` is `ctx.output_format` (set from `--json`). When `owns_output` is
5199/// true the tool already rendered its bytes (bespoke JSON envelope), so the
5200/// kernel leaves the result untouched rather than re-formatting its
5201/// `OutputData`. Otherwise the kernel renders the typed `OutputData` uniformly.
5202fn finalize_output(
5203    result: ExecResult,
5204    format: Option<crate::interpreter::OutputFormat>,
5205    owns_output: bool,
5206) -> ExecResult {
5207    match format {
5208        Some(_) if owns_output => result,
5209        Some(format) => apply_output_format(result, format),
5210        None => result,
5211    }
5212}
5213
5214/// Accumulate output from one result into another.
5215///
5216/// Appends stdout and stderr verbatim and updates the exit code to match the
5217/// new result. Used to preserve output from multiple statements, loop
5218/// iterations, and command chains. No separator is inserted between outputs —
5219/// each command's output concatenates raw, matching bash (`printf a; printf b`
5220/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
5221/// when a command emits its own, as `echo` does).
5222fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
5223    // Materialize lazy OutputData into .out before accumulating.
5224    // Without this, the first command's output stays in .output while
5225    // the second's text gets appended to .out, losing the first.
5226    accumulated.materialize();
5227    match new.out_bytes() {
5228        // A binary result must not be lossy-decoded by text_out(): concatenate
5229        // raw bytes so the combined output stays binary (this is the path every
5230        // top-level statement's result flows through). See docs/binary-data.md.
5231        Some(new_bytes) => {
5232            let mut combined: Vec<u8> = match accumulated.out_bytes() {
5233                Some(b) => b.to_vec(),
5234                None => accumulated.text_out().into_owned().into_bytes(),
5235            };
5236            combined.extend_from_slice(new_bytes);
5237            accumulated.set_out_bytes(combined);
5238        }
5239        None => accumulated.push_out(&new.text_out()),
5240    }
5241    accumulated.err.push_str(&new.err);
5242    accumulated.code = new.code;
5243    accumulated.data = new.data.clone();
5244    accumulated.did_spill = new.did_spill;
5245    accumulated.original_code = new.original_code;
5246    accumulated.content_type = new.content_type.clone();
5247    accumulated.baggage.clone_from(&new.baggage);
5248}
5249
5250/// Fold a loop's accumulated output into a break/continue signal that is
5251/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
5252/// (with `N > 1`) would otherwise be discarded when the signal replaces the
5253/// loop's result on its way up. The loop's output comes first (it ran before
5254/// the signal was raised), then the signal's already-carried output.
5255fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
5256    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5257        let mut merged = loop_output;
5258        accumulate_result(&mut merged, result);
5259        *result = merged;
5260    }
5261}
5262
5263/// Accumulate the output a break/continue signal carried (from inner loops it
5264/// propagated through) into the loop that finally handles it, so it survives
5265/// into that loop's result.
5266fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
5267    if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5268        accumulate_result(accumulated, result);
5269    }
5270}
5271
5272/// Check if a value is truthy.
5273fn is_truthy(value: &Value) -> bool {
5274    match value {
5275        Value::Null => false,
5276        Value::Bool(b) => *b,
5277        Value::Int(i) => *i != 0,
5278        Value::Float(f) => *f != 0.0,
5279        Value::String(s) => !s.is_empty(),
5280        Value::Json(json) => match json {
5281            serde_json::Value::Null => false,
5282            serde_json::Value::Array(arr) => !arr.is_empty(),
5283            serde_json::Value::Object(obj) => !obj.is_empty(),
5284            serde_json::Value::Bool(b) => *b,
5285            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
5286            serde_json::Value::String(s) => !s.is_empty(),
5287        },
5288        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
5289    }
5290}
5291
5292/// Apply tilde expansion to a value.
5293///
5294/// Only string values starting with `~` are expanded. `home` is the session
5295/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
5296/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
5297fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
5298    match value {
5299        Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
5300        _ => value,
5301    }
5302}
5303
5304/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
5305/// how the lexer tokenizes the equivalent minimally-quoted command string —
5306/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
5307/// (`build_args_async`) verbatim instead of carrying a parallel one that could
5308/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
5309/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
5310///
5311/// Classification matches the lexer's word classes:
5312/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
5313///   the binder's `past_double_dash` arms, exactly as for the string door).
5314/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
5315/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
5316///   (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
5317///   they fall through to a positional, not a flag).
5318/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
5319///   binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
5320///   `key=value` positional, per the command's word-assign allowlist).
5321/// - everything else → a literal [`Arg::Positional`].
5322///
5323/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
5324/// positional — it can never be a flag — and rides through as-is. That is the
5325/// typed passthrough the string-native door cannot offer.
5326pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
5327    argv.iter().map(classify_argv_token).collect()
5328}
5329
5330fn classify_argv_token(token: &Value) -> Arg {
5331    let Value::String(s) = token else {
5332        return Arg::Positional(Expr::Literal(token.clone()));
5333    };
5334
5335    if s == "--" {
5336        return Arg::DoubleDash;
5337    }
5338
5339    // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
5340    // long-flag words (the string door tokenizes them differently and often
5341    // errors), so they fall through to a literal positional rather than a
5342    // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
5343    if let Some(rest) = s.strip_prefix("--") {
5344        if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
5345            return match rest.split_once('=') {
5346                Some((key, val)) => Arg::Named {
5347                    key: key.to_string(),
5348                    value: Expr::Literal(Value::String(val.to_string())),
5349                },
5350                None => Arg::LongFlag(rest.to_string()),
5351            };
5352        }
5353    } else if let Some(rest) = s.strip_prefix('-') {
5354        // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
5355        // token carrying any other char — notably `=` (`-k=v` is a parse error in
5356        // the string door) — or a leading digit (`-1` lexes as a number) is not a
5357        // short-flag word, so it falls through to a literal positional instead of
5358        // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
5359        if is_short_flag_body(rest) {
5360            return Arg::ShortFlag(rest.to_string());
5361        }
5362    }
5363
5364    if let Some((key, val)) = s.split_once('=') {
5365        if is_shell_identifier(key) {
5366            return Arg::WordAssign {
5367                key: key.to_string(),
5368                value: Expr::Literal(Value::String(val.to_string())),
5369            };
5370        }
5371    }
5372
5373    Arg::Positional(Expr::Literal(Value::String(s.clone())))
5374}
5375
5376/// A short-flag word: a leading ASCII letter and no `=`. `-la`, `-A1`, `-a:`
5377/// qualify (the lexer's flag token absorbs `:`/`.` and friends); `-1` (a number)
5378/// and `-k=v` (`=` is the assignment operator — a parse error in the string door)
5379/// do not, so they fall through to a literal positional.
5380fn is_short_flag_body(s: &str) -> bool {
5381    s.starts_with(|c: char| c.is_ascii_alphabetic()) && !s.contains('=')
5382}
5383
5384/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
5385fn is_shell_identifier(s: &str) -> bool {
5386    let mut chars = s.chars();
5387    match chars.next() {
5388        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
5389        _ => return false,
5390    }
5391    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
5392}
5393
5394/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
5395/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
5396/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
5397/// must keep every value, not silently drop all but the last. Used by every flag
5398/// surface that can carry the same flag twice — the space form
5399/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
5400/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
5401/// ordered array.
5402pub(crate) fn push_repeatable_value(
5403    tool_args: &mut ToolArgs,
5404    flag_name: &str,
5405    canonical: &str,
5406    v: Value,
5407) -> anyhow::Result<()> {
5408    let occ = crate::interpreter::value_to_json(&v);
5409    let entry = tool_args
5410        .named
5411        .entry(canonical.to_string())
5412        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5413    if let Value::Json(serde_json::Value::Array(items)) = entry {
5414        items.push(occ);
5415        Ok(())
5416    } else {
5417        anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
5418    }
5419}
5420
5421/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
5422/// is one token, so it carries a single value: a repeatable flag accumulates
5423/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
5424/// first-char glued arm and the combined-bundle arm so the two can't drift on
5425/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
5426/// is a loud error, not a silent single-value bind.
5427pub(crate) fn bind_glued_short_value(
5428    tool_args: &mut ToolArgs,
5429    flag_name: &str,
5430    canonical: &str,
5431    consumes: usize,
5432    repeatable: bool,
5433    value: String,
5434) -> anyhow::Result<()> {
5435    if consumes > 1 {
5436        anyhow::bail!(
5437            "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
5438        );
5439    }
5440    if repeatable {
5441        push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
5442    } else {
5443        tool_args
5444            .named
5445            .insert(canonical.to_string(), Value::String(value));
5446        Ok(())
5447    }
5448}
5449
5450/// Wait for a child to exit, killing it if `cancel` fires first.
5451///
5452/// `target` carries a Linux pidfd (when available) for race-free direct-child
5453/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
5454/// parameter is ignored and we use tokio's cross-platform `start_kill`.
5455#[cfg(all(unix, feature = "subprocess"))]
5456pub(crate) async fn wait_or_kill(
5457    child: &mut tokio::process::Child,
5458    target: Option<&crate::pidfd::KillTarget>,
5459    cancel: &tokio_util::sync::CancellationToken,
5460    grace: Duration,
5461) -> std::io::Result<std::process::ExitStatus> {
5462    tokio::select! {
5463        biased;
5464        status = child.wait() => status,
5465        _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
5466    }
5467}
5468
5469#[cfg(all(not(unix), feature = "subprocess"))]
5470pub(crate) async fn wait_or_kill(
5471    child: &mut tokio::process::Child,
5472    _target: Option<&()>,
5473    cancel: &tokio_util::sync::CancellationToken,
5474    _grace: Duration,
5475) -> std::io::Result<std::process::ExitStatus> {
5476    tokio::select! {
5477        biased;
5478        status = child.wait() => status,
5479        _ = cancel.cancelled() => {
5480            let _ = child.start_kill();
5481            child.wait().await
5482        }
5483    }
5484}
5485
5486/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
5487///
5488/// Direct-child kill goes through `target.signal()`, which on Linux uses a
5489/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
5490/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
5491#[cfg(all(unix, feature = "subprocess"))]
5492pub(crate) async fn kill_with_grace(
5493    child: &mut tokio::process::Child,
5494    target: Option<&crate::pidfd::KillTarget>,
5495    grace: Duration,
5496) -> std::io::Result<std::process::ExitStatus> {
5497    use nix::sys::signal::Signal;
5498
5499    if let Some(t) = target {
5500        t.signal(Signal::SIGTERM);
5501        t.signal_pg(Signal::SIGTERM);
5502        if grace > Duration::ZERO
5503            && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
5504        {
5505            return status;
5506        }
5507        t.signal(Signal::SIGKILL);
5508        t.signal_pg(Signal::SIGKILL);
5509    }
5510    child.wait().await
5511}
5512
5513#[cfg(test)]
5514#[allow(clippy::unwrap_used, clippy::expect_used)]
5515mod argv_classify_tests {
5516    use super::*;
5517
5518    /// A normalized, comparable view of one `Arg` representing its *logical
5519    /// argument* (what the command observably receives), not its exact AST shape:
5520    ///
5521    /// - Value-bearing arms compare by *stringified* value, so the parser's
5522    ///   number coercion (`-1`→`Int(-1)`) vs the classifier's literal
5523    ///   (`String("-1")`) count as the same argument.
5524    /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
5525    ///   For every command except the `export`/`alias` allowlist, a bareword
5526    ///   `key=value` is stringified straight back to a `"key=value"` positional
5527    ///   (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
5528    ///   converge observably even when they disagree on the AST tag — e.g. the
5529    ///   lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
5530    ///   `WordAssign`, where the classifier (bash-correctly) makes a positional.
5531    ///   The genuine `WordAssign` *detection* on a real identifier LHS is pinned
5532    ///   separately by `classifies_each_word_class`.
5533    ///
5534    /// Returns `None` for shapes we deliberately don't compare:
5535    /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
5536    /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
5537    ///   lex to `Int`, dropping the literal text, where the classifier keeps the
5538    ///   string. That divergence is *intentional* — `execute_argv` preserves a
5539    ///   literal numeric string (pass `Value::Int` for a number), the string door
5540    ///   can only guess — so the property skips it rather than demanding the
5541    ///   classifier replicate a lossy coercion. Numeric edges are pinned exactly
5542    ///   by `classifies_each_word_class`.
5543    fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
5544        // Only a *string*-valued literal is comparable; a coerced number is not.
5545        let lit = |e: &Expr| match e {
5546            Expr::Literal(Value::String(s)) => Some(s.clone()),
5547            _ => None,
5548        };
5549        Some(match arg {
5550            Arg::DoubleDash => ("dash", String::new(), String::new()),
5551            Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
5552            Arg::LongFlag(s) => ("long", s.clone(), String::new()),
5553            Arg::Positional(e) => ("pos", String::new(), lit(e)?),
5554            Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
5555            Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
5556        })
5557    }
5558
5559    /// Classify a single string token the way `execute_argv` would.
5560    fn classify(token: &str) -> Arg {
5561        classify_argv_token(&Value::String(token.to_string()))
5562    }
5563
5564    #[test]
5565    fn classifies_each_word_class() {
5566        assert_eq!(classify("--"), Arg::DoubleDash);
5567        assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
5568        assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
5569        assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
5570        assert_eq!(
5571            classify("--key=value"),
5572            Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
5573        );
5574        assert_eq!(
5575            classify("NAME=val"),
5576            Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
5577        );
5578        // Digits after the first flag char are ordinary (kept verbatim).
5579        assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
5580        assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
5581        // Leading-digit dash is a number to the lexer, not a flag → positional.
5582        assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
5583        // Numeric strings keep their literal text — `execute_argv` does NOT
5584        // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
5585        // who wants a number passes `Value::Int`; a string stays the string.
5586        assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
5587        assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
5588        // A lone dash (stdin convention) is a positional, not a flag.
5589        assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
5590        // Non-identifier LHS is not an assignment.
5591        assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
5592        assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
5593    }
5594
5595    #[test]
5596    fn typed_values_pass_through_as_literal_positionals() {
5597        // The whole point of the `&[Value]` signature: a non-string value is a
5598        // literal positional carrying the *exact* value, never stringified and
5599        // never flag-interpreted.
5600        let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
5601        assert_eq!(
5602            classify_argv_token(&bytes),
5603            Arg::Positional(Expr::Literal(bytes.clone()))
5604        );
5605        let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
5606        assert_eq!(
5607            classify_argv_token(&json),
5608            Arg::Positional(Expr::Literal(json.clone()))
5609        );
5610        // An integer token that *looks* like a flag is still a positional value
5611        // (only strings are inspected for a leading dash).
5612        assert_eq!(
5613            classify_argv_token(&Value::Int(-9)),
5614            Arg::Positional(Expr::Literal(Value::Int(-9)))
5615        );
5616    }
5617
5618    #[test]
5619    fn double_dash_only_matches_exactly() {
5620        // `--` is the marker; `--x` is a long flag. `---` is not a flag word
5621        // (the lexer splits it `--` + `-`); as a single argv token it's literal.
5622        assert_eq!(classify("--"), Arg::DoubleDash);
5623        assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
5624        assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
5625    }
5626
5627    #[test]
5628    fn malformed_flag_words_fall_back_to_literal_positionals() {
5629        // A token that isn't a well-formed flag word must NOT be silently misbound
5630        // into the arg binder (house rule: loud/visible over silent-wrong). Each
5631        // of these is a parse error or different tokenization in the string door,
5632        // so the argv door keeps them as literal positionals.
5633        let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
5634        // `=` is not in the short-flag char class (`-k=v` parse-errors in the
5635        // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
5636        assert_eq!(classify("-k=v"), pos("-k=v"));
5637        assert_eq!(classify("-="), pos("-="));
5638        // Empty long-flag key.
5639        assert_eq!(classify("--=v"), pos("--=v"));
5640        // `--` followed by a non-letter is not a long flag.
5641        assert_eq!(classify("--1"), pos("--1"));
5642        // A bare dash and a number-dash are positionals (covered above too).
5643        assert_eq!(classify("-"), pos("-"));
5644        assert_eq!(classify("-9"), pos("-9"));
5645    }
5646
5647    proptest::proptest! {
5648        /// The core correctness claim: the classifier mirrors the lexer/parser
5649        /// on metacharacter-free tokens. For any such single token, the `Arg`
5650        /// the classifier produces matches the one the real parser produces for
5651        /// the equivalent one-word command — so `execute_argv` reusing the
5652        /// string door's binder is sound. (First proptest in the workspace.)
5653        #[test]
5654        fn classifier_matches_parser_on_clean_tokens(
5655            // No digits: this property tests the *classification* boundary
5656            // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
5657            // positional), not numeric coercion. The lexer coerces digit runs to
5658            // `Int`/`Float` and drops the literal text (even inside a colon-merged
5659            // word: `00:` → `0:`); the classifier intentionally preserves the raw
5660            // string. Those numeric edges are pinned exactly by the unit tests.
5661            token in "[a-zA-Z_=./@:+-]{1,8}"
5662        ) {
5663            let parsed = match parse(&format!("cmd {token}")) {
5664                Ok(p) => p,
5665                Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
5666            };
5667            let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
5668                return Ok(());
5669            };
5670            // Only compare when the token lexed as exactly one argument.
5671            let [arg] = cmd.args.as_slice() else { return Ok(()); };
5672
5673            let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
5674                return Ok(()); // a non-literal parsed Expr we don't model — skip
5675            };
5676            proptest::prop_assert_eq!(
5677                ours, theirs,
5678                "classifier diverged from parser on token {:?}", token
5679            );
5680        }
5681    }
5682}
5683
5684#[cfg(all(test, feature = "subprocess"))]
5685#[allow(clippy::expect_used)]
5686mod tests {
5687    use super::*;
5688
5689    #[tokio::test]
5690    async fn test_kernel_transient() {
5691        let kernel = Kernel::transient().expect("failed to create kernel");
5692        assert_eq!(kernel.name(), "transient");
5693    }
5694
5695    #[tokio::test]
5696    async fn test_kernel_execute_echo() {
5697        let kernel = Kernel::transient().expect("failed to create kernel");
5698        let result = kernel.execute("echo hello").await.expect("execution failed");
5699        assert!(result.ok());
5700        assert_eq!(result.text_out().trim(), "hello");
5701    }
5702
5703    #[tokio::test]
5704    async fn test_multiple_statements_accumulate_output() {
5705        let kernel = Kernel::transient().expect("failed to create kernel");
5706        let result = kernel
5707            .execute("echo one\necho two\necho three")
5708            .await
5709            .expect("execution failed");
5710        assert!(result.ok());
5711        // Should have all three outputs separated by newlines
5712        assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
5713        assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
5714        assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
5715    }
5716
5717    #[tokio::test]
5718    async fn test_and_chain_accumulates_output() {
5719        let kernel = Kernel::transient().expect("failed to create kernel");
5720        let result = kernel
5721            .execute("echo first && echo second")
5722            .await
5723            .expect("execution failed");
5724        assert!(result.ok());
5725        assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
5726        assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
5727    }
5728
5729    #[tokio::test]
5730    async fn test_for_loop_accumulates_output() {
5731        let kernel = Kernel::transient().expect("failed to create kernel");
5732        let result = kernel
5733            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
5734            .await
5735            .expect("execution failed");
5736        assert!(result.ok());
5737        assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
5738        assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
5739        assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
5740    }
5741
5742    #[tokio::test]
5743    async fn test_while_loop_accumulates_output() {
5744        let kernel = Kernel::transient().expect("failed to create kernel");
5745        let result = kernel
5746            .execute(r#"
5747                N=3
5748                while [[ ${N} -gt 0 ]]; do
5749                    echo "N=${N}"
5750                    N=$((N - 1))
5751                done
5752            "#)
5753            .await
5754            .expect("execution failed");
5755        assert!(result.ok());
5756        assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
5757        assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
5758        assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
5759    }
5760
5761    #[tokio::test]
5762    async fn test_kernel_set_var() {
5763        let kernel = Kernel::transient().expect("failed to create kernel");
5764
5765        kernel.execute("X=42").await.expect("set failed");
5766
5767        let value = kernel.get_var("X").await;
5768        assert_eq!(value, Some(Value::Int(42)));
5769    }
5770
5771    #[tokio::test]
5772    async fn test_kernel_var_expansion() {
5773        let kernel = Kernel::transient().expect("failed to create kernel");
5774
5775        kernel.execute("NAME=\"world\"").await.expect("set failed");
5776        let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
5777
5778        assert!(result.ok());
5779        assert_eq!(result.text_out().trim(), "hello world");
5780    }
5781
5782    #[tokio::test]
5783    async fn test_kernel_last_result() {
5784        let kernel = Kernel::transient().expect("failed to create kernel");
5785
5786        kernel.execute("echo test").await.expect("echo failed");
5787
5788        let last = kernel.last_result().await;
5789        assert!(last.ok());
5790        assert_eq!(last.text_out().trim(), "test");
5791    }
5792
5793    #[tokio::test]
5794    async fn test_kernel_tool_not_found() {
5795        let kernel = Kernel::transient().expect("failed to create kernel");
5796
5797        let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
5798        assert!(!result.ok());
5799        assert_eq!(result.code, 127);
5800        assert!(result.err.contains("command not found"));
5801    }
5802
5803    #[tokio::test]
5804    async fn test_external_command_true() {
5805        // Use REPL config for passthrough filesystem access
5806        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
5807
5808        // /bin/true should be available on any Unix system
5809        let result = kernel.execute("true").await.expect("execution failed");
5810        // This should use the builtin true, which returns 0
5811        assert!(result.ok(), "true should succeed: {:?}", result);
5812    }
5813
5814    #[tokio::test]
5815    async fn test_external_command_basic() {
5816        // Use REPL config for passthrough filesystem access
5817        let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
5818
5819        // Test with /bin/echo which is external
5820        // Note: kaish has a builtin echo, so this will use the builtin
5821        // Let's test with a command that's not a builtin
5822        // Actually, let's just test that PATH resolution works by checking the PATH var
5823        let path_var = std::env::var("PATH").unwrap_or_default();
5824        eprintln!("System PATH: {}", path_var);
5825
5826        // Set PATH in kernel to ensure it's available
5827        kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
5828
5829        // Now try an external command like /usr/bin/env
5830        // But env is also a builtin... let's try uname
5831        let result = kernel.execute("uname").await.expect("execution failed");
5832        eprintln!("uname result: {:?}", result);
5833        // uname should succeed if external commands work
5834        assert!(result.ok() || result.code == 127, "uname: {:?}", result);
5835    }
5836
5837    #[tokio::test]
5838    async fn test_kernel_reset() {
5839        let kernel = Kernel::transient().expect("failed to create kernel");
5840
5841        kernel.execute("X=1").await.expect("set failed");
5842        assert!(kernel.get_var("X").await.is_some());
5843
5844        kernel.reset().await.expect("reset failed");
5845        assert!(kernel.get_var("X").await.is_none());
5846    }
5847
5848    #[tokio::test]
5849    async fn test_kernel_cwd() {
5850        let kernel = Kernel::transient().expect("failed to create kernel");
5851
5852        // Transient kernel uses sandboxed mode with cwd=$HOME
5853        let cwd = kernel.cwd().await;
5854        let home = std::env::var("HOME")
5855            .map(PathBuf::from)
5856            .unwrap_or_else(|_| PathBuf::from("/"));
5857        assert_eq!(cwd, home);
5858
5859        kernel.set_cwd(PathBuf::from("/tmp")).await;
5860        assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
5861    }
5862
5863    #[tokio::test]
5864    async fn test_kernel_list_vars() {
5865        let kernel = Kernel::transient().expect("failed to create kernel");
5866
5867        kernel.execute("A=1").await.ok();
5868        kernel.execute("B=2").await.ok();
5869
5870        let vars = kernel.list_vars().await;
5871        assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
5872        assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
5873    }
5874
5875    #[tokio::test]
5876    async fn test_is_truthy() {
5877        assert!(!is_truthy(&Value::Null));
5878        assert!(!is_truthy(&Value::Bool(false)));
5879        assert!(is_truthy(&Value::Bool(true)));
5880        assert!(!is_truthy(&Value::Int(0)));
5881        assert!(is_truthy(&Value::Int(1)));
5882        assert!(!is_truthy(&Value::String("".into())));
5883        assert!(is_truthy(&Value::String("x".into())));
5884    }
5885
5886    #[tokio::test]
5887    async fn test_jq_in_pipeline() {
5888        let kernel = Kernel::transient().expect("failed to create kernel");
5889        // kaish uses double quotes only; escape inner quotes
5890        let result = kernel
5891            .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
5892            .await
5893            .expect("execution failed");
5894        assert!(result.ok(), "jq pipeline failed: {}", result.err);
5895        assert_eq!(result.text_out().trim(), "Alice");
5896    }
5897
5898    #[tokio::test]
5899    async fn test_user_defined_tool() {
5900        let kernel = Kernel::transient().expect("failed to create kernel");
5901
5902        // Define a function
5903        kernel
5904            .execute(r#"greet() { echo "Hello, $1!" }"#)
5905            .await
5906            .expect("function definition failed");
5907
5908        // Call the function
5909        let result = kernel
5910            .execute(r#"greet "World""#)
5911            .await
5912            .expect("function call failed");
5913
5914        assert!(result.ok(), "greet failed: {}", result.err);
5915        assert_eq!(result.text_out().trim(), "Hello, World!");
5916    }
5917
5918    #[tokio::test]
5919    async fn test_user_tool_positional_args() {
5920        let kernel = Kernel::transient().expect("failed to create kernel");
5921
5922        // Define a function with positional param
5923        kernel
5924            .execute(r#"greet() { echo "Hi $1" }"#)
5925            .await
5926            .expect("function definition failed");
5927
5928        // Call with positional argument
5929        let result = kernel
5930            .execute(r#"greet "Amy""#)
5931            .await
5932            .expect("function call failed");
5933
5934        assert!(result.ok(), "greet failed: {}", result.err);
5935        assert_eq!(result.text_out().trim(), "Hi Amy");
5936    }
5937
5938    #[tokio::test]
5939    async fn test_function_shared_scope() {
5940        let kernel = Kernel::transient().expect("failed to create kernel");
5941
5942        // Set a variable in parent scope
5943        kernel
5944            .execute(r#"SECRET="hidden""#)
5945            .await
5946            .expect("set failed");
5947
5948        // Define a function that accesses and modifies parent variable
5949        kernel
5950            .execute(r#"access_parent() {
5951                echo "${SECRET}"
5952                SECRET="modified"
5953            }"#)
5954            .await
5955            .expect("function definition failed");
5956
5957        // Call the function - it SHOULD see SECRET (shared scope like sh)
5958        let result = kernel.execute("access_parent").await.expect("function call failed");
5959
5960        // Function should have access to parent scope
5961        assert!(
5962            result.text_out().contains("hidden"),
5963            "Function should access parent scope, got: {}",
5964            result.text_out()
5965        );
5966
5967        // Function should have modified the parent variable
5968        let secret = kernel.get_var("SECRET").await;
5969        assert_eq!(
5970            secret,
5971            Some(Value::String("modified".into())),
5972            "Function should modify parent scope"
5973        );
5974    }
5975
5976    #[tokio::test]
5977    #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
5978    async fn test_exec_builtin() {
5979        let kernel = Kernel::transient().expect("failed to create kernel");
5980        // argv is now a space-separated string or JSON array string
5981        let result = kernel
5982            .execute(r#"exec command="/bin/echo" argv="hello world""#)
5983            .await
5984            .expect("exec failed");
5985
5986        assert!(result.ok(), "exec failed: {}", result.err);
5987        assert_eq!(result.text_out().trim(), "hello world");
5988    }
5989
5990    #[tokio::test]
5991    async fn test_while_false_never_runs() {
5992        let kernel = Kernel::transient().expect("failed to create kernel");
5993
5994        // A while loop with false condition should never run
5995        let result = kernel
5996            .execute(r#"
5997                while false; do
5998                    echo "should not run"
5999                done
6000            "#)
6001            .await
6002            .expect("while false failed");
6003
6004        assert!(result.ok());
6005        assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
6006    }
6007
6008    #[tokio::test]
6009    async fn test_while_string_comparison() {
6010        let kernel = Kernel::transient().expect("failed to create kernel");
6011
6012        // Set a flag
6013        kernel.execute(r#"FLAG="go""#).await.expect("set failed");
6014
6015        // Use string comparison as condition (shell-compatible [[ ]] syntax)
6016        // Note: Put echo last so we can check the output
6017        let result = kernel
6018            .execute(r#"
6019                while [[ ${FLAG} == "go" ]]; do
6020                    FLAG="stop"
6021                    echo "running"
6022                done
6023            "#)
6024            .await
6025            .expect("while with string cmp failed");
6026
6027        assert!(result.ok());
6028        assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
6029
6030        // Verify flag was changed
6031        let flag = kernel.get_var("FLAG").await;
6032        assert_eq!(flag, Some(Value::String("stop".into())));
6033    }
6034
6035    #[tokio::test]
6036    async fn test_while_numeric_comparison() {
6037        let kernel = Kernel::transient().expect("failed to create kernel");
6038
6039        // Test > comparison (shell-compatible [[ ]] with -gt)
6040        kernel.execute("N=5").await.expect("set failed");
6041
6042        // Note: Put echo last so we can check the output
6043        let result = kernel
6044            .execute(r#"
6045                while [[ ${N} -gt 3 ]]; do
6046                    N=3
6047                    echo "N was greater"
6048                done
6049            "#)
6050            .await
6051            .expect("while with > failed");
6052
6053        assert!(result.ok());
6054        assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
6055    }
6056
6057    #[tokio::test]
6058    async fn test_break_in_while_loop() {
6059        let kernel = Kernel::transient().expect("failed to create kernel");
6060
6061        let result = kernel
6062            .execute(r#"
6063                I=0
6064                while true; do
6065                    I=1
6066                    echo "before break"
6067                    break
6068                    echo "after break"
6069                done
6070            "#)
6071            .await
6072            .expect("while with break failed");
6073
6074        assert!(result.ok());
6075        assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
6076        assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
6077
6078        // Verify we exited the loop
6079        let i = kernel.get_var("I").await;
6080        assert_eq!(i, Some(Value::Int(1)));
6081    }
6082
6083    #[tokio::test]
6084    async fn test_continue_in_while_loop() {
6085        let kernel = Kernel::transient().expect("failed to create kernel");
6086
6087        // Test continue in a while loop where variables persist
6088        // We use string state transition: "start" -> "middle" -> "end"
6089        // continue on "middle" should skip to next iteration
6090        // Shell-compatible: use [[ ]] for comparisons
6091        let result = kernel
6092            .execute(r#"
6093                STATE="start"
6094                AFTER_CONTINUE="no"
6095                while [[ ${STATE} != "done" ]]; do
6096                    if [[ ${STATE} == "start" ]]; then
6097                        STATE="middle"
6098                        continue
6099                        AFTER_CONTINUE="yes"
6100                    fi
6101                    if [[ ${STATE} == "middle" ]]; then
6102                        STATE="done"
6103                    fi
6104                done
6105            "#)
6106            .await
6107            .expect("while with continue failed");
6108
6109        assert!(result.ok());
6110
6111        // STATE should be "done" (we completed the loop)
6112        let state = kernel.get_var("STATE").await;
6113        assert_eq!(state, Some(Value::String("done".into())));
6114
6115        // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
6116        let after = kernel.get_var("AFTER_CONTINUE").await;
6117        assert_eq!(after, Some(Value::String("no".into())));
6118    }
6119
6120    #[tokio::test]
6121    async fn test_break_with_level() {
6122        let kernel = Kernel::transient().expect("failed to create kernel");
6123
6124        // Nested loop with break 2 to exit both loops
6125        // We verify by checking OUTER value:
6126        // - If break 2 works, OUTER stays at 1 (set before for loop)
6127        // - If break 2 fails, OUTER becomes 2 (set after for loop)
6128        let result = kernel
6129            .execute(r#"
6130                OUTER=0
6131                while true; do
6132                    OUTER=1
6133                    for X in "1 2"; do
6134                        break 2
6135                    done
6136                    OUTER=2
6137                done
6138            "#)
6139            .await
6140            .expect("nested break failed");
6141
6142        assert!(result.ok());
6143
6144        // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
6145        let outer = kernel.get_var("OUTER").await;
6146        assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
6147    }
6148
6149    #[tokio::test]
6150    async fn test_return_from_tool() {
6151        let kernel = Kernel::transient().expect("failed to create kernel");
6152
6153        // Define a function that returns early
6154        kernel
6155            .execute(r#"early_return() {
6156                if [[ $1 == 1 ]]; then
6157                    return 42
6158                fi
6159                echo "not returned"
6160            }"#)
6161            .await
6162            .expect("function definition failed");
6163
6164        // Call with arg=1 should return with exit code 42
6165        // (POSIX shell behavior: return N sets exit code, doesn't output N)
6166        let result = kernel
6167            .execute("early_return 1")
6168            .await
6169            .expect("function call failed");
6170
6171        // Exit code should be 42 (non-zero, so not ok())
6172        assert_eq!(result.code, 42);
6173        // Output should be empty (we returned before echo)
6174        assert!(result.text_out().is_empty());
6175    }
6176
6177    #[tokio::test]
6178    async fn test_return_without_value() {
6179        let kernel = Kernel::transient().expect("failed to create kernel");
6180
6181        // Define a function that returns without a value
6182        kernel
6183            .execute(r#"early_exit() {
6184                if [[ $1 == "stop" ]]; then
6185                    return
6186                fi
6187                echo "continued"
6188            }"#)
6189            .await
6190            .expect("function definition failed");
6191
6192        // Call with arg="stop" should return early
6193        let result = kernel
6194            .execute(r#"early_exit "stop""#)
6195            .await
6196            .expect("function call failed");
6197
6198        assert!(result.ok());
6199        assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
6200    }
6201
6202    #[tokio::test]
6203    async fn test_exit_stops_execution() {
6204        let kernel = Kernel::transient().expect("failed to create kernel");
6205
6206        // exit should stop further execution
6207        kernel
6208            .execute(r#"
6209                BEFORE="yes"
6210                exit 0
6211                AFTER="yes"
6212            "#)
6213            .await
6214            .expect("execution failed");
6215
6216        // BEFORE should be set, AFTER should not
6217        let before = kernel.get_var("BEFORE").await;
6218        assert_eq!(before, Some(Value::String("yes".into())));
6219
6220        let after = kernel.get_var("AFTER").await;
6221        assert!(after.is_none(), "AFTER should not be set after exit");
6222    }
6223
6224    #[tokio::test]
6225    async fn test_exit_with_code() {
6226        let kernel = Kernel::transient().expect("failed to create kernel");
6227
6228        // exit with code should propagate the exit code
6229        let result = kernel
6230            .execute("exit 42")
6231            .await
6232            .expect("exit failed");
6233
6234        assert_eq!(result.code, 42);
6235        assert!(result.text_out().is_empty(), "exit should not produce stdout");
6236    }
6237
6238    #[tokio::test]
6239    async fn test_set_e_stops_on_failure() {
6240        let kernel = Kernel::transient().expect("failed to create kernel");
6241
6242        // Enable error-exit mode
6243        kernel.execute("set -e").await.expect("set -e failed");
6244
6245        // Run a sequence where the middle command fails
6246        kernel
6247            .execute(r#"
6248                STEP1="done"
6249                false
6250                STEP2="done"
6251            "#)
6252            .await
6253            .expect("execution failed");
6254
6255        // STEP1 should be set, but STEP2 should NOT be set (exit on false)
6256        let step1 = kernel.get_var("STEP1").await;
6257        assert_eq!(step1, Some(Value::String("done".into())));
6258
6259        let step2 = kernel.get_var("STEP2").await;
6260        assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
6261    }
6262
6263    #[tokio::test]
6264    async fn test_set_plus_e_disables_error_exit() {
6265        let kernel = Kernel::transient().expect("failed to create kernel");
6266
6267        // Enable then disable error-exit mode
6268        kernel.execute("set -e").await.expect("set -e failed");
6269        kernel.execute("set +e").await.expect("set +e failed");
6270
6271        // Now failure should NOT stop execution
6272        kernel
6273            .execute(r#"
6274                STEP1="done"
6275                false
6276                STEP2="done"
6277            "#)
6278            .await
6279            .expect("execution failed");
6280
6281        // Both should be set since +e disables error exit
6282        let step1 = kernel.get_var("STEP1").await;
6283        assert_eq!(step1, Some(Value::String("done".into())));
6284
6285        let step2 = kernel.get_var("STEP2").await;
6286        assert_eq!(step2, Some(Value::String("done".into())));
6287    }
6288
6289    #[tokio::test]
6290    async fn test_set_ignores_unknown_options() {
6291        let kernel = Kernel::transient().expect("failed to create kernel");
6292
6293        // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
6294        let result = kernel
6295            .execute("set -e -u -o pipefail")
6296            .await
6297            .expect("set with unknown options failed");
6298
6299        assert!(result.ok(), "set should succeed with unknown options");
6300
6301        // -e should still be enabled
6302        kernel
6303            .execute(r#"
6304                BEFORE="yes"
6305                false
6306                AFTER="yes"
6307            "#)
6308            .await
6309            .ok();
6310
6311        let after = kernel.get_var("AFTER").await;
6312        assert!(after.is_none(), "-e should be enabled despite unknown options");
6313    }
6314
6315    #[tokio::test]
6316    async fn test_set_no_args_shows_settings() {
6317        let kernel = Kernel::transient().expect("failed to create kernel");
6318
6319        // Enable -e
6320        kernel.execute("set -e").await.expect("set -e failed");
6321
6322        // Call set with no args to see settings
6323        let result = kernel.execute("set").await.expect("set failed");
6324
6325        assert!(result.ok());
6326        assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
6327    }
6328
6329    #[tokio::test]
6330    async fn test_set_e_in_pipeline() {
6331        let kernel = Kernel::transient().expect("failed to create kernel");
6332
6333        kernel.execute("set -e").await.expect("set -e failed");
6334
6335        // Pipeline failure should trigger exit
6336        kernel
6337            .execute(r#"
6338                BEFORE="yes"
6339                false | cat
6340                AFTER="yes"
6341            "#)
6342            .await
6343            .ok();
6344
6345        let before = kernel.get_var("BEFORE").await;
6346        assert_eq!(before, Some(Value::String("yes".into())));
6347
6348        // AFTER should not be set if pipeline failure triggers exit
6349        // Note: The exit code of a pipeline is the exit code of the last command
6350        // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
6351        // To test pipeline failure, we need the last command to fail.
6352    }
6353
6354    #[tokio::test]
6355    async fn test_set_e_with_and_chain() {
6356        let kernel = Kernel::transient().expect("failed to create kernel");
6357
6358        kernel.execute("set -e").await.expect("set -e failed");
6359
6360        // Commands in && chain should not trigger -e on the first failure
6361        // because && explicitly handles the error
6362        kernel
6363            .execute(r#"
6364                RESULT="initial"
6365                false && RESULT="chained"
6366                RESULT="continued"
6367            "#)
6368            .await
6369            .ok();
6370
6371        // In bash, commands in && don't trigger -e. The chain handles the failure.
6372        // Our implementation may differ - let's verify current behavior.
6373        let result = kernel.get_var("RESULT").await;
6374        // If we follow bash semantics, RESULT should be "continued"
6375        // If we trigger -e on the false, RESULT stays "initial"
6376        assert!(result.is_some(), "RESULT should be set");
6377    }
6378
6379    #[tokio::test]
6380    async fn test_set_e_exits_in_for_loop() {
6381        let kernel = Kernel::transient().expect("failed to create kernel");
6382
6383        kernel.execute("set -e").await.expect("set -e failed");
6384
6385        kernel
6386            .execute(r#"
6387                REACHED="no"
6388                for x in 1 2 3; do
6389                    false
6390                    REACHED="yes"
6391                done
6392            "#)
6393            .await
6394            .ok();
6395
6396        // With set -e, false should trigger exit; REACHED should remain "no"
6397        let reached = kernel.get_var("REACHED").await;
6398        assert_eq!(reached, Some(Value::String("no".into())),
6399            "set -e should exit on failure in for loop body");
6400    }
6401
6402    #[tokio::test]
6403    async fn test_for_loop_continues_without_set_e() {
6404        let kernel = Kernel::transient().expect("failed to create kernel");
6405
6406        // Without set -e, for loop should continue normally
6407        kernel
6408            .execute(r#"
6409                COUNT=0
6410                for x in 1 2 3; do
6411                    false
6412                    COUNT=$((COUNT + 1))
6413                done
6414            "#)
6415            .await
6416            .ok();
6417
6418        let count = kernel.get_var("COUNT").await;
6419        // Arithmetic produces Int values; accept either Int or String representation
6420        let count_val = match &count {
6421            Some(Value::Int(n)) => *n,
6422            Some(Value::String(s)) => s.parse().unwrap_or(-1),
6423            _ => -1,
6424        };
6425        assert_eq!(count_val, 3,
6426            "without set -e, loop should complete all iterations (got {:?})", count);
6427    }
6428
6429    // ═══════════════════════════════════════════════════════════════════════════
6430    // Source Tests
6431    // ═══════════════════════════════════════════════════════════════════════════
6432
6433    #[tokio::test]
6434    async fn test_source_sets_variables() {
6435        let kernel = Kernel::transient().expect("failed to create kernel");
6436
6437        // Write a script to the VFS
6438        kernel
6439            .execute(r#"write "/test.kai" 'FOO="bar"'"#)
6440            .await
6441            .expect("write failed");
6442
6443        // Source the script
6444        let result = kernel
6445            .execute(r#"source "/test.kai""#)
6446            .await
6447            .expect("source failed");
6448
6449        assert!(result.ok(), "source should succeed");
6450
6451        // Variable should be set in current scope
6452        let foo = kernel.get_var("FOO").await;
6453        assert_eq!(foo, Some(Value::String("bar".into())));
6454    }
6455
6456    #[tokio::test]
6457    async fn test_source_with_dot_alias() {
6458        let kernel = Kernel::transient().expect("failed to create kernel");
6459
6460        // Write a script to the VFS
6461        kernel
6462            .execute(r#"write "/vars.kai" 'X=42'"#)
6463            .await
6464            .expect("write failed");
6465
6466        // Source using . alias
6467        let result = kernel
6468            .execute(r#". "/vars.kai""#)
6469            .await
6470            .expect(". failed");
6471
6472        assert!(result.ok(), ". should succeed");
6473
6474        // Variable should be set in current scope
6475        let x = kernel.get_var("X").await;
6476        assert_eq!(x, Some(Value::Int(42)));
6477    }
6478
6479    #[tokio::test]
6480    async fn test_source_not_found() {
6481        let kernel = Kernel::transient().expect("failed to create kernel");
6482
6483        // Try to source a non-existent file
6484        let result = kernel
6485            .execute(r#"source "/nonexistent.kai""#)
6486            .await
6487            .expect("source should not fail with error");
6488
6489        assert!(!result.ok(), "source of non-existent file should fail");
6490        assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
6491    }
6492
6493    #[tokio::test]
6494    async fn test_source_missing_filename() {
6495        let kernel = Kernel::transient().expect("failed to create kernel");
6496
6497        // Call source with no arguments
6498        let result = kernel
6499            .execute("source")
6500            .await
6501            .expect("source should not fail with error");
6502
6503        assert!(!result.ok(), "source without filename should fail");
6504        assert!(result.err.contains("missing filename"), "error should mention missing filename");
6505    }
6506
6507    #[tokio::test]
6508    async fn test_source_executes_multiple_statements() {
6509        let kernel = Kernel::transient().expect("failed to create kernel");
6510
6511        // Write a script with multiple statements
6512        kernel
6513            .execute(r#"write "/multi.kai" 'A=1
6514B=2
6515C=3'"#)
6516            .await
6517            .expect("write failed");
6518
6519        // Source it
6520        kernel
6521            .execute(r#"source "/multi.kai""#)
6522            .await
6523            .expect("source failed");
6524
6525        // All variables should be set
6526        assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
6527        assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
6528        assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
6529    }
6530
6531    #[tokio::test]
6532    async fn test_source_can_define_functions() {
6533        let kernel = Kernel::transient().expect("failed to create kernel");
6534
6535        // Write a script that defines a function
6536        kernel
6537            .execute(r#"write "/functions.kai" 'greet() {
6538    echo "Hello, $1!"
6539}'"#)
6540            .await
6541            .expect("write failed");
6542
6543        // Source it
6544        kernel
6545            .execute(r#"source "/functions.kai""#)
6546            .await
6547            .expect("source failed");
6548
6549        // Use the defined function
6550        let result = kernel
6551            .execute(r#"greet "World""#)
6552            .await
6553            .expect("greet failed");
6554
6555        assert!(result.ok());
6556        assert!(result.text_out().contains("Hello, World!"));
6557    }
6558
6559    #[tokio::test]
6560    async fn test_source_inherits_error_exit() {
6561        let kernel = Kernel::transient().expect("failed to create kernel");
6562
6563        // Enable error exit
6564        kernel.execute("set -e").await.expect("set -e failed");
6565
6566        // Write a script that has a failure
6567        kernel
6568            .execute(r#"write "/fail.kai" 'BEFORE="yes"
6569false
6570AFTER="yes"'"#)
6571            .await
6572            .expect("write failed");
6573
6574        // Source it (should exit on false due to set -e)
6575        kernel
6576            .execute(r#"source "/fail.kai""#)
6577            .await
6578            .ok();
6579
6580        // BEFORE should be set, AFTER should NOT be set due to error exit
6581        let before = kernel.get_var("BEFORE").await;
6582        assert_eq!(before, Some(Value::String("yes".into())));
6583
6584        // Note: This test depends on whether error exit is checked within source
6585        // Currently our implementation checks per-statement in the main kernel
6586    }
6587
6588    // ═══════════════════════════════════════════════════════════════════════════
6589    // set -e with && / || chains
6590    // ═══════════════════════════════════════════════════════════════════════════
6591
6592    #[tokio::test]
6593    async fn test_set_e_and_chain_left_fails() {
6594        // set -e; false && echo hi; REACHED=1 → REACHED should be set
6595        let kernel = Kernel::transient().expect("failed to create kernel");
6596        kernel.execute("set -e").await.expect("set -e failed");
6597
6598        kernel
6599            .execute("false && echo hi; REACHED=1")
6600            .await
6601            .expect("execution failed");
6602
6603        let reached = kernel.get_var("REACHED").await;
6604        assert_eq!(
6605            reached,
6606            Some(Value::Int(1)),
6607            "set -e should not trigger on left side of &&"
6608        );
6609    }
6610
6611    #[tokio::test]
6612    async fn test_set_e_and_chain_right_fails() {
6613        // set -e; true && false; REACHED=1 → REACHED should NOT be set
6614        let kernel = Kernel::transient().expect("failed to create kernel");
6615        kernel.execute("set -e").await.expect("set -e failed");
6616
6617        kernel
6618            .execute("true && false; REACHED=1")
6619            .await
6620            .expect("execution failed");
6621
6622        let reached = kernel.get_var("REACHED").await;
6623        assert!(
6624            reached.is_none(),
6625            "set -e should trigger when right side of && fails"
6626        );
6627    }
6628
6629    #[tokio::test]
6630    async fn test_set_e_or_chain_recovers() {
6631        // set -e; false || echo recovered; REACHED=1 → REACHED should be set
6632        let kernel = Kernel::transient().expect("failed to create kernel");
6633        kernel.execute("set -e").await.expect("set -e failed");
6634
6635        kernel
6636            .execute("false || echo recovered; REACHED=1")
6637            .await
6638            .expect("execution failed");
6639
6640        let reached = kernel.get_var("REACHED").await;
6641        assert_eq!(
6642            reached,
6643            Some(Value::Int(1)),
6644            "set -e should not trigger when || recovers the failure"
6645        );
6646    }
6647
6648    #[tokio::test]
6649    async fn test_set_e_or_chain_both_fail() {
6650        // set -e; false || false; REACHED=1 → REACHED should NOT be set
6651        let kernel = Kernel::transient().expect("failed to create kernel");
6652        kernel.execute("set -e").await.expect("set -e failed");
6653
6654        kernel
6655            .execute("false || false; REACHED=1")
6656            .await
6657            .expect("execution failed");
6658
6659        let reached = kernel.get_var("REACHED").await;
6660        assert!(
6661            reached.is_none(),
6662            "set -e should trigger when || chain ultimately fails"
6663        );
6664    }
6665
6666    // ═══════════════════════════════════════════════════════════════════════════
6667    // Cancellation Tests
6668    // ═══════════════════════════════════════════════════════════════════════════
6669
6670    /// Helper: schedule a cancel after a delay from a background thread.
6671    /// Uses std::thread because cancel() is sync and Kernel is not Send.
6672    fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
6673        let k = Arc::clone(kernel);
6674        std::thread::spawn(move || {
6675            std::thread::sleep(delay);
6676            k.cancel();
6677        });
6678    }
6679
6680    #[tokio::test]
6681    async fn test_cancel_interrupts_for_loop() {
6682        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
6683
6684        // Schedule cancel after a short delay from a background OS thread
6685        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
6686
6687        let result = kernel
6688            .execute("for i in $(seq 1 100000); do X=$i; done")
6689            .await
6690            .expect("execute failed");
6691
6692        assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
6693
6694        // The loop variable should be set to something < 100000
6695        let x = kernel.get_var("X").await;
6696        if let Some(Value::Int(n)) = x {
6697            assert!(n < 100000, "loop should have been interrupted before finishing, got X={n}");
6698        }
6699    }
6700
6701    #[tokio::test]
6702    async fn test_cancel_interrupts_while_loop() {
6703        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
6704        kernel.execute("COUNT=0").await.expect("init failed");
6705
6706        schedule_cancel(&kernel, std::time::Duration::from_millis(10));
6707
6708        let result = kernel
6709            .execute("while true; do COUNT=$((COUNT + 1)); done")
6710            .await
6711            .expect("execute failed");
6712
6713        assert_eq!(result.code, 130);
6714
6715        let count = kernel.get_var("COUNT").await;
6716        if let Some(Value::Int(n)) = count {
6717            assert!(n > 0, "loop should have run at least once");
6718        }
6719    }
6720
6721    #[tokio::test]
6722    async fn test_reset_after_cancel() {
6723        // After cancellation, the next execute() should work normally
6724        let kernel = Kernel::transient().expect("failed to create kernel");
6725        kernel.cancel(); // cancel with nothing running
6726
6727        let result = kernel.execute("echo hello").await.expect("execute failed");
6728        assert!(result.ok(), "execute after cancel should succeed");
6729        assert_eq!(result.text_out().trim(), "hello");
6730    }
6731
6732    #[tokio::test]
6733    async fn test_cancel_interrupts_statement_sequence() {
6734        let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
6735
6736        // Schedule cancel after the first statement runs but before sleep finishes
6737        schedule_cancel(&kernel, std::time::Duration::from_millis(50));
6738
6739        let result = kernel
6740            .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
6741            .await
6742            .expect("execute failed");
6743
6744        assert_eq!(result.code, 130);
6745
6746        // STEP should be 1 (set before sleep), not 2 or 3
6747        let step = kernel.get_var("STEP").await;
6748        assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
6749    }
6750
6751    // ═══════════════════════════════════════════════════════════════════════════
6752    // Case Statement Tests
6753    // ═══════════════════════════════════════════════════════════════════════════
6754
6755    #[tokio::test]
6756    async fn test_case_simple_match() {
6757        let kernel = Kernel::transient().expect("failed to create kernel");
6758
6759        let result = kernel
6760            .execute(r#"
6761                case "hello" in
6762                    hello) echo "matched hello" ;;
6763                    world) echo "matched world" ;;
6764                esac
6765            "#)
6766            .await
6767            .expect("case failed");
6768
6769        assert!(result.ok());
6770        assert_eq!(result.text_out().trim(), "matched hello");
6771    }
6772
6773    #[tokio::test]
6774    async fn test_case_wildcard_match() {
6775        let kernel = Kernel::transient().expect("failed to create kernel");
6776
6777        let result = kernel
6778            .execute(r#"
6779                case "main.rs" in
6780                    *.py) echo "Python" ;;
6781                    *.rs) echo "Rust" ;;
6782                    *) echo "Unknown" ;;
6783                esac
6784            "#)
6785            .await
6786            .expect("case failed");
6787
6788        assert!(result.ok());
6789        assert_eq!(result.text_out().trim(), "Rust");
6790    }
6791
6792    #[tokio::test]
6793    async fn test_case_default_match() {
6794        let kernel = Kernel::transient().expect("failed to create kernel");
6795
6796        let result = kernel
6797            .execute(r#"
6798                case "unknown.xyz" in
6799                    *.py) echo "Python" ;;
6800                    *.rs) echo "Rust" ;;
6801                    *) echo "Default" ;;
6802                esac
6803            "#)
6804            .await
6805            .expect("case failed");
6806
6807        assert!(result.ok());
6808        assert_eq!(result.text_out().trim(), "Default");
6809    }
6810
6811    #[tokio::test]
6812    async fn test_case_no_match() {
6813        let kernel = Kernel::transient().expect("failed to create kernel");
6814
6815        // Case with no default branch and no match
6816        let result = kernel
6817            .execute(r#"
6818                case "nope" in
6819                    "yes") echo "yes" ;;
6820                    "no") echo "no" ;;
6821                esac
6822            "#)
6823            .await
6824            .expect("case failed");
6825
6826        assert!(result.ok());
6827        assert!(result.text_out().is_empty(), "no match should produce empty output");
6828    }
6829
6830    #[tokio::test]
6831    async fn test_case_with_variable() {
6832        let kernel = Kernel::transient().expect("failed to create kernel");
6833
6834        kernel.execute(r#"LANG="rust""#).await.expect("set failed");
6835
6836        let result = kernel
6837            .execute(r#"
6838                case ${LANG} in
6839                    python) echo "snake" ;;
6840                    rust) echo "crab" ;;
6841                    go) echo "gopher" ;;
6842                esac
6843            "#)
6844            .await
6845            .expect("case failed");
6846
6847        assert!(result.ok());
6848        assert_eq!(result.text_out().trim(), "crab");
6849    }
6850
6851    #[tokio::test]
6852    async fn test_case_multiple_patterns() {
6853        let kernel = Kernel::transient().expect("failed to create kernel");
6854
6855        let result = kernel
6856            .execute(r#"
6857                case "yes" in
6858                    "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
6859                    "n"|"no"|"N"|"NO") echo "negative" ;;
6860                esac
6861            "#)
6862            .await
6863            .expect("case failed");
6864
6865        assert!(result.ok());
6866        assert_eq!(result.text_out().trim(), "affirmative");
6867    }
6868
6869    #[tokio::test]
6870    async fn test_case_glob_question_mark() {
6871        let kernel = Kernel::transient().expect("failed to create kernel");
6872
6873        let result = kernel
6874            .execute(r#"
6875                case "test1" in
6876                    test?) echo "matched test?" ;;
6877                    *) echo "default" ;;
6878                esac
6879            "#)
6880            .await
6881            .expect("case failed");
6882
6883        assert!(result.ok());
6884        assert_eq!(result.text_out().trim(), "matched test?");
6885    }
6886
6887    #[tokio::test]
6888    async fn test_case_char_class() {
6889        let kernel = Kernel::transient().expect("failed to create kernel");
6890
6891        let result = kernel
6892            .execute(r#"
6893                case "Yes" in
6894                    [Yy]*) echo "yes-like" ;;
6895                    [Nn]*) echo "no-like" ;;
6896                esac
6897            "#)
6898            .await
6899            .expect("case failed");
6900
6901        assert!(result.ok());
6902        assert_eq!(result.text_out().trim(), "yes-like");
6903    }
6904
6905    // ═══════════════════════════════════════════════════════════════════════════
6906    // Cat Stdin Tests
6907    // ═══════════════════════════════════════════════════════════════════════════
6908
6909    #[tokio::test]
6910    async fn test_cat_from_pipeline() {
6911        let kernel = Kernel::transient().expect("failed to create kernel");
6912
6913        let result = kernel
6914            .execute(r#"echo "piped text" | cat"#)
6915            .await
6916            .expect("cat pipeline failed");
6917
6918        assert!(result.ok(), "cat failed: {}", result.err);
6919        assert_eq!(result.text_out().trim(), "piped text");
6920    }
6921
6922    #[tokio::test]
6923    async fn test_cat_from_pipeline_multiline() {
6924        let kernel = Kernel::transient().expect("failed to create kernel");
6925
6926        let result = kernel
6927            .execute(r#"echo "line1\nline2" | cat -n"#)
6928            .await
6929            .expect("cat pipeline failed");
6930
6931        assert!(result.ok(), "cat failed: {}", result.err);
6932        assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
6933    }
6934
6935    // ═══════════════════════════════════════════════════════════════════════════
6936    // Heredoc Tests
6937    // ═══════════════════════════════════════════════════════════════════════════
6938
6939    #[tokio::test]
6940    async fn test_heredoc_basic() {
6941        let kernel = Kernel::transient().expect("failed to create kernel");
6942
6943        let result = kernel
6944            .execute("cat <<EOF\nhello\nEOF")
6945            .await
6946            .expect("heredoc failed");
6947
6948        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
6949        assert_eq!(result.text_out().trim(), "hello");
6950    }
6951
6952    #[tokio::test]
6953    async fn test_arithmetic_in_string() {
6954        let kernel = Kernel::transient().expect("failed to create kernel");
6955
6956        let result = kernel
6957            .execute(r#"echo "result: $((1 + 2))""#)
6958            .await
6959            .expect("arithmetic in string failed");
6960
6961        assert!(result.ok(), "echo failed: {}", result.err);
6962        assert_eq!(result.text_out().trim(), "result: 3");
6963    }
6964
6965    #[tokio::test]
6966    async fn test_heredoc_multiline() {
6967        let kernel = Kernel::transient().expect("failed to create kernel");
6968
6969        let result = kernel
6970            .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
6971            .await
6972            .expect("heredoc failed");
6973
6974        assert!(result.ok(), "cat with heredoc failed: {}", result.err);
6975        assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
6976        assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
6977        assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
6978    }
6979
6980    #[tokio::test]
6981    async fn test_heredoc_variable_expansion() {
6982        // Bug N: unquoted heredoc should expand variables
6983        let kernel = Kernel::transient().expect("failed to create kernel");
6984
6985        kernel.execute("GREETING=hello").await.expect("set var");
6986
6987        let result = kernel
6988            .execute("cat <<EOF\n$GREETING world\nEOF")
6989            .await
6990            .expect("heredoc expansion failed");
6991
6992        assert!(result.ok(), "heredoc expansion failed: {}", result.err);
6993        assert_eq!(result.text_out().trim(), "hello world");
6994    }
6995
6996    #[tokio::test]
6997    async fn test_heredoc_quoted_no_expansion() {
6998        // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
6999        let kernel = Kernel::transient().expect("failed to create kernel");
7000
7001        kernel.execute("GREETING=hello").await.expect("set var");
7002
7003        let result = kernel
7004            .execute("cat <<'EOF'\n$GREETING world\nEOF")
7005            .await
7006            .expect("quoted heredoc failed");
7007
7008        assert!(result.ok(), "quoted heredoc failed: {}", result.err);
7009        assert_eq!(result.text_out().trim(), "$GREETING world");
7010    }
7011
7012    #[tokio::test]
7013    async fn test_heredoc_default_value_expansion() {
7014        // Bug N: ${VAR:-default} should expand in unquoted heredocs
7015        let kernel = Kernel::transient().expect("failed to create kernel");
7016
7017        let result = kernel
7018            .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
7019            .await
7020            .expect("heredoc default expansion failed");
7021
7022        assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
7023        assert_eq!(result.text_out().trim(), "fallback");
7024    }
7025
7026    // ═══════════════════════════════════════════════════════════════════════════
7027    // Read Builtin Tests
7028    // ═══════════════════════════════════════════════════════════════════════════
7029
7030    #[tokio::test]
7031    async fn test_read_from_pipeline() {
7032        let kernel = Kernel::transient().expect("failed to create kernel");
7033
7034        // Pipe input to read
7035        let result = kernel
7036            .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
7037            .await
7038            .expect("read pipeline failed");
7039
7040        assert!(result.ok(), "read failed: {}", result.err);
7041        assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
7042    }
7043
7044    #[tokio::test]
7045    async fn test_read_multiple_vars_from_pipeline() {
7046        let kernel = Kernel::transient().expect("failed to create kernel");
7047
7048        let result = kernel
7049            .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
7050            .await
7051            .expect("read pipeline failed");
7052
7053        assert!(result.ok(), "read failed: {}", result.err);
7054        assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
7055    }
7056
7057    // ═══════════════════════════════════════════════════════════════════════════
7058    // Shell-Style Function Tests
7059    // ═══════════════════════════════════════════════════════════════════════════
7060
7061    #[tokio::test]
7062    async fn test_posix_function_with_positional_params() {
7063        let kernel = Kernel::transient().expect("failed to create kernel");
7064
7065        // Define POSIX-style function
7066        kernel
7067            .execute(r#"greet() { echo "Hello, $1!" }"#)
7068            .await
7069            .expect("function definition failed");
7070
7071        // Call the function
7072        let result = kernel
7073            .execute(r#"greet "Amy""#)
7074            .await
7075            .expect("function call failed");
7076
7077        assert!(result.ok(), "greet failed: {}", result.err);
7078        assert_eq!(result.text_out().trim(), "Hello, Amy!");
7079    }
7080
7081    #[tokio::test]
7082    async fn test_posix_function_multiple_args() {
7083        let kernel = Kernel::transient().expect("failed to create kernel");
7084
7085        // Define function using $1 and $2
7086        kernel
7087            .execute(r#"add_greeting() { echo "$1 $2!" }"#)
7088            .await
7089            .expect("function definition failed");
7090
7091        // Call the function
7092        let result = kernel
7093            .execute(r#"add_greeting "Hello" "World""#)
7094            .await
7095            .expect("function call failed");
7096
7097        assert!(result.ok(), "function failed: {}", result.err);
7098        assert_eq!(result.text_out().trim(), "Hello World!");
7099    }
7100
7101    #[tokio::test]
7102    async fn test_bash_function_with_positional_params() {
7103        let kernel = Kernel::transient().expect("failed to create kernel");
7104
7105        // Define bash-style function (function keyword, no parens)
7106        kernel
7107            .execute(r#"function greet { echo "Hi $1" }"#)
7108            .await
7109            .expect("function definition failed");
7110
7111        // Call the function
7112        let result = kernel
7113            .execute(r#"greet "Bob""#)
7114            .await
7115            .expect("function call failed");
7116
7117        assert!(result.ok(), "greet failed: {}", result.err);
7118        assert_eq!(result.text_out().trim(), "Hi Bob");
7119    }
7120
7121    #[tokio::test]
7122    async fn test_shell_function_with_all_args() {
7123        let kernel = Kernel::transient().expect("failed to create kernel");
7124
7125        // Define function using $@ (all args)
7126        kernel
7127            .execute(r#"echo_all() { echo "args: $@" }"#)
7128            .await
7129            .expect("function definition failed");
7130
7131        // Call with multiple args
7132        let result = kernel
7133            .execute(r#"echo_all "a" "b" "c""#)
7134            .await
7135            .expect("function call failed");
7136
7137        assert!(result.ok(), "function failed: {}", result.err);
7138        assert_eq!(result.text_out().trim(), "args: a b c");
7139    }
7140
7141    #[tokio::test]
7142    async fn test_shell_function_with_arg_count() {
7143        let kernel = Kernel::transient().expect("failed to create kernel");
7144
7145        // Define function using $# (arg count)
7146        kernel
7147            .execute(r#"count_args() { echo "count: $#" }"#)
7148            .await
7149            .expect("function definition failed");
7150
7151        // Call with three args
7152        let result = kernel
7153            .execute(r#"count_args "x" "y" "z""#)
7154            .await
7155            .expect("function call failed");
7156
7157        assert!(result.ok(), "function failed: {}", result.err);
7158        assert_eq!(result.text_out().trim(), "count: 3");
7159    }
7160
7161    #[tokio::test]
7162    async fn test_shell_function_shared_scope() {
7163        let kernel = Kernel::transient().expect("failed to create kernel");
7164
7165        // Set a variable in parent scope
7166        kernel
7167            .execute(r#"PARENT_VAR="visible""#)
7168            .await
7169            .expect("set failed");
7170
7171        // Define shell function that reads and writes parent variable
7172        kernel
7173            .execute(r#"modify_parent() {
7174                echo "saw: ${PARENT_VAR}"
7175                PARENT_VAR="changed by function"
7176            }"#)
7177            .await
7178            .expect("function definition failed");
7179
7180        // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
7181        let result = kernel.execute("modify_parent").await.expect("function failed");
7182
7183        assert!(
7184            result.text_out().contains("visible"),
7185            "Shell function should access parent scope, got: {}",
7186            result.text_out()
7187        );
7188
7189        // Parent variable should be modified
7190        let var = kernel.get_var("PARENT_VAR").await;
7191        assert_eq!(
7192            var,
7193            Some(Value::String("changed by function".into())),
7194            "Shell function should modify parent scope"
7195        );
7196    }
7197
7198    // ═══════════════════════════════════════════════════════════════════════════
7199    // Script Execution via PATH Tests
7200    // ═══════════════════════════════════════════════════════════════════════════
7201
7202    #[tokio::test]
7203    async fn test_script_execution_from_path() {
7204        let kernel = Kernel::transient().expect("failed to create kernel");
7205
7206        // Create /bin directory and script
7207        kernel.execute(r#"mkdir "/bin""#).await.ok();
7208        kernel
7209            .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
7210            .await
7211            .expect("write script failed");
7212
7213        // Set PATH to /bin
7214        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
7215
7216        // Call script by name (without .kai extension)
7217        let result = kernel
7218            .execute("hello")
7219            .await
7220            .expect("script execution failed");
7221
7222        assert!(result.ok(), "script failed: {}", result.err);
7223        assert_eq!(result.text_out().trim(), "Hello from script!");
7224    }
7225
7226    #[tokio::test]
7227    async fn test_script_with_args() {
7228        let kernel = Kernel::transient().expect("failed to create kernel");
7229
7230        // Create script that uses positional params
7231        kernel.execute(r#"mkdir "/bin""#).await.ok();
7232        kernel
7233            .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
7234            .await
7235            .expect("write script failed");
7236
7237        // Set PATH
7238        kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
7239
7240        // Call script with arg
7241        let result = kernel
7242            .execute(r#"greet "World""#)
7243            .await
7244            .expect("script execution failed");
7245
7246        assert!(result.ok(), "script failed: {}", result.err);
7247        assert_eq!(result.text_out().trim(), "Hello, World!");
7248    }
7249
7250    #[tokio::test]
7251    async fn test_script_not_found() {
7252        let kernel = Kernel::transient().expect("failed to create kernel");
7253
7254        // Set empty PATH
7255        kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
7256
7257        // Call non-existent script
7258        let result = kernel
7259            .execute("noscript")
7260            .await
7261            .expect("execution failed");
7262
7263        assert!(!result.ok(), "should fail with command not found");
7264        assert_eq!(result.code, 127);
7265        assert!(result.err.contains("command not found"));
7266    }
7267
7268    #[tokio::test]
7269    async fn test_script_path_search_order() {
7270        let kernel = Kernel::transient().expect("failed to create kernel");
7271
7272        // Create two directories with same-named script
7273        // Note: using "myscript" not "test" to avoid conflict with test builtin
7274        kernel.execute(r#"mkdir "/first""#).await.ok();
7275        kernel.execute(r#"mkdir "/second""#).await.ok();
7276        kernel
7277            .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
7278            .await
7279            .expect("write failed");
7280        kernel
7281            .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
7282            .await
7283            .expect("write failed");
7284
7285        // Set PATH with first before second
7286        kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
7287
7288        // Should find first one
7289        let result = kernel
7290            .execute("myscript")
7291            .await
7292            .expect("script execution failed");
7293
7294        assert!(result.ok(), "script failed: {}", result.err);
7295        assert_eq!(result.text_out().trim(), "from first");
7296    }
7297
7298    // ═══════════════════════════════════════════════════════════════════════════
7299    // Special Variable Tests ($?, $$, unset vars)
7300    // ═══════════════════════════════════════════════════════════════════════════
7301
7302    #[tokio::test]
7303    async fn test_last_exit_code_success() {
7304        let kernel = Kernel::transient().expect("failed to create kernel");
7305
7306        // true exits with 0
7307        let result = kernel.execute("true; echo $?").await.expect("execution failed");
7308        assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
7309    }
7310
7311    #[tokio::test]
7312    async fn test_last_exit_code_failure() {
7313        let kernel = Kernel::transient().expect("failed to create kernel");
7314
7315        // false exits with 1
7316        let result = kernel.execute("false; echo $?").await.expect("execution failed");
7317        assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
7318    }
7319
7320    #[tokio::test]
7321    async fn test_current_pid() {
7322        let kernel = Kernel::transient().expect("failed to create kernel");
7323
7324        let result = kernel.execute("echo $$").await.expect("execution failed");
7325        // PID should be a positive number
7326        let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
7327        assert!(pid > 0, "PID should be positive");
7328    }
7329
7330    #[tokio::test]
7331    async fn test_unset_variable_expands_to_empty() {
7332        let kernel = Kernel::transient().expect("failed to create kernel");
7333
7334        // Unset variable in interpolation should be empty
7335        let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
7336        assert_eq!(result.text_out().trim(), "prefix::suffix");
7337    }
7338
7339    #[tokio::test]
7340    async fn test_eq_ne_operators() {
7341        let kernel = Kernel::transient().expect("failed to create kernel");
7342
7343        // Test -eq operator
7344        let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
7345        assert_eq!(result.text_out().trim(), "eq works");
7346
7347        // Test -ne operator
7348        let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
7349        assert_eq!(result.text_out().trim(), "ne works");
7350
7351        // Test -eq with different values
7352        let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
7353        assert_eq!(result.text_out().trim(), "correct");
7354    }
7355
7356    #[tokio::test]
7357    async fn test_escaped_dollar_in_string() {
7358        let kernel = Kernel::transient().expect("failed to create kernel");
7359
7360        // \$ should produce literal $
7361        let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
7362        assert_eq!(result.text_out().trim(), "$100");
7363    }
7364
7365    #[tokio::test]
7366    async fn test_special_vars_in_interpolation() {
7367        let kernel = Kernel::transient().expect("failed to create kernel");
7368
7369        // Test $? in string interpolation
7370        let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
7371        assert_eq!(result.text_out().trim(), "exit: 0");
7372
7373        // Test $$ in string interpolation
7374        let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
7375        assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
7376        let text = result.text_out();
7377        let pid_part = text.trim().strip_prefix("pid: ").unwrap();
7378        let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
7379    }
7380
7381    // ═══════════════════════════════════════════════════════════════════════════
7382    // Command Substitution Tests
7383    // ═══════════════════════════════════════════════════════════════════════════
7384
7385    #[tokio::test]
7386    async fn test_command_subst_assignment() {
7387        let kernel = Kernel::transient().expect("failed to create kernel");
7388
7389        // Command substitution in assignment
7390        let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
7391        assert_eq!(result.text_out().trim(), "hello");
7392    }
7393
7394    #[tokio::test]
7395    async fn test_command_subst_with_args() {
7396        let kernel = Kernel::transient().expect("failed to create kernel");
7397
7398        // Command substitution with string argument
7399        let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
7400        assert_eq!(result.text_out().trim(), "a b c");
7401    }
7402
7403    #[tokio::test]
7404    async fn test_command_subst_nested_vars() {
7405        let kernel = Kernel::transient().expect("failed to create kernel");
7406
7407        // Variables inside command substitution
7408        let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
7409        assert_eq!(result.text_out().trim(), "hello world");
7410    }
7411
7412    #[tokio::test]
7413    async fn test_background_job_basic() {
7414        use std::time::Duration;
7415
7416        let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
7417
7418        // Run a simple background command
7419        let result = kernel.execute("echo hello &").await.expect("execution failed");
7420        assert!(result.ok(), "background command should succeed: {}", result.err);
7421        assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
7422
7423        // Give the job time to complete
7424        tokio::time::sleep(Duration::from_millis(100)).await;
7425
7426        // Check job status
7427        let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
7428        assert!(status.ok(), "status should succeed: {}", status.err);
7429        assert!(
7430            status.text_out().contains("done:") || status.text_out().contains("running"),
7431            "should have valid status: {}",
7432            status.text_out()
7433        );
7434
7435        // Check stdout
7436        let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
7437        assert!(stdout.ok());
7438        assert!(stdout.text_out().contains("hello"));
7439    }
7440
7441    #[tokio::test]
7442    async fn test_heredoc_piped_to_command() {
7443        // Bug 4: heredoc content should pipe through to next command
7444        let kernel = Kernel::transient().expect("kernel");
7445        let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
7446        assert!(result.ok(), "heredoc | cat failed: {}", result.err);
7447        assert_eq!(result.text_out().trim(), "hello world");
7448    }
7449
7450    /// A transient kernel paired with a real, auto-cleaning tempdir. The
7451    /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
7452    /// tests need actual files on disk. Hold the returned `TempDir` for the
7453    /// test's lifetime: it removes the directory tree on drop — including on
7454    /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
7455    /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
7456    /// as a string for interpolation into scripts.
7457    fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
7458        let kernel = Kernel::transient().expect("kernel");
7459        let tmp = tempfile::tempdir().expect("tempdir");
7460        let dir = tmp.path().display().to_string();
7461        (kernel, tmp, dir)
7462    }
7463
7464    #[tokio::test]
7465    async fn test_for_loop_glob_iterates() {
7466        // Bug 1: for F in $(glob ...) should iterate per file, not once
7467        let (kernel, _tmp, dir) = transient_with_tempdir();
7468        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
7469        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
7470        let result = kernel.execute(&format!(r#"
7471            N=0
7472            for F in $(glob "{dir}/*.txt"); do
7473                N=$((N + 1))
7474            done
7475            echo $N
7476        "#)).await.unwrap();
7477        assert!(result.ok(), "for glob failed: {}", result.err);
7478        assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
7479    }
7480
7481    #[tokio::test]
7482    async fn test_bare_glob_expansion_echo() {
7483        let (kernel, _tmp, dir) = transient_with_tempdir();
7484        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
7485        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
7486        kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
7487        kernel.execute(&format!("cd {dir}")).await.unwrap();
7488        let result = kernel.execute("echo *.txt").await.unwrap();
7489        assert!(result.ok(), "echo *.txt failed: {}", result.err);
7490        let out = result.text_out();
7491        let out = out.trim();
7492        // Should contain both .txt files (order may vary)
7493        assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
7494        assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
7495        assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
7496    }
7497
7498    #[tokio::test]
7499    async fn test_bare_glob_no_matches_errors() {
7500        let (kernel, _tmp, dir) = transient_with_tempdir();
7501        kernel.execute(&format!("cd {dir}")).await.unwrap();
7502        let result = kernel.execute("echo *.nonexistent").await;
7503        match &result {
7504            Ok(exec) => {
7505                // No-match glob should produce a non-zero exit code
7506                assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
7507                assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
7508            }
7509            Err(e) => {
7510                assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
7511            }
7512        }
7513    }
7514
7515    #[tokio::test]
7516    async fn test_bare_glob_disabled_with_set() {
7517        let (kernel, _tmp, dir) = transient_with_tempdir();
7518        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
7519        kernel.execute(&format!("cd {dir}")).await.unwrap();
7520        // Disable glob expansion
7521        kernel.execute("set +o glob").await.unwrap();
7522        let result = kernel.execute("echo *.txt").await.unwrap();
7523        // With glob disabled, *.txt should be passed as literal string
7524        assert!(result.ok(), "echo should succeed: {}", result.err);
7525        assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
7526    }
7527
7528    #[tokio::test]
7529    async fn test_bare_glob_quoted_not_expanded() {
7530        let (kernel, _tmp, dir) = transient_with_tempdir();
7531        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
7532        kernel.execute(&format!("cd {dir}")).await.unwrap();
7533        // Quoted globs should NOT expand
7534        let result = kernel.execute("echo \"*.txt\"").await.unwrap();
7535        assert!(result.ok(), "echo should succeed: {}", result.err);
7536        assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
7537    }
7538
7539    #[tokio::test]
7540    async fn test_bare_glob_for_loop() {
7541        let (kernel, _tmp, dir) = transient_with_tempdir();
7542        kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
7543        kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
7544        kernel.execute(&format!("cd {dir}")).await.unwrap();
7545        let result = kernel.execute(r#"
7546            N=0
7547            for f in *.txt; do
7548                N=$((N + 1))
7549            done
7550            echo $N
7551        "#).await.unwrap();
7552        assert!(result.ok(), "for loop failed: {}", result.err);
7553        assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
7554    }
7555
7556    #[tokio::test]
7557    async fn test_glob_in_assignment_is_literal() {
7558        let kernel = Kernel::transient().expect("kernel");
7559        let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
7560        assert!(result.ok());
7561        assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
7562    }
7563
7564    #[tokio::test]
7565    async fn test_glob_in_test_expr_is_literal() {
7566        let kernel = Kernel::transient().expect("kernel");
7567        let result = kernel.execute(r#"
7568            if [[ *.txt == "*.txt" ]]; then
7569                echo "match"
7570            else
7571                echo "no"
7572            fi
7573        "#).await.unwrap();
7574        assert!(result.ok());
7575        assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
7576    }
7577
7578    #[tokio::test]
7579    async fn test_command_subst_echo_not_iterable() {
7580        // Regression guard: $(echo "a b c") must remain a single string
7581        let kernel = Kernel::transient().expect("kernel");
7582        let result = kernel.execute(r#"
7583            N=0
7584            for X in $(echo "a b c"); do N=$((N + 1)); done
7585            echo $N
7586        "#).await.unwrap();
7587        assert!(result.ok());
7588        assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
7589    }
7590
7591    // -- accumulate_result / newline tests --
7592
7593    #[test]
7594    fn test_accumulate_preserves_own_newlines() {
7595        // Outputs concatenate verbatim — a command's own trailing newline is
7596        // kept, none is invented.
7597        let mut acc = ExecResult::success("line1\n");
7598        let new = ExecResult::success("line2\n");
7599        accumulate_result(&mut acc, &new);
7600        assert_eq!(&*acc.text_out(), "line1\nline2\n");
7601        assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
7602    }
7603
7604    #[test]
7605    fn test_accumulate_inserts_no_separator() {
7606        // No artificial separator: `printf a; printf b` style concatenates to
7607        // `ab`, matching bash (regression for the 2026-06-09 finding).
7608        let mut acc = ExecResult::success("line1");
7609        let new = ExecResult::success("line2");
7610        accumulate_result(&mut acc, &new);
7611        assert_eq!(&*acc.text_out(), "line1line2");
7612    }
7613
7614    #[test]
7615    fn test_accumulate_empty_into_nonempty() {
7616        let mut acc = ExecResult::success("");
7617        let new = ExecResult::success("hello\n");
7618        accumulate_result(&mut acc, &new);
7619        assert_eq!(&*acc.text_out(), "hello\n");
7620    }
7621
7622    #[test]
7623    fn test_accumulate_nonempty_into_empty() {
7624        let mut acc = ExecResult::success("hello\n");
7625        let new = ExecResult::success("");
7626        accumulate_result(&mut acc, &new);
7627        assert_eq!(&*acc.text_out(), "hello\n");
7628    }
7629
7630    #[test]
7631    fn test_accumulate_stderr_no_double_newlines() {
7632        let mut acc = ExecResult::failure(1, "err1\n");
7633        let new = ExecResult::failure(1, "err2\n");
7634        accumulate_result(&mut acc, &new);
7635        assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
7636    }
7637
7638    #[tokio::test]
7639    async fn test_multiple_echo_no_blank_lines() {
7640        let kernel = Kernel::transient().expect("kernel");
7641        let result = kernel
7642            .execute("echo one\necho two\necho three")
7643            .await
7644            .expect("execution failed");
7645        assert!(result.ok());
7646        assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
7647    }
7648
7649    #[tokio::test]
7650    async fn test_for_loop_no_blank_lines() {
7651        let kernel = Kernel::transient().expect("kernel");
7652        let result = kernel
7653            .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
7654            .await
7655            .expect("execution failed");
7656        assert!(result.ok());
7657        assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
7658    }
7659
7660    #[tokio::test]
7661    async fn test_for_command_subst_no_blank_lines() {
7662        let kernel = Kernel::transient().expect("kernel");
7663        let result = kernel
7664            .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
7665            .await
7666            .expect("execution failed");
7667        assert!(result.ok());
7668        assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
7669    }
7670
7671    // ------------------------------------------------------------------
7672    // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
7673    // ------------------------------------------------------------------
7674
7675    /// Helper: a throwaway schema with one `--pair` param declared as
7676    /// consuming two positionals per occurrence. Modelled after what
7677    /// jq_native will declare for `--arg` / `--argjson`.
7678    fn multi_consume_schema() -> crate::tools::ToolSchema {
7679        use crate::tools::{ParamSchema, ToolSchema};
7680        ToolSchema::new("test", "multi-consume smoke")
7681            .param(
7682                ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
7683                    .consumes(2),
7684            )
7685    }
7686
7687    fn pos(s: &str) -> Arg {
7688        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
7689    }
7690
7691    #[tokio::test]
7692    async fn build_args_multi_consume_single_occurrence() {
7693        let kernel = Kernel::transient().expect("kernel");
7694        let schema = multi_consume_schema();
7695        // Simulates:  test --pair NAME VALUE filter
7696        let args = vec![
7697            Arg::LongFlag("pair".into()),
7698            pos("NAME"),
7699            pos("VALUE"),
7700            pos("filter"),
7701        ];
7702        let built = kernel
7703            .build_args_async(&args, Some(&schema))
7704            .await
7705            .expect("build_args should succeed");
7706
7707        // `--pair` + its two positionals are consumed into named["pair"],
7708        // which becomes an outer array of one inner 2-element array.
7709        let pair = built.named.get("pair").expect("named[pair] missing");
7710        match pair {
7711            Value::Json(serde_json::Value::Array(occurrences)) => {
7712                assert_eq!(occurrences.len(), 1, "expected one occurrence");
7713                match &occurrences[0] {
7714                    serde_json::Value::Array(values) => {
7715                        assert_eq!(values.len(), 2, "pair must have 2 values");
7716                        assert_eq!(values[0], serde_json::Value::String("NAME".into()));
7717                        assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
7718                    }
7719                    other => panic!("expected inner array, got {other:?}"),
7720                }
7721            }
7722            other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
7723        }
7724
7725        // The un-consumed positional ("filter") remains in `positional`.
7726        assert_eq!(built.positional.len(), 1);
7727        assert_eq!(built.positional[0], Value::String("filter".into()));
7728    }
7729    #[tokio::test]
7730    async fn build_args_multi_consume_two_occurrences_accumulate() {
7731        let kernel = Kernel::transient().expect("kernel");
7732        let schema = multi_consume_schema();
7733        // Simulates:  test --pair A 1 --pair B 2 filter
7734        let args = vec![
7735            Arg::LongFlag("pair".into()),
7736            pos("A"),
7737            pos("1"),
7738            Arg::LongFlag("pair".into()),
7739            pos("B"),
7740            pos("2"),
7741            pos("filter"),
7742        ];
7743        let built = kernel
7744            .build_args_async(&args, Some(&schema))
7745            .await
7746            .expect("build_args should succeed");
7747
7748        let pair = built.named.get("pair").expect("named[pair] missing");
7749        match pair {
7750            Value::Json(serde_json::Value::Array(occurrences)) => {
7751                assert_eq!(occurrences.len(), 2, "expected two occurrences");
7752                // Preserved in invocation order.
7753                match &occurrences[0] {
7754                    serde_json::Value::Array(values) => {
7755                        assert_eq!(values[0], serde_json::Value::String("A".into()));
7756                        assert_eq!(values[1], serde_json::Value::String("1".into()));
7757                    }
7758                    other => panic!("expected inner array, got {other:?}"),
7759                }
7760                match &occurrences[1] {
7761                    serde_json::Value::Array(values) => {
7762                        assert_eq!(values[0], serde_json::Value::String("B".into()));
7763                        assert_eq!(values[1], serde_json::Value::String("2".into()));
7764                    }
7765                    other => panic!("expected inner array, got {other:?}"),
7766                }
7767            }
7768            other => panic!("expected Json(Array(...)), got {other:?}"),
7769        }
7770    }
7771
7772    // ── undeclared space-form flag under map_positionals (kj --type val) ──
7773    //
7774    // A backend/MCP tool whose schema does NOT declare a flag must not let
7775    // `--flag value` (space form) silently divorce the value: that was a
7776    // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
7777    // kaish fails loud rather than guessing.
7778
7779    use crate::tools::{ParamSchema, ToolSchema};
7780
7781    /// Backend-style schema (map_positionals) declaring only a `name`
7782    /// positional — `--type` is intentionally undeclared.
7783    fn kj_like_schema() -> ToolSchema {
7784        ToolSchema::new("kj", "incomplete backend schema")
7785            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
7786            .with_positional_mapping()
7787    }
7788
7789    #[tokio::test]
7790    async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
7791        let kernel = Kernel::transient().expect("kernel");
7792        let schema = kj_like_schema();
7793        // kj context create exp --type explorer
7794        let args = vec![
7795            pos("context"),
7796            pos("create"),
7797            pos("exp"),
7798            Arg::LongFlag("type".into()),
7799            pos("explorer"),
7800        ];
7801        let err = kernel
7802            .build_args_async(&args, Some(&schema))
7803            .await
7804            .expect_err("undeclared --type with a space value must fail loud");
7805        let msg = err.to_string();
7806        assert!(msg.contains("--type"), "message should name the flag: {msg}");
7807        assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
7808        assert!(msg.contains("kj"), "message should name the tool: {msg}");
7809    }
7810
7811    #[tokio::test]
7812    async fn build_args_declared_space_flag_still_binds() {
7813        let kernel = Kernel::transient().expect("kernel");
7814        // Same tool, but now the schema DECLARES --type as a string param.
7815        let schema = ToolSchema::new("kj", "complete schema")
7816            .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
7817            .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
7818            .with_positional_mapping();
7819        let args = vec![
7820            pos("exp"),
7821            Arg::LongFlag("type".into()),
7822            pos("explorer"),
7823        ];
7824        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
7825        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
7826    }
7827
7828    #[tokio::test]
7829    async fn build_args_equals_form_binds_for_undeclared_flag() {
7830        let kernel = Kernel::transient().expect("kernel");
7831        let schema = kj_like_schema();
7832        // The unambiguous `=` form must keep working even when undeclared.
7833        let args = vec![
7834            pos("exp"),
7835            Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
7836        ];
7837        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
7838        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
7839    }
7840
7841    #[tokio::test]
7842    async fn build_args_undeclared_bool_flag_at_end_is_ok() {
7843        let kernel = Kernel::transient().expect("kernel");
7844        let schema = kj_like_schema();
7845        // No positional follows --force → unambiguously a bare flag.
7846        let args = vec![pos("exp"), Arg::LongFlag("force".into())];
7847        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
7848        assert!(built.flags.contains("force"));
7849    }
7850
7851    #[tokio::test]
7852    async fn build_args_undeclared_flag_before_another_flag_is_ok() {
7853        let kernel = Kernel::transient().expect("kernel");
7854        let schema = kj_like_schema();
7855        // --verbose is followed by a flag, not a positional → not ambiguous.
7856        let args = vec![
7857            Arg::LongFlag("verbose".into()),
7858            Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
7859        ];
7860        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
7861        assert!(built.flags.contains("verbose"));
7862    }
7863
7864    #[tokio::test]
7865    async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
7866        let kernel = Kernel::transient().expect("kernel");
7867        // Builtins set map_positionals=false; the ambiguity guard must not
7868        // fire there (clap validates their flags separately).
7869        let schema = ToolSchema::new("frobnicate", "builtin-style")
7870            .param(ParamSchema::optional("name", "string", Value::Null, "name"));
7871        let args = vec![Arg::LongFlag("frob".into()), pos("value")];
7872        let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
7873        assert!(built.flags.contains("frob"));
7874    }
7875
7876    // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
7877    //
7878    // A tool exposing a subcommand tree binds flags against the *routed leaf's*
7879    // params, not the root's. The subcommand-path positionals stay positional
7880    // (kj re-parses them with its own clap), and a value flag declared only on
7881    // a deep leaf still binds in space form.
7882
7883    /// kj → context (alias ctx) → create{--type value, --force bool}.
7884    /// map_positionals defaults false on every node (builtin/kj style).
7885    fn kj_tree_schema() -> ToolSchema {
7886        ToolSchema::new("kj", "subcommand tool").subcommand(
7887            ToolSchema::new("context", "context ops")
7888                .with_command_aliases(["ctx"])
7889                .subcommand(
7890                    ToolSchema::new("create", "create context")
7891                        .param(ParamSchema::new("type", "string").with_aliases(["t"]))
7892                        .param(ParamSchema::new("force", "bool")),
7893                ),
7894        )
7895    }
7896
7897    #[tokio::test]
7898    async fn build_args_binds_deep_leaf_value_flag_space_form() {
7899        let kernel = Kernel::transient().expect("kernel");
7900        let schema = kj_tree_schema();
7901        // kj context create --type explorer
7902        let args = vec![
7903            pos("context"),
7904            pos("create"),
7905            Arg::LongFlag("type".into()),
7906            pos("explorer"),
7907        ];
7908        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
7909        // --type (declared only on the create leaf) binds in space form.
7910        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
7911        // The subcommand path survives as positionals for kj to re-parse.
7912        let positionals: Vec<&str> = built
7913            .positional
7914            .iter()
7915            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
7916            .collect();
7917        assert_eq!(positionals, vec!["context", "create"]);
7918    }
7919
7920    #[tokio::test]
7921    async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
7922        let kernel = Kernel::transient().expect("kernel");
7923        let schema = kj_tree_schema();
7924        // kj context create --force somearg  → --force is a leaf bool flag,
7925        // it must NOT consume `somearg`.
7926        let args = vec![
7927            pos("context"),
7928            pos("create"),
7929            Arg::LongFlag("force".into()),
7930            pos("somearg"),
7931        ];
7932        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
7933        assert!(built.flags.contains("force"), "force should be a bare flag");
7934        let positionals: Vec<&str> = built
7935            .positional
7936            .iter()
7937            .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
7938            .collect();
7939        assert_eq!(positionals, vec!["context", "create", "somearg"]);
7940    }
7941
7942    #[tokio::test]
7943    async fn build_args_alias_routed_leaf_binds_value_flag() {
7944        let kernel = Kernel::transient().expect("kernel");
7945        let schema = kj_tree_schema();
7946        // kj ctx create -t explorer  → command alias + short flag alias.
7947        let args = vec![
7948            pos("ctx"),
7949            pos("create"),
7950            Arg::ShortFlag("t".into()),
7951            pos("explorer"),
7952        ];
7953        let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
7954        assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
7955    }
7956
7957    #[tokio::test]
7958    async fn build_args_computed_subcommand_selector_fails_loud() {
7959        let kernel = Kernel::transient().expect("kernel");
7960        let schema = kj_tree_schema();
7961        // kj $(echo context) — routing can't see the value; fail loud.
7962        let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
7963            crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
7964        )]))];
7965        let err = kernel
7966            .build_args_async(&args, Some(&schema))
7967            .await
7968            .expect_err("computed subcommand selector must error");
7969        assert!(
7970            err.to_string().contains("subcommand name is required"),
7971            "got: {err}"
7972        );
7973    }
7974
7975    // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
7976
7977    #[test]
7978    fn finalize_output_renders_when_kernel_owns_it() {
7979        use crate::interpreter::{OutputData, OutputFormat};
7980        let r = ExecResult::with_output(OutputData::text("RAW"));
7981        let out = finalize_output(r, Some(OutputFormat::Json), false);
7982        // Kernel renders the typed OutputData → JSON; text is no longer bare.
7983        assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
7984    }
7985
7986    #[test]
7987    fn finalize_output_skips_when_tool_owns_output() {
7988        use crate::interpreter::{OutputData, OutputFormat};
7989        let r = ExecResult::with_output(OutputData::text("RAW"));
7990        let out = finalize_output(r, Some(OutputFormat::Json), true);
7991        // owns_output: the tool already rendered; kernel leaves bytes untouched.
7992        assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
7993    }
7994
7995    #[test]
7996    fn finalize_output_no_format_is_noop() {
7997        use crate::interpreter::OutputData;
7998        let r = ExecResult::with_output(OutputData::text("RAW"));
7999        let out = finalize_output(r, None, false);
8000        assert_eq!(out.text_out(), "RAW");
8001    }
8002
8003    // ── initial_vars + execute_with_vars + hermetic env ───────────────────
8004
8005    #[tokio::test]
8006    async fn test_initial_vars_set_and_exported() {
8007        let config = KernelConfig::transient()
8008            .with_var("INIT_FOO", Value::String("bar".into()));
8009        let kernel = Kernel::new(config).expect("failed to create kernel");
8010
8011        assert_eq!(
8012            kernel.get_var("INIT_FOO").await,
8013            Some(Value::String("bar".into()))
8014        );
8015        assert!(
8016            kernel.scope.read().await.is_exported("INIT_FOO"),
8017            "initial_vars entries must be marked exported"
8018        );
8019    }
8020
8021    #[tokio::test]
8022    async fn test_execute_with_vars_overlay_visible() {
8023        let kernel = Kernel::transient().expect("failed to create kernel");
8024        let mut overlay = HashMap::new();
8025        overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
8026
8027        let result = kernel
8028            .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
8029            .await
8030            .expect("execute failed");
8031
8032        assert!(result.ok());
8033        assert_eq!(result.text_out().trim(), "yes");
8034    }
8035
8036    #[tokio::test]
8037    async fn test_execute_with_vars_overlay_cleanup() {
8038        let kernel = Kernel::transient().expect("failed to create kernel");
8039        let mut overlay = HashMap::new();
8040        overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
8041
8042        kernel
8043            .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
8044            .await
8045            .expect("execute failed");
8046
8047        assert_eq!(kernel.get_var("EPHEMERAL").await, None);
8048        assert!(
8049            !kernel.scope.read().await.is_exported("EPHEMERAL"),
8050            "overlay-only export must be cleared on return"
8051        );
8052    }
8053
8054    #[tokio::test]
8055    async fn test_execute_with_vars_does_not_clobber_existing_export() {
8056        let kernel = Kernel::transient().expect("failed to create kernel");
8057        kernel
8058            .execute("export OUTER=outer")
8059            .await
8060            .expect("export failed");
8061
8062        let mut overlay = HashMap::new();
8063        overlay.insert("OUTER".to_string(), Value::String("inner".into()));
8064        let result = kernel
8065            .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
8066            .await
8067            .expect("execute failed");
8068        assert_eq!(result.text_out().trim(), "inner");
8069
8070        assert_eq!(
8071            kernel.get_var("OUTER").await,
8072            Some(Value::String("outer".into())),
8073            "outer value must reappear after pop"
8074        );
8075        assert!(
8076            kernel.scope.read().await.is_exported("OUTER"),
8077            "outer export must survive overlay"
8078        );
8079    }
8080
8081    #[tokio::test]
8082    async fn test_execute_with_vars_inner_assignment_is_local() {
8083        let kernel = Kernel::transient().expect("failed to create kernel");
8084        let mut overlay = HashMap::new();
8085        overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
8086
8087        // Variable assignment inside a single statement uses set() (innermost
8088        // frame), not set_global() — this matches bash function-local semantics.
8089        // We explicitly use `local FOO=...` style by relying on the pushed
8090        // frame; the assignment in the script body modifies the same frame.
8091        let result = kernel
8092            .execute_with_options(
8093                r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
8094                ExecuteOptions::new().with_vars(overlay),
8095            )
8096            .await
8097            .expect("execute failed");
8098        assert!(result.ok());
8099
8100        // After the call the frame is popped, so LOCAL_FOO is gone regardless
8101        // of how the script reassigned it.
8102        assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
8103    }
8104
8105    #[tokio::test]
8106    async fn test_external_command_sees_exported_var() {
8107        let kernel = Kernel::transient().expect("failed to create kernel");
8108        // PATH must be in scope to resolve the external `printenv` — the kernel
8109        // never falls back to OS PATH. Seeding it via a scope assignment mirrors
8110        // what a frontend does through initial_vars.
8111        let path = std::env::var("PATH").unwrap_or_default();
8112        let result = kernel
8113            .execute(&format!(
8114                "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
8115            ))
8116            .await
8117            .expect("execute failed");
8118
8119        assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
8120        assert_eq!(result.text_out().trim(), "bar");
8121    }
8122
8123    #[tokio::test]
8124    async fn test_external_command_does_not_see_unexported_var() {
8125        let kernel = Kernel::transient().expect("failed to create kernel");
8126
8127        // Set without exporting; printenv must not see it (exit code != 0,
8128        // empty stdout per printenv semantics).
8129        let result = kernel
8130            .execute("EXT_BAR=hidden; printenv EXT_BAR")
8131            .await
8132            .expect("execute failed");
8133
8134        assert!(!result.ok(), "printenv should fail when var is unexported");
8135        assert!(
8136            result.text_out().trim().is_empty(),
8137            "no stdout when var is missing, got: {}",
8138            result.text_out()
8139        );
8140    }
8141
8142    #[tokio::test]
8143    async fn test_external_command_does_not_see_os_env() {
8144        // The kernel is hermetic: it never reads std::env::vars() and only
8145        // exports what it has been told to export. Cargo always sets PATH for
8146        // tests, so PATH is reliably present in the OS env — but a transient
8147        // kernel doesn't seed it into initial_vars, so `printenv PATH` from
8148        // inside the kernel must fail.
8149        assert!(
8150            std::env::var_os("PATH").is_some(),
8151            "test precondition: cargo should set PATH"
8152        );
8153
8154        let kernel = Kernel::transient().expect("failed to create kernel");
8155        let result = kernel
8156            .execute("printenv PATH")
8157            .await
8158            .expect("execute failed");
8159
8160        assert!(
8161            !result.ok(),
8162            "printenv PATH must fail in hermetic kernel, got stdout={:?}",
8163            result.text_out()
8164        );
8165        assert!(
8166            result.text_out().trim().is_empty(),
8167            "no PATH in subprocess env, got stdout={:?}",
8168            result.text_out()
8169        );
8170    }
8171
8172    #[tokio::test]
8173    async fn test_execute_with_vars_overlay_reaches_subprocess() {
8174        let kernel = Kernel::transient().expect("failed to create kernel");
8175        let mut overlay = HashMap::new();
8176        overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
8177        // PATH in the overlay so the external `printenv` resolves (no OS fallback).
8178        overlay.insert(
8179            "PATH".to_string(),
8180            Value::String(std::env::var("PATH").unwrap_or_default()),
8181        );
8182
8183        let result = kernel
8184            .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
8185            .await
8186            .expect("execute failed");
8187
8188        assert!(
8189            result.ok(),
8190            "printenv should succeed: code={} stdout={:?} stderr={:?}",
8191            result.code,
8192            result.text_out(),
8193            result.err
8194        );
8195        assert_eq!(result.text_out().trim(), "subproc");
8196    }
8197}