kaish_kernel/kernel.rs
1//! The Kernel (核) — the heart of kaish.
2//!
3//! The Kernel owns and coordinates all core components:
4//! - Interpreter state (scope, $?)
5//! - Tool registry (builtins, user tools)
6//! - VFS router (mount points)
7//! - Job manager (background jobs)
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌────────────────────────────────────────────────────────────┐
13//! │ Kernel (核) │
14//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
15//! │ │ Scope │ │ ToolRegistry │ │ VfsRouter │ │
16//! │ │ (variables) │ │ (builtins, │ │ (mount points) │ │
17//! │ │ │ │ user tools)│ │ │ │
18//! │ └──────────────┘ └──────────────┘ └──────────────────┘ │
19//! │ ┌──────────────────────────────┐ ┌──────────────────┐ │
20//! │ │ JobManager (background) │ │ ExecResult ($?) │ │
21//! │ └──────────────────────────────┘ └──────────────────┘ │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
29use std::time::Duration;
30
31use anyhow::{Context, Result};
32use tokio::sync::RwLock;
33
34/// Monotonic counter assigned to each Kernel at construction time, exposed
35/// via `$$` / `${$}`. Starts at 1; each new Kernel gets the next value.
36/// `Kernel::fork()` inherits the parent's value (matching bash's "subshell
37/// keeps parent's $$" semantics) because forks clone the parent's Scope
38/// rather than calling `set_pid` again.
39///
40/// Deliberately *not* the OS PID — kaish runs as a long-lived MCP server
41/// or embedded inside other binaries (kaijutsu), where the host PID is
42/// meaningless to the script. See
43/// `~/.claude/projects/-home-atobey-src-kaish/memory/lang_dollar_dollar_identifier.md`
44/// for the design rationale.
45static KERNEL_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47/// Maximum depth of dynamic statement-engine re-entry — command substitution
48/// (`$(…)`), shell-function calls, and `.kai` script sourcing — before a
49/// **loud** error is returned instead of letting the native call stack
50/// overflow (a `SIGSEGV`/abort with no diagnostic). Mirrors the intent of the
51/// alias re-entry cap (10) and the lexer's `MAX_PAREN_DEPTH` (256): a runaway
52/// or mutually recursive script hits a catchable ceiling, not a signal.
53///
54/// Each level stacks the dispatch chain between re-entries. After the GH #48
55/// allocation pass this measures ~50 KB (release) / ~57 KB (debug at the default
56/// `opt-level = 1` dev profile — see the root `Cargo.toml`) / ~193 KB (a fully
57/// unoptimized debug build) of native stack per level, down from ~80 / ~380 KB
58/// before; the `recursion_stack_cost_tests` probe reports the live figure.
59///
60/// This cap and [`RECOMMENDED_STACK_SIZE`] are a **matched pair**: the cap must
61/// trip *before* `cap × (worst-case per-level stack)` can exceed the floor, so a
62/// runaway is caught, not a `SIGSEGV`. The worst case is the ~193 KB unoptimized
63/// figure — the `opt-level = 1` dev profile above is local to this workspace and
64/// does **not** propagate to embedders, whose own debug builds of the kernel pay
65/// the full unoptimized cost. `48 × 193 KB ≈ 9.3 MB` under the 12 MiB floor
66/// keeps the same ~1.3× margin the pre-#48 pair had (`32 × 380 KB ≈ 12 MB` under
67/// 16 MiB); #48's smaller frames are what let the cap rise 32→48 and the floor
68/// drop 16→12 MiB together. **The guard only fires *before* the stack overflows
69/// on a thread that meets that floor** — this is why the REPL sizes its threads
70/// to it and embedders must too (see `docs/EMBEDDING.md`). Forks (background
71/// jobs, scatter workers, pipeline stages) run on fresh stacks and get a fresh
72/// counter, bounding each chain independently. GH #46 / #47 / #48.
73pub const MAX_RECURSION_DEPTH: usize = 48;
74
75/// Recommended native stack size (12 MiB) for any thread that drives kaish
76/// execution — the REPL sizes its `block_on` thread and tokio worker threads
77/// to this, and embedders that call `Kernel::execute` (directly or via a tokio
78/// runtime) should do the same (`runtime::Builder::thread_stack_size`, and a
79/// `std::thread` stack for a non-worker driver).
80///
81/// The kernel recurses on the native stack (command substitution, shell
82/// functions, `.kai` scripts). [`MAX_RECURSION_DEPTH`] converts a runaway into
83/// a loud error, but only *if the stack is at least this large* — on the
84/// default ~2 MB tokio worker stack the recursion overflows (SIGSEGV) before
85/// reaching the cap. This floor is the companion to that cap (see its docs for
86/// the `cap × per-level < floor` relationship): 12 MiB holds the depth-48 cap
87/// with margin even for an unoptimized embedder build (~193 KB/level), and #48
88/// shrank the per-level cost enough to drop it from 16 MiB. kaish can't set this
89/// itself (it doesn't own the runtime), so it exposes the floor for owners to
90/// apply. See GH #47 / #48.
91pub const RECOMMENDED_STACK_SIZE: usize = 12 * 1024 * 1024;
92
93use async_trait::async_trait;
94
95use crate::ast::{
96 spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
97 StringPart, TestExpr, ToolDef, Value,
98};
99pub use kaish_types::{CommandKind, ExecuteOptions};
100use crate::backend::{BackendError, KernelBackend};
101use kaish_glob::glob_match;
102use crate::dispatch::{CommandDispatcher, PipelinePosition};
103use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value_no_envelope, value_to_bool, value_to_string, value_to_text_sink, ControlFlow, ExecResult, LatchRequest, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, BoundedStream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream, DEFAULT_STREAM_MAX_SIZE};
108use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
109#[cfg(feature = "subprocess")]
110use crate::tools::resolve_in_path;
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127 /// LocalFs at "/" — native paths work directly.
128 ///
129 /// Full filesystem access. Use for human-operated REPL sessions where
130 /// native paths like `/home/user/project` should just work.
131 ///
132 /// Mounts:
133 /// - `/` → LocalFs("/")
134 /// - `/v` → MemoryFs (blob storage)
135 #[cfg(feature = "localfs")]
136 Passthrough,
137
138 /// Transparent sandbox — paths look native but access is restricted.
139 ///
140 /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141 /// so `/home/user/src/project` just works. But paths outside the sandbox
142 /// root are not accessible.
143 ///
144 /// **Note:** This only restricts VFS (builtin) operations. External commands
145 /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146 ///
147 /// Mounts:
148 /// - `/` → MemoryFs (catches paths outside sandbox)
149 /// - `{root}` → LocalFs(root) (e.g., `/home/user` → LocalFs)
150 /// - `/tmp` → LocalFs("/tmp")
151 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152 /// - `/v` → MemoryFs (blob storage)
153 #[cfg(feature = "localfs")]
154 Sandboxed {
155 /// Root path for local filesystem. Defaults to `$HOME`.
156 /// Can be restricted further, e.g., `~/src`.
157 root: Option<PathBuf>,
158 },
159
160 /// No local filesystem. Memory only.
161 ///
162 /// Complete isolation — no access to the host filesystem.
163 /// Useful for tests or pure sandboxed execution.
164 ///
165 /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166 /// for this mode at kernel construction: with no host filesystem mounted,
167 /// large output must not write a host spill file (`paths::spill_dir()`
168 /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169 ///
170 /// Mounts:
171 /// - `/` → MemoryFs
172 /// - `/tmp` → MemoryFs
173 /// - `/v` → MemoryFs
174 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175 NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180 fn default() -> Self {
181 #[cfg(feature = "localfs")]
182 { VfsMountMode::Sandboxed { root: None } }
183 #[cfg(not(feature = "localfs"))]
184 { VfsMountMode::NoLocal }
185 }
186}
187
188/// Configuration for kernel initialization.
189#[derive(Debug, Clone)]
190pub struct KernelConfig {
191 /// Name of this kernel (for identification).
192 pub name: String,
193
194 /// VFS mount mode — controls how local filesystem is exposed.
195 pub vfs_mode: VfsMountMode,
196
197 /// Initial working directory (VFS path).
198 pub cwd: PathBuf,
199
200 /// Whether to skip pre-execution validation.
201 ///
202 /// When false (default), scripts are validated before execution to catch
203 /// errors early. Set to true to skip validation for performance or to
204 /// allow dynamic/external commands.
205 pub skip_validation: bool,
206
207 /// When true, standalone external commands inherit stdio for real-time output.
208 ///
209 /// Set by script runner and REPL for human-visible output.
210 /// Not set by MCP server (output must be captured for structured responses).
211 pub interactive: bool,
212
213 /// Ignore file configuration for file-walking tools.
214 pub ignore_config: crate::ignore_config::IgnoreConfig,
215
216 /// Output size limit configuration for agent safety.
217 pub output_limit: crate::output_limit::OutputLimitConfig,
218
219 /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
220 ///
221 /// When `true` (default), commands not found as builtins are resolved via PATH
222 /// and executed as child processes. When `false`, only kaish builtins and
223 /// backend-registered tools are available.
224 ///
225 /// **Security:** External commands bypass the VFS sandbox entirely — they see
226 /// the real filesystem, network, and environment. Set to `false` when running
227 /// untrusted input.
228 pub allow_external_commands: bool,
229
230 /// Enable confirmation latch for dangerous operations (set -o latch).
231 ///
232 /// When enabled, destructive operations like `rm` require nonce confirmation.
233 /// Can also be enabled at runtime with `set -o latch` or via `KAISH_LATCH=1`.
234 pub latch_enabled: bool,
235
236 /// Enable trash-on-delete for rm (set -o trash).
237 ///
238 /// When enabled, small files are moved to freedesktop.org Trash instead of
239 /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
240 /// or via `KAISH_TRASH=1`.
241 pub trash_enabled: bool,
242
243 /// Shared nonce store for cross-request confirmation latch.
244 ///
245 /// When `Some`, the kernel uses this store instead of creating a fresh one.
246 /// This allows nonces issued in one MCP `execute()` call to be validated
247 /// in a subsequent call. When `None` (default), a fresh store is created.
248 pub nonce_store: Option<crate::nonce::NonceStore>,
249
250 /// Variables to populate the root scope with at construction, all marked
251 /// for export to child processes.
252 ///
253 /// The kernel itself is hermetic — it never reads `std::env::vars()` —
254 /// so frontends that want OS-env passthrough (REPL, MCP) populate this
255 /// from `std::env::vars()`. Embedders that want isolation pass nothing
256 /// (or only the keys they curate).
257 pub initial_vars: HashMap<String, Value>,
258
259 /// Default per-request timeout. When `Some`, every `execute_with_options`
260 /// call without an explicit `ExecuteOptions::timeout` uses this duration.
261 /// When elapsed, the kernel cancels the request, kills any external
262 /// children with the configured grace, and returns exit code 124.
263 ///
264 /// `None` means no default timeout — only explicit per-call timeouts apply.
265 pub request_timeout: Option<Duration>,
266
267 /// Grace period between SIGTERM and SIGKILL when killing an external
268 /// child on cancellation or timeout.
269 ///
270 /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
271 /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
272 pub kill_grace: Duration,
273
274 /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
275 ///
276 /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
277 /// construction and handed to every `MemoryFs` the kernel builds in
278 /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
279 /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
280 /// `StorageFull` — an in-band error a model reads and adapts to; fail
281 /// loud over quietly eating RAM.
282 ///
283 /// **Why the agent preset is bounded by default:** an agent embedder
284 /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
285 /// is per-call, not per-session. Embedders that know their workload needs
286 /// more opt out with `without_vfs_budget()` or raise the cap with
287 /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
288 /// All other profiles default to `None` (unbounded).
289 ///
290 /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
291 pub vfs_budget_bytes: Option<u64>,
292
293 /// Enable copy-on-write overlay mode (opt-in).
294 ///
295 /// When `true`, the primary local filesystem mount is wrapped in an
296 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
297 /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
298 /// overlay transaction.
299 ///
300 /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
301 /// **Sandboxed{root}:** the `{root}` mount becomes
302 /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
303 /// mounts stay as real `LocalFs` (real writes escape the transaction —
304 /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
305 /// **NoLocal:** incompatible — construction fails loudly (everything is
306 /// already virtual; an overlay adds no value and no lower layer to wrap).
307 /// **with_backend:** incompatible — the embedder controls the VFS; the
308 /// kernel cannot wrap it without bypassing the embedder's semantics.
309 ///
310 /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
311 /// making the overlay a per-call transaction — `kaish-vfs commit` must run
312 /// in the same call as the writes, or the transaction is discarded on drop.
313 /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
314 pub overlay: bool,
315}
316
317/// Get the default sandbox root ($HOME).
318#[cfg(feature = "localfs")]
319fn default_sandbox_root() -> PathBuf {
320 std::env::var("HOME")
321 .map(PathBuf::from)
322 .unwrap_or_else(|_| PathBuf::from("/"))
323}
324
325impl Default for KernelConfig {
326 fn default() -> Self {
327 #[cfg(feature = "localfs")]
328 {
329 let home = default_sandbox_root();
330 Self {
331 name: "default".to_string(),
332 vfs_mode: VfsMountMode::Sandboxed { root: None },
333 cwd: home,
334 skip_validation: false,
335 interactive: false,
336 ignore_config: crate::ignore_config::IgnoreConfig::none(),
337 output_limit: crate::output_limit::OutputLimitConfig::none(),
338 allow_external_commands: cfg!(feature = "subprocess"),
339 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
340 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
341 nonce_store: None,
342 initial_vars: HashMap::new(),
343 request_timeout: None,
344 kill_grace: Duration::from_secs(2),
345 vfs_budget_bytes: None,
346 overlay: false,
347 }
348 }
349 #[cfg(not(feature = "localfs"))]
350 {
351 Self {
352 name: "default".to_string(),
353 vfs_mode: VfsMountMode::NoLocal,
354 cwd: PathBuf::from("/"),
355 skip_validation: false,
356 interactive: false,
357 ignore_config: crate::ignore_config::IgnoreConfig::none(),
358 output_limit: crate::output_limit::OutputLimitConfig::none(),
359 allow_external_commands: false,
360 latch_enabled: false,
361 trash_enabled: false,
362 nonce_store: None,
363 initial_vars: HashMap::new(),
364 request_timeout: None,
365 kill_grace: Duration::from_secs(2),
366 vfs_budget_bytes: None,
367 overlay: false,
368 }
369 }
370 }
371}
372
373impl KernelConfig {
374 /// Create a transient kernel config (sandboxed, for temporary use).
375 #[cfg(feature = "localfs")]
376 pub fn transient() -> Self {
377 let home = default_sandbox_root();
378 Self {
379 name: "transient".to_string(),
380 vfs_mode: VfsMountMode::Sandboxed { root: None },
381 cwd: home,
382 skip_validation: false,
383 interactive: false,
384 ignore_config: crate::ignore_config::IgnoreConfig::none(),
385 output_limit: crate::output_limit::OutputLimitConfig::none(),
386 allow_external_commands: cfg!(feature = "subprocess"),
387 latch_enabled: false,
388 trash_enabled: false,
389 nonce_store: None,
390 initial_vars: HashMap::new(),
391 request_timeout: None,
392 kill_grace: Duration::from_secs(2),
393 vfs_budget_bytes: None,
394 overlay: false,
395 }
396 }
397
398 /// Create a transient kernel config (isolated, no-default-features).
399 #[cfg(not(feature = "localfs"))]
400 pub fn transient() -> Self {
401 Self::isolated()
402 }
403
404 /// Create a kernel config with the given name (sandboxed by default).
405 #[cfg(feature = "localfs")]
406 pub fn named(name: &str) -> Self {
407 let home = default_sandbox_root();
408 Self {
409 name: name.to_string(),
410 vfs_mode: VfsMountMode::Sandboxed { root: None },
411 cwd: home,
412 skip_validation: false,
413 interactive: false,
414 ignore_config: crate::ignore_config::IgnoreConfig::none(),
415 output_limit: crate::output_limit::OutputLimitConfig::none(),
416 allow_external_commands: cfg!(feature = "subprocess"),
417 latch_enabled: false,
418 trash_enabled: false,
419 nonce_store: None,
420 initial_vars: HashMap::new(),
421 request_timeout: None,
422 kill_grace: Duration::from_secs(2),
423 vfs_budget_bytes: None,
424 overlay: false,
425 }
426 }
427
428 /// Create a kernel config with the given name (isolated, no-default-features).
429 #[cfg(not(feature = "localfs"))]
430 pub fn named(name: &str) -> Self {
431 Self {
432 name: name.to_string(),
433 ..Self::isolated()
434 }
435 }
436
437 /// Create a REPL config with passthrough filesystem access.
438 ///
439 /// Native paths like `/home/user/project` work directly.
440 /// The cwd is set to the actual current working directory.
441 #[cfg(feature = "localfs")]
442 pub fn repl() -> Self {
443 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
444 Self {
445 name: "repl".to_string(),
446 vfs_mode: VfsMountMode::Passthrough,
447 cwd,
448 skip_validation: false,
449 interactive: false,
450 // Ignore-aware by default (GH #134): .gitignore + default ignores
451 // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
452 ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
453 output_limit: crate::output_limit::OutputLimitConfig::none(),
454 allow_external_commands: cfg!(feature = "subprocess"),
455 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
456 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
457 nonce_store: None,
458 initial_vars: HashMap::new(),
459 request_timeout: None,
460 kill_grace: Duration::from_secs(2),
461 vfs_budget_bytes: None,
462 overlay: false,
463 }
464 }
465
466 /// Create a sandboxed-agent config with sandboxed filesystem access.
467 ///
468 /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
469 /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
470 /// memory and output. Local filesystem is accessible at its real path (e.g.,
471 /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
472 /// accessible through builtins. External commands still access the real
473 /// filesystem — use `.with_allow_external_commands(false)` to block them.
474 ///
475 /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
476 /// embedder typically creates a fresh kernel per call). Raise or remove with
477 /// `with_vfs_budget` / `without_vfs_budget`.
478 #[cfg(feature = "localfs")]
479 pub fn agent() -> Self {
480 let home = default_sandbox_root();
481 Self {
482 name: "agent".to_string(),
483 vfs_mode: VfsMountMode::Sandboxed { root: None },
484 cwd: home,
485 skip_validation: false,
486 interactive: false,
487 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
488 output_limit: crate::output_limit::OutputLimitConfig::agent(),
489 allow_external_commands: cfg!(feature = "subprocess"),
490 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
491 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
492 nonce_store: None,
493 initial_vars: HashMap::new(),
494 request_timeout: None,
495 kill_grace: Duration::from_secs(2),
496 vfs_budget_bytes: Some(64 * 1024 * 1024),
497 overlay: false,
498 }
499 }
500
501 /// Create a sandboxed-agent config with a custom sandbox root.
502 ///
503 /// Use this to restrict access to a subdirectory like `~/src`.
504 ///
505 /// VFS memory is bounded at 64 MiB per `execute()` call by default.
506 /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
507 #[cfg(feature = "localfs")]
508 pub fn agent_with_root(root: PathBuf) -> Self {
509 Self {
510 name: "agent".to_string(),
511 vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
512 cwd: root,
513 skip_validation: false,
514 interactive: false,
515 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
516 output_limit: crate::output_limit::OutputLimitConfig::agent(),
517 allow_external_commands: cfg!(feature = "subprocess"),
518 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
519 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
520 nonce_store: None,
521 initial_vars: HashMap::new(),
522 request_timeout: None,
523 kill_grace: Duration::from_secs(2),
524 vfs_budget_bytes: Some(64 * 1024 * 1024),
525 overlay: false,
526 }
527 }
528
529 /// Create a config with no local filesystem (memory only).
530 ///
531 /// Complete isolation: no local filesystem and external commands are disabled.
532 /// Useful for tests or pure sandboxed execution.
533 pub fn isolated() -> Self {
534 Self {
535 name: "isolated".to_string(),
536 vfs_mode: VfsMountMode::NoLocal,
537 cwd: PathBuf::from("/"),
538 skip_validation: false,
539 interactive: false,
540 ignore_config: crate::ignore_config::IgnoreConfig::none(),
541 output_limit: crate::output_limit::OutputLimitConfig::none(),
542 allow_external_commands: false,
543 latch_enabled: false,
544 trash_enabled: false,
545 nonce_store: None,
546 initial_vars: HashMap::new(),
547 request_timeout: None,
548 kill_grace: Duration::from_secs(2),
549 vfs_budget_bytes: None,
550 overlay: false,
551 }
552 }
553
554 /// Set the VFS mount mode.
555 pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
556 self.vfs_mode = mode;
557 self
558 }
559
560 /// Set the initial working directory.
561 pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
562 self.cwd = cwd;
563 self
564 }
565
566 /// Skip pre-execution validation.
567 pub fn with_skip_validation(mut self, skip: bool) -> Self {
568 self.skip_validation = skip;
569 self
570 }
571
572 /// Enable interactive mode (external commands inherit stdio).
573 pub fn with_interactive(mut self, interactive: bool) -> Self {
574 self.interactive = interactive;
575 self
576 }
577
578 /// Set the ignore file configuration.
579 pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
580 self.ignore_config = config;
581 self
582 }
583
584 /// Set the output limit configuration.
585 pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
586 self.output_limit = config;
587 self
588 }
589
590 /// Set whether external command execution is allowed.
591 ///
592 /// When `false`, commands not found as builtins produce "command not found"
593 /// instead of searching PATH. The `exec` and `spawn` builtins also return
594 /// errors. Use this to prevent VFS sandbox bypass via external binaries.
595 pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
596 self.allow_external_commands = allow;
597 self
598 }
599
600 /// Enable or disable confirmation latch at startup.
601 pub fn with_latch(mut self, enabled: bool) -> Self {
602 self.latch_enabled = enabled;
603 self
604 }
605
606 /// Enable or disable trash-on-delete at startup.
607 pub fn with_trash(mut self, enabled: bool) -> Self {
608 self.trash_enabled = enabled;
609 self
610 }
611
612 /// Use a shared nonce store for cross-request confirmation latch.
613 ///
614 /// Pass a `NonceStore` that outlives individual kernel instances so nonces
615 /// issued in one MCP `execute()` call can be validated in subsequent calls.
616 pub fn with_nonce_store(mut self, store: crate::nonce::NonceStore) -> Self {
617 self.nonce_store = Some(store);
618 self
619 }
620
621 /// Add a single initial variable; marked exported when the kernel boots.
622 ///
623 /// Repeated calls add (last write wins on key collision).
624 pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
625 self.initial_vars.insert(name.into(), value);
626 self
627 }
628
629 /// Replace the entire initial-vars map. All entries are marked exported.
630 pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
631 self.initial_vars = vars;
632 self
633 }
634
635 /// Extend the initial-vars map with the given entries (last write wins).
636 pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
637 self.initial_vars.extend(vars);
638 self
639 }
640
641 /// Set the default per-request timeout (kernel-wide).
642 ///
643 /// Each `execute_with_options` call without an explicit timeout uses
644 /// this. On elapsed, the kernel cancels and returns exit code 124.
645 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
646 self.request_timeout = Some(timeout);
647 self
648 }
649
650 /// Set the SIGTERM-to-SIGKILL grace period for child kills.
651 pub fn with_kill_grace(mut self, grace: Duration) -> Self {
652 self.kill_grace = grace;
653 self
654 }
655
656 /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
657 /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
658 /// created at kernel construction and passed to every `MemoryFs` the
659 /// kernel builds (see `setup_vfs` and `with_backend`).
660 ///
661 /// Writes that would exceed the cap fail loudly with `StorageFull` — an
662 /// in-band error a model reads and adapts to; fail loud over quietly eating
663 /// RAM. Use `without_vfs_budget` to remove the cap entirely.
664 pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
665 self.vfs_budget_bytes = Some(bytes);
666 self
667 }
668
669 /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
670 ///
671 /// Use when the caller knows the workload and the default 64 MiB cap
672 /// (set by `KernelConfig::agent`) is too conservative.
673 pub fn without_vfs_budget(mut self) -> Self {
674 self.vfs_budget_bytes = None;
675 self
676 }
677
678 /// Enable or disable copy-on-write overlay mode.
679 ///
680 /// When `true`, the primary local filesystem mount is wrapped in an
681 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
682 /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
683 /// and `with_backend` kernels (same — the embedder controls the VFS).
684 pub fn with_overlay(mut self, overlay: bool) -> Self {
685 self.overlay = overlay;
686 self
687 }
688}
689
690/// Handle to an active overlay session, kept on the kernel and shared to
691/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
692///
693/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
694/// `/home/user`); `commit_root` is the real filesystem path the overlay's
695/// lower is backed by (used as the target for `kaish-vfs commit`).
696#[cfg(all(feature = "localfs", feature = "overlay"))]
697#[derive(Clone)]
698pub struct OverlayHandle {
699 /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
700 /// methods without holding a VfsRouter lock.
701 pub fs: Arc<OverlayFs>,
702 /// VFS path this overlay is mounted at (e.g. `/home/user`).
703 pub mount_path: PathBuf,
704 /// Real filesystem root to commit into. Same as the lower's root.
705 pub commit_root: PathBuf,
706}
707
708/// The Kernel (核) — executes kaish code.
709///
710/// This is the primary interface for running kaish commands. It owns all
711/// the runtime state: variables, tools, VFS, jobs, and persistence.
712pub struct Kernel {
713 /// Kernel name.
714 name: String,
715 /// Variable scope.
716 scope: RwLock<Scope>,
717 /// Tool registry.
718 tools: Arc<ToolRegistry>,
719 /// User-defined tools (from `tool name { body }` statements).
720 user_tools: RwLock<HashMap<String, ToolDef>>,
721 /// Virtual filesystem router.
722 vfs: Arc<VfsRouter>,
723 /// Background job manager.
724 jobs: Arc<JobManager>,
725 /// Pipeline runner.
726 runner: PipelineRunner,
727 /// Execution context (cwd, stdin, etc.).
728 exec_ctx: RwLock<ExecContext>,
729 /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
730 /// retained past construction so `reset()` can re-seed them into the fresh
731 /// scope instead of silently dropping them.
732 initial_vars: HashMap<String, Value>,
733 /// Whether to skip pre-execution validation.
734 skip_validation: bool,
735 /// When true, standalone external commands inherit stdio for real-time output.
736 interactive: bool,
737 /// Whether external command execution is allowed.
738 allow_external_commands: bool,
739 /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
740 ///
741 /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
742 /// `Some` is Arc-cloned into forks so all concurrent execution draws from
743 /// the same pool — a background job's writes reduce the same cap as
744 /// foreground writes, which is the correct behaviour.
745 vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
746 /// Active overlay session handle, if this kernel was constructed with
747 /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
748 /// `kaish-vfs` builtin) can inspect and mutate the overlay without
749 /// holding a kernel write lock. Propagated to forks via `fork_inner`
750 /// and `child_for_pipeline` so `kaish-vfs` works inside background
751 /// jobs, scatter workers, and pipeline stages.
752 #[cfg(all(feature = "localfs", feature = "overlay"))]
753 overlay_handle: Option<Arc<OverlayHandle>>,
754 /// Default per-request timeout (None = no default).
755 request_timeout: Option<Duration>,
756 /// SIGTERM-to-SIGKILL grace period for child kills.
757 kill_grace: Duration,
758 /// Receiver for the kernel stderr stream.
759 ///
760 /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
761 /// The kernel drains this after each statement in `execute_streaming`.
762 stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
763 /// Cancellation token for interrupting execution (Ctrl-C).
764 ///
765 /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
766 /// needs sync access. Each `execute()` call gets a fresh child token;
767 /// `cancel()` cancels the current token and replaces it.
768 cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
769 /// Terminal state for job control (interactive mode only, Unix only).
770 #[cfg(all(unix, feature = "subprocess"))]
771 terminal_state: Option<Arc<crate::terminal::TerminalState>>,
772 /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
773 ///
774 /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
775 /// through the full Kernel resolution chain.
776 self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
777 /// Background job this kernel (a fork) is executing on behalf of, if any.
778 /// Set on the fork created by `execute_background` and inherited by all its
779 /// sub-forks (pipeline stages, scatter workers), so an external command
780 /// spawned anywhere under a background job can record its process group on
781 /// that job for `kill -<sig> %N`. `None` for foreground execution.
782 bg_job_id: Option<crate::scheduler::JobId>,
783 /// Serializes concurrent `execute()` / `execute_streaming()` callers on
784 /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
785 /// queue. Background jobs, scatter workers, and concurrent pipeline
786 /// stages do NOT take this lock — they run against a *forked* Kernel
787 /// (see [`Kernel::fork`]) so they never contend with the foreground.
788 execute_lock: tokio::sync::Mutex<()>,
789 /// Current dynamic statement-engine re-entry depth — incremented on entry
790 /// to command substitution, a shell-function call, or a `.kai` source, and
791 /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
792 /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
793 /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
794 /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
795 /// the recursion chain is single-threaded (top-level `execute` is
796 /// serialized by `execute_lock`; concurrency happens on forks).
797 recursion_depth: AtomicUsize,
798}
799
800/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
801/// (in `enter_recursion`) and decrements on drop, so a cancelled or
802/// error-unwound re-entry can never leave the counter inflated (which would
803/// spuriously trip later, unrelated recursions).
804struct RecursionGuard<'a> {
805 counter: &'a AtomicUsize,
806}
807
808impl Drop for RecursionGuard<'_> {
809 fn drop(&mut self) {
810 self.counter.fetch_sub(1, Ordering::Relaxed);
811 }
812}
813
814/// Internal result of [`Kernel::setup_vfs`].
815struct VfsSetupResult {
816 vfs: VfsRouter,
817 budget: Option<Arc<ByteBudget>>,
818 #[cfg(all(feature = "localfs", feature = "overlay"))]
819 overlay_handle: Option<Arc<OverlayHandle>>,
820}
821
822impl Kernel {
823 /// Create a new kernel with the given configuration.
824 pub fn new(config: KernelConfig) -> Result<Self> {
825 let mut setup = Self::setup_vfs(&config)?;
826 let jobs = Arc::new(JobManager::new());
827
828 // Mount JobFs for job observability at /v/jobs
829 setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
830
831 #[cfg(all(feature = "localfs", feature = "overlay"))]
832 let overlay_handle = setup.overlay_handle.take();
833
834 // Mode-based construction: the kernel owns its host mounts, so whether
835 // host side channels are allowed is decided by the VFS mode inside
836 // `assemble` (NoLocal forbids them).
837 let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
838 ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
839 })?;
840
841 #[cfg(all(feature = "localfs", feature = "overlay"))]
842 {
843 let mut kernel = kernel;
844 kernel.overlay_handle = overlay_handle;
845 // Also set it on the ExecContext so builtins can access it.
846 if let Some(ref handle) = kernel.overlay_handle {
847 kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
848 }
849 return Ok(kernel);
850 }
851
852 #[allow(unreachable_code)]
853 Ok(kernel)
854 }
855
856 /// Set up VFS based on mount mode.
857 ///
858 /// Returns the router, the budget handle (if bounded), and an optional
859 /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
860 /// every `MemoryFs` the kernel creates here holds a clone of the same
861 /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
862 /// in-memory content across all kernel-owned memory mounts.
863 ///
864 /// # Errors
865 /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
866 /// (overlay is meaningless when everything is already virtual — there is
867 /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
868 /// this as an `anyhow::Error`.
869 fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
870 let mut vfs = VfsRouter::new();
871
872 // One budget for all memory mounts this kernel owns — labeled so the
873 // error message tells the user exactly which knob to raise.
874 let budget: Option<Arc<ByteBudget>> = config
875 .vfs_budget_bytes
876 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
877
878 /// Helper: construct a `MemoryFs` wired to `budget` if present.
879 fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
880 match budget {
881 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
882 None => MemoryFs::new(),
883 }
884 }
885
886 // Overlay handle — populated below if config.overlay is true.
887 #[cfg(all(feature = "localfs", feature = "overlay"))]
888 let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
889
890 match &config.vfs_mode {
891 #[cfg(feature = "localfs")]
892 VfsMountMode::Passthrough => {
893 #[cfg(feature = "overlay")]
894 if config.overlay {
895 // Wrap "/" in an OverlayFs so writes are virtual.
896 let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
897 let overlay_fs = Arc::new(match &budget {
898 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
899 None => OverlayFs::over(lower),
900 });
901 let handle = Arc::new(OverlayHandle {
902 fs: Arc::clone(&overlay_fs),
903 mount_path: PathBuf::from("/"),
904 commit_root: PathBuf::from("/"),
905 });
906 vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
907 overlay_handle = Some(handle);
908 } else {
909 // LocalFs at "/" — native paths work directly
910 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
911 }
912 #[cfg(not(feature = "overlay"))]
913 {
914 if config.overlay {
915 return Err(anyhow::anyhow!(
916 "overlay=true requires the `overlay` feature, but this build \
917 was compiled without it. Recompile with --features overlay \
918 (or the default feature set) to enable overlay mode."
919 ));
920 }
921 // LocalFs at "/" — native paths work directly
922 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
923 }
924 // Memory for blobs
925 vfs.mount("/v", mem(&budget));
926 }
927 #[cfg(feature = "localfs")]
928 VfsMountMode::Sandboxed { root } => {
929 // Memory at root for safety (catches paths outside sandbox).
930 // Note: /tmp and the XDG runtime dir are LocalFs — writes
931 // there escape the VFS budget and are NOT virtual. This is
932 // intentional: /tmp interop with other processes matters more
933 // than accounting for scratch files there.
934 vfs.mount("/", mem(&budget));
935 vfs.mount("/v", mem(&budget));
936
937 // Synthetic /dev: the host's real /dev isn't reachable here, so
938 // /dev/null and /dev/zero are software-backed (see DevFs).
939 vfs.mount("/dev", DevFs::new());
940
941 // Real /tmp for interop with other processes
942 vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
943
944 // Mount XDG runtime dir for spill files and socket access
945 let runtime = crate::paths::xdg_runtime_dir();
946 if runtime.exists() {
947 let runtime_str = runtime.to_string_lossy().to_string();
948 vfs.mount(&runtime_str, LocalFs::new(runtime));
949 }
950
951 // Resolve the sandbox root (defaults to $HOME)
952 let local_root = root.clone().unwrap_or_else(|| {
953 std::env::var("HOME")
954 .map(PathBuf::from)
955 .unwrap_or_else(|_| PathBuf::from("/"))
956 });
957
958 let mount_point = local_root.to_string_lossy().to_string();
959
960 #[cfg(feature = "overlay")]
961 if config.overlay {
962 // Wrap the sandbox root in an OverlayFs.
963 let lower = Arc::new(LocalFs::read_only(local_root.clone()));
964 let overlay_fs = Arc::new(match &budget {
965 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
966 None => OverlayFs::over(lower),
967 });
968 let handle = Arc::new(OverlayHandle {
969 fs: Arc::clone(&overlay_fs),
970 mount_path: PathBuf::from(&mount_point),
971 commit_root: local_root,
972 });
973 vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
974 overlay_handle = Some(handle);
975 } else {
976 // Mount at the real path for transparent access
977 // e.g., /home/atobey → LocalFs("/home/atobey")
978 // so /home/atobey/src/kaish just works
979 vfs.mount(&mount_point, LocalFs::new(local_root));
980 }
981 #[cfg(not(feature = "overlay"))]
982 {
983 if config.overlay {
984 return Err(anyhow::anyhow!(
985 "overlay=true requires the `overlay` feature, but this build \
986 was compiled without it. Recompile with --features overlay \
987 (or the default feature set) to enable overlay mode."
988 ));
989 }
990 // Mount at the real path for transparent access
991 vfs.mount(&mount_point, LocalFs::new(local_root));
992 }
993 }
994 VfsMountMode::NoLocal => {
995 if config.overlay {
996 return Err(anyhow::anyhow!(
997 "overlay=true is incompatible with VfsMountMode::NoLocal: \
998 everything is already virtual, there is no real lower layer \
999 to wrap. Use with_overlay(false) or switch to a Passthrough \
1000 or Sandboxed VFS mode."
1001 ));
1002 }
1003 // Pure memory mode — no local filesystem
1004 vfs.mount("/", mem(&budget));
1005 vfs.mount("/tmp", mem(&budget));
1006 vfs.mount("/v", mem(&budget));
1007 // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1008 vfs.mount("/dev", DevFs::new());
1009 }
1010 }
1011
1012 Ok(VfsSetupResult {
1013 vfs,
1014 budget,
1015 #[cfg(all(feature = "localfs", feature = "overlay"))]
1016 overlay_handle,
1017 })
1018 }
1019
1020 /// Create a transient kernel (no persistence).
1021 pub fn transient() -> Result<Self> {
1022 Self::new(KernelConfig::transient())
1023 }
1024
1025 /// Create a kernel with a custom backend and `/v/*` virtual path support.
1026 ///
1027 /// This is the constructor for embedding kaish in other systems that provide
1028 /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1029 ///
1030 /// A `VirtualOverlayBackend` routes paths automatically:
1031 /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1032 /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1033 /// `/dev/urandom`) — kernel-owned so it works even when your backend is
1034 /// read-only
1035 /// - Everything else → Your custom backend
1036 ///
1037 /// The optional `configure_vfs` closure lets you add additional virtual mounts
1038 /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1039 ///
1040 /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1041 /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1042 /// `skip_validation`, and `interactive`.
1043 ///
1044 /// # Example
1045 ///
1046 /// ```ignore
1047 /// // Simple: default /v/* mounts only
1048 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1049 ///
1050 /// // With custom mounts
1051 /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1052 /// vfs.mount_arc("/v/docs", docs_fs);
1053 /// vfs.mount_arc("/v/g", git_fs);
1054 /// }, |_| {})?;
1055 ///
1056 /// // With custom tools
1057 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1058 /// tools.register(MyCustomTool::new());
1059 /// })?;
1060 /// ```
1061 pub fn with_backend(
1062 backend: Arc<dyn KernelBackend>,
1063 config: KernelConfig,
1064 configure_vfs: impl FnOnce(&mut VfsRouter),
1065 configure_tools: impl FnOnce(&mut ToolRegistry),
1066 ) -> Result<Self> {
1067 use crate::backend::VirtualOverlayBackend;
1068
1069 // overlay=true is incompatible with with_backend: the embedder controls
1070 // the VFS and the kernel cannot wrap it without bypassing the embedder's
1071 // semantics. Fail loudly rather than silently ignoring the flag.
1072 if config.overlay {
1073 return Err(anyhow::anyhow!(
1074 "overlay=true is incompatible with Kernel::with_backend: the embedder \
1075 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1076 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1077 ));
1078 }
1079
1080 let mut vfs = VfsRouter::new();
1081 let jobs = Arc::new(JobManager::new());
1082
1083 // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1084 // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1085 // kernel-owned memory mount here — embedders own the rest of the VFS.
1086 let vfs_budget: Option<Arc<ByteBudget>> = config
1087 .vfs_budget_bytes
1088 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1089
1090 vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1091 let blobs_fs = match &vfs_budget {
1092 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1093 None => MemoryFs::new(),
1094 };
1095 vfs.mount("/v/blobs", blobs_fs);
1096
1097 // /dev/null and friends are software-backed (see DevFs) and must not
1098 // depend on the embedder's backend — a read-only embedder backend
1099 // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1100 // to /dev/null as a filesystem error instead of discarding them.
1101 vfs.mount("/dev", DevFs::new());
1102
1103 // Let caller add custom mounts (e.g., /v/docs, /v/g)
1104 configure_vfs(&mut vfs);
1105
1106 // A custom-backend kernel owns no host mounts — the embedder supplies
1107 // the entire VFS — so any kernel write to a host filesystem via
1108 // `std::fs` (output spill, job output files) bypasses that VFS and its
1109 // read-only guarantees. Forbid host side channels unconditionally.
1110 Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1111 let overlay: Arc<dyn KernelBackend> =
1112 Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1113 ExecContext::with_backend(overlay)
1114 })
1115 }
1116
1117 /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1118 ///
1119 /// The `make_ctx` closure receives the VFS and tools so backends that need
1120 /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1121 /// that already have their own storage can ignore these parameters.
1122 fn assemble(
1123 config: KernelConfig,
1124 mut vfs: VfsRouter,
1125 jobs: Arc<JobManager>,
1126 no_host_filesystem: bool,
1127 vfs_budget: Option<Arc<ByteBudget>>,
1128 configure_tools: impl FnOnce(&mut ToolRegistry),
1129 make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1130 ) -> Result<Self> {
1131 // A kernel with no host filesystem of its own must never write to one
1132 // through a side channel. Two paths bypass the VFS by going straight to
1133 // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1134 // background-job output files (`Job::write_output_file` → host temp).
1135 // Both would punch through the isolation, so force them off:
1136 // in-memory truncation for spill, no host file for job output.
1137 //
1138 // This is true for a `NoLocal` kernel (mounts nothing) and for any
1139 // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1140 // VFS, so the kernel controls no host mounts and any host write is a
1141 // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1142 // when there is no kernel-owned host filesystem to spill to.
1143 let no_host_side_channel =
1144 no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1145
1146 let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, latch_enabled, trash_enabled, nonce_store, initial_vars, request_timeout, kill_grace, .. } = config;
1147
1148 if no_host_side_channel {
1149 output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1150 jobs.set_persist_output_files(false);
1151 }
1152
1153 let mut tools = ToolRegistry::new();
1154 register_builtins(&mut tools);
1155 configure_tools(&mut tools);
1156 let tools = Arc::new(tools);
1157
1158 // Mount BuiltinFs so `ls /v/bin` lists builtins
1159 vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1160
1161 let vfs = Arc::new(vfs);
1162
1163 let runner = PipelineRunner::new(tools.clone());
1164
1165 let (stderr_writer, stderr_receiver) = stderr_stream();
1166
1167 let mut exec_ctx = make_ctx(&vfs, &tools);
1168 exec_ctx.set_cwd(cwd);
1169 exec_ctx.set_job_manager(jobs.clone());
1170 exec_ctx.set_tool_schemas(tools.schemas());
1171 exec_ctx.set_tools(tools.clone());
1172 #[cfg(feature = "os-integration")]
1173 exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1174 exec_ctx.stderr = Some(stderr_writer);
1175 exec_ctx.ignore_config = ignore_config;
1176 exec_ctx.output_limit = output_limit;
1177 exec_ctx.allow_external_commands = allow_external_commands;
1178 exec_ctx.vfs_budget = vfs_budget.clone();
1179 if let Some(store) = nonce_store {
1180 exec_ctx.nonce_store = store;
1181 }
1182
1183 Ok(Self {
1184 name,
1185 scope: RwLock::new({
1186 let mut scope = Scope::new();
1187 scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1188 // HOME is NOT read from the host env here — the kernel is
1189 // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1190 // below (from `std::env::vars()`); a hermetic embedder leaves
1191 // `initial_vars` empty and gets no HOME (tilde stays literal).
1192 // Apply caller-supplied initial variables, all marked exported.
1193 // Frontends (REPL, MCP) populate this from std::env::vars()
1194 // for shell-like UX; embedders that want hermetic behavior
1195 // simply leave it empty.
1196 for (name, value) in initial_vars.clone() {
1197 scope.set_exported(name, value);
1198 }
1199 scope.set_latch_enabled(latch_enabled);
1200 scope.set_trash_enabled(trash_enabled);
1201 scope
1202 }),
1203 initial_vars,
1204 tools,
1205 user_tools: RwLock::new(HashMap::new()),
1206 vfs,
1207 jobs,
1208 runner,
1209 exec_ctx: RwLock::new(exec_ctx),
1210 skip_validation,
1211 interactive,
1212 allow_external_commands,
1213 vfs_budget,
1214 request_timeout,
1215 kill_grace,
1216 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1217 cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1218 #[cfg(all(unix, feature = "subprocess"))]
1219 terminal_state: None,
1220 self_weak: std::sync::OnceLock::new(),
1221 execute_lock: tokio::sync::Mutex::new(()),
1222 recursion_depth: AtomicUsize::new(0),
1223 bg_job_id: None,
1224 // Overlay handle is set by Kernel::new after assemble returns;
1225 // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1226 // with_backend always has None (overlay=true is rejected above).
1227 #[cfg(all(feature = "localfs", feature = "overlay"))]
1228 overlay_handle: None,
1229 })
1230 }
1231
1232 /// Get the kernel name.
1233 pub fn name(&self) -> &str {
1234 &self.name
1235 }
1236
1237 /// Wrap this Kernel in an Arc and initialize its self-reference.
1238 ///
1239 /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1240 /// to child contexts, allowing builtins like `timeout` to dispatch inner
1241 /// commands through the full resolution chain (user tools → builtins →
1242 /// .kai scripts → external commands).
1243 pub fn into_arc(self) -> Arc<Self> {
1244 let arc = Arc::new(self);
1245 let _ = arc.self_weak.set(Arc::downgrade(&arc));
1246 arc
1247 }
1248
1249 /// Fork a subsidiary kernel for concurrent execution.
1250 ///
1251 /// The fork is a fully-functional `Kernel` that:
1252 /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1253 /// user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1254 /// the fork do NOT propagate back to the parent — matching bash
1255 /// subshell / background-job semantics.
1256 /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1257 /// registry, the VFS router, and the job manager. A job registered by
1258 /// the fork is visible to the parent's `jobs` builtin, and the fork
1259 /// sees the same VFS mounts.
1260 /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1261 /// `execute_lock`. It is never the TTY owner, so `interactive` is
1262 /// `false` and `terminal_state` is `None`.
1263 ///
1264 /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1265 /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1266 /// routes through the fork itself, not the parent — which is essential
1267 /// for concurrency safety.
1268 ///
1269 /// Use this for **detached** background concurrency where the fork should
1270 /// survive parent cancellation: the `&` background-job operator and any
1271 /// other "fire and forget" worker. The fork gets a fresh, independent
1272 /// cancellation token.
1273 ///
1274 /// For foreground concurrency (scatter workers, concurrent pipeline
1275 /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1276 /// into the fork's external children, use [`Self::fork_attached`].
1277 pub async fn fork(&self) -> Arc<Self> {
1278 self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1279 .await
1280 }
1281
1282 /// Fork attached to the parent's cancellation.
1283 ///
1284 /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1285 /// the parent's. When the parent cancels (request timeout, embedder
1286 /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1287 /// turn kills any external children spawned in the fork via the
1288 /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1289 pub async fn fork_attached(&self) -> Arc<Self> {
1290 let child_token = {
1291 #[allow(clippy::expect_used)]
1292 let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1293 parent.child_token()
1294 };
1295 self.fork_inner(child_token, self.bg_job_id).await
1296 }
1297
1298 /// Fork for a background job, stamping the job id so external commands
1299 /// spawned anywhere beneath it record their process groups on that job
1300 /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1301 /// `JobManager::cancel`.
1302 pub async fn fork_for_background(
1303 &self,
1304 cancel: tokio_util::sync::CancellationToken,
1305 job_id: crate::scheduler::JobId,
1306 ) -> Arc<Self> {
1307 self.fork_inner(cancel, Some(job_id)).await
1308 }
1309
1310 /// Shared fork implementation. Caller decides the cancellation token and
1311 /// which background job (if any) this fork runs on behalf of.
1312 async fn fork_inner(
1313 &self,
1314 cancel: tokio_util::sync::CancellationToken,
1315 bg_job_id: Option<crate::scheduler::JobId>,
1316 ) -> Arc<Self> {
1317 let scope_snapshot = self.scope.read().await.clone();
1318 let user_tools_snapshot = self.user_tools.read().await.clone();
1319
1320 // Snapshot exec_ctx by cloning the cloneable fields, then override
1321 // the ones that should not carry over (stderr channel, dispatcher,
1322 // interactive flag, terminal state, cancel — set from `cancel` arg).
1323 let mut fork_ctx = {
1324 let parent_ctx = self.exec_ctx.read().await;
1325 parent_ctx.child_for_pipeline()
1326 };
1327 let (stderr_writer, stderr_receiver) = stderr_stream();
1328 fork_ctx.stderr = Some(stderr_writer);
1329 // Clear dispatcher; dispatch_command will repopulate it to point at
1330 // the fork on the first dispatch call.
1331 fork_ctx.dispatcher = None;
1332 fork_ctx.interactive = false;
1333 fork_ctx.cancel = cancel.clone();
1334 #[cfg(all(unix, feature = "subprocess"))]
1335 {
1336 fork_ctx.terminal_state = None;
1337 }
1338
1339 let fork = Self {
1340 name: format!("{}:fork", self.name),
1341 scope: RwLock::new(scope_snapshot),
1342 initial_vars: self.initial_vars.clone(),
1343 tools: Arc::clone(&self.tools),
1344 user_tools: RwLock::new(user_tools_snapshot),
1345 vfs: Arc::clone(&self.vfs),
1346 jobs: Arc::clone(&self.jobs),
1347 runner: self.runner.clone(),
1348 exec_ctx: RwLock::new(fork_ctx),
1349 skip_validation: self.skip_validation,
1350 // Forks are never the TTY owner — they run in the background.
1351 interactive: false,
1352 allow_external_commands: self.allow_external_commands,
1353 // Arc-clone the budget so the fork draws from the same pool as the
1354 // parent — background jobs and scatter workers count against the same
1355 // cap as foreground writes.
1356 vfs_budget: self.vfs_budget.clone(),
1357 request_timeout: self.request_timeout,
1358 kill_grace: self.kill_grace,
1359 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1360 cancel_token: std::sync::Mutex::new(cancel),
1361 #[cfg(all(unix, feature = "subprocess"))]
1362 terminal_state: None,
1363 self_weak: std::sync::OnceLock::new(),
1364 execute_lock: tokio::sync::Mutex::new(()),
1365 // A fork runs on a fresh stack (spawned task) — its recursion
1366 // budget is independent of the parent's current depth (GH #46).
1367 recursion_depth: AtomicUsize::new(0),
1368 bg_job_id,
1369 // Arc-clone the overlay handle so forks (background jobs, scatter
1370 // workers, pipeline stages) can reach the same overlay transaction
1371 // via `kaish-vfs status/diff/commit/reset`.
1372 #[cfg(all(feature = "localfs", feature = "overlay"))]
1373 overlay_handle: self.overlay_handle.clone(),
1374 };
1375
1376 fork.into_arc()
1377 }
1378
1379 /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1380 ///
1381 /// Returns `None` if the Kernel was not wrapped, or if all strong references
1382 /// have been dropped (the `Weak` can no longer upgrade).
1383 pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1384 self.self_weak
1385 .get()
1386 .and_then(|weak| weak.upgrade())
1387 .map(|arc| arc as Arc<dyn CommandDispatcher>)
1388 }
1389
1390 /// Initialize terminal state for interactive job control.
1391 ///
1392 /// Call this after kernel creation when running as an interactive REPL
1393 /// and stdin is a TTY. Sets up process groups and signal handling.
1394 #[cfg(all(unix, feature = "subprocess"))]
1395 pub fn init_terminal(&mut self) {
1396 if !self.interactive {
1397 return;
1398 }
1399 match crate::terminal::TerminalState::init() {
1400 Ok(state) => {
1401 let state = Arc::new(state);
1402 self.terminal_state = Some(state.clone());
1403 // Set on exec_ctx so builtins (fg, bg, kill) can access it
1404 self.exec_ctx.get_mut().terminal_state = Some(state);
1405 tracing::debug!("terminal job control initialized");
1406 }
1407 Err(e) => {
1408 tracing::warn!("failed to initialize terminal job control: {}", e);
1409 }
1410 }
1411 }
1412
1413 /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1414 ///
1415 /// The kernel installs the OS trash (`SystemTrash`) automatically when
1416 /// built with the `os-integration` feature. Embedders and tests can swap
1417 /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1418 /// it — with trash enabled but no backend present, `rm` fails loud
1419 /// rather than falling through to permanent delete.
1420 pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1421 self.exec_ctx.get_mut().trash_backend = backend;
1422 }
1423
1424 /// Cancel the current execution.
1425 ///
1426 /// This cancels the current cancellation token, causing any execution
1427 /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1428 /// A fresh token is installed for the next `execute()` call.
1429 pub fn cancel(&self) {
1430 #[allow(clippy::expect_used)]
1431 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1432 token.cancel();
1433 }
1434
1435 /// Check if the current execution has been cancelled.
1436 pub fn is_cancelled(&self) -> bool {
1437 #[allow(clippy::expect_used)]
1438 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1439 token.is_cancelled()
1440 }
1441
1442 /// Reset the cancellation token (called at the start of each execute).
1443 fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1444 #[allow(clippy::expect_used)]
1445 let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1446 if token.is_cancelled() {
1447 *token = tokio_util::sync::CancellationToken::new();
1448 }
1449 token.clone()
1450 }
1451
1452 /// Acquire the per-Kernel execute lock, warning on contention.
1453 ///
1454 /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1455 /// the lock is already held, emit a warning so the silent serialization
1456 /// is observable in logs — if you need real parallelism, fork the kernel.
1457 async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1458 match self.execute_lock.try_lock() {
1459 Ok(guard) => guard,
1460 Err(_) => {
1461 tracing::warn!(
1462 target: "kaish::kernel::concurrency",
1463 kernel = %self.name,
1464 "execute() contended — serializing concurrent caller; \
1465 use Kernel::fork() for parallelism instead of sharing"
1466 );
1467 self.execute_lock.lock().await
1468 }
1469 }
1470 }
1471
1472 /// Execute kaish source code with default options.
1473 ///
1474 /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1475 /// Returns the result of the last statement executed.
1476 pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1477 self.run_inner(input, ExecuteOptions::default(), None, None).await
1478 }
1479
1480 /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1481 /// are **already tokenized**.
1482 ///
1483 /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1484 /// that already holds OS/structured argv (a busybox-style multicall binary, a
1485 /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1486 /// into a string just to have the lexer split it apart again — a round-trip
1487 /// that is lossy for typed values, since `to_argv()` stringifies
1488 /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1489 ///
1490 /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1491 /// command substitution, no word splitting — the "single-quoted word"
1492 /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1493 /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1494 /// does still apply, for consistency with the string door: a leading `~` is
1495 /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1496 /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1497 /// non-string `Value`
1498 /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1499 /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1500 /// clap arg model means a builtin that re-parses its own `to_argv()` still
1501 /// sees a stringified value; the typed-passthrough win fully lands only for
1502 /// builtins that read `args.positional` directly — the documented pattern.)
1503 ///
1504 /// This is a *peer*, not a subset: a command string can carry pipelines,
1505 /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1506 /// doors converge **late** (at the shared dispatch chain) rather than one
1507 /// wrapping the other. From argv classification onward `execute_argv` reuses
1508 /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1509 /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1510 /// transform, and the confirmation latch — so a latched `rm` still emits a
1511 /// nonce and an `ls --json` still applies output formatting. The kernel's
1512 /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1513 /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1514 ///
1515 /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1516 /// and the kernel's configured `request_timeout` applies (a hung builtin or
1517 /// external is interrupted at the deadline with exit code 124, the same as the
1518 /// string door). There is no per-call options surface yet — a future
1519 /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1520 #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1521 pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1522 let _guard = self.acquire_execute_lock().await;
1523 // Fresh cancel surface for this call: `execute_pipeline` reads
1524 // `self.cancel_token`, so a stale cancelled token from a prior call must be
1525 // replaced first. The returned clone is the token the watchdog cancels on
1526 // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1527 // cascading SIGTERM/SIGKILL to any external child.
1528 let cancel = self.reset_cancel();
1529
1530 // Honor the kernel-configured request timeout for parity with `execute`.
1531 let timeout = self.request_timeout;
1532 if timeout == Some(Duration::ZERO) {
1533 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1534 }
1535
1536 let pipeline = crate::ast::Pipeline {
1537 commands: vec![crate::ast::Command {
1538 name: name.to_string(),
1539 args: argv_to_args(argv),
1540 redirects: Vec::new(),
1541 }],
1542 background: false,
1543 };
1544 let result = self
1545 .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1546 .await?;
1547 self.update_last_result(&result).await;
1548 Ok(result)
1549 }
1550
1551 /// Fulfill a confirmation latch by replaying its exact captured invocation
1552 /// with the nonce — the highest-fidelity approval path.
1553 ///
1554 /// Inspect a gated result with [`ExecResult::latch_request`]; apply whatever
1555 /// policy (allowlist, model review) over `req.command`/`req.paths`; then call
1556 /// this to approve. It replays `execute_argv(req.tool, req.argv)` with
1557 /// `--confirm=<nonce>` prepended — no re-parsing of the human `hint`, so a
1558 /// path with spaces or glob characters round-trips exactly. Share the nonce
1559 /// store ([`KernelConfig::with_nonce_store`]) to confirm from a *later*
1560 /// kernel call than the one that produced the latch.
1561 ///
1562 /// Errors (exit 2) if the latch carries no captured invocation — a latch
1563 /// produced outside a dispatch seam (a direct `tool.execute` in a unit
1564 /// test). Those are confirmable only by re-running with `--confirm=<nonce>`.
1565 ///
1566 /// If `latch.job_id` is set (the gate came from a *backgrounded* job —
1567 /// `rm x &` reaching its gate), a successful replay also retires that job
1568 /// from the `JobManager` (GH #124 part 4) — mirroring the existing manual
1569 /// discard path (`kill --discard %N`), automated. A failed replay leaves
1570 /// the job in place for inspection/retry. Guarded by `is_latched` so a
1571 /// stale/foreign `job_id` can never remove an unrelated running job;
1572 /// idempotent on a repeat confirm (nonces are reusable within TTL, and
1573 /// removing an already-absent job is a no-op).
1574 pub async fn confirm(&self, latch: &LatchRequest) -> Result<ExecResult> {
1575 if latch.tool.is_empty() {
1576 return Ok(ExecResult::failure(
1577 2,
1578 "confirm: latch carries no captured invocation to replay — \
1579 re-run the command with --confirm=<nonce> instead",
1580 ));
1581 }
1582 // Prepend the nonce as a `--confirm=` flag: `to_argv()` trails a `--`
1583 // positional terminator, so appending would let it swallow the flag.
1584 let mut argv: Vec<Value> = Vec::with_capacity(latch.argv.len() + 1);
1585 argv.push(Value::String(format!("--confirm={}", latch.nonce)));
1586 argv.extend(latch.argv.iter().map(|a| Value::String(a.clone())));
1587 let result = self.execute_argv(&latch.tool, &argv).await?;
1588
1589 if result.ok()
1590 && let Some(id) = latch.job_id
1591 {
1592 let job_id = crate::scheduler::JobId(id);
1593 if self.jobs.is_latched(job_id).await {
1594 self.jobs.remove(job_id).await;
1595 }
1596 }
1597
1598 Ok(result)
1599 }
1600
1601 /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1602 /// string door ([`Self::execute_with_options`]) and the argv door
1603 /// ([`Self::execute_argv`]).
1604 ///
1605 /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1606 /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1607 /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1608 /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1609 /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1610 /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1611 /// stale handle would silently suspend nothing). Callers must short-circuit a
1612 /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1613 async fn run_under_watchdog<F>(
1614 &self,
1615 timeout: Option<Duration>,
1616 cancel: &tokio_util::sync::CancellationToken,
1617 work: F,
1618 ) -> Result<ExecResult>
1619 where
1620 F: std::future::Future<Output = Result<ExecResult>>,
1621 {
1622 // Assigned unconditionally (clearing any stale handle); None without a timeout.
1623 let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1624 {
1625 let mut ec = self.exec_ctx.write().await;
1626 ec.watchdog = watchdog.clone();
1627 }
1628
1629 let result = if let Some(d) = timeout {
1630 #[allow(clippy::expect_used)]
1631 let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1632 let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1633 let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1634 let r = work.await;
1635 timer.abort();
1636 match r {
1637 Ok(mut res) => {
1638 if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1639 res.code = 124;
1640 if res.err.is_empty() {
1641 res.err = format!("timeout: timed out after {:?}", d);
1642 }
1643 }
1644 Ok(res)
1645 }
1646 Err(e) => Err(e),
1647 }
1648 } else {
1649 work.await
1650 };
1651
1652 // The timer task is gone (fired or aborted); drop the stale handle.
1653 {
1654 let mut ec = self.exec_ctx.write().await;
1655 ec.watchdog = None;
1656 }
1657 result
1658 }
1659
1660 /// Execute with per-call options. The primary entry point for embedders
1661 /// that don't need per-statement output streaming.
1662 ///
1663 /// `opts` carries timeout, transient vars overlay, optional cwd override,
1664 /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1665 /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1666 ///
1667 /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1668 /// against the kernel's internal token. Either firing cancels and kills
1669 /// external children. The embedder's token is read-only — kernel
1670 /// timeouts do NOT propagate into it. Distinguish via the returned
1671 /// `code`: 124 = timeout, 130 = cancellation.
1672 ///
1673 /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1674 /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1675 ///
1676 /// Concurrent callers on the same Kernel serialize on the kernel-wide
1677 /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1678 /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1679 pub async fn execute_with_options(
1680 &self,
1681 input: &str,
1682 opts: ExecuteOptions,
1683 ) -> Result<ExecResult> {
1684 self.run_inner(input, opts, None, None).await
1685 }
1686
1687 /// Same as [`Self::execute_with_options`] but with a per-statement output
1688 /// callback. The callback fires after each top-level statement so the
1689 /// embedder (REPL, MCP streaming) can flush output incrementally.
1690 pub async fn execute_with_options_streaming(
1691 &self,
1692 input: &str,
1693 opts: ExecuteOptions,
1694 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1695 ) -> Result<ExecResult> {
1696 self.run_inner(input, opts, None, Some(on_output)).await
1697 }
1698
1699 /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1700 ///
1701 /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read `String`), this never
1702 /// forces the input to be drained before execution: the reader seeds the
1703 /// first top-level command's `pipe_stdin`, and a command that does not read
1704 /// stdin (`echo`) returns without touching it. This is the seam a
1705 /// non-interactive frontend uses to forward an *open* process stdin without
1706 /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1707 ///
1708 /// Embedders that already hold a complete buffer should prefer the simpler
1709 /// [`ExecuteOptions::with_stdin`] String path.
1710 pub async fn execute_with_pipe_stdin(
1711 &self,
1712 input: &str,
1713 opts: ExecuteOptions,
1714 pipe_stdin: crate::scheduler::PipeReader,
1715 ) -> Result<ExecResult> {
1716 self.run_inner(input, opts, Some(pipe_stdin), None).await
1717 }
1718
1719 /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1720 /// `-c`/script frontend uses this to print output incrementally while
1721 /// feeding a lazy process-stdin pipe.
1722 pub async fn execute_with_pipe_stdin_streaming(
1723 &self,
1724 input: &str,
1725 opts: ExecuteOptions,
1726 pipe_stdin: crate::scheduler::PipeReader,
1727 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1728 ) -> Result<ExecResult> {
1729 self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1730 }
1731
1732 /// Execute kaish source code with a transient overlay of exported variables.
1733 ///
1734 /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1735 /// should use that method directly:
1736 /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1737 #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1738 pub async fn execute_with_vars(
1739 &self,
1740 input: &str,
1741 vars: HashMap<String, Value>,
1742 ) -> Result<ExecResult> {
1743 self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1744 }
1745
1746 /// Execute kaish source code with a per-statement callback.
1747 ///
1748 /// Deprecated thin wrapper. New code should use
1749 /// [`Self::execute_with_options_streaming`].
1750 #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1751 pub async fn execute_streaming(
1752 &self,
1753 input: &str,
1754 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1755 ) -> Result<ExecResult> {
1756 self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1757 }
1758
1759 /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1760 ///
1761 /// The `#[instrument]` execution span resolves its parent from the *current*
1762 /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1763 /// captured when the span is first entered — not when the future is
1764 /// constructed. So a thread-local `attach()` scoped to construction is too
1765 /// early to be seen (the integration test confirms this). `with_context`
1766 /// re-attaches the embedder's context on *every* poll of the inner future,
1767 /// so the context is current at first-enter and survives runtime thread
1768 /// hops. With no embedder trace context, the future runs unwrapped.
1769 async fn run_inner(
1770 &self,
1771 input: &str,
1772 opts: ExecuteOptions,
1773 pipe_stdin: Option<crate::scheduler::PipeReader>,
1774 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1775 ) -> Result<ExecResult> {
1776 use opentelemetry::context::FutureExt;
1777
1778 // Capture the embedder's baggage before `opts` is consumed so it can be
1779 // echoed back onto the result on egress (see `merge_egress_baggage`).
1780 let embedder_baggage = opts.baggage.clone();
1781
1782 let result = match crate::telemetry::extract_parent(&opts) {
1783 Some(parent) => self
1784 .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1785 .with_context(parent)
1786 .await,
1787 None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1788 };
1789
1790 result.map(|mut r| {
1791 crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1792 r
1793 })
1794 }
1795
1796 /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1797 /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1798 /// cwd override, and timeout race.
1799 #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1800 async fn execute_with_options_inner(
1801 &self,
1802 input: &str,
1803 opts: ExecuteOptions,
1804 pipe_stdin: Option<crate::scheduler::PipeReader>,
1805 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1806 ) -> Result<ExecResult> {
1807 let _guard = self.acquire_execute_lock().await;
1808
1809 // Always reset to a fresh internal token; this is the kernel's own
1810 // cancel surface for embedders calling `Kernel::cancel()`. The
1811 // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1812 // is NOT written into `self.cancel_token`, because doing so would
1813 // (a) leak the embedder's token past this call's lifetime,
1814 // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1815 // (c) extend the token's lifetime via the kernel's strong clone.
1816 let internal = self.reset_cancel();
1817 // Race the embedder token against the kernel's internal token via a
1818 // tracked watcher task. We hold the JoinHandle so we can abort the
1819 // task at function exit — otherwise it would wait forever for either
1820 // token to fire and leak per call.
1821 let (effective_cancel, watcher_handle): (
1822 tokio_util::sync::CancellationToken,
1823 Option<tokio::task::JoinHandle<()>>,
1824 ) = if let Some(ext) = opts.cancel_token {
1825 let combined = tokio_util::sync::CancellationToken::new();
1826 let combined_writer = combined.clone();
1827 let i = internal.clone();
1828 let handle = tokio::spawn(async move {
1829 tokio::select! {
1830 _ = i.cancelled() => combined_writer.cancel(),
1831 _ = ext.cancelled() => combined_writer.cancel(),
1832 }
1833 });
1834 (combined, Some(handle))
1835 } else {
1836 (internal, None)
1837 };
1838
1839 // Effective timeout: per-call wins over kernel-config default.
1840 let timeout = opts.timeout.or(self.request_timeout);
1841
1842 // ZERO timeout: return 124 immediately without spawning anything.
1843 if timeout == Some(Duration::ZERO) {
1844 if let Some(h) = watcher_handle {
1845 h.abort();
1846 }
1847 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1848 }
1849
1850 // Apply per-call vars overlay (push frame + set_exported), wrapped in
1851 // an RAII guard so a panic inside `execute_streaming_inner` still
1852 // pops the frame and unexports the temporarily-exported names.
1853 struct VarsFrameGuard<'a> {
1854 kernel: &'a Kernel,
1855 newly_exported: Vec<String>,
1856 }
1857 impl Drop for VarsFrameGuard<'_> {
1858 fn drop(&mut self) {
1859 // Best-effort cleanup using try_write. The execute_lock held
1860 // throughout execute_with_options means there is no concurrent
1861 // foreground caller; forks have their own scope and won't
1862 // block this. blocking_write would deadlock the runtime when
1863 // called from a tokio worker thread, so we explicitly do NOT
1864 // fall back to it — if try_write fails (which we've never
1865 // seen in practice), log loudly and accept the leak rather
1866 // than deadlock the entire kernel.
1867 let Ok(mut scope) = self.kernel.scope.try_write() else {
1868 tracing::error!(
1869 "vars frame guard: scope lock unexpectedly busy; \
1870 skipping pop_frame to avoid runtime deadlock — \
1871 transient vars may leak"
1872 );
1873 return;
1874 };
1875 scope.pop_frame();
1876 for name in self.newly_exported.drain(..) {
1877 scope.unexport(&name);
1878 }
1879 }
1880 }
1881
1882 // Per-call cwd override: save current cwd, set the new one, restore
1883 // on Drop so the kernel's persistent cwd doesn't leak between calls.
1884 // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1885 struct CwdGuard<'a> {
1886 kernel: &'a Kernel,
1887 saved: PathBuf,
1888 }
1889 impl Drop for CwdGuard<'_> {
1890 fn drop(&mut self) {
1891 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1892 tracing::error!(
1893 "cwd guard: exec_ctx lock unexpectedly busy; \
1894 skipping cwd restore — kernel cwd may be wrong for next call"
1895 );
1896 return;
1897 };
1898 ec.cwd = std::mem::take(&mut self.saved);
1899 }
1900 }
1901 let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1902 let mut ec = self.exec_ctx.write().await;
1903 let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1904 drop(ec);
1905 Some(CwdGuard { kernel: self, saved })
1906 } else {
1907 None
1908 };
1909
1910 // Per-call stdin: seed the persistent exec_ctx so the first top-level
1911 // command that reads stdin consumes it (it's `take()`n at dispatch).
1912 // Restore the prior value on Drop — normally `None`, so this also drops
1913 // any residual seed an stdin-less program never consumed, keeping it
1914 // from bleeding into the next call. Same RAII pattern as CwdGuard.
1915 struct StdinGuard<'a> {
1916 kernel: &'a Kernel,
1917 saved: Option<String>,
1918 }
1919 impl Drop for StdinGuard<'_> {
1920 fn drop(&mut self) {
1921 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1922 tracing::error!(
1923 "stdin guard: exec_ctx lock unexpectedly busy; \
1924 skipping stdin restore — stale stdin may leak to next call"
1925 );
1926 return;
1927 };
1928 ec.stdin = self.saved.take();
1929 }
1930 }
1931 let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1932 let mut ec = self.exec_ctx.write().await;
1933 let saved = ec.stdin.replace(stdin);
1934 drop(ec);
1935 Some(StdinGuard { kernel: self, saved })
1936 } else {
1937 None
1938 };
1939
1940 // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1941 // persistent exec_ctx so the first stdin-reading command drains it (it's
1942 // `take()`n at pipeline build). The RAII guard restores the prior value
1943 // on Drop (normally `None`), so an unread reader doesn't bleed into the
1944 // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1945 struct PipeStdinGuard<'a> {
1946 kernel: &'a Kernel,
1947 saved: Option<crate::scheduler::PipeReader>,
1948 }
1949 impl Drop for PipeStdinGuard<'_> {
1950 fn drop(&mut self) {
1951 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1952 tracing::error!(
1953 "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1954 skipping restore — stale pipe stdin may leak to next call"
1955 );
1956 return;
1957 };
1958 ec.pipe_stdin = self.saved.take();
1959 }
1960 }
1961 let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
1962 let mut ec = self.exec_ctx.write().await;
1963 let saved = ec.pipe_stdin.replace(reader);
1964 drop(ec);
1965 Some(PipeStdinGuard { kernel: self, saved })
1966 } else {
1967 None
1968 };
1969
1970 let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
1971 let mut scope = self.scope.write().await;
1972 scope.push_frame();
1973 let mut newly = Vec::with_capacity(opts.vars.len());
1974 for (name, value) in opts.vars {
1975 if !scope.is_exported(&name) {
1976 newly.push(name.clone());
1977 }
1978 scope.set_exported(name, value);
1979 }
1980 drop(scope);
1981 Some(VarsFrameGuard { kernel: self, newly_exported: newly })
1982 } else {
1983 None
1984 };
1985
1986 // Sync the effective cancel into self.exec_ctx so try_execute_external
1987 // (which reads via self.cancel_token) sees cancellation. We also need
1988 // builtins to see it via ctx.cancel — handled in execute_command.
1989 // For simplicity here we mirror effective_cancel into self.cancel_token
1990 // for the duration of this call, then restore the internal token at
1991 // the end (so a later Kernel::cancel still hits our internal surface).
1992 {
1993 #[allow(clippy::expect_used)]
1994 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
1995 *cur = effective_cancel.clone();
1996 }
1997
1998 // Run the script under the movable-deadline watchdog (shared with the
1999 // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2000 // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2001 // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2002 // already handled by the early return above.
2003 let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2004 let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2005 Some(cb) => cb,
2006 None => &mut *noop_cb,
2007 };
2008
2009 let result = self
2010 .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2011 .await;
2012
2013 // Restore self.cancel_token to a fresh, uncancelled token so the
2014 // embedder's view of `Kernel::cancel()` stays predictable on the
2015 // next call (it cancels the kernel's own token, not whatever was
2016 // left over from this call's combined token).
2017 {
2018 #[allow(clippy::expect_used)]
2019 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2020 *cur = tokio_util::sync::CancellationToken::new();
2021 }
2022
2023 // Tear down the embedder-token race watcher (if any). Leaving it
2024 // alive would idle forever waiting for tokens that may never fire.
2025 if let Some(h) = watcher_handle {
2026 h.abort();
2027 }
2028
2029 // VarsFrameGuard drops here on the success path and on early-return
2030 // paths above (error path included). Panic safety preserved.
2031 result
2032 }
2033
2034 /// The actual body of `execute_streaming`, run while holding the execute lock.
2035 ///
2036 /// Split out so internal kernel paths that are already under the lock can
2037 /// call this without deadlocking on re-entry. External callers must go
2038 /// through [`Self::execute_streaming`] so they acquire the lock.
2039 async fn execute_streaming_inner(
2040 &self,
2041 input: &str,
2042 on_output: &mut (dyn FnMut(&ExecResult) + Send),
2043 ) -> Result<ExecResult> {
2044 let program = parse(input).map_err(|errors| {
2045 let msg = errors
2046 .iter()
2047 .map(|e| e.format(input))
2048 .collect::<Vec<_>>()
2049 .join("\n");
2050 anyhow::anyhow!("parse error:\n{}", msg)
2051 })?;
2052
2053 // AST display mode: show AST instead of executing
2054 {
2055 let scope = self.scope.read().await;
2056 if scope.show_ast() {
2057 let output = format!("{:#?}\n", program);
2058 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2059 }
2060 }
2061
2062 // Pre-execution validation. Most warnings stay trace-only (every
2063 // external command fires an `UndefinedCommand` warning), but a warning
2064 // whose code opts into agent surfacing is collected here and prepended
2065 // to the result's stderr at each return point below.
2066 let mut surfaced_warnings = String::new();
2067 if !self.skip_validation {
2068 let user_tools = self.user_tools.read().await;
2069 let validator = Validator::new(&self.tools, &user_tools);
2070 let issues = validator.validate(&program);
2071
2072 // Collect errors (warnings are logged but don't prevent execution)
2073 let errors: Vec<_> = issues
2074 .iter()
2075 .filter(|i| i.severity == Severity::Error)
2076 .collect();
2077
2078 if !errors.is_empty() {
2079 let error_msg = errors
2080 .iter()
2081 .map(|e| e.format(input))
2082 .collect::<Vec<_>>()
2083 .join("\n");
2084 return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2085 }
2086
2087 // Log warnings via tracing (trace level to avoid noise); surface the
2088 // opted-in ones to the agent so the guidance is actually seen.
2089 for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2090 tracing::trace!("validation: {}", warning.format(input));
2091 if warning.code.surfaces_to_agent() {
2092 surfaced_warnings.push_str(&warning.format(input));
2093 surfaced_warnings.push('\n');
2094 }
2095 }
2096 }
2097
2098 // Surface opted-in validation warnings to the streaming frontend once,
2099 // before any command output. The streaming consumer (`-c`, REPL) prints
2100 // per `on_output` and ignores the returned aggregate err; non-streaming
2101 // callers (`kernel.execute`) use a noop callback and read the aggregate
2102 // `result.err` (prepended at each return below). The two paths are
2103 // disjoint, so this prints the advisory exactly once on each.
2104 if !surfaced_warnings.is_empty() {
2105 let mut advisory = ExecResult::success("");
2106 advisory.err = surfaced_warnings.clone();
2107 on_output(&advisory);
2108 }
2109
2110 let mut result = ExecResult::success("");
2111
2112 // Reset cancellation token for this execution.
2113 let cancel = self.reset_cancel();
2114
2115 for stmt in program.statements {
2116 if matches!(stmt, Stmt::Empty) {
2117 continue;
2118 }
2119
2120 // Cancellation checkpoint
2121 if cancel.is_cancelled() {
2122 result.code = 130;
2123 return Ok(result);
2124 }
2125
2126 let flow = self.execute_stmt_flow(&stmt).await?;
2127
2128 // Drain any stderr written by pipeline stages during this statement.
2129 // This captures stderr from intermediate pipeline stages that would
2130 // otherwise be lost (only the last stage's result is returned).
2131 let drained_stderr = {
2132 let mut receiver = self.stderr_receiver.lock().await;
2133 receiver.drain_lossy()
2134 };
2135
2136 match flow {
2137 ControlFlow::Normal(mut r) => {
2138 if !drained_stderr.is_empty() {
2139 if !r.err.is_empty() && !r.err.ends_with('\n') {
2140 r.err.push('\n');
2141 }
2142 // Prepend pipeline stderr before the last stage's stderr
2143 let combined = format!("{}{}", drained_stderr, r.err);
2144 r.err = combined;
2145 }
2146 on_output(&r);
2147 // Carry the last statement's structured output for MCP TOON encoding.
2148 // Must be done here (not in accumulate_result) because accumulate_result
2149 // is also used in loops where per-iteration output would be wrong.
2150 let last_output = r.output().cloned();
2151 accumulate_result(&mut result, &r);
2152 result.set_output(last_output);
2153 }
2154 ControlFlow::Exit { code } => {
2155 if !drained_stderr.is_empty() {
2156 result.err.push_str(&drained_stderr);
2157 }
2158 result.code = code;
2159 if !surfaced_warnings.is_empty() {
2160 result.err = format!("{surfaced_warnings}{}", result.err);
2161 }
2162 return Ok(result);
2163 }
2164 ControlFlow::Return { mut value } => {
2165 if !drained_stderr.is_empty() {
2166 value.err = format!("{}{}", drained_stderr, value.err);
2167 }
2168 on_output(&value);
2169 // A top-level `return` stops the script, like `exit` —
2170 // it must not discard prior statements' accumulated
2171 // output nor let execution continue past it.
2172 accumulate_result(&mut result, &value);
2173 if !surfaced_warnings.is_empty() {
2174 result.err = format!("{surfaced_warnings}{}", result.err);
2175 }
2176 return Ok(result);
2177 }
2178 ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2179 if !drained_stderr.is_empty() {
2180 r.err = format!("{}{}", drained_stderr, r.err);
2181 }
2182 on_output(&r);
2183 accumulate_result(&mut result, &r);
2184 }
2185 }
2186 }
2187
2188 if !surfaced_warnings.is_empty() {
2189 result.err = format!("{surfaced_warnings}{}", result.err);
2190 }
2191 Ok(result)
2192 }
2193
2194 /// Execute a single statement, returning control flow information.
2195 fn execute_stmt_flow<'a>(
2196 &'a self,
2197 stmt: &'a Stmt,
2198 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2199 // No per-statement span here: `execute_stmt_flow` is the largest future
2200 // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2201 // the span's state through every `.await` at every level, costing native
2202 // stack per level (GH #48). Coarser spans on the outer execute entries
2203 // remain. See item 3 of the #48 burndown.
2204 Box::pin(async move {
2205 match stmt {
2206 Stmt::Assignment(assign) => {
2207 // Use async evaluator to support command substitution
2208 let value = self.eval_expr_async(&assign.value).await
2209 .context("failed to evaluate assignment")?;
2210 let mut scope = self.scope.write().await;
2211 if assign.path.segments.len() == 1 {
2212 // Plain `NAME=value` — no subscript, so `local` applies.
2213 if assign.local {
2214 // local: set in innermost (current function) frame
2215 scope.set(assign.name(), value.clone());
2216 } else {
2217 // non-local: update existing or create in root frame
2218 scope.set_global(assign.name(), value.clone());
2219 }
2220 } else {
2221 // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2222 // mutates the existing root wherever it lives — `local` is
2223 // meaningless here (see docs/arrays-and-hashes.md, "Assignment
2224 // lvalues").
2225 scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2226 PathError::UndefinedRoot(name) => anyhow::anyhow!(
2227 "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2228 ),
2229 PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2230 })?;
2231 }
2232 drop(scope);
2233
2234 // Assignments don't produce output (like sh)
2235 Ok(ControlFlow::ok(ExecResult::success("")))
2236 }
2237 Stmt::Command(cmd) => {
2238 // Route single commands through execute_pipeline for a unified path.
2239 // This ensures all commands go through the dispatcher chain.
2240 let pipeline = crate::ast::Pipeline {
2241 commands: vec![cmd.clone()],
2242 background: false,
2243 };
2244 let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2245 self.update_last_result(&result).await;
2246
2247 // Check for error exit mode (set -e)
2248 if !result.ok() {
2249 let scope = self.scope.read().await;
2250 if scope.error_exit_enabled() {
2251 return Ok(ControlFlow::exit_code(result.code));
2252 }
2253 }
2254
2255 Ok(ControlFlow::ok(result))
2256 }
2257 Stmt::Pipeline(pipeline) => {
2258 let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2259 self.update_last_result(&result).await;
2260
2261 // Check for error exit mode (set -e)
2262 if !result.ok() {
2263 let scope = self.scope.read().await;
2264 if scope.error_exit_enabled() {
2265 return Ok(ControlFlow::exit_code(result.code));
2266 }
2267 }
2268
2269 Ok(ControlFlow::ok(result))
2270 }
2271 Stmt::If(if_stmt) => {
2272 // Use async evaluator to support command substitution in conditions
2273 let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2274
2275 let branch = if is_truthy(&cond_value) {
2276 &if_stmt.then_branch
2277 } else {
2278 if_stmt.else_branch.as_deref().unwrap_or(&[])
2279 };
2280
2281 let mut result = ExecResult::success("");
2282 for stmt in branch {
2283 let flow = self.execute_stmt_flow(stmt).await?;
2284 match flow {
2285 ControlFlow::Normal(r) => {
2286 accumulate_result(&mut result, &r);
2287 self.drain_stderr_into(&mut result).await;
2288 }
2289 other => {
2290 self.drain_stderr_into(&mut result).await;
2291 return Ok(other);
2292 }
2293 }
2294 }
2295 Ok(ControlFlow::ok(result))
2296 }
2297 Stmt::For(for_loop) => {
2298 // Evaluate all items and collect values for iteration
2299 // Use async evaluator to support command substitution like $(seq 1 5)
2300 let mut items: Vec<Value> = Vec::new();
2301 for item_expr in &for_loop.items {
2302 // Glob expansion in for-loop items: `for f in *.txt`
2303 if let Expr::GlobPattern(pattern) = item_expr {
2304 let glob_enabled = {
2305 let scope = self.scope.read().await;
2306 scope.glob_enabled()
2307 };
2308 if glob_enabled {
2309 let (paths, cwd) = {
2310 let ctx = self.exec_ctx.read().await;
2311 let paths = ctx.expand_glob(pattern).await
2312 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2313 let cwd = ctx.resolve_path(".");
2314 (paths, cwd)
2315 };
2316 if paths.is_empty() {
2317 return Err(anyhow::anyhow!("no matches: {}", pattern));
2318 }
2319 for path in paths {
2320 let display = if !pattern.starts_with('/') {
2321 path.strip_prefix(&cwd)
2322 .unwrap_or(&path)
2323 .to_string_lossy().into_owned()
2324 } else {
2325 path.to_string_lossy().into_owned()
2326 };
2327 items.push(Value::String(display));
2328 }
2329 continue;
2330 }
2331 }
2332 // Track whether this item came from $(cmd); that's the
2333 // only position where multi-line stdout auto-splits per
2334 // line. Arrays still spread element-by-element; bare
2335 // $VAR is rejected upstream by validator E012. See
2336 // docs/LANGUAGE.md.
2337 let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2338 let item = self.eval_expr_async(item_expr).await?;
2339 match item {
2340 // JSON arrays iterate over elements (preferred path
2341 // when builtins emit .data — seq, jq, cut, find, …)
2342 Value::Json(serde_json::Value::Array(arr)) => {
2343 for elem in arr {
2344 // Envelope-free: an element that happens to be
2345 // envelope-shaped (e.g. from `fromjson`) is
2346 // external data, not an internal bytes round-trip,
2347 // so it must NOT be re-decoded to Value::Bytes.
2348 items.push(json_to_value_no_envelope(elem));
2349 }
2350 }
2351 // Strings from $(cmd): empty → 0 iterations,
2352 // multi-line → split per line (trimming trailing
2353 // newlines and per-line trailing \r), single-line
2354 // → one iteration. Whitespace within a line is
2355 // NOT split — the "$VAR with spaces just works"
2356 // promise is preserved because this only fires
2357 // in CommandSubst position.
2358 Value::String(s) if from_command_subst => {
2359 let trimmed = s.trim_end_matches(['\n', '\r']);
2360 if trimmed.is_empty() {
2361 continue;
2362 }
2363 if trimmed.contains('\n') {
2364 for line in trimmed.split('\n') {
2365 let line = line.trim_end_matches('\r');
2366 items.push(Value::String(line.to_string()));
2367 }
2368 } else {
2369 items.push(Value::String(trimmed.to_string()));
2370 }
2371 }
2372 // Binary isn't iterable — fail loud rather than loop
2373 // once over an opaque byte blob.
2374 Value::Bytes(_) => {
2375 anyhow::bail!(
2376 "for: cannot iterate over binary data — decode it \
2377 (base64/xxd) first"
2378 );
2379 }
2380 // Strings not from $(cmd) stay as one value.
2381 other => items.push(other),
2382 }
2383 }
2384
2385 let mut result = ExecResult::success("");
2386 {
2387 let mut scope = self.scope.write().await;
2388 scope.push_frame();
2389 }
2390
2391 'outer: for item in items {
2392 // Cancellation checkpoint per iteration
2393 if self.is_cancelled() {
2394 let mut scope = self.scope.write().await;
2395 scope.pop_frame();
2396 result.code = 130;
2397 return Ok(ControlFlow::ok(result));
2398 }
2399 {
2400 let mut scope = self.scope.write().await;
2401 scope.set(&for_loop.variable, item);
2402 }
2403 for stmt in &for_loop.body {
2404 let mut flow = match self.execute_stmt_flow(stmt).await {
2405 Ok(f) => f,
2406 Err(e) => {
2407 let mut scope = self.scope.write().await;
2408 scope.pop_frame();
2409 return Err(e);
2410 }
2411 };
2412 self.drain_stderr_into(&mut result).await;
2413 match &mut flow {
2414 ControlFlow::Normal(r) => {
2415 accumulate_result(&mut result, r);
2416 if !r.ok() {
2417 let scope = self.scope.read().await;
2418 if scope.error_exit_enabled() {
2419 drop(scope);
2420 let mut scope = self.scope.write().await;
2421 scope.pop_frame();
2422 return Ok(ControlFlow::exit_code(r.code));
2423 }
2424 }
2425 }
2426 ControlFlow::Break { .. } => {
2427 if flow.decrement_level() {
2428 accumulate_flow_output(&mut result, &flow);
2429 break 'outer;
2430 }
2431 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2432 let mut scope = self.scope.write().await;
2433 scope.pop_frame();
2434 return Ok(flow);
2435 }
2436 ControlFlow::Continue { .. } => {
2437 if flow.decrement_level() {
2438 accumulate_flow_output(&mut result, &flow);
2439 continue 'outer;
2440 }
2441 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2442 let mut scope = self.scope.write().await;
2443 scope.pop_frame();
2444 return Ok(flow);
2445 }
2446 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2447 let mut scope = self.scope.write().await;
2448 scope.pop_frame();
2449 return Ok(flow);
2450 }
2451 }
2452 }
2453 }
2454
2455 {
2456 let mut scope = self.scope.write().await;
2457 scope.pop_frame();
2458 }
2459 Ok(ControlFlow::ok(result))
2460 }
2461 Stmt::While(while_loop) => {
2462 let mut result = ExecResult::success("");
2463
2464 'outer: loop {
2465 // Evaluate condition - use async to support command substitution
2466 // Cancellation checkpoint per iteration
2467 if self.is_cancelled() {
2468 result.code = 130;
2469 return Ok(ControlFlow::ok(result));
2470 }
2471
2472 let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2473
2474 if !is_truthy(&cond_value) {
2475 break;
2476 }
2477
2478 // Execute body
2479 for stmt in &while_loop.body {
2480 let mut flow = self.execute_stmt_flow(stmt).await?;
2481 self.drain_stderr_into(&mut result).await;
2482 match &mut flow {
2483 ControlFlow::Normal(r) => {
2484 accumulate_result(&mut result, r);
2485 if !r.ok() {
2486 let scope = self.scope.read().await;
2487 if scope.error_exit_enabled() {
2488 return Ok(ControlFlow::exit_code(r.code));
2489 }
2490 }
2491 }
2492 ControlFlow::Break { .. } => {
2493 if flow.decrement_level() {
2494 accumulate_flow_output(&mut result, &flow);
2495 break 'outer;
2496 }
2497 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2498 return Ok(flow);
2499 }
2500 ControlFlow::Continue { .. } => {
2501 if flow.decrement_level() {
2502 accumulate_flow_output(&mut result, &flow);
2503 continue 'outer;
2504 }
2505 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2506 return Ok(flow);
2507 }
2508 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2509 return Ok(flow);
2510 }
2511 }
2512 }
2513 }
2514
2515 Ok(ControlFlow::ok(result))
2516 }
2517 Stmt::Case(case_stmt) => {
2518 // Evaluate the expression to match against. Text sink: a
2519 // `case $bin in ...)` pattern match on binary goes loud
2520 // rather than glob-matching against the `[binary: N bytes]`
2521 // placeholder (Decision E — same class as `==`/`in`).
2522 let match_value = {
2523 let value = self.eval_expr_async(&case_stmt.expr).await?;
2524 value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2525 };
2526
2527 // Try each branch until we find a match
2528 for branch in &case_stmt.branches {
2529 let matched = branch.patterns.iter().any(|pattern| {
2530 glob_match(pattern, &match_value)
2531 });
2532
2533 if matched {
2534 // Execute the branch body
2535 let mut result = ExecResult::success("");
2536 for stmt in &branch.body {
2537 let flow = self.execute_stmt_flow(stmt).await?;
2538 match flow {
2539 ControlFlow::Normal(r) => {
2540 accumulate_result(&mut result, &r);
2541 self.drain_stderr_into(&mut result).await;
2542 }
2543 other => {
2544 self.drain_stderr_into(&mut result).await;
2545 return Ok(other);
2546 }
2547 }
2548 }
2549 return Ok(ControlFlow::ok(result));
2550 }
2551 }
2552
2553 // No match - return success with empty output (like sh)
2554 Ok(ControlFlow::ok(ExecResult::success("")))
2555 }
2556 Stmt::Break(levels) => {
2557 Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2558 }
2559 Stmt::Continue(levels) => {
2560 Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2561 }
2562 Stmt::Return(expr) => {
2563 // return [N] - N becomes the exit code, NOT stdout
2564 // Shell semantics: return sets exit code, doesn't produce output
2565 let result = if let Some(e) = expr {
2566 let val = self.eval_expr_async(e).await?;
2567 let code = crate::interpreter::value_to_exit_code(&val)
2568 .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2569 ExecResult::from_parts(code, String::new(), String::new(), None)
2570 } else {
2571 ExecResult::success("")
2572 };
2573 Ok(ControlFlow::return_value(result))
2574 }
2575 Stmt::Exit(expr) => {
2576 let code = if let Some(e) = expr {
2577 let val = self.eval_expr_async(e).await?;
2578 crate::interpreter::value_to_exit_code(&val)
2579 .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2580 } else {
2581 0
2582 };
2583 Ok(ControlFlow::exit_code(code))
2584 }
2585 Stmt::ToolDef(tool_def) => {
2586 let mut user_tools = self.user_tools.write().await;
2587 user_tools.insert(tool_def.name.clone(), tool_def.clone());
2588 Ok(ControlFlow::ok(ExecResult::success("")))
2589 }
2590 Stmt::AndChain { left, right } => {
2591 // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2592 // Suppress errexit for the left side — && handles failure itself.
2593 {
2594 let mut scope = self.scope.write().await;
2595 scope.suppress_errexit();
2596 }
2597 let left_flow = match self.execute_stmt_flow(left).await {
2598 Ok(f) => f,
2599 Err(e) => {
2600 let mut scope = self.scope.write().await;
2601 scope.unsuppress_errexit();
2602 return Err(e);
2603 }
2604 };
2605 {
2606 let mut scope = self.scope.write().await;
2607 scope.unsuppress_errexit();
2608 }
2609 match left_flow {
2610 ControlFlow::Normal(mut left_result) => {
2611 self.drain_stderr_into(&mut left_result).await;
2612 self.update_last_result(&left_result).await;
2613 if left_result.ok() {
2614 let right_flow = self.execute_stmt_flow(right).await?;
2615 match right_flow {
2616 ControlFlow::Normal(mut right_result) => {
2617 self.drain_stderr_into(&mut right_result).await;
2618 self.update_last_result(&right_result).await;
2619 let mut combined = left_result;
2620 accumulate_result(&mut combined, &right_result);
2621 Ok(ControlFlow::ok(combined))
2622 }
2623 other => Ok(other),
2624 }
2625 } else {
2626 Ok(ControlFlow::ok(left_result))
2627 }
2628 }
2629 _ => Ok(left_flow),
2630 }
2631 }
2632 Stmt::OrChain { left, right } => {
2633 // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2634 // Suppress errexit for the left side — || handles failure itself.
2635 {
2636 let mut scope = self.scope.write().await;
2637 scope.suppress_errexit();
2638 }
2639 let left_flow = match self.execute_stmt_flow(left).await {
2640 Ok(f) => f,
2641 Err(e) => {
2642 let mut scope = self.scope.write().await;
2643 scope.unsuppress_errexit();
2644 return Err(e);
2645 }
2646 };
2647 {
2648 let mut scope = self.scope.write().await;
2649 scope.unsuppress_errexit();
2650 }
2651 match left_flow {
2652 ControlFlow::Normal(mut left_result) => {
2653 self.drain_stderr_into(&mut left_result).await;
2654 self.update_last_result(&left_result).await;
2655 if !left_result.ok() {
2656 let right_flow = self.execute_stmt_flow(right).await?;
2657 match right_flow {
2658 ControlFlow::Normal(mut right_result) => {
2659 self.drain_stderr_into(&mut right_result).await;
2660 self.update_last_result(&right_result).await;
2661 let mut combined = left_result;
2662 accumulate_result(&mut combined, &right_result);
2663 Ok(ControlFlow::ok(combined))
2664 }
2665 other => Ok(other),
2666 }
2667 } else {
2668 Ok(ControlFlow::ok(left_result))
2669 }
2670 }
2671 _ => Ok(left_flow), // Propagate non-normal flow
2672 }
2673 }
2674 Stmt::Test(test_expr) => {
2675 let is_true = self.eval_test_async(test_expr).await?;
2676 let result = if is_true {
2677 ExecResult::success("")
2678 } else {
2679 ExecResult::failure(1, "")
2680 };
2681 // A bare test writes `$?` and honors `set -e` like any command
2682 // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2683 // safe: the chain arms suppress errexit around their left side,
2684 // and `if`/`while` conditions evaluate as expressions, never
2685 // through this statement arm.
2686 self.update_last_result(&result).await;
2687 if !result.ok() {
2688 let scope = self.scope.read().await;
2689 if scope.error_exit_enabled() {
2690 return Ok(ControlFlow::exit_code(result.code));
2691 }
2692 }
2693 Ok(ControlFlow::ok(result))
2694 }
2695 Stmt::EnvScoped { assignments, body } => {
2696 // Inline env prefix (`NAME=value ... command`): apply the
2697 // assignments as EXPORTED vars in a fresh frame so the command
2698 // — and its subprocess environment — sees them, then unwind so
2699 // they do NOT persist (bash-style command-scoped env). Values
2700 // evaluate left-to-right with earlier ones already in scope, so
2701 // `A=1 B=$A cmd` works.
2702 {
2703 let mut scope = self.scope.write().await;
2704 scope.push_frame();
2705 }
2706 let mut prior_export: Vec<(String, bool)> =
2707 Vec::with_capacity(assignments.len());
2708 let mut setup_err: Option<anyhow::Error> = None;
2709 for assign in assignments {
2710 match self.eval_expr_async(&assign.value).await {
2711 Ok(value) => {
2712 let mut scope = self.scope.write().await;
2713 prior_export
2714 .push((assign.name().to_string(), scope.is_exported(assign.name())));
2715 scope.set_exported(assign.name(), value);
2716 }
2717 Err(e) => {
2718 setup_err = Some(e);
2719 break;
2720 }
2721 }
2722 }
2723
2724 let flow = if setup_err.is_none() {
2725 self.execute_stmt_flow(body).await
2726 } else {
2727 Ok(ControlFlow::ok(ExecResult::success("")))
2728 };
2729
2730 // Unwind the env frame and restore export marks unconditionally
2731 // (names that were not exported before must not stay exported).
2732 {
2733 let mut scope = self.scope.write().await;
2734 scope.pop_frame();
2735 for (name, was_exported) in &prior_export {
2736 if !*was_exported {
2737 scope.unexport(name);
2738 }
2739 }
2740 }
2741
2742 match setup_err {
2743 Some(e) => Err(e),
2744 None => flow,
2745 }
2746 }
2747 Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2748 }
2749 })
2750 }
2751
2752 /// Build a boxed per-command `ExecContext` snapshot from the persistent
2753 /// kernel state (`ec`/`scope`, both already locked by the caller).
2754 ///
2755 /// Sync on purpose: the ~30 field clones live in this transient frame rather
2756 /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2757 /// — not the 960-byte struct — rides the dispatch await at every recursion
2758 /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2759 /// per-site differences (the pipeline runner uses the kernel's own cancel
2760 /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2761 /// they're parameters; every other field is snapshotted identically.
2762 fn snapshot_exec_ctx(
2763 &self,
2764 ec: &ExecContext,
2765 scope: &Scope,
2766 pipeline_position: PipelinePosition,
2767 cancel: tokio_util::sync::CancellationToken,
2768 ) -> Box<ExecContext> {
2769 Box::new(ExecContext {
2770 backend: ec.backend.clone(),
2771 scope: scope.clone(),
2772 cwd: ec.cwd.clone(),
2773 prev_cwd: ec.prev_cwd.clone(),
2774 stdin: ec.stdin.clone(),
2775 stdin_data: ec.stdin_data.clone(),
2776 stdin_data_rx: None,
2777 pipe_stdin: None,
2778 pipe_stdout: None,
2779 stderr: ec.stderr.clone(),
2780 tool_schemas: ec.tool_schemas.clone(),
2781 tools: ec.tools.clone(),
2782 job_manager: ec.job_manager.clone(),
2783 pipeline_position,
2784 interactive: self.interactive,
2785 aliases: ec.aliases.clone(),
2786 ignore_config: ec.ignore_config.clone(),
2787 output_limit: ec.output_limit.clone(),
2788 allow_external_commands: self.allow_external_commands,
2789 nonce_store: ec.nonce_store.clone(),
2790 trash_backend: ec.trash_backend.clone(),
2791 #[cfg(all(unix, feature = "subprocess"))]
2792 terminal_state: ec.terminal_state.clone(),
2793 dispatcher: self.dispatcher(),
2794 cancel,
2795 output_format: None,
2796 current_invocation: None,
2797 vfs_budget: self.vfs_budget.clone(),
2798 watchdog: ec.watchdog.clone(),
2799 #[cfg(all(feature = "localfs", feature = "overlay"))]
2800 overlay_handle: self.overlay_handle.clone(),
2801 })
2802 }
2803
2804 /// Execute a pipeline.
2805 async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2806 if pipeline.commands.is_empty() {
2807 return Ok(ExecResult::success(""));
2808 }
2809
2810 // Handle background execution (`&` operator)
2811 if pipeline.background {
2812 return self.execute_background(pipeline).await;
2813 }
2814
2815 // All commands go through the runner with the Kernel as dispatcher.
2816 // This is the single execution path — no fast path for single commands.
2817 //
2818 // IMPORTANT: We snapshot exec_ctx into a local context and release the
2819 // lock before running. This prevents deadlocks when dispatch_command
2820 // is called from within the pipeline and recursively triggers another
2821 // pipeline (e.g., via user-defined tools).
2822 let (mut ctx, has_pipe_stdin) = {
2823 let ec = self.exec_ctx.read().await;
2824 let scope = self.scope.read().await;
2825 // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2826 // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2827 // the consume-once block below, so note its presence here.
2828 let has_pipe_stdin = ec.pipe_stdin.is_some();
2829 // The pipeline runner drives stage 0 with the first stage's stdin
2830 // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2831 // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2832 // and uses the kernel's own cancel token so a `cancel()` reaches the
2833 // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2834 let cancel = {
2835 #[allow(clippy::expect_used)]
2836 let token = self.cancel_token.lock().expect("cancel_token poisoned");
2837 token.clone()
2838 };
2839 (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2840 }; // locks released
2841
2842 // Consume-once: move/clear the seeded stdin sources from the persistent
2843 // exec_ctx now that this pipeline's ctx owns them, so a later statement
2844 // in the same call (`cat ; cat`) does not re-receive them — matching
2845 // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2846 // (the ctx above was built with `pipe_stdin: None`).
2847 if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2848 let mut ec = self.exec_ctx.write().await;
2849 ctx.pipe_stdin = ec.pipe_stdin.take();
2850 ec.stdin = None;
2851 ec.stdin_data = None;
2852 }
2853
2854 let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2855
2856 // Post-hoc spill check (catches builtins and fast external commands)
2857 if ctx.output_limit.is_enabled() {
2858 let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2859 }
2860
2861 // Signal spill with exit 3; agent reads the spill file directly
2862 // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2863 if result.did_spill {
2864 result.original_code = Some(result.code);
2865 result.code = 3;
2866 }
2867
2868 // Sync changes back from context
2869 {
2870 let mut ec = self.exec_ctx.write().await;
2871 ec.cwd = ctx.cwd.clone();
2872 ec.prev_cwd = ctx.prev_cwd.clone();
2873 ec.aliases = ctx.aliases.clone();
2874 ec.ignore_config = ctx.ignore_config.clone();
2875 ec.output_limit = ctx.output_limit.clone();
2876 }
2877 {
2878 let mut scope = self.scope.write().await;
2879 *scope = ctx.scope.clone();
2880 }
2881
2882 Ok(result)
2883 }
2884
2885 /// Execute a pipeline in the background.
2886 ///
2887 /// The command is spawned as a tokio task, registered with the JobManager,
2888 /// and its output is captured via BoundedStreams. The job is observable via
2889 /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2890 ///
2891 /// Returns immediately with a job ID like "[1]".
2892 #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2893 async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2894 use tokio::sync::oneshot;
2895
2896 // Format the command for display in /v/jobs/{id}/command
2897 let command_str = self.format_pipeline(pipeline);
2898
2899 // Create bounded streams for output capture
2900 let stdout = Arc::new(BoundedStream::default_size());
2901 let stderr = Arc::new(BoundedStream::default_size());
2902
2903 // Create channel for result notification
2904 let (tx, rx) = oneshot::channel();
2905
2906 // Register with JobManager to get job ID and create VFS entries
2907 let job_id = self.jobs.register_with_streams(
2908 command_str.clone(),
2909 rx,
2910 stdout.clone(),
2911 stderr.clone(),
2912 ).await;
2913
2914 // Fork the kernel for this background job. The fork snapshots the
2915 // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2916 // while sharing the job manager, VFS, and tool registry. The fork's
2917 // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2918 // is available here — something BackendDispatcher couldn't provide.
2919 //
2920 // The fork gets its own cancellation token (recorded on the job so
2921 // `kill %N` can stop the job — including a pure-builtin job with no OS
2922 // process group) and is stamped with the job id so any external
2923 // command it spawns records its process group for `kill -<sig> %N`.
2924 let cancel = tokio_util::sync::CancellationToken::new();
2925 self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2926 let fork = self.fork_for_background(cancel, job_id).await;
2927 let runner = self.runner.clone();
2928 let commands = pipeline.commands.clone();
2929
2930 // Snapshot the fork's exec_ctx for the spawned task. We have to do
2931 // this before tokio::spawn because the fork's exec_ctx is behind a
2932 // tokio RwLock and we want the spawned task to own its ctx.
2933 let mut bg_ctx = {
2934 let ec = fork.exec_ctx.read().await;
2935 ec.child_for_pipeline()
2936 };
2937 bg_ctx.scope = fork.scope.read().await.clone();
2938 // The fork's dispatcher points at the fork itself; set it here so
2939 // builtins inside the background task (e.g. timeout) re-dispatch
2940 // through the fork, not the parent.
2941 bg_ctx.dispatcher = fork.dispatcher();
2942
2943 // Spawn the background task. Propagate the embedder's trace context
2944 // across the spawn boundary so the job's spans stay in the same trace.
2945 tokio::spawn(crate::telemetry::bind_current_context(async move {
2946 // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2947 // gives us that (Kernel implements CommandDispatcher).
2948 let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2949
2950 // Write output to streams
2951 let text = result.text_out();
2952 if !text.is_empty() {
2953 stdout.write(text.as_bytes()).await;
2954 }
2955 if !result.err.is_empty() {
2956 stderr.write(result.err.as_bytes()).await;
2957 }
2958
2959 // Close streams
2960 stdout.close().await;
2961 stderr.close().await;
2962
2963 // Send result to JobManager (ignore error if receiver dropped)
2964 let _ = tx.send(result);
2965 }));
2966
2967 Ok(ExecResult::success(format!("[{}]", job_id)))
2968 }
2969
2970 /// Format a pipeline as a command string for display.
2971 fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
2972 pipeline.commands
2973 .iter()
2974 .map(|cmd| {
2975 let mut parts = vec![cmd.name.clone()];
2976 for arg in &cmd.args {
2977 match arg {
2978 Arg::Positional(expr) => {
2979 parts.push(self.format_expr(expr));
2980 }
2981 Arg::Named { key, value } => {
2982 parts.push(format!("--{}={}", key, self.format_expr(value)));
2983 }
2984 Arg::WordAssign { key, value } => {
2985 parts.push(format!("{}={}", key, self.format_expr(value)));
2986 }
2987 Arg::ShortFlag(name) => {
2988 parts.push(format!("-{}", name));
2989 }
2990 Arg::LongFlag(name) => {
2991 parts.push(format!("--{}", name));
2992 }
2993 Arg::DoubleDash => {
2994 parts.push("--".to_string());
2995 }
2996 }
2997 }
2998 parts.join(" ")
2999 })
3000 .collect::<Vec<_>>()
3001 .join(" | ")
3002 }
3003
3004 /// Format an expression as a string for display.
3005 fn format_expr(&self, expr: &Expr) -> String {
3006 match expr {
3007 Expr::Literal(Value::String(s)) => {
3008 if s.contains(' ') || s.contains('"') {
3009 format!("'{}'", s.replace('\'', "\\'"))
3010 } else {
3011 s.clone()
3012 }
3013 }
3014 Expr::Literal(Value::Int(i)) => i.to_string(),
3015 Expr::Literal(Value::Float(f)) => f.to_string(),
3016 Expr::Literal(Value::Bool(b)) => b.to_string(),
3017 Expr::Literal(Value::Null) => "null".to_string(),
3018 Expr::VarRef(path) => {
3019 let mut name = String::new();
3020 for (i, seg) in path.segments.iter().enumerate() {
3021 match seg {
3022 crate::ast::VarSegment::Field(f) => {
3023 if i > 0 {
3024 name.push('.');
3025 }
3026 name.push_str(f);
3027 }
3028 crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3029 crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3030 crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3031 crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3032 "[{}:{}]",
3033 a.map(|n| n.to_string()).unwrap_or_default(),
3034 b.map(|n| n.to_string()).unwrap_or_default()
3035 )),
3036 }
3037 }
3038 format!("${{{}}}", name)
3039 }
3040 Expr::Interpolated(_) => "\"...\"".to_string(),
3041 Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3042 _ => "...".to_string(),
3043 }
3044 }
3045
3046 /// Execute a single command.
3047 async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3048 self.execute_command_depth(name, args, 0).await
3049 }
3050
3051 async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3052 // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3053 // most-recursed function on the ring, so wrapping its future in
3054 // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3055 // every level (GH #48, item 3). A `trace!` event records the command name
3056 // without living in the future.
3057 tracing::trace!(command = %name, alias_depth, "dispatch");
3058 // Special built-ins. `SpecialForm::from_name` is the single source of
3059 // truth (shared with `classify_command` via `is_runtime_special_form`),
3060 // and this match on the enum is *exhaustive* — adding a special-form is a
3061 // compile error until both the name mapping and the behavior here are
3062 // updated. A name that is not a special-form falls through to alias /
3063 // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3064 if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3065 return match form {
3066 crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3067 crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3068 crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3069 };
3070 }
3071
3072 // Alias expansion (with recursion limit)
3073 if alias_depth < 10 {
3074 let alias_value = {
3075 let ctx = self.exec_ctx.read().await;
3076 ctx.aliases.get(name).cloned()
3077 };
3078 if let Some(alias_val) = alias_value {
3079 // Split alias value into command + args
3080 let parts: Vec<&str> = alias_val.split_whitespace().collect();
3081 if let Some((alias_cmd, alias_args)) = parts.split_first() {
3082 let mut new_args: Vec<Arg> = alias_args
3083 .iter()
3084 .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3085 .collect();
3086 new_args.extend_from_slice(args);
3087 return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3088 }
3089 }
3090 }
3091
3092 // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3093 if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3094 return match self.tools.get(builtin_name) {
3095 Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3096 None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3097 };
3098 }
3099
3100 // Check user-defined tools first
3101 {
3102 let user_tools = self.user_tools.read().await;
3103 if let Some(tool_def) = user_tools.get(name) {
3104 let tool_def = tool_def.clone();
3105 drop(user_tools);
3106 return Box::pin(self.execute_user_tool(tool_def, args)).await;
3107 }
3108 }
3109
3110 // Look up builtin tool
3111 let tool = match self.tools.get(name) {
3112 Some(t) => t,
3113 None => {
3114 // Try executing as .kai script from PATH
3115 if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3116 return Ok(result);
3117 }
3118 // Try executing as external command from PATH — boxed because its
3119 // future is the heaviest branch here (holds a `tokio::process::Command`,
3120 // argv, the child's stdio streams, and kill/reap drop guards); leaving
3121 // it inline fattens every `execute_command_depth` frame on the recursion
3122 // ring even when the command is a builtin.
3123 if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3124 return Ok(result);
3125 }
3126
3127 // Try backend-registered tools (embedder engines, etc.)
3128 // Look up tool schema for positional→named mapping.
3129 // Clone backend and drop read lock before awaiting (may involve network I/O).
3130 // Backend tools expect named JSON params, so enable positional mapping.
3131 let backend = self.exec_ctx.read().await.backend.clone();
3132 let tool_schema = backend
3133 .get_tool(name)
3134 .await
3135 .unwrap_or_else(|e| {
3136 // Schema lookup failing just means positionals won't
3137 // get name-mapped below — `call_tool` is still
3138 // attempted. Trace it so the degradation is visible
3139 // rather than silently swallowed.
3140 tracing::debug!("backend get_tool error for {name}: {e}");
3141 None
3142 })
3143 .map(|t| {
3144 let mut s = t.schema;
3145 // Flat backend/MCP tools expect named JSON params, so map
3146 // bare positionals onto named params. Subcommand-aware tools
3147 // route positionals through the subcommand path and declare
3148 // map_positionals per leaf (kj keeps it false so it re-parses
3149 // the argv with its own clap) — don't blanket-override them.
3150 if s.subcommands.is_empty() {
3151 s.map_positionals = true;
3152 }
3153 s
3154 });
3155 let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3156 let mut ctx = self.exec_ctx.write().await;
3157 {
3158 let scope = self.scope.read().await;
3159 ctx.scope = scope.clone();
3160 }
3161 let backend = ctx.backend.clone();
3162 match backend.call_tool(name, tool_args, &mut *ctx).await {
3163 Ok(tool_result) => {
3164 let mut scope = self.scope.write().await;
3165 *scope = ctx.scope.clone();
3166 // Preserve every field (data/content_type/baggage/latch,
3167 // not just stdout text) — this is the embedder seam:
3168 // `x=$(embedder_tool)` and structured iteration over
3169 // its result depend on `.data` surviving the crossing
3170 // back into the kernel.
3171 return Ok(ExecResult::from(tool_result));
3172 }
3173 Err(BackendError::ToolNotFound(_)) => {
3174 // The backend confirms no such tool exists — fall
3175 // through to "command not found" below.
3176 }
3177 Err(e) => {
3178 // The tool was found (dispatch reached real
3179 // execution) but running it failed — a genuine
3180 // execution error, not "command not found". Surface
3181 // it loudly instead of masking it as exit-127.
3182 return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3183 }
3184 }
3185
3186 return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3187 }
3188 };
3189
3190 // Build arguments (async to support command substitution, schema-aware for flag values)
3191 let schema = tool.schema();
3192 let tool_args = self.build_args_async(args, Some(&schema)).await?;
3193
3194 // --help / -h: show the generic whole-tool help, unless either the tool's
3195 // root schema claims that flag OR the tool owns its output. Owned-output
3196 // tools re-parse their own argv and route their own `--help` — including
3197 // leaf/subcommand help — through their internal (clap) parser, so the root
3198 // schema can't express "this leaf claims help" and intercepting here would
3199 // render top-level help and return before `execute()` ever sees the
3200 // request (#51). Pass it through and let the tool render its own help.
3201 let schema_claims = |flag: &str| -> bool {
3202 let bare = flag.trim_start_matches('-');
3203 schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3204 };
3205 let wants_help = !schema.owns_output
3206 && ((tool_args.flags.contains("help") && !schema_claims("help"))
3207 || (tool_args.flags.contains("h") && !schema_claims("-h")));
3208 if wants_help {
3209 let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3210 let ctx = self.exec_ctx.read().await;
3211 let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3212 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3213 }
3214
3215 // `owns_output` is the only thing read from `schema` after the recursive
3216 // `tool.execute` await below; capture the bool and drop the (heap-backed)
3217 // `ToolSchema` now so it doesn't ride that await in every command's frame
3218 // (GH #48, item 7).
3219 let owns_output = schema.owns_output;
3220 drop(schema);
3221
3222 // Snapshot exec_ctx into a local context and release the write lock
3223 // before calling tool.execute. Holding the write across tool execution
3224 // would deadlock any builtin that re-dispatches through ctx.dispatcher
3225 // (timeout, scatter) — the inner dispatch_command needs its own
3226 // exec_ctx.write() and would block forever.
3227 let mut ctx = {
3228 let ec = self.exec_ctx.write().await;
3229 let scope = self.scope.read().await;
3230 // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3231 // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3232 // child token — e.g. timeout's — reaches the spawned external via
3233 // wait_or_kill; it falls back to the kernel's own token on a
3234 // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3235 self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3236 }; // both locks released — tool.execute can re-dispatch safely
3237
3238 // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3239 // semantics): take() so a later dispatch doesn't see stale stdin.
3240 // Done after the snapshot above so we hold the write briefly.
3241 {
3242 let mut ec = self.exec_ctx.write().await;
3243 ctx.stdin = ec.stdin.take();
3244 ctx.stdin_data = ec.stdin_data.take();
3245 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3246 ctx.pipe_stdin = ec.pipe_stdin.take();
3247 ctx.pipe_stdout = ec.pipe_stdout.take();
3248 }
3249
3250 // Honor --json before the builtin runs so its setting survives a clap
3251 // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3252 // --json on the floor when `try_parse_from` returns Err early).
3253 // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3254 GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3255
3256 // Capture the exact invocation at the dispatch seam so a latch producer
3257 // (`latch_result`/`gate_overwrites`) can stamp it into the LatchRequest
3258 // for a precise `Kernel::confirm` replay — no re-parsing of the human
3259 // `hint`. `to_argv()` is computed before `tool_args` moves into execute.
3260 //
3261 // Captured unconditionally, NOT gated on `latch_enabled`: `kaish-trash
3262 // empty` gates every time (it's inherently destructive, independent of
3263 // `set -o latch`), so a `latch_enabled`-only gate would leave its
3264 // `tool`/`argv` empty and break `confirm`. The cost is a small argv
3265 // clone per command — marginal beside the per-command ExecContext
3266 // snapshot above — and it does NOT reintroduce the deep-`$()` stack
3267 // overflow (that was the inline `LatchRequest` in `ExecResult`, now
3268 // boxed; the capture's temporaries don't survive into the recursive
3269 // `tool.execute` below).
3270 ctx.current_invocation = Some(Box::new((name.to_string(), tool_args.to_argv())));
3271
3272 let result = tool.execute(tool_args, &mut *ctx).await;
3273
3274 // Sync mutations back. Tools may have changed scope (set/cd),
3275 // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3276 // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3277 // hands them back to the pipeline runner — the runner uses
3278 // stage_ctx.pipe_stdout to write the result to the next stage when
3279 // the tool itself didn't take and write to it.
3280 {
3281 let mut scope = self.scope.write().await;
3282 *scope = ctx.scope.clone();
3283 }
3284 {
3285 let mut ec = self.exec_ctx.write().await;
3286 ec.cwd = ctx.cwd;
3287 ec.prev_cwd = ctx.prev_cwd;
3288 ec.aliases = ctx.aliases;
3289 // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3290 // mutate the runtime output limit; without this sync the change is
3291 // dropped here and never reaches dispatch_command's read-back, so
3292 // it would not survive past the current statement.
3293 ec.output_limit = ctx.output_limit.clone();
3294 // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3295 // was missing from this sync, so every runtime ignore mutation
3296 // silently died at the end of its own statement — including the
3297 // documented `kaish-ignore add .gitignore` rc-file recipe.
3298 ec.ignore_config = ctx.ignore_config.clone();
3299 ec.pipe_stdin = ctx.pipe_stdin.take();
3300 ec.pipe_stdout = ctx.pipe_stdout.take();
3301 }
3302
3303 // Builtins parse --json via the GlobalFlags flatten in their clap
3304 // struct and write ctx.output_format. The kernel applies it — unless the
3305 // tool owns its own output (renders --json itself), in which case we
3306 // leave its bytes untouched.
3307 let result = finalize_output(result, ctx.output_format, owns_output);
3308
3309 Ok(result)
3310 }
3311
3312 /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3313 /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3314 /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3315 /// unexpanded rather than leaking the host home directory.
3316 async fn scope_home(&self) -> Option<String> {
3317 match self.scope.read().await.get("HOME") {
3318 Some(Value::String(s)) => Some(s.clone()),
3319 _ => None,
3320 }
3321 }
3322
3323 // (see `push_repeatable_value` below for the repeatable-flag accumulation.)
3324
3325 /// Pull `consumes` positional args after a non-bool flag and stash them
3326 /// on `tool_args.named` under the canonical param name.
3327 ///
3328 /// - `consumes == 1` (non-repeatable) keeps the historical contract: a
3329 /// single scalar value (last write wins on the rare duplicate).
3330 /// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
3331 /// inside `named[canonical] = Value::Json(Array(...))`, preserving
3332 /// invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
3333 /// a repeated single-value flag must keep every value, not silently drop
3334 /// all but the last (a "no silent corruption" violation).
3335 /// - `consumes > 1` accumulates each occurrence as an inner
3336 /// `serde_json::Value::Array` inside `named[canonical] =
3337 /// Value::Json(Array(...))`, preserving invocation order. This is the
3338 /// shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
3339 ///
3340 /// Errors loudly if the flag is missing required positionals — matches
3341 /// kaish's "no silent fallback" posture and mirrors real jq, which
3342 /// errors on `--arg NAME` with no value.
3343 #[allow(clippy::too_many_arguments)]
3344 async fn consume_flag_positionals(
3345 &self,
3346 args: &[Arg],
3347 flag_name: &str,
3348 canonical: &str,
3349 consumes: usize,
3350 repeatable: bool,
3351 positional_indices: &[usize],
3352 consumed: &mut std::collections::HashSet<usize>,
3353 current_idx: usize,
3354 tool_args: &mut ToolArgs,
3355 ) -> Result<()> {
3356 let home = self.scope_home().await;
3357 let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
3358 for _ in 0..consumes.max(1) {
3359 // A `key=value` (WordAssign) token is consumable only by a
3360 // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
3361 // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
3362 // filter` would reassemble `x=1` into the first slot and steal the
3363 // filter into the second. Multi-value flags take plain positionals.
3364 let allow_word_assign = consumes <= 1;
3365 let next_pos = positional_indices
3366 .iter()
3367 .find(|idx| {
3368 **idx > current_idx
3369 && !consumed.contains(idx)
3370 && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
3371 })
3372 .copied();
3373 match next_pos {
3374 Some(pos_idx) => match &args[pos_idx] {
3375 Arg::Positional(expr) => {
3376 let value = self.eval_expr_async(expr).await?;
3377 let value = apply_tilde_expansion(value, home.as_deref());
3378 collected.push(value);
3379 consumed.insert(pos_idx);
3380 }
3381 // `-v a=1`: reassemble the `key=value` token as the flag's
3382 // scalar value (see `positional_indices` construction).
3383 Arg::WordAssign { key, value } => {
3384 let val = self.eval_expr_async(value).await?;
3385 let val = apply_tilde_expansion(val, home.as_deref());
3386 // Loud on binary (GH #116): `-v a=$BIN` must not silently
3387 // reassemble the `[binary: N bytes]` placeholder into the
3388 // flag's value — same text-sink boundary as the primary
3389 // sinks fixed in #93 item 1.
3390 let val_str = crate::interpreter::value_to_text_sink_named(
3391 &val,
3392 "a key=value argument",
3393 )
3394 .map_err(|e| anyhow::anyhow!("{e}"))?;
3395 collected.push(Value::String(format!("{key}={val_str}")));
3396 consumed.insert(pos_idx);
3397 }
3398 _ => {}
3399 },
3400 None => {
3401 if consumes <= 1 && collected.is_empty() {
3402 // Back-compat: a flag with no follow-up positional
3403 // becomes a bare flag. `--path` with nothing after
3404 // lands in `flags`, same as before this refactor.
3405 tool_args.flags.insert(flag_name.to_string());
3406 return Ok(());
3407 }
3408 anyhow::bail!(
3409 "--{flag_name} requires {consumes} argument{}, got {}",
3410 if consumes == 1 { "" } else { "s" },
3411 collected.len()
3412 );
3413 }
3414 }
3415 }
3416
3417 if consumes <= 1 {
3418 if let Some(v) = collected.pop() {
3419 if repeatable {
3420 push_repeatable_value(tool_args, flag_name, canonical, v)?;
3421 } else {
3422 tool_args.named.insert(canonical.to_string(), v);
3423 }
3424 }
3425 return Ok(());
3426 }
3427
3428 // Multi-consume: accumulate under named[canonical] as array-of-arrays.
3429 let occ: Vec<serde_json::Value> = collected
3430 .into_iter()
3431 .map(|v| crate::interpreter::value_to_json(&v))
3432 .collect();
3433 let entry = tool_args
3434 .named
3435 .entry(canonical.to_string())
3436 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
3437 if let Value::Json(serde_json::Value::Array(outer)) = entry {
3438 outer.push(serde_json::Value::Array(occ));
3439 } else {
3440 anyhow::bail!(
3441 "--{flag_name}: named[{canonical}] already holds a non-array value"
3442 );
3443 }
3444 Ok(())
3445 }
3446
3447 /// Build tool arguments from AST args.
3448 ///
3449 /// Uses async evaluation to support command substitution in arguments.
3450 async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3451 let mut tool_args = ToolArgs::new();
3452 let home = self.scope_home().await;
3453
3454 // A glob-passthrough tool (`glob`) consumes patterns as data: skip
3455 // argv glob expansion so the pattern reaches the tool as written —
3456 // otherwise `glob **/*.rs` binds the first *matching path* as its
3457 // pattern. The eval fallback turns `Expr::GlobPattern` into its
3458 // literal string.
3459 let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
3460
3461 // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
3462 // in source order with types preserved — operators (`-f`, `=`, `!`) as
3463 // strings, operands keeping their `Value` — leaving `flags`/`named`
3464 // empty. A position-sensitive command needs the *true* argv: an operand
3465 // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
3466 // hoisted into the unordered flag set the normal binder splits into.
3467 // Globs still expand and `~` still resolves, matching normal positional
3468 // binding — so `test -f *.rs` errors on too many args, not a literal
3469 // pattern stat.
3470 if schema.is_some_and(|s| s.raw_argv) {
3471 for arg in args {
3472 match arg {
3473 Arg::Positional(expr) => {
3474 let glob = if let Expr::GlobPattern(p) = expr {
3475 (!glob_passthrough && self.scope.read().await.glob_enabled())
3476 .then(|| p.clone())
3477 } else {
3478 None
3479 };
3480 if let Some(pattern) = glob {
3481 let (paths, cwd) = {
3482 let ctx = self.exec_ctx.read().await;
3483 let paths = ctx
3484 .expand_glob(&pattern)
3485 .await
3486 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3487 let cwd = ctx.resolve_path(".");
3488 (paths, cwd)
3489 };
3490 if paths.is_empty() {
3491 return Err(anyhow::anyhow!("no matches: {}", pattern));
3492 }
3493 for path in paths {
3494 let display = if !pattern.starts_with('/') {
3495 path.strip_prefix(&cwd)
3496 .unwrap_or(&path)
3497 .to_string_lossy()
3498 .into_owned()
3499 } else {
3500 path.to_string_lossy().into_owned()
3501 };
3502 tool_args.positional.push(Value::String(display));
3503 }
3504 } else {
3505 let value = self.eval_expr_async(expr).await?;
3506 let value = apply_tilde_expansion(value, home.as_deref());
3507 tool_args.positional.push(value);
3508 }
3509 }
3510 Arg::ShortFlag(name) => {
3511 tool_args.positional.push(Value::String(format!("-{name}")));
3512 }
3513 Arg::LongFlag(name) => {
3514 tool_args.positional.push(Value::String(format!("--{name}")));
3515 }
3516 Arg::Named { key, value } => {
3517 let val = self.eval_expr_async(value).await?;
3518 let val = apply_tilde_expansion(val, home.as_deref());
3519 // Loud on binary (GH #116): `test --k=$BIN` must not
3520 // silently reassemble the placeholder into the raw-argv
3521 // positional stream `test` binds against.
3522 let val_str = crate::interpreter::value_to_text_sink_named(
3523 &val,
3524 "a --key=value argument",
3525 )
3526 .map_err(|e| anyhow::anyhow!("{e}"))?;
3527 tool_args
3528 .positional
3529 .push(Value::String(format!("--{key}={val_str}")));
3530 }
3531 Arg::WordAssign { key, value } => {
3532 let val = self.eval_expr_async(value).await?;
3533 let val = apply_tilde_expansion(val, home.as_deref());
3534 // Loud on binary (GH #116): same reasoning as the Named
3535 // arm above, for the bare `key=value` raw-argv form.
3536 let val_str = crate::interpreter::value_to_text_sink_named(
3537 &val,
3538 "a key=value argument",
3539 )
3540 .map_err(|e| anyhow::anyhow!("{e}"))?;
3541 tool_args
3542 .positional
3543 .push(Value::String(format!("{key}={val_str}")));
3544 }
3545 Arg::DoubleDash => {
3546 tool_args.positional.push(Value::String("--".to_string()));
3547 }
3548 }
3549 }
3550 return Ok(tool_args);
3551 }
3552
3553 // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
3554 // schemas; pick the leaf the leading positionals route to and bind
3555 // flags against *its* params. Flat tools return the root. select_leaf
3556 // errors (fail loud) if a computed positional sits where a subcommand
3557 // selector is required.
3558 let leaf = match schema {
3559 Some(s) => Some(select_leaf(s, args)?),
3560 None => None,
3561 };
3562 // Bind against the leaf's params, but MERGE the root schema's params on
3563 // top as "global" flags: a value-flag declared at the tool's top level
3564 // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
3565 // it trails the subcommand path (`kj context retag a b --confirm <n>`).
3566 // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
3567 // merge is a harmless no-op.
3568 let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
3569 if let Some(l) = leaf {
3570 param_lookup.extend(schema_param_lookup(l));
3571 }
3572 // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
3573 // not the leaf — it's a property of the command, not the subcommand.
3574 let accepts_word_assign = schema
3575 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
3576 .unwrap_or(false);
3577
3578 // Track which positional indices have been consumed as flag values
3579 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
3580 let mut past_double_dash = false;
3581
3582 // Indices a value-flag may consume as its value. Positionals always
3583 // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
3584 // itself treat `key=value` as an assignment (everything but
3585 // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
3586 // `-v`, rather than skipping it and grabbing the next positional (the
3587 // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
3588 // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
3589 let positional_indices: Vec<usize> = args.iter().enumerate()
3590 .filter_map(|(i, a)| {
3591 let consumable = matches!(a, Arg::Positional(_))
3592 || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
3593 consumable.then_some(i)
3594 })
3595 .collect();
3596
3597 let mut i = 0;
3598 while i < args.len() {
3599 match &args[i] {
3600 Arg::DoubleDash => {
3601 past_double_dash = true;
3602 }
3603 Arg::Positional(expr) => {
3604 if !consumed.contains(&i) {
3605 // Glob expansion: bare glob patterns expand to matching files
3606 if let Expr::GlobPattern(pattern) = expr {
3607 let glob_enabled = {
3608 let scope = self.scope.read().await;
3609 scope.glob_enabled()
3610 };
3611 if glob_enabled && !glob_passthrough {
3612 let (paths, cwd) = {
3613 let ctx = self.exec_ctx.read().await;
3614 let paths = ctx.expand_glob(pattern).await
3615 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3616 let cwd = ctx.resolve_path(".");
3617 (paths, cwd)
3618 };
3619 if paths.is_empty() {
3620 return Err(anyhow::anyhow!("no matches: {}", pattern));
3621 }
3622 for path in paths {
3623 let display = if !pattern.starts_with('/') {
3624 path.strip_prefix(&cwd)
3625 .unwrap_or(&path)
3626 .to_string_lossy().into_owned()
3627 } else {
3628 path.to_string_lossy().into_owned()
3629 };
3630 tool_args.positional.push(Value::String(display));
3631 }
3632 i += 1;
3633 continue;
3634 }
3635 }
3636 let value = self.eval_expr_async(expr).await?;
3637 let value = apply_tilde_expansion(value, home.as_deref());
3638 tool_args.positional.push(value);
3639 }
3640 }
3641 Arg::Named { key, value } => {
3642 let val = self.eval_expr_async(value).await?;
3643 let val = apply_tilde_expansion(val, home.as_deref());
3644 // A repeatable flag in `--flag=value` form must accumulate too,
3645 // not overwrite — otherwise `--expression=A --expression=B`
3646 // would silently keep only B, and mixing with the `-e` space
3647 // form would clobber the array. Route it through the same
3648 // accumulator the space form uses.
3649 if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
3650 push_repeatable_value(&mut tool_args, key, canonical, val)?;
3651 } else {
3652 tool_args.named.insert(key.clone(), val);
3653 }
3654 }
3655 Arg::WordAssign { key, value } => {
3656 // Already pulled in as a preceding value-flag's argument
3657 // (`awk -v a=1`); don't also emit it as a positional.
3658 if consumed.contains(&i) {
3659 i += 1;
3660 continue;
3661 }
3662 let val = self.eval_expr_async(value).await?;
3663 let val = apply_tilde_expansion(val, home.as_deref());
3664 if accepts_word_assign {
3665 tool_args.named.insert(key.clone(), val);
3666 } else {
3667 // Stringify "key=value" and pass as a positional.
3668 // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
3669 // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
3670 // must not silently become a path/operand literally named
3671 // `foo=[binary: N bytes]`.
3672 let val_str = crate::interpreter::value_to_text_sink_named(
3673 &val,
3674 "a key=value argument",
3675 )
3676 .map_err(|e| anyhow::anyhow!("{e}"))?;
3677 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
3678 }
3679 }
3680 Arg::ShortFlag(name) => {
3681 if past_double_dash {
3682 tool_args.positional.push(Value::String(format!("-{name}")));
3683 } else if name.len() == 1 {
3684 let flag_name = name.as_str();
3685 let lookup = param_lookup.get(flag_name);
3686 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3687
3688 if is_bool {
3689 tool_args.flags.insert(flag_name.to_string());
3690 } else {
3691 // Non-bool: consume `consumes` positionals as value(s)
3692 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
3693 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3694 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3695 self.consume_flag_positionals(
3696 args,
3697 name,
3698 canonical,
3699 consumes,
3700 repeatable,
3701 &positional_indices,
3702 &mut consumed,
3703 i,
3704 &mut tool_args,
3705 )
3706 .await?;
3707 }
3708 } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
3709 // Multi-char short flag matches a schema param (POSIX style: -name value)
3710 if is_bool_type(typ) {
3711 tool_args.flags.insert(canonical.to_string());
3712 } else {
3713 self.consume_flag_positionals(
3714 args,
3715 name,
3716 canonical,
3717 consumes,
3718 repeatable,
3719 &positional_indices,
3720 &mut consumed,
3721 i,
3722 &mut tool_args,
3723 )
3724 .await?;
3725 }
3726 } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
3727 .get(&name[..1])
3728 .filter(|(_, typ, ..)| !is_bool_type(typ))
3729 {
3730 // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
3731 // `grep -A1`, `sed -e1d`. The first char is a declared
3732 // value-taking short flag, so the rest of the token is its
3733 // value — the coreutils idiom. The lexer's flag char class is
3734 // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
3735 // (safe to slice) and the tail is a plain literal.
3736 bind_glued_short_value(
3737 &mut tool_args,
3738 &name[..1],
3739 canonical,
3740 consumes,
3741 repeatable,
3742 name[1..].to_string(),
3743 )?;
3744 } else {
3745 // Multi-char combined short flags. Bool flags stack
3746 // (`-la`), but the FIRST value-taking flag reached
3747 // consumes the rest of the token as its glued value
3748 // (`-ivC3` → C=3) or, if it is the last char, the next
3749 // positional (`grep -ivC 3` → C=3). Before this, a
3750 // trailing value-flag was silently treated as a bool,
3751 // stranding its argument as a stray positional (arity
3752 // error). Undeclared/bool chars stay bare flags, so a
3753 // schemaless tool keeps the old all-boolean behavior.
3754 // The first char being value-taking is handled by the
3755 // glued arm above, so it never reaches here. The flag
3756 // char class is ASCII, so byte indexing is char indexing
3757 // (no `Vec<char>` allocation needed).
3758 let bytes = name.as_bytes();
3759 let mut p = 0;
3760 while p < bytes.len() {
3761 let key = &name[p..p + 1];
3762 match param_lookup.get(key) {
3763 Some(&(canonical, typ, consumes, repeatable))
3764 if !is_bool_type(typ) =>
3765 {
3766 let glued = name[p + 1..].to_string();
3767 if glued.is_empty() {
3768 // Value flag is the last char: take the
3769 // next positional. `consume_flag_positionals`
3770 // respects `consumes`.
3771 self.consume_flag_positionals(
3772 args,
3773 key,
3774 canonical,
3775 consumes,
3776 repeatable,
3777 &positional_indices,
3778 &mut consumed,
3779 i,
3780 &mut tool_args,
3781 )
3782 .await?;
3783 } else {
3784 bind_glued_short_value(
3785 &mut tool_args,
3786 key,
3787 canonical,
3788 consumes,
3789 repeatable,
3790 glued,
3791 )?;
3792 }
3793 break;
3794 }
3795 _ => {
3796 tool_args.flags.insert(key.to_string());
3797 p += 1;
3798 }
3799 }
3800 }
3801 }
3802 }
3803 Arg::LongFlag(name) => {
3804 if past_double_dash {
3805 tool_args.positional.push(Value::String(format!("--{name}")));
3806 } else {
3807 let lookup = param_lookup.get(name.as_str());
3808 // An *undeclared* long flag under a `map_positionals`
3809 // (backend/MCP) schema that is immediately followed by an
3810 // unconsumed positional is ambiguous: kaish can't tell the
3811 // space-form value (`--type explorer`) from a bool flag
3812 // before a real positional (`--force file.txt`). Defaulting
3813 // to bool here silently divorces the value and misroutes it
3814 // — a privilege-escalation-by-typo against deny-by-default
3815 // embedders (docs/issues.md). Fail loud instead of guessing.
3816 let ambiguous_value = (lookup.is_none()
3817 && leaf.is_some_and(|s| s.map_positionals)
3818 && !consumed.contains(&(i + 1)))
3819 .then(|| match args.get(i + 1) {
3820 // Echo a concrete value for a copy-pasteable fix
3821 // when it's a plain literal; fall back to VALUE.
3822 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
3823 Some(s.clone())
3824 }
3825 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
3826 _ => None,
3827 })
3828 .flatten();
3829 if let Some(val) = ambiguous_value {
3830 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
3831 anyhow::bail!(
3832 "{tool}: --{name} is not a declared flag, so the \
3833 space-separated value would be silently dropped. \
3834 Use --{name}={val}, or have {tool} declare --{name} \
3835 in its schema."
3836 );
3837 }
3838 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
3839
3840 if is_bool {
3841 tool_args.flags.insert(name.clone());
3842 } else {
3843 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
3844 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
3845 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
3846 self.consume_flag_positionals(
3847 args,
3848 name,
3849 canonical,
3850 consumes,
3851 repeatable,
3852 &positional_indices,
3853 &mut consumed,
3854 i,
3855 &mut tool_args,
3856 )
3857 .await?;
3858 }
3859 }
3860 }
3861 }
3862 i += 1;
3863 }
3864
3865 // Map remaining positionals to unfilled non-bool schema params (in order).
3866 // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
3867 // Positionals that appeared after `--` are never mapped (they're raw data).
3868 // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
3869 // Keyed off the routed leaf so a subcommand tool maps against the active
3870 // leaf's params (kj leaves keep map_positionals=false → block skipped).
3871 if let Some(schema) = leaf.filter(|s| s.map_positionals) {
3872 let pre_dash_count = if past_double_dash {
3873 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
3874 positional_indices.iter()
3875 .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
3876 .count()
3877 } else {
3878 tool_args.positional.len()
3879 };
3880
3881 let mut remaining = Vec::new();
3882 let mut positional_iter = tool_args.positional.drain(..).enumerate();
3883
3884 for param in &schema.params {
3885 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
3886 continue;
3887 }
3888 if is_bool_type(¶m.param_type) {
3889 continue;
3890 }
3891 loop {
3892 match positional_iter.next() {
3893 Some((idx, val)) if idx < pre_dash_count => {
3894 tool_args.named.insert(param.name.clone(), val);
3895 break;
3896 }
3897 Some((_, val)) => {
3898 remaining.push(val);
3899 }
3900 None => break,
3901 }
3902 }
3903 }
3904
3905 remaining.extend(positional_iter.map(|(_, v)| v));
3906 tool_args.positional = remaining;
3907 }
3908
3909 Ok(tool_args)
3910 }
3911
3912 /// Build arguments as flat string list for external commands.
3913 ///
3914 /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3915 /// this preserves the original flag format as strings for external commands:
3916 /// - `-l` stays as `-l`
3917 /// - `--verbose` stays as `--verbose`
3918 /// - `key=value` stays as `key=value`
3919 ///
3920 /// This is what external commands expect in their argv.
3921 #[cfg(feature = "subprocess")]
3922 async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3923 let mut argv = Vec::new();
3924 let home = self.scope_home().await;
3925 for arg in args {
3926 match arg {
3927 Arg::Positional(expr) => {
3928 // Glob expansion for external commands
3929 if let Expr::GlobPattern(pattern) = expr {
3930 let glob_enabled = {
3931 let scope = self.scope.read().await;
3932 scope.glob_enabled()
3933 };
3934 if glob_enabled {
3935 let (paths, cwd) = {
3936 let ctx = self.exec_ctx.read().await;
3937 let paths = ctx.expand_glob(pattern).await
3938 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3939 let cwd = ctx.resolve_path(".");
3940 (paths, cwd)
3941 };
3942 if paths.is_empty() {
3943 return Err(anyhow::anyhow!("no matches: {}", pattern));
3944 }
3945 for path in paths {
3946 let display = if !pattern.starts_with('/') {
3947 path.strip_prefix(&cwd)
3948 .unwrap_or(&path)
3949 .to_string_lossy().into_owned()
3950 } else {
3951 path.to_string_lossy().into_owned()
3952 };
3953 argv.push(display);
3954 }
3955 continue;
3956 }
3957 }
3958 let value = self.eval_expr_async(expr).await?;
3959 // Decision D: a bare collection can't cross the external
3960 // process boundary as an argv element — refuse rather than
3961 // silently JSON-serializing it. A quoted `"$x"` already
3962 // reduced to a `Value::String` above (via `Expr::Interpolated`),
3963 // so only a live, un-interpolated `$x` trips this.
3964 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3965 return Err(anyhow::anyhow!(msg));
3966 }
3967 let value = apply_tilde_expansion(value, home.as_deref());
3968 // External-command argv is a text sink: a bare `$BIN` binary
3969 // word goes loud, never the `[binary: N bytes]` placeholder.
3970 argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3971 }
3972 Arg::Named { key, value } => {
3973 let val = self.eval_expr_async(value).await?;
3974 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3975 return Err(anyhow::anyhow!(msg));
3976 }
3977 let val = apply_tilde_expansion(val, home.as_deref());
3978 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3979 argv.push(format!("--{key}={val_str}"));
3980 }
3981 Arg::WordAssign { key, value } => {
3982 let val = self.eval_expr_async(value).await?;
3983 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3984 return Err(anyhow::anyhow!(msg));
3985 }
3986 let val = apply_tilde_expansion(val, home.as_deref());
3987 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3988 argv.push(format!("{key}={val_str}"));
3989 }
3990 Arg::ShortFlag(name) => {
3991 // Preserve original format: -l, -la (combined flags)
3992 argv.push(format!("-{}", name));
3993 }
3994 Arg::LongFlag(name) => {
3995 // Preserve original format: --verbose
3996 argv.push(format!("--{}", name));
3997 }
3998 Arg::DoubleDash => {
3999 // Preserve the -- marker
4000 argv.push("--".to_string());
4001 }
4002 }
4003 }
4004 Ok(argv)
4005 }
4006
4007 /// Async expression evaluator that supports command substitution.
4008 ///
4009 /// This is used for contexts where expressions may contain `$(...)` command
4010 /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
4011 fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4012 Box::pin(async move {
4013 match expr {
4014 Expr::Literal(value) => Ok(value.clone()),
4015 Expr::VarRef(path) => {
4016 let scope = self.scope.read().await;
4017 match scope.resolve_path(path) {
4018 Ok(v) => Ok(v),
4019 Err(PathError::UndefinedRoot(_)) => {
4020 Err(anyhow::anyhow!("undefined variable"))
4021 }
4022 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4023 Err(anyhow::anyhow!(msg))
4024 }
4025 }
4026 }
4027 Expr::Interpolated(parts) => {
4028 let mut result = String::new();
4029 for part in parts {
4030 result.push_str(&self.eval_string_part_async(part).await?);
4031 }
4032 Ok(Value::String(result))
4033 }
4034 Expr::HereDocBody { parts, strip_tabs } => {
4035 // Assemble part-by-part so `<<-` tab stripping applies to the
4036 // literal source, not to tabs from a `$var` value (bash strips
4037 // source-line tabs before parameter expansion).
4038 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
4039 for sp in parts {
4040 match &sp.part {
4041 StringPart::Literal(s) => asm.push_literal(s),
4042 other => {
4043 asm.push_interpolated(&self.eval_string_part_async(other).await?)
4044 }
4045 }
4046 }
4047 Ok(Value::String(asm.into_string()))
4048 }
4049 Expr::BinaryOp { left, op, right } => match op {
4050 BinaryOp::And => {
4051 let left_val = self.eval_expr_async(left).await?;
4052 if !is_truthy(&left_val) {
4053 return Ok(left_val);
4054 }
4055 self.eval_expr_async(right).await
4056 }
4057 BinaryOp::Or => {
4058 let left_val = self.eval_expr_async(left).await?;
4059 if is_truthy(&left_val) {
4060 return Ok(left_val);
4061 }
4062 self.eval_expr_async(right).await
4063 }
4064 },
4065 Expr::CommandSubst(stmts) => {
4066 // Snapshot scope, cwd, and session config before running —
4067 // only output escapes, not side effects like `cd`, variable
4068 // assignments, or config mutations (`kaish-ignore`,
4069 // `kaish-output-limit`, `alias`/`unalias`) — matching how
4070 // every other execution context (background forks, scatter
4071 // workers) already isolates mutations (GH #139).
4072 // Boxed: this ~470 B scope snapshot is held across the nested
4073 // `$(…)` recursion await below, so inlining it grows every
4074 // command-substitution level's future (GH #48, item 4).
4075 let saved_scope = Box::new(self.scope.read().await.clone());
4076 let saved_ec = {
4077 let ec = self.exec_ctx.read().await;
4078 (
4079 ec.cwd.clone(),
4080 ec.prev_cwd.clone(),
4081 ec.aliases.clone(),
4082 ec.ignore_config.clone(),
4083 ec.output_limit.clone(),
4084 )
4085 };
4086
4087 // Capture result without `?` — restore state unconditionally
4088 let run_result = self.execute_block_capturing(stmts).await;
4089
4090 // Restore scope and cwd regardless of success/failure
4091 {
4092 let mut scope = self.scope.write().await;
4093 *scope = *saved_scope;
4094 if let Ok(ref r) = run_result {
4095 scope.set_last_result(r.clone());
4096 }
4097 }
4098 {
4099 let mut ec = self.exec_ctx.write().await;
4100 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4101 ec.cwd = cwd;
4102 ec.prev_cwd = prev_cwd;
4103 ec.aliases = aliases;
4104 ec.ignore_config = ignore_config;
4105 ec.output_limit = output_limit;
4106 }
4107
4108 // Now propagate the error
4109 let result = run_result?;
4110
4111 // A binary result is preserved as bytes — never lossy-decoded to
4112 // a string. No trailing-newline trim (every byte is significant).
4113 if let Some(bytes) = result.out_bytes() {
4114 Ok(Value::Bytes(bytes.to_vec()))
4115 // Prefer structured data (enables `for i in $(cmd)` iteration)
4116 } else if let Some(data) = &result.data {
4117 Ok(data.clone())
4118 } else if let Some(output) = result.output() {
4119 // Flat non-text node lists (glob, ls, tree) → iterable array
4120 if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
4121 let items: Vec<serde_json::Value> = output.root.iter()
4122 .map(|n| serde_json::Value::String(n.display_name().to_string()))
4123 .collect();
4124 Ok(Value::Json(serde_json::Value::Array(items)))
4125 } else {
4126 // Strip trailing newlines only (POSIX command-subst),
4127 // not all trailing whitespace — spaces/tabs are
4128 // significant. Use the exact same trim as the quoted
4129 // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
4130 // `trim_end_matches('\n')`) so bare and quoted command
4131 // substitution agree.
4132 Ok(Value::String(
4133 result.text_out().trim_end_matches('\n').to_string(),
4134 ))
4135 }
4136 } else {
4137 // Otherwise return stdout as single string (NO implicit splitting)
4138 Ok(Value::String(
4139 result.text_out().trim_end_matches('\n').to_string(),
4140 ))
4141 }
4142 }
4143 Expr::Test(test_expr) => {
4144 Ok(Value::Bool(self.eval_test_async(test_expr).await?))
4145 }
4146 Expr::Positional(n) => {
4147 let scope = self.scope.read().await;
4148 match scope.get_positional(*n) {
4149 Some(s) => Ok(Value::String(s.to_string())),
4150 None => Ok(Value::String(String::new())),
4151 }
4152 }
4153 Expr::AllArgs => {
4154 let scope = self.scope.read().await;
4155 Ok(Value::String(scope.all_args().join(" ")))
4156 }
4157 Expr::ArgCount => {
4158 let scope = self.scope.read().await;
4159 Ok(Value::Int(scope.arg_count() as i64))
4160 }
4161 Expr::VarLength(path) => {
4162 let scope = self.scope.read().await;
4163 crate::interpreter::resolve_length(&scope, path)
4164 .map(Value::Int)
4165 .map_err(|msg| anyhow::anyhow!(msg))
4166 }
4167 Expr::VarWithDefault { path, default } => {
4168 // Resolve inside a scoped guard so the lock is released before the
4169 // recursive default evaluation.
4170 let resolved = {
4171 let scope = self.scope.read().await;
4172 crate::interpreter::resolve_default(&scope, path)
4173 .map_err(|msg| anyhow::anyhow!(msg))?
4174 };
4175 match resolved {
4176 Some(value) => Ok(value),
4177 None => self.eval_string_parts_async(default).await.map(Value::String),
4178 }
4179 }
4180 Expr::Arithmetic(expr_str) => {
4181 let scope = self.scope.read().await;
4182 crate::arithmetic::eval_arithmetic(expr_str, &scope)
4183 .map(Value::Int)
4184 .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
4185 }
4186 Expr::Command(cmd) => {
4187 // Execute command and return boolean based on exit code
4188 let result = self.execute_command(&cmd.name, &cmd.args).await?;
4189 Ok(Value::Bool(result.code == 0))
4190 }
4191 Expr::LastExitCode => {
4192 let scope = self.scope.read().await;
4193 Ok(Value::Int(scope.last_result().code))
4194 }
4195 Expr::CurrentPid => {
4196 let scope = self.scope.read().await;
4197 Ok(Value::Int(scope.pid() as i64))
4198 }
4199 Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4200 Expr::ListLiteral(elems) => {
4201 // Spread must itself be a list — a scalar/record spread is a
4202 // loud error, never silently coerced or dropped (mirrors the
4203 // sync `Evaluator::eval_list_literal`; wording shared via
4204 // `spread_non_list_message` so the two paths can't diverge).
4205 let mut out = Vec::with_capacity(elems.len());
4206 for elem in elems {
4207 match elem {
4208 ListElem::Item(e) => {
4209 let value = self.eval_expr_async(e).await?;
4210 out.push(crate::interpreter::value_to_json(&value));
4211 }
4212 ListElem::Spread(e) => {
4213 let value = self.eval_expr_async(e).await?;
4214 match value {
4215 Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4216 other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4217 }
4218 }
4219 }
4220 }
4221 Ok(Value::Json(serde_json::Value::Array(out)))
4222 }
4223 Expr::RecordLiteral(entries) => {
4224 // Insertion order preserved (workspace serde_json has
4225 // `preserve_order`); a duplicate key keeps the last value
4226 // written, matching plain map-insert semantics.
4227 let mut map = serde_json::Map::new();
4228 for entry in entries {
4229 let key = match &entry.key {
4230 RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4231 // `{"$k": v}` resolves like any double-quoted string
4232 // (used to silently create a literal "$k" key).
4233 RecordKey::Interpolated(parts) => {
4234 self.eval_string_parts_async(parts).await?
4235 }
4236 };
4237 let value = self.eval_expr_async(&entry.value).await?;
4238 map.insert(key, crate::interpreter::value_to_json(&value));
4239 }
4240 Ok(Value::Json(serde_json::Value::Object(map)))
4241 }
4242 }
4243 })
4244 }
4245
4246 /// Async helper to evaluate multiple StringParts into a single string.
4247 fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4248 Box::pin(async move {
4249 let mut result = String::new();
4250 for part in parts {
4251 result.push_str(&self.eval_string_part_async(part).await?);
4252 }
4253 Ok(result)
4254 })
4255 }
4256
4257 /// Async helper to evaluate a StringPart.
4258 /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4259 /// through the VFS backend instead of using raw `std::path`.
4260 fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4261 Box::pin(async move {
4262 match test_expr {
4263 TestExpr::FileTest { op, path } => {
4264 let path_value = self.eval_expr_async(path).await?;
4265 // Expand `~` against the session HOME before stat'ing, the
4266 // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4267 // stats the literal `~/x` and is always false.
4268 let home = self.scope_home().await;
4269 let path_value = apply_tilde_expansion(path_value, home.as_deref());
4270 // A binary `[[ -f $bin ]]` operand goes loud rather than
4271 // silently stat'ing a file literally named
4272 // `[binary: N bytes]` (the same path-positional guard
4273 // builtins like `stat`/`cp` use).
4274 let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4275 .map_err(|e| anyhow::anyhow!("{e}"))?;
4276 // Resolve against the *session* cwd, not the process cwd, so a
4277 // relative `[[ -f rel ]]` honors `cd` and agrees with the
4278 // VFS-aware `test` builtin (GH #101). Backend stats a raw
4279 // relative path against the process cwd otherwise.
4280 let (resolved, backend) = {
4281 let ctx = self.exec_ctx.read().await;
4282 (ctx.resolve_path(&path_str), ctx.backend.clone())
4283 };
4284 let entry = backend.stat(&resolved).await.ok();
4285 Ok(match op {
4286 FileTestOp::Exists => entry.is_some(),
4287 FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4288 FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4289 FileTestOp::Readable => entry.is_some(),
4290 FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4291 e.permissions.is_none_or(|p| p & 0o222 != 0)
4292 }),
4293 FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4294 e.permissions.is_some_and(|p| p & 0o111 != 0)
4295 }),
4296 })
4297 }
4298 TestExpr::StringTest { op, value } => match op {
4299 crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4300 let val = self.eval_expr_async(value).await?;
4301 // Decision E: a collection operand is a loud Shape error
4302 // here too — must not diverge from the sync path in
4303 // interpreter/eval.rs (shared `scalar_test_operand_error`).
4304 let symbol = match op {
4305 crate::ast::StringTestOp::IsEmpty => "-z",
4306 crate::ast::StringTestOp::IsNonEmpty => "-n",
4307 crate::ast::StringTestOp::IsList
4308 | crate::ast::StringTestOp::IsRecord => unreachable!(),
4309 };
4310 if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4311 anyhow::bail!(msg);
4312 }
4313 let s = value_to_string(&val);
4314 Ok(match op {
4315 crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4316 crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4317 crate::ast::StringTestOp::IsList
4318 | crate::ast::StringTestOp::IsRecord => unreachable!(),
4319 })
4320 }
4321 // Shape guard: propagates eval errors like -z/-n (a bare
4322 // `$unset` is an undefined-variable error, not a silent
4323 // false). A defined-but-wrong-shaped value is false. Must
4324 // not diverge from the sync path in interpreter/eval.rs.
4325 crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4326 let val = self.eval_expr_async(value).await?;
4327 Ok(op.matches_shape(&val))
4328 }
4329 },
4330 TestExpr::Comparison { left, op, right } => {
4331 // Evaluate operands async (handles $(cmd)), then compare sync
4332 let left_val = self.eval_expr_async(left).await?;
4333 let right_val = self.eval_expr_async(right).await?;
4334 let resolved = TestExpr::Comparison {
4335 left: Box::new(Expr::Literal(left_val)),
4336 op: *op,
4337 right: Box::new(Expr::Literal(right_val)),
4338 };
4339 let expr = Expr::Test(Box::new(resolved));
4340 let mut scope = self.scope.write().await;
4341 let value = eval_expr(&expr, &mut scope)
4342 .map_err(|e| anyhow::anyhow!("{}", e))?;
4343 Ok(value_to_bool(&value))
4344 }
4345 TestExpr::And { left, right } => {
4346 if !self.eval_test_async(left).await? {
4347 Ok(false)
4348 } else {
4349 self.eval_test_async(right).await
4350 }
4351 }
4352 TestExpr::Or { left, right } => {
4353 if self.eval_test_async(left).await? {
4354 Ok(true)
4355 } else {
4356 self.eval_test_async(right).await
4357 }
4358 }
4359 TestExpr::Not { expr } => {
4360 Ok(!self.eval_test_async(expr).await?)
4361 }
4362 TestExpr::In { left, right } => {
4363 let left_val = self.eval_expr_async(left).await?;
4364 let right_val = self.eval_expr_async(right).await?;
4365 let resolved = TestExpr::In {
4366 left: Box::new(Expr::Literal(left_val)),
4367 right: Box::new(Expr::Literal(right_val)),
4368 };
4369 let expr = Expr::Test(Box::new(resolved));
4370 let mut scope = self.scope.write().await;
4371 let value = eval_expr(&expr, &mut scope)
4372 .map_err(|e| anyhow::anyhow!("{}", e))?;
4373 Ok(value_to_bool(&value))
4374 }
4375 TestExpr::NotIn { left, right } => {
4376 let left_val = self.eval_expr_async(left).await?;
4377 let right_val = self.eval_expr_async(right).await?;
4378 let resolved = TestExpr::NotIn {
4379 left: Box::new(Expr::Literal(left_val)),
4380 right: Box::new(Expr::Literal(right_val)),
4381 };
4382 let expr = Expr::Test(Box::new(resolved));
4383 let mut scope = self.scope.write().await;
4384 let value = eval_expr(&expr, &mut scope)
4385 .map_err(|e| anyhow::anyhow!("{}", e))?;
4386 Ok(value_to_bool(&value))
4387 }
4388 }
4389 })
4390 }
4391
4392 fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4393 Box::pin(async move {
4394 match part {
4395 StringPart::Literal(s) => Ok(s.clone()),
4396 StringPart::Var(path) => {
4397 let scope = self.scope.read().await;
4398 match scope.resolve_path(path) {
4399 // Text sink: binary goes loud, never the placeholder —
4400 // a `b=$(cat blob)` capture holds real bytes; splicing
4401 // `[binary: N bytes]` into "$b" would be silent loss.
4402 Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4403 // Unset vars expand to empty; loud path errors surface.
4404 Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4405 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4406 Err(anyhow::anyhow!(msg))
4407 }
4408 }
4409 }
4410 StringPart::VarWithDefault { path, default } => {
4411 let resolved = {
4412 let scope = self.scope.read().await;
4413 crate::interpreter::resolve_default(&scope, path)
4414 .map_err(|msg| anyhow::anyhow!(msg))?
4415 };
4416 match resolved {
4417 Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4418 None => self.eval_string_parts_async(default).await,
4419 }
4420 }
4421 StringPart::VarLength(path) => {
4422 let scope = self.scope.read().await;
4423 crate::interpreter::resolve_length(&scope, path)
4424 .map(|n| n.to_string())
4425 .map_err(|msg| anyhow::anyhow!(msg))
4426 }
4427 StringPart::Positional(n) => {
4428 let scope = self.scope.read().await;
4429 match scope.get_positional(*n) {
4430 Some(s) => Ok(s.to_string()),
4431 None => Ok(String::new()),
4432 }
4433 }
4434 StringPart::AllArgs => {
4435 let scope = self.scope.read().await;
4436 Ok(scope.all_args().join(" "))
4437 }
4438 StringPart::ArgCount => {
4439 let scope = self.scope.read().await;
4440 Ok(scope.arg_count().to_string())
4441 }
4442 StringPart::Arithmetic(expr) => {
4443 let scope = self.scope.read().await;
4444 match crate::arithmetic::eval_arithmetic(expr, &scope) {
4445 Ok(value) => Ok(value.to_string()),
4446 Err(_) => Ok(String::new()),
4447 }
4448 }
4449 StringPart::CommandSubst(stmts) => {
4450 // Snapshot scope, cwd, and session config — command
4451 // substitution in strings must not leak side effects (e.g.,
4452 // `"dir: $(cd /; pwd)"` must not change cwd, and
4453 // `"$(kaish-ignore clear)"` must not change the session's
4454 // ignore config) — matching how every other execution
4455 // context (background forks, scatter workers) already
4456 // isolates mutations (GH #139).
4457 // Boxed: this ~470 B scope snapshot is held across the nested
4458 // `$(…)` recursion await below, so inlining it grows every
4459 // command-substitution level's future (GH #48, item 4).
4460 let saved_scope = Box::new(self.scope.read().await.clone());
4461 let saved_ec = {
4462 let ec = self.exec_ctx.read().await;
4463 (
4464 ec.cwd.clone(),
4465 ec.prev_cwd.clone(),
4466 ec.aliases.clone(),
4467 ec.ignore_config.clone(),
4468 ec.output_limit.clone(),
4469 )
4470 };
4471
4472 // Capture result without `?` — restore state unconditionally
4473 let run_result = self.execute_block_capturing(stmts).await;
4474
4475 // Restore scope and cwd regardless of success/failure
4476 {
4477 let mut scope = self.scope.write().await;
4478 *scope = *saved_scope;
4479 if let Ok(ref r) = run_result {
4480 scope.set_last_result(r.clone());
4481 }
4482 }
4483 {
4484 let mut ec = self.exec_ctx.write().await;
4485 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4486 ec.cwd = cwd;
4487 ec.prev_cwd = prev_cwd;
4488 ec.aliases = aliases;
4489 ec.ignore_config = ignore_config;
4490 ec.output_limit = output_limit;
4491 }
4492
4493 // Now propagate the error
4494 let result = run_result?;
4495
4496 // Embedding binary into a string is a text context: fail loud
4497 // rather than splice in U+FFFD garbage.
4498 match result.try_text_out() {
4499 // Text wins when present — unchanged behavior.
4500 Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4501 // `.out` is empty: a builtin/tool that set only structured
4502 // `.data` must not silently evaporate to "" (SILENT DATA
4503 // LOSS). Render it the same way a bare `"$x"`
4504 // collection-valued variable renders — compact JSON for
4505 // lists/records, plain form for scalars — by reusing
4506 // `value_to_string` (the exact `StringPart::Var` helper
4507 // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4508 // identically. No trailing-newline trim here: that's a
4509 // text-path artifact, not applicable to a freshly
4510 // rendered JSON/scalar string.
4511 Ok(_) => match &result.data {
4512 Some(data) => Ok(value_to_string(data)),
4513 None => Ok(String::new()),
4514 },
4515 Err(e) => anyhow::bail!(
4516 "command substitution in a string produced binary data ({e}) — \
4517 pipe through base64/xxd"
4518 ),
4519 }
4520 }
4521 StringPart::LastExitCode => {
4522 let scope = self.scope.read().await;
4523 Ok(scope.last_result().code.to_string())
4524 }
4525 StringPart::CurrentPid => {
4526 let scope = self.scope.read().await;
4527 Ok(scope.pid().to_string())
4528 }
4529 }
4530 })
4531 }
4532
4533 /// Update the last result in scope.
4534 async fn update_last_result(&self, result: &ExecResult) {
4535 let mut scope = self.scope.write().await;
4536 scope.set_last_result(result.clone());
4537 }
4538
4539 /// Drain accumulated pipeline stderr into a result.
4540 ///
4541 /// Called after each sub-statement inside control structures (`if`, `for`,
4542 /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4543 /// than batching until the entire structure finishes.
4544 async fn drain_stderr_into(&self, result: &mut ExecResult) {
4545 let drained = {
4546 let mut receiver = self.stderr_receiver.lock().await;
4547 receiver.drain_lossy()
4548 };
4549 if !drained.is_empty() {
4550 if !result.err.is_empty() && !result.err.ends_with('\n') {
4551 result.err.push('\n');
4552 }
4553 result.err.push_str(&drained);
4554 }
4555 }
4556
4557 /// Execute a user-defined function with local variable scoping.
4558 ///
4559 /// Functions push a new scope frame for local variables. Variables declared
4560 /// with `local` are scoped to the function; other assignments modify outer
4561 /// scopes (or create in root if new).
4562 async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4563 let _depth = self.enter_recursion("a shell function")?;
4564
4565 // 1. Build function args from AST args (async to support command substitution)
4566 let tool_args = self.build_args_async(args, None).await?;
4567
4568 // 2. Push a new scope frame for local variables
4569 {
4570 let mut scope = self.scope.write().await;
4571 scope.push_frame();
4572 }
4573
4574 // 3. Save current positional parameters and set new ones for this function
4575 let saved_positional = {
4576 let mut scope = self.scope.write().await;
4577 let saved = scope.save_positional();
4578
4579 // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4580 let positional_args: Vec<String> = tool_args.positional
4581 .iter()
4582 .map(value_to_string)
4583 .collect();
4584 scope.set_positional(&def.name, positional_args);
4585
4586 saved
4587 };
4588
4589 // 3. Execute body statements with control flow handling
4590 // Accumulate output across statements (like sh)
4591 // Accumulate stdout as raw bytes so a binary-producing statement in a
4592 // function body survives instead of being lossy-decoded here.
4593 let mut accumulated_out: Vec<u8> = Vec::new();
4594 let mut accumulated_err = String::new();
4595 let mut last_code = 0i64;
4596 let mut last_data: Option<Value> = None;
4597
4598 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4599 match r.out_bytes() {
4600 Some(b) => buf.extend_from_slice(b),
4601 None => buf.extend_from_slice(r.text_out().as_bytes()),
4602 }
4603 }
4604
4605 // Track execution error for propagation after cleanup
4606 let mut exec_error: Option<anyhow::Error> = None;
4607 let mut exit_code: Option<i64> = None;
4608
4609 for stmt in &def.body {
4610 match self.execute_stmt_flow(stmt).await {
4611 Ok(flow) => {
4612 // Drain pipeline stderr after each sub-statement.
4613 let drained = {
4614 let mut receiver = self.stderr_receiver.lock().await;
4615 receiver.drain_lossy()
4616 };
4617 if !drained.is_empty() {
4618 accumulated_err.push_str(&drained);
4619 }
4620
4621 match flow {
4622 ControlFlow::Normal(r) => {
4623 push_out(&mut accumulated_out, &r);
4624 accumulated_err.push_str(&r.err);
4625 last_code = r.code;
4626 last_data = r.data;
4627 }
4628 ControlFlow::Return { value } => {
4629 push_out(&mut accumulated_out, &value);
4630 accumulated_err.push_str(&value.err);
4631 last_code = value.code;
4632 last_data = value.data;
4633 break;
4634 }
4635 ControlFlow::Exit { code } => {
4636 exit_code = Some(code);
4637 break;
4638 }
4639 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4640 push_out(&mut accumulated_out, &r);
4641 accumulated_err.push_str(&r.err);
4642 last_code = r.code;
4643 last_data = r.data;
4644 }
4645 }
4646 }
4647 Err(e) => {
4648 exec_error = Some(e);
4649 break;
4650 }
4651 }
4652 }
4653
4654 // 4. Pop scope frame and restore original positional parameters (unconditionally)
4655 {
4656 let mut scope = self.scope.write().await;
4657 scope.pop_frame();
4658 scope.set_positional(saved_positional.0, saved_positional.1);
4659 }
4660
4661 // 5. Propagate error or exit after cleanup
4662 if let Some(e) = exec_error {
4663 return Err(e);
4664 }
4665 let code = exit_code.unwrap_or(last_code);
4666 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4667 result.err = accumulated_err;
4668 result.data = last_data;
4669 Ok(result)
4670 }
4671
4672 /// Execute a command-substitution body — a block of statements — and return
4673 /// the combined result. Stdout/stderr accumulate across statements with **no
4674 /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4675 /// and the last statement's exit code and structured `.data` ride through,
4676 /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4677 /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4678 /// cwd) is the caller's responsibility.
4679 /// Enter one level of dynamic statement-engine re-entry (command
4680 /// substitution / function call / script source), returning an RAII guard
4681 /// that releases the level on drop. Past [`MAX_RECURSION_DEPTH`] it returns
4682 /// a loud, catchable error instead of letting the native stack overflow
4683 /// (GH #46). `what` names the re-entry kind for the message.
4684 ///
4685 /// The guard is constructed *before* the ceiling check so the error path
4686 /// unwinds it too — the counter is always balanced, even when we reject.
4687 fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4688 let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4689 let guard = RecursionGuard { counter: &self.recursion_depth };
4690 if depth > MAX_RECURSION_DEPTH {
4691 return Err(anyhow::anyhow!(
4692 "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4693 a runaway or mutually recursive script (deeply nested $(…), or \
4694 functions/scripts that call each other without a base case) was \
4695 stopped before it could overflow the stack"
4696 ));
4697 }
4698 Ok(guard)
4699 }
4700
4701 async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4702 let _depth = self.enter_recursion("command substitution")?;
4703 // Accumulate stdout as raw bytes so a binary-producing statement
4704 // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4705 // caller can preserve it. The final result is text iff valid UTF-8.
4706 let mut accumulated_out: Vec<u8> = Vec::new();
4707 let mut accumulated_err = String::new();
4708 let mut last_code = 0i64;
4709 let mut last_data: Option<Value> = None;
4710
4711 // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4712 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4713 match r.out_bytes() {
4714 Some(b) => buf.extend_from_slice(b),
4715 None => buf.extend_from_slice(r.text_out().as_bytes()),
4716 }
4717 }
4718
4719 for stmt in stmts {
4720 let flow = self.execute_stmt_flow(stmt).await?;
4721
4722 // Drain pipeline stderr after each sub-statement (incremental, like
4723 // the control-structure and function-body executors).
4724 let drained = {
4725 let mut receiver = self.stderr_receiver.lock().await;
4726 receiver.drain_lossy()
4727 };
4728 if !drained.is_empty() {
4729 accumulated_err.push_str(&drained);
4730 }
4731
4732 match flow {
4733 ControlFlow::Normal(r)
4734 | ControlFlow::Break { result: r, .. }
4735 | ControlFlow::Continue { result: r, .. } => {
4736 push_out(&mut accumulated_out, &r);
4737 accumulated_err.push_str(&r.err);
4738 last_code = r.code;
4739 last_data = r.data;
4740 }
4741 ControlFlow::Return { value } => {
4742 push_out(&mut accumulated_out, &value);
4743 accumulated_err.push_str(&value.err);
4744 last_code = value.code;
4745 last_data = value.data;
4746 break;
4747 }
4748 ControlFlow::Exit { code } => {
4749 last_code = code;
4750 break;
4751 }
4752 }
4753 }
4754
4755 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4756 result.err = accumulated_err;
4757 result.data = last_data;
4758 Ok(result)
4759 }
4760
4761 /// Execute the `source` / `.` command to include and run a script.
4762 ///
4763 /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4764 /// allowing the sourced script to set variables and modify shell state.
4765 async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4766 // `source`/`.` is the fourth dynamic re-entry point: it runs the
4767 // sourced file's statements inline via `execute_stmt_flow`, so a file
4768 // that sources itself recurses unbounded just like a runaway function
4769 // (GH #46). It's intercepted as a special form *before* the other
4770 // guarded paths, so it needs its own guard.
4771 let _depth = self.enter_recursion("source")?;
4772
4773 // Get the file path from the first positional argument
4774 let tool_args = self.build_args_async(args, None).await?;
4775 let path = match tool_args.positional.first() {
4776 Some(Value::String(s)) => s.clone(),
4777 Some(v) => value_to_string(v),
4778 None => {
4779 return Ok(ExecResult::failure(1, "source: missing filename"));
4780 }
4781 };
4782
4783 // Resolve path relative to cwd
4784 let full_path = {
4785 let ctx = self.exec_ctx.read().await;
4786 if path.starts_with('/') {
4787 std::path::PathBuf::from(&path)
4788 } else {
4789 ctx.cwd.join(&path)
4790 }
4791 };
4792
4793 // Read file content via backend
4794 let content = {
4795 let ctx = self.exec_ctx.read().await;
4796 match ctx.backend.read(&full_path, None).await {
4797 Ok(bytes) => {
4798 String::from_utf8(bytes).map_err(|e| {
4799 anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4800 })?
4801 }
4802 Err(e) => {
4803 return Ok(ExecResult::failure(
4804 1,
4805 format!("source: {}: {}", path, e),
4806 ));
4807 }
4808 }
4809 };
4810
4811 // Parse the content
4812 let program = match crate::parser::parse(&content) {
4813 Ok(p) => p,
4814 Err(errors) => {
4815 let msg = errors
4816 .iter()
4817 .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4818 .collect::<Vec<_>>()
4819 .join("\n");
4820 return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4821 }
4822 };
4823
4824 // Execute each statement in the CURRENT scope (not isolated), accumulating
4825 // stdout/stderr across statements like `execute_user_tool` — a sourced
4826 // script's earlier statements must not be silently dropped in favor of
4827 // just the last one.
4828 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4829 match r.out_bytes() {
4830 Some(b) => buf.extend_from_slice(b),
4831 None => buf.extend_from_slice(r.text_out().as_bytes()),
4832 }
4833 }
4834
4835 let mut accumulated_out: Vec<u8> = Vec::new();
4836 let mut accumulated_err = String::new();
4837 let mut last_code = 0i64;
4838 let mut last_data: Option<Value> = None;
4839
4840 for stmt in program.statements {
4841 if matches!(stmt, crate::ast::Stmt::Empty) {
4842 continue;
4843 }
4844
4845 match self.execute_stmt_flow(&stmt).await {
4846 Ok(flow) => {
4847 let drained = {
4848 let mut receiver = self.stderr_receiver.lock().await;
4849 receiver.drain_lossy()
4850 };
4851 if !drained.is_empty() {
4852 accumulated_err.push_str(&drained);
4853 }
4854 match flow {
4855 ControlFlow::Normal(r) => {
4856 push_out(&mut accumulated_out, &r);
4857 accumulated_err.push_str(&r.err);
4858 last_code = r.code;
4859 last_data = r.data.clone();
4860 self.update_last_result(&r).await;
4861 }
4862 ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4863 return Err(anyhow::anyhow!(
4864 "source: {}: unexpected break/continue outside loop",
4865 path
4866 ));
4867 }
4868 ControlFlow::Return { value } => {
4869 push_out(&mut accumulated_out, &value);
4870 accumulated_err.push_str(&value.err);
4871 let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4872 .with_code(value.code);
4873 result.err = accumulated_err;
4874 result.data = value.data;
4875 return Ok(result);
4876 }
4877 ControlFlow::Exit { code } => {
4878 let mut result =
4879 ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4880 result.err = accumulated_err;
4881 result.data = last_data;
4882 return Ok(result);
4883 }
4884 }
4885 }
4886 Err(e) => {
4887 return Err(e.context(format!("source: {}", path)));
4888 }
4889 }
4890 }
4891
4892 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4893 result.err = accumulated_err;
4894 result.data = last_data;
4895 Ok(result)
4896 }
4897
4898 /// Try to execute a script from PATH directories.
4899 ///
4900 /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4901 /// (like user-defined tools). Returns None if no script is found.
4902 async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4903 // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4904 // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4905 // A non-script command pays only a transient, balanced increment during
4906 // the probe before falling through to the external path.
4907 let _depth = self.enter_recursion("a .kai script")?;
4908
4909 // Get PATH from scope (default to "/bin")
4910 let path_value = {
4911 let scope = self.scope.read().await;
4912 scope
4913 .get("PATH")
4914 .map(value_to_string)
4915 .unwrap_or_else(|| "/bin".to_string())
4916 };
4917
4918 // Search PATH directories for script
4919 for dir in path_value.split(':') {
4920 if dir.is_empty() {
4921 continue;
4922 }
4923
4924 // Build script path: {dir}/{name}.kai
4925 let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4926
4927 // Check if script exists
4928 let exists = {
4929 let ctx = self.exec_ctx.read().await;
4930 ctx.backend.exists(&script_path).await
4931 };
4932
4933 if !exists {
4934 continue;
4935 }
4936
4937 // Read script content
4938 let content = {
4939 let ctx = self.exec_ctx.read().await;
4940 match ctx.backend.read(&script_path, None).await {
4941 Ok(bytes) => match String::from_utf8(bytes) {
4942 Ok(s) => s,
4943 Err(e) => {
4944 return Ok(Some(ExecResult::failure(
4945 1,
4946 format!("{}: invalid UTF-8: {}", script_path.display(), e),
4947 )));
4948 }
4949 },
4950 Err(e) => {
4951 return Ok(Some(ExecResult::failure(
4952 1,
4953 format!("{}: {}", script_path.display(), e),
4954 )));
4955 }
4956 }
4957 };
4958
4959 // Parse the script
4960 let program = match crate::parser::parse(&content) {
4961 Ok(p) => p,
4962 Err(errors) => {
4963 let msg = errors
4964 .iter()
4965 .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4966 .collect::<Vec<_>>()
4967 .join("\n");
4968 return Ok(Some(ExecResult::failure(1, msg)));
4969 }
4970 };
4971
4972 // Build tool_args from args (async for command substitution support)
4973 let tool_args = self.build_args_async(args, None).await?;
4974
4975 // Create isolated scope (like user tools)
4976 let mut isolated_scope = Scope::new();
4977
4978 // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4979 let positional_args: Vec<String> = tool_args.positional
4980 .iter()
4981 .map(value_to_string)
4982 .collect();
4983 isolated_scope.set_positional(name, positional_args);
4984
4985 // Save current scope and swap with isolated scope
4986 let original_scope = {
4987 let mut scope = self.scope.write().await;
4988 std::mem::replace(&mut *scope, isolated_scope)
4989 };
4990
4991 // Execute script statements — accumulate stdout/stderr across
4992 // statements like `execute_user_tool`, rather than keeping only the
4993 // last one's result.
4994 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4995 match r.out_bytes() {
4996 Some(b) => buf.extend_from_slice(b),
4997 None => buf.extend_from_slice(r.text_out().as_bytes()),
4998 }
4999 }
5000
5001 let mut accumulated_out: Vec<u8> = Vec::new();
5002 let mut accumulated_err = String::new();
5003 let mut last_code = 0i64;
5004 let mut last_data: Option<Value> = None;
5005 let mut exec_error: Option<anyhow::Error> = None;
5006 let mut exit_code: Option<i64> = None;
5007
5008 for stmt in program.statements {
5009 if matches!(stmt, crate::ast::Stmt::Empty) {
5010 continue;
5011 }
5012
5013 match self.execute_stmt_flow(&stmt).await {
5014 Ok(flow) => {
5015 let drained = {
5016 let mut receiver = self.stderr_receiver.lock().await;
5017 receiver.drain_lossy()
5018 };
5019 if !drained.is_empty() {
5020 accumulated_err.push_str(&drained);
5021 }
5022 match flow {
5023 ControlFlow::Normal(r) => {
5024 push_out(&mut accumulated_out, &r);
5025 accumulated_err.push_str(&r.err);
5026 last_code = r.code;
5027 last_data = r.data;
5028 }
5029 ControlFlow::Return { value } => {
5030 push_out(&mut accumulated_out, &value);
5031 accumulated_err.push_str(&value.err);
5032 last_code = value.code;
5033 last_data = value.data;
5034 break;
5035 }
5036 ControlFlow::Exit { code } => {
5037 exit_code = Some(code);
5038 break;
5039 }
5040 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
5041 push_out(&mut accumulated_out, &r);
5042 accumulated_err.push_str(&r.err);
5043 last_code = r.code;
5044 last_data = r.data;
5045 }
5046 }
5047 }
5048 Err(e) => {
5049 exec_error = Some(e);
5050 break;
5051 }
5052 }
5053 }
5054
5055 // Restore original scope unconditionally
5056 {
5057 let mut scope = self.scope.write().await;
5058 *scope = original_scope;
5059 }
5060
5061 // Propagate error or exit after cleanup
5062 if let Some(e) = exec_error {
5063 return Err(e.context(format!("script: {}", script_path.display())));
5064 }
5065 let code = exit_code.unwrap_or(last_code);
5066 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5067 result.err = accumulated_err;
5068 result.data = last_data;
5069 return Ok(Some(result));
5070 }
5071
5072 // No script found
5073 Ok(None)
5074 }
5075
5076 /// Try to execute an external command from PATH.
5077 ///
5078 /// This is the fallback when no builtin or user-defined tool matches.
5079 /// External commands receive a clean argv (flags preserved in their original format).
5080 ///
5081 /// # Requirements
5082 /// - Command must be found in PATH
5083 /// - Current working directory must be on a real filesystem (not virtual like /v)
5084 ///
5085 /// # Returns
5086 /// - `Ok(Some(result))` if command was found and executed
5087 /// - `Ok(None)` if command was not found in PATH
5088 /// - `Err` on execution errors
5089 #[cfg(not(feature = "subprocess"))]
5090 async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
5091 Ok(None)
5092 }
5093
5094 /// Try to execute an external command from PATH.
5095 #[cfg(feature = "subprocess")]
5096 #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
5097 async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5098 // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
5099 // populates from the inbound ctx.cancel on every dispatch. This is
5100 // what makes the `timeout` builtin's swapped child token reach the
5101 // wait_or_kill discipline below — reading `self.cancel_token` would
5102 // give the kernel-wide token and miss the timeout's child cascade.
5103 let cancel = {
5104 let ec = self.exec_ctx.read().await;
5105 ec.cancel.clone()
5106 };
5107 let kill_grace = self.kill_grace;
5108 if !self.allow_external_commands {
5109 return Ok(None);
5110 }
5111
5112 // Get real working directory for relative path resolution and child cwd.
5113 // If the CWD is virtual (no real filesystem path), skip external command
5114 // execution entirely — return None so the dispatch can fall through to
5115 // backend-registered tools.
5116 let real_cwd = {
5117 let ctx = self.exec_ctx.read().await;
5118 match ctx.backend.resolve_real_path(&ctx.cwd) {
5119 Some(p) => p,
5120 None => return Ok(None),
5121 }
5122 };
5123
5124 let executable = if name.contains('/') {
5125 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
5126 let resolved = if std::path::Path::new(name).is_absolute() {
5127 std::path::PathBuf::from(name)
5128 } else {
5129 real_cwd.join(name)
5130 };
5131 if !resolved.exists() {
5132 return Ok(Some(ExecResult::failure(
5133 127,
5134 format!("{}: No such file or directory", name),
5135 )));
5136 }
5137 if !resolved.is_file() {
5138 return Ok(Some(ExecResult::failure(
5139 126,
5140 format!("{}: Is a directory", name),
5141 )));
5142 }
5143 #[cfg(unix)]
5144 {
5145 use std::os::unix::fs::PermissionsExt;
5146 let mode = std::fs::metadata(&resolved)
5147 .map(|m| m.permissions().mode())
5148 .unwrap_or(0);
5149 if mode & 0o111 == 0 {
5150 return Ok(Some(ExecResult::failure(
5151 126,
5152 format!("{}: Permission denied", name),
5153 )));
5154 }
5155 }
5156 resolved.to_string_lossy().into_owned()
5157 } else {
5158 // Get PATH from scope only. The kernel never reads OS env: a
5159 // frontend that wants host PATH seeds it via initial_vars (the REPL
5160 // does, with os_env_vars()). No PATH in scope → nothing resolves.
5161 let path_var = {
5162 let scope = self.scope.read().await;
5163 scope.get("PATH").map(value_to_string).unwrap_or_default()
5164 };
5165
5166 // Resolve command in PATH
5167 match resolve_in_path(name, &path_var) {
5168 Some(path) => path,
5169 None => return Ok(None), // Not found - let caller handle error
5170 }
5171 };
5172
5173 tracing::debug!(executable = %executable, "resolved external command");
5174
5175 // Build flat argv (preserves flag format)
5176 let argv = self.build_args_flat(args).await?;
5177
5178 // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5179 // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5180 // `String`. Take both out under the lock but do NOT drain here — a pipe
5181 // read can block on its producer (a still-running upstream stage), so
5182 // draining before spawn would serialize the pipeline (deadlocking
5183 // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5184 // `set_stdin` clears `pipe_stdin`, so a redirect-set String and a pipe
5185 // are mutually exclusive in practice; prefer the pipe.
5186 let (pipe_stdin, stdin_string) = {
5187 let mut ctx = self.exec_ctx.write().await;
5188 (ctx.pipe_stdin.take(), ctx.take_stdin())
5189 };
5190 let has_stdin = pipe_stdin.is_some() || stdin_string.is_some();
5191
5192 // Build and spawn the command
5193 use tokio::process::Command;
5194
5195 let mut cmd = Command::new(&executable);
5196 cmd.args(&argv);
5197 cmd.current_dir(&real_cwd);
5198
5199 // Hermetic env: child sees only kaish's exported vars, not the kaish
5200 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
5201 // populate it via KernelConfig::initial_vars at construction.
5202 cmd.env_clear();
5203 {
5204 let scope = self.scope.read().await;
5205 let exported = scope.exported_vars();
5206 // A structured value can't cross the process boundary; refuse rather
5207 // than silently JSON-serialize it into the child's environment.
5208 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
5209 return Err(anyhow::anyhow!(msg));
5210 }
5211 for (var_name, value) in exported {
5212 // Binary can't cross the process boundary as an env var value
5213 // either — loud, not the `[binary: N bytes]` placeholder
5214 // silently exported in its place (kept in sync with
5215 // dispatch.rs::try_external and env.rs::execute_with_env).
5216 let value_str = crate::interpreter::value_to_text_sink_named(
5217 &value,
5218 "an exported environment variable value",
5219 )
5220 .map_err(|e| anyhow::anyhow!("{e}"))?;
5221 cmd.env(var_name, value_str);
5222 }
5223 }
5224
5225 // Handle stdin
5226 cmd.stdin(if has_stdin {
5227 std::process::Stdio::piped()
5228 } else if self.interactive {
5229 std::process::Stdio::inherit()
5230 } else {
5231 std::process::Stdio::null()
5232 });
5233
5234 // In interactive mode, standalone or last-in-pipeline commands inherit
5235 // the terminal's stdout/stderr so output streams in real-time.
5236 // First/middle commands must capture stdout for the pipe — same as bash.
5237 let pipeline_position = {
5238 let ctx = self.exec_ctx.read().await;
5239 ctx.pipeline_position
5240 };
5241 let inherit_output = self.interactive
5242 && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5243
5244 if inherit_output {
5245 cmd.stdout(std::process::Stdio::inherit());
5246 cmd.stderr(std::process::Stdio::inherit());
5247 } else {
5248 cmd.stdout(std::process::Stdio::piped());
5249 cmd.stderr(std::process::Stdio::piped());
5250 }
5251
5252 // On Unix, always put the child in its own process group so cancellation
5253 // can `killpg` the whole tree (the child plus any grandchildren).
5254 // Restoring default tty-related signal handlers stays gated on
5255 // job-control mode — those only matter when the child has a controlling
5256 // terminal.
5257 #[cfg(unix)]
5258 {
5259 let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5260 // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
5261 #[allow(unsafe_code)]
5262 unsafe {
5263 cmd.pre_exec(move || {
5264 // Own process group — for kill scope.
5265 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5266 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5267 if restore_jc_signals {
5268 use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5269 let mut sa: nix::libc::sigaction = std::mem::zeroed();
5270 sa.sa_sigaction = SIG_DFL;
5271 if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5272 return Err(std::io::Error::last_os_error());
5273 }
5274 if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5275 return Err(std::io::Error::last_os_error());
5276 }
5277 if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5278 return Err(std::io::Error::last_os_error());
5279 }
5280 if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5281 return Err(std::io::Error::last_os_error());
5282 }
5283 }
5284 Ok(())
5285 });
5286 }
5287 }
5288
5289 // Backstop for kill on drop in case our explicit kill path is bypassed
5290 // (panic, early return, etc) on the **capture** wait path. We do NOT
5291 // set this on the JC inherit path: that uses sync `waitpid` outside
5292 // tokio's view of the child, so on drop tokio would try to kill an
5293 // already-reaped (possibly-reused) PID. The JC path has its own
5294 // cancel handling via the side-task watcher.
5295 let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5296 if !in_jc_inherit_path {
5297 cmd.kill_on_drop(true);
5298 }
5299
5300 // Spawn the process. Capture a `KillTarget` immediately so cancel/
5301 // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5302 // to this process's generation, immune to PID reuse if the OS reaps
5303 // the child before our kill syscalls fire.
5304 let mut child = match cmd.spawn() {
5305 Ok(child) => child,
5306 Err(e) => {
5307 return Ok(Some(ExecResult::failure(
5308 127,
5309 format!("{}: {}", name, e),
5310 )));
5311 }
5312 };
5313 let kill_target = crate::pidfd::KillTarget::from_child(&child);
5314
5315 // If this external runs on behalf of a background job, record its
5316 // process group on the job so `kill -<sig> %N` can signal the real
5317 // process directly (STOP/CONT/USR1/…, not just terminate). The child
5318 // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5319 if let Some(job_id) = self.bg_job_id
5320 && let Some(pid) = child.id()
5321 {
5322 self.jobs.add_pgid(job_id, pid).await;
5323 }
5324
5325 // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5326 // detached task (bounded memory, no pre-drain) so an upstream stage and
5327 // this child run concurrently — and a child that never reads stdin (or
5328 // is killed) just breaks the copy, which stops. A buffered `String` is
5329 // written inline and stdin dropped to signal EOF. Bytes are copied
5330 // verbatim, so binary stdin survives.
5331 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5332 child.stdin.take().map(|mut child_stdin| {
5333 tokio::spawn(async move {
5334 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5335 let mut buf = [0u8; 8192];
5336 loop {
5337 match pipe_in.read(&mut buf).await {
5338 Ok(0) => break, // EOF
5339 Ok(n) => {
5340 if child_stdin.write_all(&buf[..n]).await.is_err() {
5341 break; // child closed stdin
5342 }
5343 }
5344 Err(_) => break,
5345 }
5346 }
5347 // Dropping child_stdin signals EOF to the child.
5348 })
5349 })
5350 } else if let Some(data) = stdin_string {
5351 // Write the buffered String from a detached task too — NOT inline.
5352 // An inline write blocks once the stdin pipe fills, and the output
5353 // drain hasn't spawned yet, so a child that emits a lot before
5354 // consuming all its input (every pipe buffer full) deadlocks. A
5355 // write error here is normal, not a failure: a child that closes
5356 // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5357 // signals EOF.
5358 child.stdin.take().map(|mut child_stdin| {
5359 tokio::spawn(async move {
5360 use tokio::io::AsyncWriteExt;
5361 let _ = child_stdin.write_all(data.as_bytes()).await;
5362 })
5363 })
5364 } else {
5365 None
5366 };
5367
5368 // Abort the stdin-copy task on EVERY exit path (the capture path, both
5369 // interactive `inherit_output` returns, and any early error return).
5370 // Once the child is reaped the copy has nothing left to deliver; if it
5371 // were left parked on `pipe_in.read()` it would leak and hold the
5372 // upstream pipe reader open. A drop guard is the single place that
5373 // covers all returns — explicit per-return aborts were error-prone (an
5374 // earlier version missed the two inherit_output returns).
5375 struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5376 impl Drop for AbortStdinCopyOnDrop {
5377 fn drop(&mut self) {
5378 if let Some(t) = self.0.take() {
5379 t.abort();
5380 }
5381 }
5382 }
5383 let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5384
5385 if inherit_output {
5386 // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5387 #[cfg(unix)]
5388 if let Some(ref term) = self.terminal_state {
5389 let child_id = child.id().unwrap_or(0);
5390 let pid = nix::unistd::Pid::from_raw(child_id as i32);
5391 let pgid = pid; // child is its own pgid leader
5392
5393 // Give the terminal to the child's process group
5394 if let Err(e) = term.give_terminal_to(pgid) {
5395 tracing::warn!("failed to give terminal to child: {}", e);
5396 }
5397
5398 let term_clone = term.clone();
5399 let cmd_name = name.to_string();
5400 let cmd_display = format!("{} {}", name, argv.join(" "));
5401 let jobs = self.jobs.clone();
5402
5403 // Side task that watches for cancellation while the blocking
5404 // waitpid runs. On cancel, it SIGTERMs the process group, waits
5405 // the grace period, then SIGKILLs. The blocking waitpid returns
5406 // when the child dies. AbortOnDrop guard cancels the watcher
5407 // on the success path so it doesn't keep running after wait
5408 // returns naturally.
5409 //
5410 // `wait_complete` shrinks the PID-reuse race: the watcher
5411 // checks it before each kill syscall and bails out if
5412 // wait_for_foreground has already reaped the child. This
5413 // doesn't fully eliminate the race (atomic load + kill is
5414 // not atomic with the OS reap+reuse), but narrows the window
5415 // to nanoseconds — enough to be ignorable in practice.
5416 let wait_complete = std::sync::Arc::new(
5417 std::sync::atomic::AtomicBool::new(false)
5418 );
5419 let cancel_watcher = {
5420 let cancel = cancel.clone();
5421 let wc = wait_complete.clone();
5422 // Ownership transfer: the JC path's sync wait inside
5423 // block_in_place owns the child's reaping, so the
5424 // cancel_watcher drives the kill side via KillTarget
5425 // (pidfd-bound on Linux). When kill_target is None
5426 // (older kernel + open failure, or non-Linux), falls
5427 // through to the older PID-based path the closure
5428 // captures from `pid`.
5429 let target = kill_target.as_ref().map(|t| {
5430 // Re-borrow the components we need into Owned-ish form
5431 // so the spawned task is 'static. We can't move
5432 // KillTarget directly because try_execute_external
5433 // still uses it after the spawn — but on the JC path
5434 // there is no further use after the watcher spawn,
5435 // so a clone-of-pid + owned None pidfd is safe.
5436 // Simpler: signal via the existing target by cloning
5437 // a fresh pidfd; the original keeps its handle.
5438 // Pidfd is just an OwnedFd — not Clone — so do it
5439 // by re-opening from the pid. Fall back if reopen
5440 // fails (race already reaped → best-effort kill).
5441 crate::pidfd::KillTarget::from_pid(t.pid())
5442 });
5443 tokio::spawn(async move {
5444 cancel.cancelled().await;
5445 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5446 use nix::sys::signal::Signal;
5447 if let Some(t) = &target {
5448 t.signal(Signal::SIGTERM);
5449 t.signal_pg(Signal::SIGTERM);
5450 } else {
5451 let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5452 let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5453 }
5454 if kill_grace > Duration::ZERO {
5455 tokio::time::sleep(kill_grace).await;
5456 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5457 }
5458 if let Some(t) = &target {
5459 t.signal(Signal::SIGKILL);
5460 t.signal_pg(Signal::SIGKILL);
5461 } else {
5462 let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5463 let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5464 }
5465 })
5466 };
5467 struct AbortOnDrop(tokio::task::JoinHandle<()>);
5468 impl Drop for AbortOnDrop {
5469 fn drop(&mut self) {
5470 self.0.abort();
5471 }
5472 }
5473 let _watcher_guard = AbortOnDrop(cancel_watcher);
5474
5475 let wait_complete_setter = wait_complete.clone();
5476 let code = tokio::task::block_in_place(move || {
5477 let result = term_clone.wait_for_foreground(pid);
5478 // Mark wait done before the watcher might fire.
5479 wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5480
5481 // Always reclaim the terminal
5482 if let Err(e) = term_clone.reclaim_terminal() {
5483 tracing::warn!("failed to reclaim terminal: {}", e);
5484 }
5485
5486 match result {
5487 crate::terminal::WaitResult::Exited(code) => code as i64,
5488 crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5489 crate::terminal::WaitResult::Stopped(_sig) => {
5490 // Register as a stopped job
5491 let rt = tokio::runtime::Handle::current();
5492 let job_id = rt.block_on(jobs.register_stopped(
5493 cmd_display,
5494 child_id,
5495 child_id, // pgid = pid for group leader
5496 ));
5497 eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5498 148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5499 }
5500 }
5501 });
5502
5503 return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5504 }
5505
5506 // Non-job-control path with inherited stdio.
5507 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5508 Ok(s) => s,
5509 Err(e) => {
5510 return Ok(Some(ExecResult::failure(
5511 1,
5512 format!("{}: failed to wait: {}", name, e),
5513 )));
5514 }
5515 };
5516
5517 let code = exit_code_from_status(&status);
5518
5519 // stdout/stderr already went to the terminal
5520 Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5521 } else {
5522 // Capture output via bounded streams
5523 let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5524 let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5525
5526 let stdout_pipe = child.stdout.take();
5527 let stderr_pipe = child.stderr.take();
5528
5529 let stdout_clone = stdout_stream.clone();
5530 let stderr_clone = stderr_stream.clone();
5531
5532 let stdout_task = stdout_pipe.map(|pipe| {
5533 tokio::spawn(async move {
5534 drain_to_stream(pipe, stdout_clone).await;
5535 })
5536 });
5537
5538 let stderr_task = stderr_pipe.map(|pipe| {
5539 tokio::spawn(async move {
5540 drain_to_stream(pipe, stderr_clone).await;
5541 })
5542 });
5543
5544 let cancelled_before_wait = cancel.is_cancelled();
5545 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5546 Ok(s) => s,
5547 Err(e) => {
5548 // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5549 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5550 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5551 return Ok(Some(ExecResult::failure(
5552 1,
5553 format!("{}: failed to wait: {}", name, e),
5554 )));
5555 }
5556 };
5557
5558 // On cancel, abort the drain tasks (the child's pipes are gone;
5559 // late output is lost but predictable death beats partial capture).
5560 // On normal exit, await drains so we don't lose buffered output.
5561 if cancelled_before_wait || cancel.is_cancelled() {
5562 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5563 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5564 } else {
5565 if let Some(task) = stdout_task {
5566 // Ignore join error — the drain task logs its own errors
5567 let _ = task.await;
5568 }
5569 if let Some(task) = stderr_task {
5570 let _ = task.await;
5571 }
5572 }
5573
5574 let code = exit_code_from_status(&status);
5575
5576 // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5577 // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5578 // intact. stderr stays text. See docs/binary-data.md.
5579 let stdout = stdout_stream.read().await;
5580 let stderr = stderr_stream.read_string().await;
5581 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5582 result.err = stderr;
5583 Ok(Some(result))
5584 }
5585 }
5586
5587 // --- Variable Access ---
5588
5589 /// Get a variable value.
5590 pub async fn get_var(&self, name: &str) -> Option<Value> {
5591 let scope = self.scope.read().await;
5592 scope.get(name).cloned()
5593 }
5594
5595 /// Check if error-exit mode is enabled (for testing).
5596 #[cfg(test)]
5597 pub async fn error_exit_enabled(&self) -> bool {
5598 let scope = self.scope.read().await;
5599 scope.error_exit_enabled()
5600 }
5601
5602 /// Set a variable value.
5603 pub async fn set_var(&self, name: &str, value: Value) {
5604 let mut scope = self.scope.write().await;
5605 scope.set(name.to_string(), value);
5606 }
5607
5608 /// Set positional parameters ($0 script name and $1-$9 args).
5609 pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5610 let mut scope = self.scope.write().await;
5611 scope.set_positional(script_name, args);
5612 }
5613
5614 /// List all variables.
5615 pub async fn list_vars(&self) -> Vec<(String, Value)> {
5616 let scope = self.scope.read().await;
5617 scope.all()
5618 }
5619
5620 /// List exported variables (name, value), sorted by name. These are the
5621 /// vars a child process would see (see `dispatch`'s hermetic env build).
5622 pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5623 let scope = self.scope.read().await;
5624 scope.exported_vars()
5625 }
5626
5627 // --- CWD ---
5628
5629 /// Get current working directory.
5630 pub async fn cwd(&self) -> PathBuf {
5631 self.exec_ctx.read().await.cwd.clone()
5632 }
5633
5634 /// Set current working directory.
5635 pub async fn set_cwd(&self, path: PathBuf) {
5636 let mut ctx = self.exec_ctx.write().await;
5637 ctx.set_cwd(path);
5638 }
5639
5640 /// Set the working directory only if `path` resolves to a directory in the
5641 /// kernel's backend — the same namespace `cd` validates against. Unlike a
5642 /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5643 /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5644 /// disappeared. Returns whether the cwd was changed.
5645 pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5646 // Clone the backend Arc out before the stat so we never hold the
5647 // exec_ctx lock across the await.
5648 let backend = self.exec_ctx.read().await.backend.clone();
5649 let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5650 if is_dir {
5651 self.exec_ctx.write().await.set_cwd(path);
5652 }
5653 is_dir
5654 }
5655
5656 // --- Last Result ---
5657
5658 /// Get the last result ($?).
5659 pub async fn last_result(&self) -> ExecResult {
5660 let scope = self.scope.read().await;
5661 scope.last_result().clone()
5662 }
5663
5664 // --- Tools ---
5665
5666 /// Check if a user-defined function exists.
5667 pub async fn has_function(&self, name: &str) -> bool {
5668 self.user_tools.read().await.contains_key(name)
5669 }
5670
5671 /// Get available tool schemas.
5672 pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5673 self.tools.schemas()
5674 }
5675
5676 /// Classify how the kernel will resolve a command name.
5677 ///
5678 /// This is the supported, single source of truth for command resolution that
5679 /// embedders should call instead of re-deriving the rules. Walk a parsed
5680 /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5681 /// this per command name to bucket each into builtin / user-function /
5682 /// special-form / dynamic / external — for example a consent gate that blocks
5683 /// a script until external commands are approved.
5684 ///
5685 /// The classification mirrors the interpreter's real resolution order
5686 /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5687 /// short-circuit first, then **aliases are expanded** (bounded recursion,
5688 /// re-checking special-forms each step, exactly as execution does), then user
5689 /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5690 /// name that is a variable or command-substitution expansion (`$cmd`,
5691 /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5692 /// be resolved statically.
5693 ///
5694 /// Aliases are resolved against the kernel's current alias table, so an
5695 /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5696 /// thing it would actually run. The safe direction of any residual imprecision
5697 /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5698 /// `.kai`/backend-tool resolution are reported `External` even though some of
5699 /// those resolve in-process, so a consent gate over-gates rather than letting
5700 /// a `PATH` escape slip through.
5701 pub async fn classify_command(&self, name: &str) -> CommandKind {
5702 // Resolve the command head the way `execute_command_depth` does: a
5703 // special-form short-circuits before any alias lookup, otherwise expand
5704 // aliases (bounded, recursive) and re-check from the top. A dynamic name
5705 // can't be resolved at all.
5706 let mut name = name.to_string();
5707 let mut alias_depth = 0u8;
5708 loop {
5709 if !crate::validator::is_static_command_name(&name) {
5710 return CommandKind::Dynamic;
5711 }
5712 if crate::validator::is_runtime_special_form(&name) {
5713 return CommandKind::Special;
5714 }
5715 if alias_depth >= 10 {
5716 break;
5717 }
5718 let alias_value = {
5719 let ctx = self.exec_ctx.read().await;
5720 ctx.aliases.get(&name).cloned()
5721 };
5722 // Expand to the alias's head command. An empty alias value (no head)
5723 // is ignored by execution, so resolution continues with this name.
5724 match alias_value
5725 .as_deref()
5726 .and_then(|v| v.split_whitespace().next())
5727 {
5728 Some(head) => {
5729 name = head.to_string();
5730 alias_depth += 1;
5731 }
5732 None => break,
5733 }
5734 }
5735
5736 let is_user_tool = self.user_tools.read().await.contains_key(&name);
5737 let is_builtin = self.tools.contains(&name);
5738 crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5739 }
5740
5741 // --- Jobs ---
5742
5743 /// Get job manager.
5744 pub fn jobs(&self) -> Arc<JobManager> {
5745 self.jobs.clone()
5746 }
5747
5748 // --- VFS ---
5749
5750 /// Get VFS router.
5751 pub fn vfs(&self) -> Arc<VfsRouter> {
5752 self.vfs.clone()
5753 }
5754
5755 // --- State ---
5756
5757 /// Reset kernel to initial state.
5758 ///
5759 /// Clears in-memory variables and resets cwd to root. History is not
5760 /// cleared (it persists across resets). The kernel's `$$` identity, the
5761 /// confirmation latch / trash-on-delete configuration, and any
5762 /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
5763 /// are re-applied to the fresh scope rather than silently reverting to
5764 /// defaults — an embedder that opted into `with_latch(true)` must not
5765 /// find the gate quietly disabled after a `reset()` between requests.
5766 pub async fn reset(&self) -> Result<()> {
5767 {
5768 let mut scope = self.scope.write().await;
5769 let pid = scope.pid();
5770 let latch_enabled = scope.latch_enabled();
5771 let trash_enabled = scope.trash_enabled();
5772 let mut fresh = Scope::new();
5773 fresh.set_pid(pid);
5774 for (name, value) in self.initial_vars.clone() {
5775 fresh.set_exported(name, value);
5776 }
5777 fresh.set_latch_enabled(latch_enabled);
5778 fresh.set_trash_enabled(trash_enabled);
5779 *scope = fresh;
5780 }
5781 {
5782 let mut ctx = self.exec_ctx.write().await;
5783 ctx.cwd = PathBuf::from("/");
5784 }
5785 Ok(())
5786 }
5787
5788 /// Shutdown the kernel.
5789 pub async fn shutdown(self) -> Result<()> {
5790 // Wait for all background jobs
5791 self.jobs.wait_all().await;
5792 Ok(())
5793 }
5794
5795 /// Dispatch a single command using the full resolution chain.
5796 ///
5797 /// This is the core of `CommandDispatcher` — it syncs state between the
5798 /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5799 /// then delegates to `execute_command` for the actual dispatch.
5800 ///
5801 /// State flow:
5802 /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5803 /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5804 /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5805 async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5806 // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5807 // its inner command via ctx.dispatcher) routes through THIS kernel,
5808 // not a stale parent. Critical for forks: the fork's builtins must
5809 // use the fork's dispatcher, not the parent's.
5810 if let Some(d) = self.dispatcher() {
5811 ctx.dispatcher = Some(d);
5812 }
5813
5814 // 1. Sync ctx → self internals
5815 {
5816 let mut scope = self.scope.write().await;
5817 *scope = ctx.scope.clone();
5818 }
5819 {
5820 let mut ec = self.exec_ctx.write().await;
5821 ec.cwd = ctx.cwd.clone();
5822 ec.prev_cwd = ctx.prev_cwd.clone();
5823 ec.stdin = ctx.stdin.take();
5824 ec.stdin_data = ctx.stdin_data.take();
5825 // The structured-data sideband receiver (set by the concurrent
5826 // pipeline runner on the stage ctx) must reach the tool's snapshot
5827 // too — same reason as the pipe endpoints below. Without this a
5828 // pipeline consumer never sees the producer's `.data`.
5829 ec.stdin_data_rx = ctx.stdin_data_rx.take();
5830 // Streaming pipe endpoints and kernel stderr must flow to the
5831 // tool via self.exec_ctx — execute_command reads that, not the
5832 // passed-in ctx. Without moving these, concurrent pipeline
5833 // stages dispatched via a fork get pipe_stdin = None and
5834 // silently read nothing.
5835 ec.pipe_stdin = ctx.pipe_stdin.take();
5836 ec.pipe_stdout = ctx.pipe_stdout.take();
5837 if let Some(stderr) = ctx.stderr.clone() {
5838 ec.stderr = Some(stderr);
5839 }
5840 ec.aliases = ctx.aliases.clone();
5841 ec.ignore_config = ctx.ignore_config.clone();
5842 ec.output_limit = ctx.output_limit.clone();
5843 ec.pipeline_position = ctx.pipeline_position;
5844 // Sync the cancel token from ctx → ec. Builtins like `timeout`
5845 // swap ctx.cancel to a derived child token before re-dispatching;
5846 // execute_command's snapshot reads ec.cancel (kept aligned by
5847 // this sync), so try_execute_external sees the right token.
5848 ec.cancel = ctx.cancel.clone();
5849 // Same alignment for the watchdog: a fork dispatching through its
5850 // own kernel must hand the shared script clock to the snapshot so
5851 // patient holds in forked stages suspend the right timer.
5852 ec.watchdog = ctx.watchdog.clone();
5853 }
5854
5855 // 2. Execute via the full dispatch chain
5856 let result = self.execute_command(&cmd.name, &cmd.args).await?;
5857
5858 // 3. Sync self → ctx
5859 {
5860 let scope = self.scope.read().await;
5861 ctx.scope = scope.clone();
5862 }
5863 {
5864 let mut ec = self.exec_ctx.write().await;
5865 ctx.cwd = ec.cwd.clone();
5866 ctx.prev_cwd = ec.prev_cwd.clone();
5867 ctx.aliases = ec.aliases.clone();
5868 ctx.ignore_config = ec.ignore_config.clone();
5869 ctx.output_limit = ec.output_limit.clone();
5870 // Return any pipe endpoints that the tool didn't consume.
5871 // `take()` here keeps the fork's exec_ctx in a clean state for
5872 // the next dispatch — these are per-command and shouldn't leak
5873 // between calls.
5874 ctx.pipe_stdin = ec.pipe_stdin.take();
5875 ctx.pipe_stdout = ec.pipe_stdout.take();
5876 }
5877
5878 Ok(result)
5879 }
5880}
5881
5882#[async_trait]
5883impl CommandDispatcher for Kernel {
5884 /// Dispatch a command through the Kernel's full resolution chain.
5885 ///
5886 /// This is the single path for all command execution when called from
5887 /// the pipeline runner. It provides the full dispatch chain:
5888 /// user tools → builtins → .kai scripts → external commands → backend tools.
5889 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5890 self.dispatch_command(cmd, ctx).await
5891 }
5892
5893 /// Evaluate an expression through the kernel's async chain, including
5894 /// command substitution. Delegates to `eval_expr_async`, which snapshots
5895 /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
5896 /// only command output escapes. The `ctx` is unused here because the
5897 /// kernel evaluates against its own session state (a fork carries the
5898 /// pipeline stage's snapshot); var refs resolve against that scope.
5899 async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
5900 self.eval_expr_async(expr).await
5901 }
5902
5903 /// Produce a forked dispatcher with independent mutable state (detached).
5904 ///
5905 /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
5906 /// recursing into the trait method we're defining) and coerces the
5907 /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
5908 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
5909 let fork: Arc<Kernel> = Kernel::fork(self).await;
5910 fork
5911 }
5912
5913 /// Produce a forked dispatcher with cancellation cascading from this kernel.
5914 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
5915 let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
5916 fork
5917 }
5918}
5919
5920/// Apply the requested output format to a builtin's result, unless the tool
5921/// owns its own output.
5922///
5923/// `format` is `ctx.output_format` (set from `--json`). When `owns_output` is
5924/// true the tool already rendered its bytes (bespoke JSON envelope), so the
5925/// kernel leaves the result untouched rather than re-formatting its
5926/// `OutputData`. Otherwise the kernel renders the typed `OutputData` uniformly.
5927fn finalize_output(
5928 result: ExecResult,
5929 format: Option<crate::interpreter::OutputFormat>,
5930 owns_output: bool,
5931) -> ExecResult {
5932 match format {
5933 Some(_) if owns_output => result,
5934 Some(format) => apply_output_format(result, format),
5935 None => result,
5936 }
5937}
5938
5939/// Accumulate output from one result into another.
5940///
5941/// Appends stdout and stderr verbatim and updates the exit code to match the
5942/// new result. Used to preserve output from multiple statements, loop
5943/// iterations, and command chains. No separator is inserted between outputs —
5944/// each command's output concatenates raw, matching bash (`printf a; printf b`
5945/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
5946/// when a command emits its own, as `echo` does).
5947fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
5948 // Materialize lazy OutputData into .out before accumulating.
5949 // Without this, the first command's output stays in .output while
5950 // the second's text gets appended to .out, losing the first.
5951 accumulated.materialize();
5952 match new.out_bytes() {
5953 // A binary result must not be lossy-decoded by text_out(): concatenate
5954 // raw bytes so the combined output stays binary (this is the path every
5955 // top-level statement's result flows through). See docs/binary-data.md.
5956 Some(new_bytes) => {
5957 let mut combined: Vec<u8> = match accumulated.out_bytes() {
5958 Some(b) => b.to_vec(),
5959 None => accumulated.text_out().into_owned().into_bytes(),
5960 };
5961 combined.extend_from_slice(new_bytes);
5962 accumulated.set_out_bytes(combined);
5963 }
5964 None => accumulated.push_out(&new.text_out()),
5965 }
5966 accumulated.err.push_str(&new.err);
5967 accumulated.code = new.code;
5968 accumulated.data = new.data.clone();
5969 accumulated.did_spill = new.did_spill;
5970 accumulated.original_code = new.original_code;
5971 accumulated.content_type = new.content_type.clone();
5972 accumulated.baggage.clone_from(&new.baggage);
5973 // A latch gate (exit-2 + nonce) is the last statement's result; carry its
5974 // control-plane field through accumulation or the confirmation is lost.
5975 accumulated.latch = new.latch.clone();
5976}
5977
5978/// Fold a loop's accumulated output into a break/continue signal that is
5979/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
5980/// (with `N > 1`) would otherwise be discarded when the signal replaces the
5981/// loop's result on its way up. The loop's output comes first (it ran before
5982/// the signal was raised), then the signal's already-carried output.
5983fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
5984 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5985 let mut merged = loop_output;
5986 accumulate_result(&mut merged, result);
5987 *result = merged;
5988 }
5989}
5990
5991/// Accumulate the output a break/continue signal carried (from inner loops it
5992/// propagated through) into the loop that finally handles it, so it survives
5993/// into that loop's result.
5994fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
5995 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
5996 accumulate_result(accumulated, result);
5997 }
5998}
5999
6000/// Check if a value is truthy.
6001fn is_truthy(value: &Value) -> bool {
6002 match value {
6003 Value::Null => false,
6004 Value::Bool(b) => *b,
6005 Value::Int(i) => *i != 0,
6006 Value::Float(f) => *f != 0.0,
6007 Value::String(s) => !s.is_empty(),
6008 Value::Json(json) => match json {
6009 serde_json::Value::Null => false,
6010 serde_json::Value::Array(arr) => !arr.is_empty(),
6011 serde_json::Value::Object(obj) => !obj.is_empty(),
6012 serde_json::Value::Bool(b) => *b,
6013 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6014 serde_json::Value::String(s) => !s.is_empty(),
6015 },
6016 Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6017 }
6018}
6019
6020/// Apply tilde expansion to a value.
6021///
6022/// Only string values starting with `~` are expanded. `home` is the session
6023/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6024/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6025fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6026 match value {
6027 Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6028 _ => value,
6029 }
6030}
6031
6032/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6033/// how the lexer tokenizes the equivalent minimally-quoted command string —
6034/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6035/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6036/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6037/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6038///
6039/// Classification matches the lexer's word classes:
6040/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6041/// the binder's `past_double_dash` arms, exactly as for the string door).
6042/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6043/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6044/// (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6045/// they fall through to a positional, not a flag).
6046/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6047/// binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6048/// `key=value` positional, per the command's word-assign allowlist).
6049/// - everything else → a literal [`Arg::Positional`].
6050///
6051/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6052/// positional — it can never be a flag — and rides through as-is. That is the
6053/// typed passthrough the string-native door cannot offer.
6054pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6055 argv.iter().map(classify_argv_token).collect()
6056}
6057
6058fn classify_argv_token(token: &Value) -> Arg {
6059 let Value::String(s) = token else {
6060 return Arg::Positional(Expr::Literal(token.clone()));
6061 };
6062
6063 if s == "--" {
6064 return Arg::DoubleDash;
6065 }
6066
6067 // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6068 // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6069 // literal word (GH #137), matching this classifier's own literal
6070 // fallback — so they fall through to a literal positional rather than a
6071 // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6072 if let Some(rest) = s.strip_prefix("--") {
6073 if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6074 return match rest.split_once('=') {
6075 Some((key, val)) => Arg::Named {
6076 key: key.to_string(),
6077 value: Expr::Literal(Value::String(val.to_string())),
6078 },
6079 None => Arg::LongFlag(rest.to_string()),
6080 };
6081 }
6082 } else if let Some(rest) = s.strip_prefix('-') {
6083 // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6084 // token carrying any other char — notably `=` (`-k=v` is a parse error in
6085 // the string door) — or a leading digit (`-1` lexes as a number) is not a
6086 // short-flag word, so it falls through to a literal positional instead of
6087 // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6088 if is_short_flag_body(rest) {
6089 return Arg::ShortFlag(rest.to_string());
6090 }
6091 }
6092
6093 if let Some((key, val)) = s.split_once('=') {
6094 if is_shell_identifier(key) {
6095 return Arg::WordAssign {
6096 key: key.to_string(),
6097 value: Expr::Literal(Value::String(val.to_string())),
6098 };
6099 }
6100 }
6101
6102 Arg::Positional(Expr::Literal(Value::String(s.clone())))
6103}
6104
6105/// A short-flag word: a leading ASCII letter, then only ASCII
6106/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6107/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6108/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6109/// (`=` is the assignment operator — a parse error in the string door), and
6110/// any non-ASCII tail (never produced by the lexer, and not safe for the
6111/// combined-short-flag binder's byte-index slicing) do not, so they fall
6112/// through to a literal positional instead of a malformed `ShortFlag`.
6113fn is_short_flag_body(s: &str) -> bool {
6114 s.starts_with(|c: char| c.is_ascii_alphabetic())
6115 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6116}
6117
6118/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6119fn is_shell_identifier(s: &str) -> bool {
6120 let mut chars = s.chars();
6121 match chars.next() {
6122 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6123 _ => return false,
6124 }
6125 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6126}
6127
6128/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6129/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6130/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6131/// must keep every value, not silently drop all but the last. Used by every flag
6132/// surface that can carry the same flag twice — the space form
6133/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6134/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6135/// ordered array.
6136pub(crate) fn push_repeatable_value(
6137 tool_args: &mut ToolArgs,
6138 flag_name: &str,
6139 canonical: &str,
6140 v: Value,
6141) -> anyhow::Result<()> {
6142 let occ = crate::interpreter::value_to_json(&v);
6143 let entry = tool_args
6144 .named
6145 .entry(canonical.to_string())
6146 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6147 if let Value::Json(serde_json::Value::Array(items)) = entry {
6148 items.push(occ);
6149 Ok(())
6150 } else {
6151 anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6152 }
6153}
6154
6155/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6156/// is one token, so it carries a single value: a repeatable flag accumulates
6157/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6158/// first-char glued arm and the combined-bundle arm so the two can't drift on
6159/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6160/// is a loud error, not a silent single-value bind.
6161pub(crate) fn bind_glued_short_value(
6162 tool_args: &mut ToolArgs,
6163 flag_name: &str,
6164 canonical: &str,
6165 consumes: usize,
6166 repeatable: bool,
6167 value: String,
6168) -> anyhow::Result<()> {
6169 if consumes > 1 {
6170 anyhow::bail!(
6171 "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6172 );
6173 }
6174 if repeatable {
6175 push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6176 } else {
6177 tool_args
6178 .named
6179 .insert(canonical.to_string(), Value::String(value));
6180 Ok(())
6181 }
6182}
6183
6184/// Map a child's exit status to a shell-style exit code.
6185///
6186/// `ExitStatus::code()` is `None` when the process died from a signal rather
6187/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6188/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6189/// number. Shared by both external-command spawn sites — production
6190/// (`try_execute_external`, below) and the test-only twin
6191/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6192/// this mapping again (GH #133 item 1).
6193#[cfg(feature = "subprocess")]
6194pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6195 status.code().unwrap_or_else(|| {
6196 #[cfg(unix)]
6197 {
6198 use std::os::unix::process::ExitStatusExt;
6199 128 + status.signal().unwrap_or(0)
6200 }
6201 #[cfg(not(unix))]
6202 {
6203 -1
6204 }
6205 }) as i64
6206}
6207
6208/// Wait for a child to exit, killing it if `cancel` fires first.
6209///
6210/// `target` carries a Linux pidfd (when available) for race-free direct-child
6211/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6212/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6213#[cfg(all(unix, feature = "subprocess"))]
6214pub(crate) async fn wait_or_kill(
6215 child: &mut tokio::process::Child,
6216 target: Option<&crate::pidfd::KillTarget>,
6217 cancel: &tokio_util::sync::CancellationToken,
6218 grace: Duration,
6219) -> std::io::Result<std::process::ExitStatus> {
6220 tokio::select! {
6221 biased;
6222 status = child.wait() => status,
6223 _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6224 }
6225}
6226
6227#[cfg(all(not(unix), feature = "subprocess"))]
6228pub(crate) async fn wait_or_kill(
6229 child: &mut tokio::process::Child,
6230 _target: Option<&()>,
6231 cancel: &tokio_util::sync::CancellationToken,
6232 _grace: Duration,
6233) -> std::io::Result<std::process::ExitStatus> {
6234 tokio::select! {
6235 biased;
6236 status = child.wait() => status,
6237 _ = cancel.cancelled() => {
6238 let _ = child.start_kill();
6239 child.wait().await
6240 }
6241 }
6242}
6243
6244/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6245///
6246/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6247/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6248/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6249#[cfg(all(unix, feature = "subprocess"))]
6250pub(crate) async fn kill_with_grace(
6251 child: &mut tokio::process::Child,
6252 target: Option<&crate::pidfd::KillTarget>,
6253 grace: Duration,
6254) -> std::io::Result<std::process::ExitStatus> {
6255 use nix::sys::signal::Signal;
6256
6257 if let Some(t) = target {
6258 t.signal(Signal::SIGTERM);
6259 t.signal_pg(Signal::SIGTERM);
6260 if grace > Duration::ZERO
6261 && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6262 {
6263 return status;
6264 }
6265 t.signal(Signal::SIGKILL);
6266 t.signal_pg(Signal::SIGKILL);
6267 }
6268 child.wait().await
6269}
6270
6271#[cfg(test)]
6272#[allow(clippy::unwrap_used, clippy::expect_used)]
6273mod argv_classify_tests {
6274 use super::*;
6275
6276 /// A normalized, comparable view of one `Arg` representing its *logical
6277 /// argument* (what the command observably receives), not its exact AST shape:
6278 ///
6279 /// - Value-bearing arms compare by *stringified* value, so the parser's
6280 /// number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6281 /// (`String("-1")`) count as the same argument.
6282 /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6283 /// For every command except the `export`/`alias` allowlist, a bareword
6284 /// `key=value` is stringified straight back to a `"key=value"` positional
6285 /// (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6286 /// converge observably even when they disagree on the AST tag — e.g. the
6287 /// lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6288 /// `WordAssign`, where the classifier (bash-correctly) makes a positional.
6289 /// The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6290 /// separately by `classifies_each_word_class`.
6291 ///
6292 /// Returns `None` for shapes we deliberately don't compare:
6293 /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6294 /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6295 /// lex to `Int`, dropping the literal text, where the classifier keeps the
6296 /// string. That divergence is *intentional* — `execute_argv` preserves a
6297 /// literal numeric string (pass `Value::Int` for a number), the string door
6298 /// can only guess — so the property skips it rather than demanding the
6299 /// classifier replicate a lossy coercion. Numeric edges are pinned exactly
6300 /// by `classifies_each_word_class`.
6301 fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6302 // Only a *string*-valued literal is comparable; a coerced number is not.
6303 let lit = |e: &Expr| match e {
6304 Expr::Literal(Value::String(s)) => Some(s.clone()),
6305 _ => None,
6306 };
6307 Some(match arg {
6308 Arg::DoubleDash => ("dash", String::new(), String::new()),
6309 Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6310 Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6311 Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6312 Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6313 Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6314 })
6315 }
6316
6317 /// Classify a single string token the way `execute_argv` would.
6318 fn classify(token: &str) -> Arg {
6319 classify_argv_token(&Value::String(token.to_string()))
6320 }
6321
6322 #[test]
6323 fn classifies_each_word_class() {
6324 assert_eq!(classify("--"), Arg::DoubleDash);
6325 assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6326 assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6327 assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6328 assert_eq!(
6329 classify("--key=value"),
6330 Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6331 );
6332 assert_eq!(
6333 classify("NAME=val"),
6334 Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6335 );
6336 // Digits after the first flag char are ordinary (kept verbatim).
6337 assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6338 assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6339 // Leading-digit dash is a number to the lexer, not a flag → positional.
6340 assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6341 // Numeric strings keep their literal text — `execute_argv` does NOT
6342 // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6343 // who wants a number passes `Value::Int`; a string stays the string.
6344 assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6345 assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6346 // A lone dash (stdin convention) is a positional, not a flag.
6347 assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6348 // Non-identifier LHS is not an assignment.
6349 assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6350 assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6351 }
6352
6353 #[test]
6354 fn typed_values_pass_through_as_literal_positionals() {
6355 // The whole point of the `&[Value]` signature: a non-string value is a
6356 // literal positional carrying the *exact* value, never stringified and
6357 // never flag-interpreted.
6358 let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6359 assert_eq!(
6360 classify_argv_token(&bytes),
6361 Arg::Positional(Expr::Literal(bytes.clone()))
6362 );
6363 let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6364 assert_eq!(
6365 classify_argv_token(&json),
6366 Arg::Positional(Expr::Literal(json.clone()))
6367 );
6368 // An integer token that *looks* like a flag is still a positional value
6369 // (only strings are inspected for a leading dash).
6370 assert_eq!(
6371 classify_argv_token(&Value::Int(-9)),
6372 Arg::Positional(Expr::Literal(Value::Int(-9)))
6373 );
6374 }
6375
6376 #[test]
6377 fn double_dash_only_matches_exactly() {
6378 // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6379 // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6380 // as a single argv token here it's likewise literal.
6381 assert_eq!(classify("--"), Arg::DoubleDash);
6382 assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6383 assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6384 }
6385
6386 #[test]
6387 fn malformed_flag_words_fall_back_to_literal_positionals() {
6388 // A token that isn't a well-formed flag word must NOT be silently misbound
6389 // into the arg binder (house rule: loud/visible over silent-wrong). Each
6390 // of these is a parse error or different tokenization in the string door,
6391 // so the argv door keeps them as literal positionals.
6392 let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6393 // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6394 // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6395 assert_eq!(classify("-k=v"), pos("-k=v"));
6396 assert_eq!(classify("-="), pos("-="));
6397 // Empty long-flag key.
6398 assert_eq!(classify("--=v"), pos("--=v"));
6399 // `--` followed by a non-letter is not a long flag.
6400 assert_eq!(classify("--1"), pos("--1"));
6401 // A bare dash and a number-dash are positionals (covered above too).
6402 assert_eq!(classify("-"), pos("-"));
6403 assert_eq!(classify("-9"), pos("-9"));
6404 // A non-ASCII tail is not part of the lexer's short-flag char class
6405 // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6406 // absorbs) — classifying it as `ShortFlag` would hand the combined
6407 // short-flag binder a byte string it (correctly, for real ASCII flag
6408 // words) slices by *byte* index, panicking on a multi-byte char
6409 // boundary. Fall back to a literal positional instead.
6410 assert_eq!(classify("-lé"), pos("-lé"));
6411 assert_eq!(classify("-é"), pos("-é"));
6412 }
6413
6414 #[tokio::test]
6415 async fn non_ascii_short_flag_bundle_does_not_panic() {
6416 // Regression: `execute_argv`'s combined-short-flag loop assumed the
6417 // flag body was ASCII (safe to byte-slice) because the lexer's
6418 // grammar guarantees that on the *string* door. The argv door's
6419 // classifier let a non-ASCII tail through as `ShortFlag`, so
6420 // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6421 let kernel = Kernel::transient().expect("failed to create kernel");
6422 let result = kernel
6423 .execute_argv("ls", &[Value::String("-lé".into())])
6424 .await
6425 .expect("execute_argv must not panic on a non-ASCII short-flag token");
6426 // Not a well-formed flag word, so it's a literal positional — `ls`
6427 // then reports it as a missing path rather than mangling flags.
6428 assert_ne!(result.code, 0);
6429 }
6430
6431 proptest::proptest! {
6432 /// The core correctness claim: the classifier mirrors the lexer/parser
6433 /// on metacharacter-free tokens. For any such single token, the `Arg`
6434 /// the classifier produces matches the one the real parser produces for
6435 /// the equivalent one-word command — so `execute_argv` reusing the
6436 /// string door's binder is sound. (First proptest in the workspace.)
6437 #[test]
6438 fn classifier_matches_parser_on_clean_tokens(
6439 // No digits: this property tests the *classification* boundary
6440 // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6441 // positional), not numeric coercion. The lexer coerces digit runs to
6442 // `Int`/`Float` and drops the literal text (even inside a colon-merged
6443 // word: `00:` → `0:`); the classifier intentionally preserves the raw
6444 // string. Those numeric edges are pinned exactly by the unit tests.
6445 token in "[a-zA-Z_=./@:+-]{1,8}"
6446 ) {
6447 let parsed = match parse(&format!("cmd {token}")) {
6448 Ok(p) => p,
6449 Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6450 };
6451 let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6452 return Ok(());
6453 };
6454 // Only compare when the token lexed as exactly one argument.
6455 let [arg] = cmd.args.as_slice() else { return Ok(()); };
6456
6457 let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6458 return Ok(()); // a non-literal parsed Expr we don't model — skip
6459 };
6460 proptest::prop_assert_eq!(
6461 ours, theirs,
6462 "classifier diverged from parser on token {:?}", token
6463 );
6464 }
6465 }
6466}
6467
6468#[cfg(all(test, feature = "subprocess"))]
6469#[allow(clippy::expect_used)]
6470mod tests {
6471 use super::*;
6472
6473 #[tokio::test]
6474 async fn test_kernel_transient() {
6475 let kernel = Kernel::transient().expect("failed to create kernel");
6476 assert_eq!(kernel.name(), "transient");
6477 }
6478
6479 #[tokio::test]
6480 async fn test_kernel_execute_echo() {
6481 let kernel = Kernel::transient().expect("failed to create kernel");
6482 let result = kernel.execute("echo hello").await.expect("execution failed");
6483 assert!(result.ok());
6484 assert_eq!(result.text_out().trim(), "hello");
6485 }
6486
6487 #[tokio::test]
6488 async fn test_multiple_statements_accumulate_output() {
6489 let kernel = Kernel::transient().expect("failed to create kernel");
6490 let result = kernel
6491 .execute("echo one\necho two\necho three")
6492 .await
6493 .expect("execution failed");
6494 assert!(result.ok());
6495 // Should have all three outputs separated by newlines
6496 assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6497 assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6498 assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6499 }
6500
6501 #[tokio::test]
6502 async fn test_and_chain_accumulates_output() {
6503 let kernel = Kernel::transient().expect("failed to create kernel");
6504 let result = kernel
6505 .execute("echo first && echo second")
6506 .await
6507 .expect("execution failed");
6508 assert!(result.ok());
6509 assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6510 assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6511 }
6512
6513 #[tokio::test]
6514 async fn test_for_loop_accumulates_output() {
6515 let kernel = Kernel::transient().expect("failed to create kernel");
6516 let result = kernel
6517 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6518 .await
6519 .expect("execution failed");
6520 assert!(result.ok());
6521 assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6522 assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6523 assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6524 }
6525
6526 #[tokio::test]
6527 async fn test_while_loop_accumulates_output() {
6528 let kernel = Kernel::transient().expect("failed to create kernel");
6529 let result = kernel
6530 .execute(r#"
6531 N=3
6532 while [[ ${N} -gt 0 ]]; do
6533 echo "N=${N}"
6534 N=$((N - 1))
6535 done
6536 "#)
6537 .await
6538 .expect("execution failed");
6539 assert!(result.ok());
6540 assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
6541 assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
6542 assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
6543 }
6544
6545 #[tokio::test]
6546 async fn test_kernel_set_var() {
6547 let kernel = Kernel::transient().expect("failed to create kernel");
6548
6549 kernel.execute("X=42").await.expect("set failed");
6550
6551 let value = kernel.get_var("X").await;
6552 assert_eq!(value, Some(Value::Int(42)));
6553 }
6554
6555 #[tokio::test]
6556 async fn test_kernel_var_expansion() {
6557 let kernel = Kernel::transient().expect("failed to create kernel");
6558
6559 kernel.execute("NAME=\"world\"").await.expect("set failed");
6560 let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
6561
6562 assert!(result.ok());
6563 assert_eq!(result.text_out().trim(), "hello world");
6564 }
6565
6566 #[tokio::test]
6567 async fn test_kernel_last_result() {
6568 let kernel = Kernel::transient().expect("failed to create kernel");
6569
6570 kernel.execute("echo test").await.expect("echo failed");
6571
6572 let last = kernel.last_result().await;
6573 assert!(last.ok());
6574 assert_eq!(last.text_out().trim(), "test");
6575 }
6576
6577 #[tokio::test]
6578 async fn test_kernel_tool_not_found() {
6579 let kernel = Kernel::transient().expect("failed to create kernel");
6580
6581 let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
6582 assert!(!result.ok());
6583 assert_eq!(result.code, 127);
6584 assert!(result.err.contains("command not found"));
6585 }
6586
6587 #[tokio::test]
6588 async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
6589 // The embedder seam: a backend-registered tool (kaijutsu, an MCP
6590 // engine, …) returns a `ToolResult` with structured `data` — this
6591 // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
6592 // and `for r in $(embedder_tool)` see the typed value, not just
6593 // stdout text.
6594 use crate::backend::testing::MockBackend;
6595 use crate::backend::ToolResult;
6596 let (mock, _calls) = MockBackend::new();
6597 let backend = mock.with_tool_result(|_name| {
6598 let mut baggage = std::collections::BTreeMap::new();
6599 baggage.insert("trace_id".to_string(), "abc123".to_string());
6600 // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
6601 // construct via with_data + the with_* setters, not a struct literal.
6602 Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
6603 .with_content_type("application/json")
6604 .with_baggage(baggage))
6605 });
6606 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6607 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6608 .expect("with_backend kernel");
6609
6610 let result = kernel
6611 .execute("embedder_tool")
6612 .await
6613 .expect("execution failed");
6614 assert!(result.ok(), "backend tool call should succeed: {result:?}");
6615 assert_eq!(
6616 result.data,
6617 Some(Value::Json(serde_json::json!({"key": "value"}))),
6618 "backend tool's structured data must survive into ExecResult, not be dropped"
6619 );
6620 assert_eq!(
6621 result.content_type.as_deref(),
6622 Some("application/json"),
6623 "backend tool's content_type must survive into ExecResult"
6624 );
6625 assert_eq!(
6626 result.baggage.get("trace_id").map(String::as_str),
6627 Some("abc123"),
6628 "backend tool's baggage must survive into ExecResult"
6629 );
6630 }
6631
6632 #[tokio::test]
6633 async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
6634 // A backend tool that IS found but fails during execution (`Io`,
6635 // `PermissionDenied`, …) must surface its real error, not get
6636 // misreported as exit-127 "command not found" — that masks a genuine
6637 // failure as a lookup miss.
6638 use crate::backend::testing::MockBackend;
6639 let (mock, _calls) = MockBackend::new();
6640 let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
6641 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6642 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6643 .expect("with_backend kernel");
6644
6645 let result = kernel
6646 .execute("embedder_tool")
6647 .await
6648 .expect("execution failed");
6649 assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
6650 assert!(!result.ok());
6651 assert!(
6652 result.err.contains("disk exploded"),
6653 "the real backend error must be visible, not masked: {result:?}"
6654 );
6655 }
6656
6657 #[tokio::test]
6658 async fn test_external_command_true() {
6659 // Use REPL config for passthrough filesystem access
6660 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6661
6662 // /bin/true should be available on any Unix system
6663 let result = kernel.execute("true").await.expect("execution failed");
6664 // This should use the builtin true, which returns 0
6665 assert!(result.ok(), "true should succeed: {:?}", result);
6666 }
6667
6668 #[tokio::test]
6669 async fn test_external_command_basic() {
6670 // Use REPL config for passthrough filesystem access
6671 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6672
6673 // Test with /bin/echo which is external
6674 // Note: kaish has a builtin echo, so this will use the builtin
6675 // Let's test with a command that's not a builtin
6676 // Actually, let's just test that PATH resolution works by checking the PATH var
6677 let path_var = std::env::var("PATH").unwrap_or_default();
6678 eprintln!("System PATH: {}", path_var);
6679
6680 // Set PATH in kernel to ensure it's available
6681 kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
6682
6683 // Now try an external command like /usr/bin/env
6684 // But env is also a builtin... let's try uname
6685 let result = kernel.execute("uname").await.expect("execution failed");
6686 eprintln!("uname result: {:?}", result);
6687 // uname should succeed if external commands work
6688 assert!(result.ok() || result.code == 127, "uname: {:?}", result);
6689 }
6690
6691 #[tokio::test]
6692 async fn test_kernel_reset() {
6693 let kernel = Kernel::transient().expect("failed to create kernel");
6694
6695 kernel.execute("X=1").await.expect("set failed");
6696 assert!(kernel.get_var("X").await.is_some());
6697
6698 kernel.reset().await.expect("reset failed");
6699 assert!(kernel.get_var("X").await.is_none());
6700 }
6701
6702 #[tokio::test]
6703 async fn test_kernel_reset_preserves_latch_and_trash_config() {
6704 // An embedder configuring `with_latch(true)` must not have the
6705 // confirmation gate silently disabled by a later `reset()` — that
6706 // would let a destructive command through with no nonce and no
6707 // error, exactly the "silent fallback" the latch exists to prevent.
6708 let kernel = Kernel::new(
6709 KernelConfig::transient()
6710 .with_latch(true)
6711 .with_skip_validation(true),
6712 )
6713 .expect("failed to create kernel");
6714
6715 // Write and rm relative to `/` (reset()'s post-reset cwd) so the file
6716 // is reachable identically before and after reset.
6717 kernel.execute("cd /; echo hi > latch-probe.txt").await.expect("setup write failed");
6718
6719 let before = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6720 assert_eq!(before.code, 2, "latch should require confirmation before reset: {before:?}");
6721
6722 kernel.reset().await.expect("reset failed");
6723
6724 // reset() only clears scope/cwd (to `/`), not the VFS — the
6725 // un-deleted probe file (the latch blocked the delete above) is
6726 // still there.
6727 let after = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
6728 assert_eq!(
6729 after.code, 2,
6730 "latch must still require confirmation after reset, not silently disable: {after:?}"
6731 );
6732 }
6733
6734 #[tokio::test]
6735 async fn test_kernel_reset_preserves_pid_and_initial_vars() {
6736 let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
6737 .expect("failed to create kernel");
6738
6739 let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6740 assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
6741
6742 kernel.reset().await.expect("reset failed");
6743
6744 let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
6745 assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
6746 assert_eq!(
6747 kernel.get_var("HOME").await,
6748 Some(Value::String("/home/probe".into())),
6749 "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
6750 );
6751 }
6752
6753 #[tokio::test]
6754 async fn test_kernel_cwd() {
6755 let kernel = Kernel::transient().expect("failed to create kernel");
6756
6757 // Transient kernel uses sandboxed mode with cwd=$HOME
6758 let cwd = kernel.cwd().await;
6759 let home = std::env::var("HOME")
6760 .map(PathBuf::from)
6761 .unwrap_or_else(|_| PathBuf::from("/"));
6762 assert_eq!(cwd, home);
6763
6764 kernel.set_cwd(PathBuf::from("/tmp")).await;
6765 assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
6766 }
6767
6768 #[tokio::test]
6769 async fn test_kernel_list_vars() {
6770 let kernel = Kernel::transient().expect("failed to create kernel");
6771
6772 kernel.execute("A=1").await.ok();
6773 kernel.execute("B=2").await.ok();
6774
6775 let vars = kernel.list_vars().await;
6776 assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
6777 assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
6778 }
6779
6780 #[tokio::test]
6781 async fn test_is_truthy() {
6782 assert!(!is_truthy(&Value::Null));
6783 assert!(!is_truthy(&Value::Bool(false)));
6784 assert!(is_truthy(&Value::Bool(true)));
6785 assert!(!is_truthy(&Value::Int(0)));
6786 assert!(is_truthy(&Value::Int(1)));
6787 assert!(!is_truthy(&Value::String("".into())));
6788 assert!(is_truthy(&Value::String("x".into())));
6789 }
6790
6791 #[tokio::test]
6792 async fn test_jq_in_pipeline() {
6793 let kernel = Kernel::transient().expect("failed to create kernel");
6794 // kaish uses double quotes only; escape inner quotes
6795 let result = kernel
6796 .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
6797 .await
6798 .expect("execution failed");
6799 assert!(result.ok(), "jq pipeline failed: {}", result.err);
6800 assert_eq!(result.text_out().trim(), "Alice");
6801 }
6802
6803 #[tokio::test]
6804 async fn test_user_defined_tool() {
6805 let kernel = Kernel::transient().expect("failed to create kernel");
6806
6807 // Define a function
6808 kernel
6809 .execute(r#"greet() { echo "Hello, $1!" }"#)
6810 .await
6811 .expect("function definition failed");
6812
6813 // Call the function
6814 let result = kernel
6815 .execute(r#"greet "World""#)
6816 .await
6817 .expect("function call failed");
6818
6819 assert!(result.ok(), "greet failed: {}", result.err);
6820 assert_eq!(result.text_out().trim(), "Hello, World!");
6821 }
6822
6823 #[tokio::test]
6824 async fn test_user_tool_positional_args() {
6825 let kernel = Kernel::transient().expect("failed to create kernel");
6826
6827 // Define a function with positional param
6828 kernel
6829 .execute(r#"greet() { echo "Hi $1" }"#)
6830 .await
6831 .expect("function definition failed");
6832
6833 // Call with positional argument
6834 let result = kernel
6835 .execute(r#"greet "Amy""#)
6836 .await
6837 .expect("function call failed");
6838
6839 assert!(result.ok(), "greet failed: {}", result.err);
6840 assert_eq!(result.text_out().trim(), "Hi Amy");
6841 }
6842
6843 #[tokio::test]
6844 async fn test_function_shared_scope() {
6845 let kernel = Kernel::transient().expect("failed to create kernel");
6846
6847 // Set a variable in parent scope
6848 kernel
6849 .execute(r#"SECRET="hidden""#)
6850 .await
6851 .expect("set failed");
6852
6853 // Define a function that accesses and modifies parent variable
6854 kernel
6855 .execute(r#"access_parent() {
6856 echo "${SECRET}"
6857 SECRET="modified"
6858 }"#)
6859 .await
6860 .expect("function definition failed");
6861
6862 // Call the function - it SHOULD see SECRET (shared scope like sh)
6863 let result = kernel.execute("access_parent").await.expect("function call failed");
6864
6865 // Function should have access to parent scope
6866 assert!(
6867 result.text_out().contains("hidden"),
6868 "Function should access parent scope, got: {}",
6869 result.text_out()
6870 );
6871
6872 // Function should have modified the parent variable
6873 let secret = kernel.get_var("SECRET").await;
6874 assert_eq!(
6875 secret,
6876 Some(Value::String("modified".into())),
6877 "Function should modify parent scope"
6878 );
6879 }
6880
6881 #[tokio::test]
6882 #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
6883 async fn test_exec_builtin() {
6884 let kernel = Kernel::transient().expect("failed to create kernel");
6885 // argv is now a space-separated string or JSON array string
6886 let result = kernel
6887 .execute(r#"exec command="/bin/echo" argv="hello world""#)
6888 .await
6889 .expect("exec failed");
6890
6891 assert!(result.ok(), "exec failed: {}", result.err);
6892 assert_eq!(result.text_out().trim(), "hello world");
6893 }
6894
6895 #[tokio::test]
6896 async fn test_while_false_never_runs() {
6897 let kernel = Kernel::transient().expect("failed to create kernel");
6898
6899 // A while loop with false condition should never run
6900 let result = kernel
6901 .execute(r#"
6902 while false; do
6903 echo "should not run"
6904 done
6905 "#)
6906 .await
6907 .expect("while false failed");
6908
6909 assert!(result.ok());
6910 assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
6911 }
6912
6913 #[tokio::test]
6914 async fn test_while_string_comparison() {
6915 let kernel = Kernel::transient().expect("failed to create kernel");
6916
6917 // Set a flag
6918 kernel.execute(r#"FLAG="go""#).await.expect("set failed");
6919
6920 // Use string comparison as condition (shell-compatible [[ ]] syntax)
6921 // Note: Put echo last so we can check the output
6922 let result = kernel
6923 .execute(r#"
6924 while [[ ${FLAG} == "go" ]]; do
6925 FLAG="stop"
6926 echo "running"
6927 done
6928 "#)
6929 .await
6930 .expect("while with string cmp failed");
6931
6932 assert!(result.ok());
6933 assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
6934
6935 // Verify flag was changed
6936 let flag = kernel.get_var("FLAG").await;
6937 assert_eq!(flag, Some(Value::String("stop".into())));
6938 }
6939
6940 #[tokio::test]
6941 async fn test_while_numeric_comparison() {
6942 let kernel = Kernel::transient().expect("failed to create kernel");
6943
6944 // Test > comparison (shell-compatible [[ ]] with -gt)
6945 kernel.execute("N=5").await.expect("set failed");
6946
6947 // Note: Put echo last so we can check the output
6948 let result = kernel
6949 .execute(r#"
6950 while [[ ${N} -gt 3 ]]; do
6951 N=3
6952 echo "N was greater"
6953 done
6954 "#)
6955 .await
6956 .expect("while with > failed");
6957
6958 assert!(result.ok());
6959 assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
6960 }
6961
6962 #[tokio::test]
6963 async fn test_break_in_while_loop() {
6964 let kernel = Kernel::transient().expect("failed to create kernel");
6965
6966 let result = kernel
6967 .execute(r#"
6968 I=0
6969 while true; do
6970 I=1
6971 echo "before break"
6972 break
6973 echo "after break"
6974 done
6975 "#)
6976 .await
6977 .expect("while with break failed");
6978
6979 assert!(result.ok());
6980 assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
6981 assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
6982
6983 // Verify we exited the loop
6984 let i = kernel.get_var("I").await;
6985 assert_eq!(i, Some(Value::Int(1)));
6986 }
6987
6988 #[tokio::test]
6989 async fn test_continue_in_while_loop() {
6990 let kernel = Kernel::transient().expect("failed to create kernel");
6991
6992 // Test continue in a while loop where variables persist
6993 // We use string state transition: "start" -> "middle" -> "end"
6994 // continue on "middle" should skip to next iteration
6995 // Shell-compatible: use [[ ]] for comparisons
6996 let result = kernel
6997 .execute(r#"
6998 STATE="start"
6999 AFTER_CONTINUE="no"
7000 while [[ ${STATE} != "done" ]]; do
7001 if [[ ${STATE} == "start" ]]; then
7002 STATE="middle"
7003 continue
7004 AFTER_CONTINUE="yes"
7005 fi
7006 if [[ ${STATE} == "middle" ]]; then
7007 STATE="done"
7008 fi
7009 done
7010 "#)
7011 .await
7012 .expect("while with continue failed");
7013
7014 assert!(result.ok());
7015
7016 // STATE should be "done" (we completed the loop)
7017 let state = kernel.get_var("STATE").await;
7018 assert_eq!(state, Some(Value::String("done".into())));
7019
7020 // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7021 let after = kernel.get_var("AFTER_CONTINUE").await;
7022 assert_eq!(after, Some(Value::String("no".into())));
7023 }
7024
7025 #[tokio::test]
7026 async fn test_break_with_level() {
7027 let kernel = Kernel::transient().expect("failed to create kernel");
7028
7029 // Nested loop with break 2 to exit both loops
7030 // We verify by checking OUTER value:
7031 // - If break 2 works, OUTER stays at 1 (set before for loop)
7032 // - If break 2 fails, OUTER becomes 2 (set after for loop)
7033 let result = kernel
7034 .execute(r#"
7035 OUTER=0
7036 while true; do
7037 OUTER=1
7038 for X in "1 2"; do
7039 break 2
7040 done
7041 OUTER=2
7042 done
7043 "#)
7044 .await
7045 .expect("nested break failed");
7046
7047 assert!(result.ok());
7048
7049 // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7050 let outer = kernel.get_var("OUTER").await;
7051 assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7052 }
7053
7054 #[tokio::test]
7055 async fn test_return_from_tool() {
7056 let kernel = Kernel::transient().expect("failed to create kernel");
7057
7058 // Define a function that returns early
7059 kernel
7060 .execute(r#"early_return() {
7061 if [[ $1 == 1 ]]; then
7062 return 42
7063 fi
7064 echo "not returned"
7065 }"#)
7066 .await
7067 .expect("function definition failed");
7068
7069 // Call with arg=1 should return with exit code 42
7070 // (POSIX shell behavior: return N sets exit code, doesn't output N)
7071 let result = kernel
7072 .execute("early_return 1")
7073 .await
7074 .expect("function call failed");
7075
7076 // Exit code should be 42 (non-zero, so not ok())
7077 assert_eq!(result.code, 42);
7078 // Output should be empty (we returned before echo)
7079 assert!(result.text_out().is_empty());
7080 }
7081
7082 #[tokio::test]
7083 async fn test_return_without_value() {
7084 let kernel = Kernel::transient().expect("failed to create kernel");
7085
7086 // Define a function that returns without a value
7087 kernel
7088 .execute(r#"early_exit() {
7089 if [[ $1 == "stop" ]]; then
7090 return
7091 fi
7092 echo "continued"
7093 }"#)
7094 .await
7095 .expect("function definition failed");
7096
7097 // Call with arg="stop" should return early
7098 let result = kernel
7099 .execute(r#"early_exit "stop""#)
7100 .await
7101 .expect("function call failed");
7102
7103 assert!(result.ok());
7104 assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7105 }
7106
7107 #[tokio::test]
7108 async fn test_exit_stops_execution() {
7109 let kernel = Kernel::transient().expect("failed to create kernel");
7110
7111 // exit should stop further execution
7112 kernel
7113 .execute(r#"
7114 BEFORE="yes"
7115 exit 0
7116 AFTER="yes"
7117 "#)
7118 .await
7119 .expect("execution failed");
7120
7121 // BEFORE should be set, AFTER should not
7122 let before = kernel.get_var("BEFORE").await;
7123 assert_eq!(before, Some(Value::String("yes".into())));
7124
7125 let after = kernel.get_var("AFTER").await;
7126 assert!(after.is_none(), "AFTER should not be set after exit");
7127 }
7128
7129 #[tokio::test]
7130 async fn test_exit_with_code() {
7131 let kernel = Kernel::transient().expect("failed to create kernel");
7132
7133 // exit with code should propagate the exit code
7134 let result = kernel
7135 .execute("exit 42")
7136 .await
7137 .expect("exit failed");
7138
7139 assert_eq!(result.code, 42);
7140 assert!(result.text_out().is_empty(), "exit should not produce stdout");
7141 }
7142
7143 #[tokio::test]
7144 async fn test_set_e_stops_on_failure() {
7145 let kernel = Kernel::transient().expect("failed to create kernel");
7146
7147 // Enable error-exit mode
7148 kernel.execute("set -e").await.expect("set -e failed");
7149
7150 // Run a sequence where the middle command fails
7151 kernel
7152 .execute(r#"
7153 STEP1="done"
7154 false
7155 STEP2="done"
7156 "#)
7157 .await
7158 .expect("execution failed");
7159
7160 // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7161 let step1 = kernel.get_var("STEP1").await;
7162 assert_eq!(step1, Some(Value::String("done".into())));
7163
7164 let step2 = kernel.get_var("STEP2").await;
7165 assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7166 }
7167
7168 #[tokio::test]
7169 async fn test_set_plus_e_disables_error_exit() {
7170 let kernel = Kernel::transient().expect("failed to create kernel");
7171
7172 // Enable then disable error-exit mode
7173 kernel.execute("set -e").await.expect("set -e failed");
7174 kernel.execute("set +e").await.expect("set +e failed");
7175
7176 // Now failure should NOT stop execution
7177 kernel
7178 .execute(r#"
7179 STEP1="done"
7180 false
7181 STEP2="done"
7182 "#)
7183 .await
7184 .expect("execution failed");
7185
7186 // Both should be set since +e disables error exit
7187 let step1 = kernel.get_var("STEP1").await;
7188 assert_eq!(step1, Some(Value::String("done".into())));
7189
7190 let step2 = kernel.get_var("STEP2").await;
7191 assert_eq!(step2, Some(Value::String("done".into())));
7192 }
7193
7194 #[tokio::test]
7195 async fn test_set_ignores_unknown_options() {
7196 let kernel = Kernel::transient().expect("failed to create kernel");
7197
7198 // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7199 let result = kernel
7200 .execute("set -e -u -o pipefail")
7201 .await
7202 .expect("set with unknown options failed");
7203
7204 assert!(result.ok(), "set should succeed with unknown options");
7205
7206 // -e should still be enabled
7207 kernel
7208 .execute(r#"
7209 BEFORE="yes"
7210 false
7211 AFTER="yes"
7212 "#)
7213 .await
7214 .ok();
7215
7216 let after = kernel.get_var("AFTER").await;
7217 assert!(after.is_none(), "-e should be enabled despite unknown options");
7218 }
7219
7220 #[tokio::test]
7221 async fn test_set_no_args_shows_settings() {
7222 let kernel = Kernel::transient().expect("failed to create kernel");
7223
7224 // Enable -e
7225 kernel.execute("set -e").await.expect("set -e failed");
7226
7227 // Call set with no args to see settings
7228 let result = kernel.execute("set").await.expect("set failed");
7229
7230 assert!(result.ok());
7231 assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7232 }
7233
7234 #[tokio::test]
7235 async fn test_set_e_in_pipeline() {
7236 let kernel = Kernel::transient().expect("failed to create kernel");
7237
7238 kernel.execute("set -e").await.expect("set -e failed");
7239
7240 // Pipeline failure should trigger exit
7241 kernel
7242 .execute(r#"
7243 BEFORE="yes"
7244 false | cat
7245 AFTER="yes"
7246 "#)
7247 .await
7248 .ok();
7249
7250 let before = kernel.get_var("BEFORE").await;
7251 assert_eq!(before, Some(Value::String("yes".into())));
7252
7253 // AFTER should not be set if pipeline failure triggers exit
7254 // Note: The exit code of a pipeline is the exit code of the last command
7255 // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7256 // To test pipeline failure, we need the last command to fail.
7257 }
7258
7259 #[tokio::test]
7260 async fn test_set_e_with_and_chain() {
7261 let kernel = Kernel::transient().expect("failed to create kernel");
7262
7263 kernel.execute("set -e").await.expect("set -e failed");
7264
7265 // Commands in && chain should not trigger -e on the first failure
7266 // because && explicitly handles the error
7267 kernel
7268 .execute(r#"
7269 RESULT="initial"
7270 false && RESULT="chained"
7271 RESULT="continued"
7272 "#)
7273 .await
7274 .ok();
7275
7276 // In bash, commands in && don't trigger -e. The chain handles the failure.
7277 // Our implementation may differ - let's verify current behavior.
7278 let result = kernel.get_var("RESULT").await;
7279 // If we follow bash semantics, RESULT should be "continued"
7280 // If we trigger -e on the false, RESULT stays "initial"
7281 assert!(result.is_some(), "RESULT should be set");
7282 }
7283
7284 #[tokio::test]
7285 async fn test_set_e_exits_in_for_loop() {
7286 let kernel = Kernel::transient().expect("failed to create kernel");
7287
7288 kernel.execute("set -e").await.expect("set -e failed");
7289
7290 kernel
7291 .execute(r#"
7292 REACHED="no"
7293 for x in 1 2 3; do
7294 false
7295 REACHED="yes"
7296 done
7297 "#)
7298 .await
7299 .ok();
7300
7301 // With set -e, false should trigger exit; REACHED should remain "no"
7302 let reached = kernel.get_var("REACHED").await;
7303 assert_eq!(reached, Some(Value::String("no".into())),
7304 "set -e should exit on failure in for loop body");
7305 }
7306
7307 #[tokio::test]
7308 async fn test_for_loop_continues_without_set_e() {
7309 let kernel = Kernel::transient().expect("failed to create kernel");
7310
7311 // Without set -e, for loop should continue normally
7312 kernel
7313 .execute(r#"
7314 COUNT=0
7315 for x in 1 2 3; do
7316 false
7317 COUNT=$((COUNT + 1))
7318 done
7319 "#)
7320 .await
7321 .ok();
7322
7323 let count = kernel.get_var("COUNT").await;
7324 // Arithmetic produces Int values; accept either Int or String representation
7325 let count_val = match &count {
7326 Some(Value::Int(n)) => *n,
7327 Some(Value::String(s)) => s.parse().unwrap_or(-1),
7328 _ => -1,
7329 };
7330 assert_eq!(count_val, 3,
7331 "without set -e, loop should complete all iterations (got {:?})", count);
7332 }
7333
7334 // ═══════════════════════════════════════════════════════════════════════════
7335 // Source Tests
7336 // ═══════════════════════════════════════════════════════════════════════════
7337
7338 #[tokio::test]
7339 async fn test_source_sets_variables() {
7340 let kernel = Kernel::transient().expect("failed to create kernel");
7341
7342 // Write a script to the VFS
7343 kernel
7344 .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7345 .await
7346 .expect("write failed");
7347
7348 // Source the script
7349 let result = kernel
7350 .execute(r#"source "/test.kai""#)
7351 .await
7352 .expect("source failed");
7353
7354 assert!(result.ok(), "source should succeed");
7355
7356 // Variable should be set in current scope
7357 let foo = kernel.get_var("FOO").await;
7358 assert_eq!(foo, Some(Value::String("bar".into())));
7359 }
7360
7361 #[tokio::test]
7362 async fn test_source_with_dot_alias() {
7363 let kernel = Kernel::transient().expect("failed to create kernel");
7364
7365 // Write a script to the VFS
7366 kernel
7367 .execute(r#"write "/vars.kai" 'X=42'"#)
7368 .await
7369 .expect("write failed");
7370
7371 // Source using . alias
7372 let result = kernel
7373 .execute(r#". "/vars.kai""#)
7374 .await
7375 .expect(". failed");
7376
7377 assert!(result.ok(), ". should succeed");
7378
7379 // Variable should be set in current scope
7380 let x = kernel.get_var("X").await;
7381 assert_eq!(x, Some(Value::Int(42)));
7382 }
7383
7384 #[tokio::test]
7385 async fn test_source_not_found() {
7386 let kernel = Kernel::transient().expect("failed to create kernel");
7387
7388 // Try to source a non-existent file
7389 let result = kernel
7390 .execute(r#"source "/nonexistent.kai""#)
7391 .await
7392 .expect("source should not fail with error");
7393
7394 assert!(!result.ok(), "source of non-existent file should fail");
7395 assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7396 }
7397
7398 #[tokio::test]
7399 async fn test_source_missing_filename() {
7400 let kernel = Kernel::transient().expect("failed to create kernel");
7401
7402 // Call source with no arguments
7403 let result = kernel
7404 .execute("source")
7405 .await
7406 .expect("source should not fail with error");
7407
7408 assert!(!result.ok(), "source without filename should fail");
7409 assert!(result.err.contains("missing filename"), "error should mention missing filename");
7410 }
7411
7412 #[tokio::test]
7413 async fn test_source_executes_multiple_statements() {
7414 let kernel = Kernel::transient().expect("failed to create kernel");
7415
7416 // Write a script with multiple statements
7417 kernel
7418 .execute(r#"write "/multi.kai" 'A=1
7419B=2
7420C=3'"#)
7421 .await
7422 .expect("write failed");
7423
7424 // Source it
7425 kernel
7426 .execute(r#"source "/multi.kai""#)
7427 .await
7428 .expect("source failed");
7429
7430 // All variables should be set
7431 assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7432 assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7433 assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7434 }
7435
7436 #[tokio::test]
7437 async fn test_source_can_define_functions() {
7438 let kernel = Kernel::transient().expect("failed to create kernel");
7439
7440 // Write a script that defines a function
7441 kernel
7442 .execute(r#"write "/functions.kai" 'greet() {
7443 echo "Hello, $1!"
7444}'"#)
7445 .await
7446 .expect("write failed");
7447
7448 // Source it
7449 kernel
7450 .execute(r#"source "/functions.kai""#)
7451 .await
7452 .expect("source failed");
7453
7454 // Use the defined function
7455 let result = kernel
7456 .execute(r#"greet "World""#)
7457 .await
7458 .expect("greet failed");
7459
7460 assert!(result.ok());
7461 assert!(result.text_out().contains("Hello, World!"));
7462 }
7463
7464 #[tokio::test]
7465 async fn test_source_inherits_error_exit() {
7466 let kernel = Kernel::transient().expect("failed to create kernel");
7467
7468 // Enable error exit
7469 kernel.execute("set -e").await.expect("set -e failed");
7470
7471 // Write a script that has a failure
7472 kernel
7473 .execute(r#"write "/fail.kai" 'BEFORE="yes"
7474false
7475AFTER="yes"'"#)
7476 .await
7477 .expect("write failed");
7478
7479 // Source it (should exit on false due to set -e)
7480 kernel
7481 .execute(r#"source "/fail.kai""#)
7482 .await
7483 .ok();
7484
7485 // BEFORE should be set, AFTER should NOT be set due to error exit
7486 let before = kernel.get_var("BEFORE").await;
7487 assert_eq!(before, Some(Value::String("yes".into())));
7488
7489 // Note: This test depends on whether error exit is checked within source
7490 // Currently our implementation checks per-statement in the main kernel
7491 }
7492
7493 // ═══════════════════════════════════════════════════════════════════════════
7494 // set -e with && / || chains
7495 // ═══════════════════════════════════════════════════════════════════════════
7496
7497 #[tokio::test]
7498 async fn test_set_e_and_chain_left_fails() {
7499 // set -e; false && echo hi; REACHED=1 → REACHED should be set
7500 let kernel = Kernel::transient().expect("failed to create kernel");
7501 kernel.execute("set -e").await.expect("set -e failed");
7502
7503 kernel
7504 .execute("false && echo hi; REACHED=1")
7505 .await
7506 .expect("execution failed");
7507
7508 let reached = kernel.get_var("REACHED").await;
7509 assert_eq!(
7510 reached,
7511 Some(Value::Int(1)),
7512 "set -e should not trigger on left side of &&"
7513 );
7514 }
7515
7516 #[tokio::test]
7517 async fn test_set_e_and_chain_right_fails() {
7518 // set -e; true && false; REACHED=1 → REACHED should NOT be set
7519 let kernel = Kernel::transient().expect("failed to create kernel");
7520 kernel.execute("set -e").await.expect("set -e failed");
7521
7522 kernel
7523 .execute("true && false; REACHED=1")
7524 .await
7525 .expect("execution failed");
7526
7527 let reached = kernel.get_var("REACHED").await;
7528 assert!(
7529 reached.is_none(),
7530 "set -e should trigger when right side of && fails"
7531 );
7532 }
7533
7534 #[tokio::test]
7535 async fn test_set_e_or_chain_recovers() {
7536 // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7537 let kernel = Kernel::transient().expect("failed to create kernel");
7538 kernel.execute("set -e").await.expect("set -e failed");
7539
7540 kernel
7541 .execute("false || echo recovered; REACHED=1")
7542 .await
7543 .expect("execution failed");
7544
7545 let reached = kernel.get_var("REACHED").await;
7546 assert_eq!(
7547 reached,
7548 Some(Value::Int(1)),
7549 "set -e should not trigger when || recovers the failure"
7550 );
7551 }
7552
7553 #[tokio::test]
7554 async fn test_set_e_or_chain_both_fail() {
7555 // set -e; false || false; REACHED=1 → REACHED should NOT be set
7556 let kernel = Kernel::transient().expect("failed to create kernel");
7557 kernel.execute("set -e").await.expect("set -e failed");
7558
7559 kernel
7560 .execute("false || false; REACHED=1")
7561 .await
7562 .expect("execution failed");
7563
7564 let reached = kernel.get_var("REACHED").await;
7565 assert!(
7566 reached.is_none(),
7567 "set -e should trigger when || chain ultimately fails"
7568 );
7569 }
7570
7571 // ═══════════════════════════════════════════════════════════════════════════
7572 // Cancellation Tests
7573 // ═══════════════════════════════════════════════════════════════════════════
7574
7575 /// Helper: schedule a cancel after a delay from a background thread.
7576 /// Uses std::thread because cancel() is sync and Kernel is not Send.
7577 fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
7578 let k = Arc::clone(kernel);
7579 std::thread::spawn(move || {
7580 std::thread::sleep(delay);
7581 k.cancel();
7582 });
7583 }
7584
7585 #[tokio::test]
7586 async fn test_cancel_interrupts_for_loop() {
7587 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7588
7589 // Schedule cancel after a short delay from a background OS thread
7590 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7591
7592 let result = kernel
7593 .execute("for i in $(seq 1 100000); do X=$i; done")
7594 .await
7595 .expect("execute failed");
7596
7597 assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
7598
7599 // The loop variable should be set to something < 100000
7600 let x = kernel.get_var("X").await;
7601 if let Some(Value::Int(n)) = x {
7602 assert!(n < 100000, "loop should have been interrupted before finishing, got X={n}");
7603 }
7604 }
7605
7606 #[tokio::test]
7607 async fn test_cancel_interrupts_while_loop() {
7608 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7609 kernel.execute("COUNT=0").await.expect("init failed");
7610
7611 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7612
7613 let result = kernel
7614 .execute("while true; do COUNT=$((COUNT + 1)); done")
7615 .await
7616 .expect("execute failed");
7617
7618 assert_eq!(result.code, 130);
7619
7620 let count = kernel.get_var("COUNT").await;
7621 if let Some(Value::Int(n)) = count {
7622 assert!(n > 0, "loop should have run at least once");
7623 }
7624 }
7625
7626 #[tokio::test]
7627 async fn test_reset_after_cancel() {
7628 // After cancellation, the next execute() should work normally
7629 let kernel = Kernel::transient().expect("failed to create kernel");
7630 kernel.cancel(); // cancel with nothing running
7631
7632 let result = kernel.execute("echo hello").await.expect("execute failed");
7633 assert!(result.ok(), "execute after cancel should succeed");
7634 assert_eq!(result.text_out().trim(), "hello");
7635 }
7636
7637 #[tokio::test]
7638 async fn test_cancel_interrupts_statement_sequence() {
7639 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7640
7641 // Schedule cancel after the first statement runs but before sleep finishes
7642 schedule_cancel(&kernel, std::time::Duration::from_millis(50));
7643
7644 let result = kernel
7645 .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
7646 .await
7647 .expect("execute failed");
7648
7649 assert_eq!(result.code, 130);
7650
7651 // STEP should be 1 (set before sleep), not 2 or 3
7652 let step = kernel.get_var("STEP").await;
7653 assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
7654 }
7655
7656 // ═══════════════════════════════════════════════════════════════════════════
7657 // Case Statement Tests
7658 // ═══════════════════════════════════════════════════════════════════════════
7659
7660 #[tokio::test]
7661 async fn test_case_simple_match() {
7662 let kernel = Kernel::transient().expect("failed to create kernel");
7663
7664 let result = kernel
7665 .execute(r#"
7666 case "hello" in
7667 hello) echo "matched hello" ;;
7668 world) echo "matched world" ;;
7669 esac
7670 "#)
7671 .await
7672 .expect("case failed");
7673
7674 assert!(result.ok());
7675 assert_eq!(result.text_out().trim(), "matched hello");
7676 }
7677
7678 #[tokio::test]
7679 async fn test_case_wildcard_match() {
7680 let kernel = Kernel::transient().expect("failed to create kernel");
7681
7682 let result = kernel
7683 .execute(r#"
7684 case "main.rs" in
7685 *.py) echo "Python" ;;
7686 *.rs) echo "Rust" ;;
7687 *) echo "Unknown" ;;
7688 esac
7689 "#)
7690 .await
7691 .expect("case failed");
7692
7693 assert!(result.ok());
7694 assert_eq!(result.text_out().trim(), "Rust");
7695 }
7696
7697 #[tokio::test]
7698 async fn test_case_default_match() {
7699 let kernel = Kernel::transient().expect("failed to create kernel");
7700
7701 let result = kernel
7702 .execute(r#"
7703 case "unknown.xyz" in
7704 *.py) echo "Python" ;;
7705 *.rs) echo "Rust" ;;
7706 *) echo "Default" ;;
7707 esac
7708 "#)
7709 .await
7710 .expect("case failed");
7711
7712 assert!(result.ok());
7713 assert_eq!(result.text_out().trim(), "Default");
7714 }
7715
7716 #[tokio::test]
7717 async fn test_case_no_match() {
7718 let kernel = Kernel::transient().expect("failed to create kernel");
7719
7720 // Case with no default branch and no match
7721 let result = kernel
7722 .execute(r#"
7723 case "nope" in
7724 "yes") echo "yes" ;;
7725 "no") echo "no" ;;
7726 esac
7727 "#)
7728 .await
7729 .expect("case failed");
7730
7731 assert!(result.ok());
7732 assert!(result.text_out().is_empty(), "no match should produce empty output");
7733 }
7734
7735 #[tokio::test]
7736 async fn test_case_with_variable() {
7737 let kernel = Kernel::transient().expect("failed to create kernel");
7738
7739 kernel.execute(r#"LANG="rust""#).await.expect("set failed");
7740
7741 let result = kernel
7742 .execute(r#"
7743 case ${LANG} in
7744 python) echo "snake" ;;
7745 rust) echo "crab" ;;
7746 go) echo "gopher" ;;
7747 esac
7748 "#)
7749 .await
7750 .expect("case failed");
7751
7752 assert!(result.ok());
7753 assert_eq!(result.text_out().trim(), "crab");
7754 }
7755
7756 #[tokio::test]
7757 async fn test_case_multiple_patterns() {
7758 let kernel = Kernel::transient().expect("failed to create kernel");
7759
7760 let result = kernel
7761 .execute(r#"
7762 case "yes" in
7763 "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
7764 "n"|"no"|"N"|"NO") echo "negative" ;;
7765 esac
7766 "#)
7767 .await
7768 .expect("case failed");
7769
7770 assert!(result.ok());
7771 assert_eq!(result.text_out().trim(), "affirmative");
7772 }
7773
7774 #[tokio::test]
7775 async fn test_case_glob_question_mark() {
7776 let kernel = Kernel::transient().expect("failed to create kernel");
7777
7778 let result = kernel
7779 .execute(r#"
7780 case "test1" in
7781 test?) echo "matched test?" ;;
7782 *) echo "default" ;;
7783 esac
7784 "#)
7785 .await
7786 .expect("case failed");
7787
7788 assert!(result.ok());
7789 assert_eq!(result.text_out().trim(), "matched test?");
7790 }
7791
7792 #[tokio::test]
7793 async fn test_case_char_class() {
7794 let kernel = Kernel::transient().expect("failed to create kernel");
7795
7796 let result = kernel
7797 .execute(r#"
7798 case "Yes" in
7799 [Yy]*) echo "yes-like" ;;
7800 [Nn]*) echo "no-like" ;;
7801 esac
7802 "#)
7803 .await
7804 .expect("case failed");
7805
7806 assert!(result.ok());
7807 assert_eq!(result.text_out().trim(), "yes-like");
7808 }
7809
7810 // ═══════════════════════════════════════════════════════════════════════════
7811 // Cat Stdin Tests
7812 // ═══════════════════════════════════════════════════════════════════════════
7813
7814 #[tokio::test]
7815 async fn test_cat_from_pipeline() {
7816 let kernel = Kernel::transient().expect("failed to create kernel");
7817
7818 let result = kernel
7819 .execute(r#"echo "piped text" | cat"#)
7820 .await
7821 .expect("cat pipeline failed");
7822
7823 assert!(result.ok(), "cat failed: {}", result.err);
7824 assert_eq!(result.text_out().trim(), "piped text");
7825 }
7826
7827 #[tokio::test]
7828 async fn test_cat_from_pipeline_multiline() {
7829 let kernel = Kernel::transient().expect("failed to create kernel");
7830
7831 let result = kernel
7832 .execute(r#"echo "line1\nline2" | cat -n"#)
7833 .await
7834 .expect("cat pipeline failed");
7835
7836 assert!(result.ok(), "cat failed: {}", result.err);
7837 assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
7838 }
7839
7840 // ═══════════════════════════════════════════════════════════════════════════
7841 // Heredoc Tests
7842 // ═══════════════════════════════════════════════════════════════════════════
7843
7844 #[tokio::test]
7845 async fn test_heredoc_basic() {
7846 let kernel = Kernel::transient().expect("failed to create kernel");
7847
7848 let result = kernel
7849 .execute("cat <<EOF\nhello\nEOF")
7850 .await
7851 .expect("heredoc failed");
7852
7853 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7854 assert_eq!(result.text_out().trim(), "hello");
7855 }
7856
7857 #[tokio::test]
7858 async fn test_arithmetic_in_string() {
7859 let kernel = Kernel::transient().expect("failed to create kernel");
7860
7861 let result = kernel
7862 .execute(r#"echo "result: $((1 + 2))""#)
7863 .await
7864 .expect("arithmetic in string failed");
7865
7866 assert!(result.ok(), "echo failed: {}", result.err);
7867 assert_eq!(result.text_out().trim(), "result: 3");
7868 }
7869
7870 #[tokio::test]
7871 async fn test_heredoc_multiline() {
7872 let kernel = Kernel::transient().expect("failed to create kernel");
7873
7874 let result = kernel
7875 .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
7876 .await
7877 .expect("heredoc failed");
7878
7879 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
7880 assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
7881 assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
7882 assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
7883 }
7884
7885 #[tokio::test]
7886 async fn test_heredoc_variable_expansion() {
7887 // Bug N: unquoted heredoc should expand variables
7888 let kernel = Kernel::transient().expect("failed to create kernel");
7889
7890 kernel.execute("GREETING=hello").await.expect("set var");
7891
7892 let result = kernel
7893 .execute("cat <<EOF\n$GREETING world\nEOF")
7894 .await
7895 .expect("heredoc expansion failed");
7896
7897 assert!(result.ok(), "heredoc expansion failed: {}", result.err);
7898 assert_eq!(result.text_out().trim(), "hello world");
7899 }
7900
7901 #[tokio::test]
7902 async fn test_heredoc_quoted_no_expansion() {
7903 // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
7904 let kernel = Kernel::transient().expect("failed to create kernel");
7905
7906 kernel.execute("GREETING=hello").await.expect("set var");
7907
7908 let result = kernel
7909 .execute("cat <<'EOF'\n$GREETING world\nEOF")
7910 .await
7911 .expect("quoted heredoc failed");
7912
7913 assert!(result.ok(), "quoted heredoc failed: {}", result.err);
7914 assert_eq!(result.text_out().trim(), "$GREETING world");
7915 }
7916
7917 #[tokio::test]
7918 async fn test_heredoc_default_value_expansion() {
7919 // Bug N: ${VAR:-default} should expand in unquoted heredocs
7920 let kernel = Kernel::transient().expect("failed to create kernel");
7921
7922 let result = kernel
7923 .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
7924 .await
7925 .expect("heredoc default expansion failed");
7926
7927 assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
7928 assert_eq!(result.text_out().trim(), "fallback");
7929 }
7930
7931 // ═══════════════════════════════════════════════════════════════════════════
7932 // Read Builtin Tests
7933 // ═══════════════════════════════════════════════════════════════════════════
7934
7935 #[tokio::test]
7936 async fn test_read_from_pipeline() {
7937 let kernel = Kernel::transient().expect("failed to create kernel");
7938
7939 // Pipe input to read
7940 let result = kernel
7941 .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
7942 .await
7943 .expect("read pipeline failed");
7944
7945 assert!(result.ok(), "read failed: {}", result.err);
7946 assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
7947 }
7948
7949 #[tokio::test]
7950 async fn test_read_multiple_vars_from_pipeline() {
7951 let kernel = Kernel::transient().expect("failed to create kernel");
7952
7953 let result = kernel
7954 .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
7955 .await
7956 .expect("read pipeline failed");
7957
7958 assert!(result.ok(), "read failed: {}", result.err);
7959 assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
7960 }
7961
7962 // ═══════════════════════════════════════════════════════════════════════════
7963 // Shell-Style Function Tests
7964 // ═══════════════════════════════════════════════════════════════════════════
7965
7966 #[tokio::test]
7967 async fn test_posix_function_with_positional_params() {
7968 let kernel = Kernel::transient().expect("failed to create kernel");
7969
7970 // Define POSIX-style function
7971 kernel
7972 .execute(r#"greet() { echo "Hello, $1!" }"#)
7973 .await
7974 .expect("function definition failed");
7975
7976 // Call the function
7977 let result = kernel
7978 .execute(r#"greet "Amy""#)
7979 .await
7980 .expect("function call failed");
7981
7982 assert!(result.ok(), "greet failed: {}", result.err);
7983 assert_eq!(result.text_out().trim(), "Hello, Amy!");
7984 }
7985
7986 #[tokio::test]
7987 async fn test_posix_function_multiple_args() {
7988 let kernel = Kernel::transient().expect("failed to create kernel");
7989
7990 // Define function using $1 and $2
7991 kernel
7992 .execute(r#"add_greeting() { echo "$1 $2!" }"#)
7993 .await
7994 .expect("function definition failed");
7995
7996 // Call the function
7997 let result = kernel
7998 .execute(r#"add_greeting "Hello" "World""#)
7999 .await
8000 .expect("function call failed");
8001
8002 assert!(result.ok(), "function failed: {}", result.err);
8003 assert_eq!(result.text_out().trim(), "Hello World!");
8004 }
8005
8006 #[tokio::test]
8007 async fn test_bash_function_with_positional_params() {
8008 let kernel = Kernel::transient().expect("failed to create kernel");
8009
8010 // Define bash-style function (function keyword, no parens)
8011 kernel
8012 .execute(r#"function greet { echo "Hi $1" }"#)
8013 .await
8014 .expect("function definition failed");
8015
8016 // Call the function
8017 let result = kernel
8018 .execute(r#"greet "Bob""#)
8019 .await
8020 .expect("function call failed");
8021
8022 assert!(result.ok(), "greet failed: {}", result.err);
8023 assert_eq!(result.text_out().trim(), "Hi Bob");
8024 }
8025
8026 #[tokio::test]
8027 async fn test_shell_function_with_all_args() {
8028 let kernel = Kernel::transient().expect("failed to create kernel");
8029
8030 // Define function using $@ (all args)
8031 kernel
8032 .execute(r#"echo_all() { echo "args: $@" }"#)
8033 .await
8034 .expect("function definition failed");
8035
8036 // Call with multiple args
8037 let result = kernel
8038 .execute(r#"echo_all "a" "b" "c""#)
8039 .await
8040 .expect("function call failed");
8041
8042 assert!(result.ok(), "function failed: {}", result.err);
8043 assert_eq!(result.text_out().trim(), "args: a b c");
8044 }
8045
8046 #[tokio::test]
8047 async fn test_shell_function_with_arg_count() {
8048 let kernel = Kernel::transient().expect("failed to create kernel");
8049
8050 // Define function using $# (arg count)
8051 kernel
8052 .execute(r#"count_args() { echo "count: $#" }"#)
8053 .await
8054 .expect("function definition failed");
8055
8056 // Call with three args
8057 let result = kernel
8058 .execute(r#"count_args "x" "y" "z""#)
8059 .await
8060 .expect("function call failed");
8061
8062 assert!(result.ok(), "function failed: {}", result.err);
8063 assert_eq!(result.text_out().trim(), "count: 3");
8064 }
8065
8066 #[tokio::test]
8067 async fn test_shell_function_shared_scope() {
8068 let kernel = Kernel::transient().expect("failed to create kernel");
8069
8070 // Set a variable in parent scope
8071 kernel
8072 .execute(r#"PARENT_VAR="visible""#)
8073 .await
8074 .expect("set failed");
8075
8076 // Define shell function that reads and writes parent variable
8077 kernel
8078 .execute(r#"modify_parent() {
8079 echo "saw: ${PARENT_VAR}"
8080 PARENT_VAR="changed by function"
8081 }"#)
8082 .await
8083 .expect("function definition failed");
8084
8085 // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8086 let result = kernel.execute("modify_parent").await.expect("function failed");
8087
8088 assert!(
8089 result.text_out().contains("visible"),
8090 "Shell function should access parent scope, got: {}",
8091 result.text_out()
8092 );
8093
8094 // Parent variable should be modified
8095 let var = kernel.get_var("PARENT_VAR").await;
8096 assert_eq!(
8097 var,
8098 Some(Value::String("changed by function".into())),
8099 "Shell function should modify parent scope"
8100 );
8101 }
8102
8103 // ═══════════════════════════════════════════════════════════════════════════
8104 // Script Execution via PATH Tests
8105 // ═══════════════════════════════════════════════════════════════════════════
8106
8107 #[tokio::test]
8108 async fn test_script_execution_from_path() {
8109 let kernel = Kernel::transient().expect("failed to create kernel");
8110
8111 // Create /bin directory and script
8112 kernel.execute(r#"mkdir "/bin""#).await.ok();
8113 kernel
8114 .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8115 .await
8116 .expect("write script failed");
8117
8118 // Set PATH to /bin
8119 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8120
8121 // Call script by name (without .kai extension)
8122 let result = kernel
8123 .execute("hello")
8124 .await
8125 .expect("script execution failed");
8126
8127 assert!(result.ok(), "script failed: {}", result.err);
8128 assert_eq!(result.text_out().trim(), "Hello from script!");
8129 }
8130
8131 #[tokio::test]
8132 async fn test_script_with_args() {
8133 let kernel = Kernel::transient().expect("failed to create kernel");
8134
8135 // Create script that uses positional params
8136 kernel.execute(r#"mkdir "/bin""#).await.ok();
8137 kernel
8138 .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8139 .await
8140 .expect("write script failed");
8141
8142 // Set PATH
8143 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8144
8145 // Call script with arg
8146 let result = kernel
8147 .execute(r#"greet "World""#)
8148 .await
8149 .expect("script execution failed");
8150
8151 assert!(result.ok(), "script failed: {}", result.err);
8152 assert_eq!(result.text_out().trim(), "Hello, World!");
8153 }
8154
8155 #[tokio::test]
8156 async fn test_script_not_found() {
8157 let kernel = Kernel::transient().expect("failed to create kernel");
8158
8159 // Set empty PATH
8160 kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8161
8162 // Call non-existent script
8163 let result = kernel
8164 .execute("noscript")
8165 .await
8166 .expect("execution failed");
8167
8168 assert!(!result.ok(), "should fail with command not found");
8169 assert_eq!(result.code, 127);
8170 assert!(result.err.contains("command not found"));
8171 }
8172
8173 #[tokio::test]
8174 async fn test_script_path_search_order() {
8175 let kernel = Kernel::transient().expect("failed to create kernel");
8176
8177 // Create two directories with same-named script
8178 // Note: using "myscript" not "test" to avoid conflict with test builtin
8179 kernel.execute(r#"mkdir "/first""#).await.ok();
8180 kernel.execute(r#"mkdir "/second""#).await.ok();
8181 kernel
8182 .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8183 .await
8184 .expect("write failed");
8185 kernel
8186 .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8187 .await
8188 .expect("write failed");
8189
8190 // Set PATH with first before second
8191 kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8192
8193 // Should find first one
8194 let result = kernel
8195 .execute("myscript")
8196 .await
8197 .expect("script execution failed");
8198
8199 assert!(result.ok(), "script failed: {}", result.err);
8200 assert_eq!(result.text_out().trim(), "from first");
8201 }
8202
8203 // ═══════════════════════════════════════════════════════════════════════════
8204 // Special Variable Tests ($?, $$, unset vars)
8205 // ═══════════════════════════════════════════════════════════════════════════
8206
8207 #[tokio::test]
8208 async fn test_last_exit_code_success() {
8209 let kernel = Kernel::transient().expect("failed to create kernel");
8210
8211 // true exits with 0
8212 let result = kernel.execute("true; echo $?").await.expect("execution failed");
8213 assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8214 }
8215
8216 #[tokio::test]
8217 async fn test_last_exit_code_failure() {
8218 let kernel = Kernel::transient().expect("failed to create kernel");
8219
8220 // false exits with 1
8221 let result = kernel.execute("false; echo $?").await.expect("execution failed");
8222 assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8223 }
8224
8225 #[tokio::test]
8226 async fn test_current_pid() {
8227 let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229 let result = kernel.execute("echo $$").await.expect("execution failed");
8230 // PID should be a positive number
8231 let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8232 assert!(pid > 0, "PID should be positive");
8233 }
8234
8235 #[tokio::test]
8236 async fn test_unset_variable_expands_to_empty() {
8237 let kernel = Kernel::transient().expect("failed to create kernel");
8238
8239 // Unset variable in interpolation should be empty
8240 let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8241 assert_eq!(result.text_out().trim(), "prefix::suffix");
8242 }
8243
8244 #[tokio::test]
8245 async fn test_eq_ne_operators() {
8246 let kernel = Kernel::transient().expect("failed to create kernel");
8247
8248 // Test -eq operator
8249 let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8250 assert_eq!(result.text_out().trim(), "eq works");
8251
8252 // Test -ne operator
8253 let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8254 assert_eq!(result.text_out().trim(), "ne works");
8255
8256 // Test -eq with different values
8257 let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8258 assert_eq!(result.text_out().trim(), "correct");
8259 }
8260
8261 #[tokio::test]
8262 async fn test_escaped_dollar_in_string() {
8263 let kernel = Kernel::transient().expect("failed to create kernel");
8264
8265 // \$ should produce literal $
8266 let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8267 assert_eq!(result.text_out().trim(), "$100");
8268 }
8269
8270 #[tokio::test]
8271 async fn test_special_vars_in_interpolation() {
8272 let kernel = Kernel::transient().expect("failed to create kernel");
8273
8274 // Test $? in string interpolation
8275 let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8276 assert_eq!(result.text_out().trim(), "exit: 0");
8277
8278 // Test $$ in string interpolation
8279 let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8280 assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8281 let text = result.text_out();
8282 let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8283 let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8284 }
8285
8286 // ═══════════════════════════════════════════════════════════════════════════
8287 // Command Substitution Tests
8288 // ═══════════════════════════════════════════════════════════════════════════
8289
8290 #[tokio::test]
8291 async fn test_command_subst_assignment() {
8292 let kernel = Kernel::transient().expect("failed to create kernel");
8293
8294 // Command substitution in assignment
8295 let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8296 assert_eq!(result.text_out().trim(), "hello");
8297 }
8298
8299 #[tokio::test]
8300 async fn test_command_subst_with_args() {
8301 let kernel = Kernel::transient().expect("failed to create kernel");
8302
8303 // Command substitution with string argument
8304 let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8305 assert_eq!(result.text_out().trim(), "a b c");
8306 }
8307
8308 #[tokio::test]
8309 async fn test_command_subst_nested_vars() {
8310 let kernel = Kernel::transient().expect("failed to create kernel");
8311
8312 // Variables inside command substitution
8313 let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8314 assert_eq!(result.text_out().trim(), "hello world");
8315 }
8316
8317 #[tokio::test]
8318 async fn test_background_job_basic() {
8319 use std::time::Duration;
8320
8321 let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8322
8323 // Run a simple background command
8324 let result = kernel.execute("echo hello &").await.expect("execution failed");
8325 assert!(result.ok(), "background command should succeed: {}", result.err);
8326 assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8327
8328 // Give the job time to complete
8329 tokio::time::sleep(Duration::from_millis(100)).await;
8330
8331 // Check job status
8332 let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8333 assert!(status.ok(), "status should succeed: {}", status.err);
8334 assert!(
8335 status.text_out().contains("done:") || status.text_out().contains("running"),
8336 "should have valid status: {}",
8337 status.text_out()
8338 );
8339
8340 // Check stdout
8341 let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
8342 assert!(stdout.ok());
8343 assert!(stdout.text_out().contains("hello"));
8344 }
8345
8346 #[tokio::test]
8347 async fn test_heredoc_piped_to_command() {
8348 // Bug 4: heredoc content should pipe through to next command
8349 let kernel = Kernel::transient().expect("kernel");
8350 let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8351 assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8352 assert_eq!(result.text_out().trim(), "hello world");
8353 }
8354
8355 /// A transient kernel paired with a real, auto-cleaning tempdir. The
8356 /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8357 /// tests need actual files on disk. Hold the returned `TempDir` for the
8358 /// test's lifetime: it removes the directory tree on drop — including on
8359 /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8360 /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8361 /// as a string for interpolation into scripts.
8362 fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8363 let kernel = Kernel::transient().expect("kernel");
8364 let tmp = tempfile::tempdir().expect("tempdir");
8365 let dir = tmp.path().display().to_string();
8366 (kernel, tmp, dir)
8367 }
8368
8369 #[tokio::test]
8370 async fn test_for_loop_glob_iterates() {
8371 // Bug 1: for F in $(glob ...) should iterate per file, not once
8372 let (kernel, _tmp, dir) = transient_with_tempdir();
8373 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8374 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8375 let result = kernel.execute(&format!(r#"
8376 N=0
8377 for F in $(glob "{dir}/*.txt"); do
8378 N=$((N + 1))
8379 done
8380 echo $N
8381 "#)).await.unwrap();
8382 assert!(result.ok(), "for glob failed: {}", result.err);
8383 assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8384 }
8385
8386 #[tokio::test]
8387 async fn test_bare_glob_expansion_echo() {
8388 let (kernel, _tmp, dir) = transient_with_tempdir();
8389 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8390 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8391 kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8392 kernel.execute(&format!("cd {dir}")).await.unwrap();
8393 let result = kernel.execute("echo *.txt").await.unwrap();
8394 assert!(result.ok(), "echo *.txt failed: {}", result.err);
8395 let out = result.text_out();
8396 let out = out.trim();
8397 // Should contain both .txt files (order may vary)
8398 assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8399 assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8400 assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8401 }
8402
8403 #[tokio::test]
8404 async fn test_bare_glob_no_matches_errors() {
8405 let (kernel, _tmp, dir) = transient_with_tempdir();
8406 kernel.execute(&format!("cd {dir}")).await.unwrap();
8407 let result = kernel.execute("echo *.nonexistent").await;
8408 match &result {
8409 Ok(exec) => {
8410 // No-match glob should produce a non-zero exit code
8411 assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8412 assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8413 }
8414 Err(e) => {
8415 assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8416 }
8417 }
8418 }
8419
8420 #[tokio::test]
8421 async fn test_bare_glob_disabled_with_set() {
8422 let (kernel, _tmp, dir) = transient_with_tempdir();
8423 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8424 kernel.execute(&format!("cd {dir}")).await.unwrap();
8425 // Disable glob expansion
8426 kernel.execute("set +o glob").await.unwrap();
8427 let result = kernel.execute("echo *.txt").await.unwrap();
8428 // With glob disabled, *.txt should be passed as literal string
8429 assert!(result.ok(), "echo should succeed: {}", result.err);
8430 assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8431 }
8432
8433 #[tokio::test]
8434 async fn test_bare_glob_quoted_not_expanded() {
8435 let (kernel, _tmp, dir) = transient_with_tempdir();
8436 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8437 kernel.execute(&format!("cd {dir}")).await.unwrap();
8438 // Quoted globs should NOT expand
8439 let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8440 assert!(result.ok(), "echo should succeed: {}", result.err);
8441 assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8442 }
8443
8444 #[tokio::test]
8445 async fn test_bare_glob_for_loop() {
8446 let (kernel, _tmp, dir) = transient_with_tempdir();
8447 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8448 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8449 kernel.execute(&format!("cd {dir}")).await.unwrap();
8450 let result = kernel.execute(r#"
8451 N=0
8452 for f in *.txt; do
8453 N=$((N + 1))
8454 done
8455 echo $N
8456 "#).await.unwrap();
8457 assert!(result.ok(), "for loop failed: {}", result.err);
8458 assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8459 }
8460
8461 #[tokio::test]
8462 async fn test_glob_in_assignment_is_literal() {
8463 let kernel = Kernel::transient().expect("kernel");
8464 let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8465 assert!(result.ok());
8466 assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8467 }
8468
8469 #[tokio::test]
8470 async fn test_glob_in_test_expr_is_literal() {
8471 let kernel = Kernel::transient().expect("kernel");
8472 let result = kernel.execute(r#"
8473 if [[ *.txt == "*.txt" ]]; then
8474 echo "match"
8475 else
8476 echo "no"
8477 fi
8478 "#).await.unwrap();
8479 assert!(result.ok());
8480 assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8481 }
8482
8483 #[tokio::test]
8484 async fn test_command_subst_echo_not_iterable() {
8485 // Regression guard: $(echo "a b c") must remain a single string
8486 let kernel = Kernel::transient().expect("kernel");
8487 let result = kernel.execute(r#"
8488 N=0
8489 for X in $(echo "a b c"); do N=$((N + 1)); done
8490 echo $N
8491 "#).await.unwrap();
8492 assert!(result.ok());
8493 assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8494 }
8495
8496 // -- accumulate_result / newline tests --
8497
8498 #[test]
8499 fn test_accumulate_preserves_own_newlines() {
8500 // Outputs concatenate verbatim — a command's own trailing newline is
8501 // kept, none is invented.
8502 let mut acc = ExecResult::success("line1\n");
8503 let new = ExecResult::success("line2\n");
8504 accumulate_result(&mut acc, &new);
8505 assert_eq!(&*acc.text_out(), "line1\nline2\n");
8506 assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8507 }
8508
8509 #[test]
8510 fn test_accumulate_inserts_no_separator() {
8511 // No artificial separator: `printf a; printf b` style concatenates to
8512 // `ab`, matching bash (regression for the 2026-06-09 finding).
8513 let mut acc = ExecResult::success("line1");
8514 let new = ExecResult::success("line2");
8515 accumulate_result(&mut acc, &new);
8516 assert_eq!(&*acc.text_out(), "line1line2");
8517 }
8518
8519 #[test]
8520 fn test_accumulate_empty_into_nonempty() {
8521 let mut acc = ExecResult::success("");
8522 let new = ExecResult::success("hello\n");
8523 accumulate_result(&mut acc, &new);
8524 assert_eq!(&*acc.text_out(), "hello\n");
8525 }
8526
8527 #[test]
8528 fn test_accumulate_nonempty_into_empty() {
8529 let mut acc = ExecResult::success("hello\n");
8530 let new = ExecResult::success("");
8531 accumulate_result(&mut acc, &new);
8532 assert_eq!(&*acc.text_out(), "hello\n");
8533 }
8534
8535 #[test]
8536 fn test_accumulate_stderr_no_double_newlines() {
8537 let mut acc = ExecResult::failure(1, "err1\n");
8538 let new = ExecResult::failure(1, "err2\n");
8539 accumulate_result(&mut acc, &new);
8540 assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
8541 }
8542
8543 #[tokio::test]
8544 async fn test_multiple_echo_no_blank_lines() {
8545 let kernel = Kernel::transient().expect("kernel");
8546 let result = kernel
8547 .execute("echo one\necho two\necho three")
8548 .await
8549 .expect("execution failed");
8550 assert!(result.ok());
8551 assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
8552 }
8553
8554 #[tokio::test]
8555 async fn test_for_loop_no_blank_lines() {
8556 let kernel = Kernel::transient().expect("kernel");
8557 let result = kernel
8558 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8559 .await
8560 .expect("execution failed");
8561 assert!(result.ok());
8562 assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
8563 }
8564
8565 #[tokio::test]
8566 async fn test_for_command_subst_no_blank_lines() {
8567 let kernel = Kernel::transient().expect("kernel");
8568 let result = kernel
8569 .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
8570 .await
8571 .expect("execution failed");
8572 assert!(result.ok());
8573 assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
8574 }
8575
8576 // ------------------------------------------------------------------
8577 // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
8578 // ------------------------------------------------------------------
8579
8580 /// Helper: a throwaway schema with one `--pair` param declared as
8581 /// consuming two positionals per occurrence. Modelled after what
8582 /// jq_native will declare for `--arg` / `--argjson`.
8583 fn multi_consume_schema() -> crate::tools::ToolSchema {
8584 use crate::tools::{ParamSchema, ToolSchema};
8585 ToolSchema::new("test", "multi-consume smoke")
8586 .param(
8587 ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
8588 .consumes(2),
8589 )
8590 }
8591
8592 fn pos(s: &str) -> Arg {
8593 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
8594 }
8595
8596 #[tokio::test]
8597 async fn build_args_multi_consume_single_occurrence() {
8598 let kernel = Kernel::transient().expect("kernel");
8599 let schema = multi_consume_schema();
8600 // Simulates: test --pair NAME VALUE filter
8601 let args = vec![
8602 Arg::LongFlag("pair".into()),
8603 pos("NAME"),
8604 pos("VALUE"),
8605 pos("filter"),
8606 ];
8607 let built = kernel
8608 .build_args_async(&args, Some(&schema))
8609 .await
8610 .expect("build_args should succeed");
8611
8612 // `--pair` + its two positionals are consumed into named["pair"],
8613 // which becomes an outer array of one inner 2-element array.
8614 let pair = built.named.get("pair").expect("named[pair] missing");
8615 match pair {
8616 Value::Json(serde_json::Value::Array(occurrences)) => {
8617 assert_eq!(occurrences.len(), 1, "expected one occurrence");
8618 match &occurrences[0] {
8619 serde_json::Value::Array(values) => {
8620 assert_eq!(values.len(), 2, "pair must have 2 values");
8621 assert_eq!(values[0], serde_json::Value::String("NAME".into()));
8622 assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
8623 }
8624 other => panic!("expected inner array, got {other:?}"),
8625 }
8626 }
8627 other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
8628 }
8629
8630 // The un-consumed positional ("filter") remains in `positional`.
8631 assert_eq!(built.positional.len(), 1);
8632 assert_eq!(built.positional[0], Value::String("filter".into()));
8633 }
8634 #[tokio::test]
8635 async fn build_args_multi_consume_two_occurrences_accumulate() {
8636 let kernel = Kernel::transient().expect("kernel");
8637 let schema = multi_consume_schema();
8638 // Simulates: test --pair A 1 --pair B 2 filter
8639 let args = vec![
8640 Arg::LongFlag("pair".into()),
8641 pos("A"),
8642 pos("1"),
8643 Arg::LongFlag("pair".into()),
8644 pos("B"),
8645 pos("2"),
8646 pos("filter"),
8647 ];
8648 let built = kernel
8649 .build_args_async(&args, Some(&schema))
8650 .await
8651 .expect("build_args should succeed");
8652
8653 let pair = built.named.get("pair").expect("named[pair] missing");
8654 match pair {
8655 Value::Json(serde_json::Value::Array(occurrences)) => {
8656 assert_eq!(occurrences.len(), 2, "expected two occurrences");
8657 // Preserved in invocation order.
8658 match &occurrences[0] {
8659 serde_json::Value::Array(values) => {
8660 assert_eq!(values[0], serde_json::Value::String("A".into()));
8661 assert_eq!(values[1], serde_json::Value::String("1".into()));
8662 }
8663 other => panic!("expected inner array, got {other:?}"),
8664 }
8665 match &occurrences[1] {
8666 serde_json::Value::Array(values) => {
8667 assert_eq!(values[0], serde_json::Value::String("B".into()));
8668 assert_eq!(values[1], serde_json::Value::String("2".into()));
8669 }
8670 other => panic!("expected inner array, got {other:?}"),
8671 }
8672 }
8673 other => panic!("expected Json(Array(...)), got {other:?}"),
8674 }
8675 }
8676
8677 // ── undeclared space-form flag under map_positionals (kj --type val) ──
8678 //
8679 // A backend/MCP tool whose schema does NOT declare a flag must not let
8680 // `--flag value` (space form) silently divorce the value: that was a
8681 // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
8682 // kaish fails loud rather than guessing.
8683
8684 use crate::tools::{ParamSchema, ToolSchema};
8685
8686 /// Backend-style schema (map_positionals) declaring only a `name`
8687 /// positional — `--type` is intentionally undeclared.
8688 fn kj_like_schema() -> ToolSchema {
8689 ToolSchema::new("kj", "incomplete backend schema")
8690 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8691 .with_positional_mapping()
8692 }
8693
8694 #[tokio::test]
8695 async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
8696 let kernel = Kernel::transient().expect("kernel");
8697 let schema = kj_like_schema();
8698 // kj context create exp --type explorer
8699 let args = vec![
8700 pos("context"),
8701 pos("create"),
8702 pos("exp"),
8703 Arg::LongFlag("type".into()),
8704 pos("explorer"),
8705 ];
8706 let err = kernel
8707 .build_args_async(&args, Some(&schema))
8708 .await
8709 .expect_err("undeclared --type with a space value must fail loud");
8710 let msg = err.to_string();
8711 assert!(msg.contains("--type"), "message should name the flag: {msg}");
8712 assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
8713 assert!(msg.contains("kj"), "message should name the tool: {msg}");
8714 }
8715
8716 #[tokio::test]
8717 async fn build_args_declared_space_flag_still_binds() {
8718 let kernel = Kernel::transient().expect("kernel");
8719 // Same tool, but now the schema DECLARES --type as a string param.
8720 let schema = ToolSchema::new("kj", "complete schema")
8721 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
8722 .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
8723 .with_positional_mapping();
8724 let args = vec![
8725 pos("exp"),
8726 Arg::LongFlag("type".into()),
8727 pos("explorer"),
8728 ];
8729 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8730 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8731 }
8732
8733 #[tokio::test]
8734 async fn build_args_equals_form_binds_for_undeclared_flag() {
8735 let kernel = Kernel::transient().expect("kernel");
8736 let schema = kj_like_schema();
8737 // The unambiguous `=` form must keep working even when undeclared.
8738 let args = vec![
8739 pos("exp"),
8740 Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
8741 ];
8742 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8743 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8744 }
8745
8746 #[tokio::test]
8747 async fn build_args_undeclared_bool_flag_at_end_is_ok() {
8748 let kernel = Kernel::transient().expect("kernel");
8749 let schema = kj_like_schema();
8750 // No positional follows --force → unambiguously a bare flag.
8751 let args = vec![pos("exp"), Arg::LongFlag("force".into())];
8752 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8753 assert!(built.flags.contains("force"));
8754 }
8755
8756 #[tokio::test]
8757 async fn build_args_undeclared_flag_before_another_flag_is_ok() {
8758 let kernel = Kernel::transient().expect("kernel");
8759 let schema = kj_like_schema();
8760 // --verbose is followed by a flag, not a positional → not ambiguous.
8761 let args = vec![
8762 Arg::LongFlag("verbose".into()),
8763 Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
8764 ];
8765 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8766 assert!(built.flags.contains("verbose"));
8767 }
8768
8769 #[tokio::test]
8770 async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
8771 let kernel = Kernel::transient().expect("kernel");
8772 // Builtins set map_positionals=false; the ambiguity guard must not
8773 // fire there (clap validates their flags separately).
8774 let schema = ToolSchema::new("frobnicate", "builtin-style")
8775 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
8776 let args = vec![Arg::LongFlag("frob".into()), pos("value")];
8777 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
8778 assert!(built.flags.contains("frob"));
8779 }
8780
8781 // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
8782 //
8783 // A tool exposing a subcommand tree binds flags against the *routed leaf's*
8784 // params, not the root's. The subcommand-path positionals stay positional
8785 // (kj re-parses them with its own clap), and a value flag declared only on
8786 // a deep leaf still binds in space form.
8787
8788 /// kj → context (alias ctx) → create{--type value, --force bool}.
8789 /// map_positionals defaults false on every node (builtin/kj style).
8790 fn kj_tree_schema() -> ToolSchema {
8791 ToolSchema::new("kj", "subcommand tool").subcommand(
8792 ToolSchema::new("context", "context ops")
8793 .with_command_aliases(["ctx"])
8794 .subcommand(
8795 ToolSchema::new("create", "create context")
8796 .param(ParamSchema::new("type", "string").with_aliases(["t"]))
8797 .param(ParamSchema::new("force", "bool")),
8798 ),
8799 )
8800 }
8801
8802 #[tokio::test]
8803 async fn build_args_binds_deep_leaf_value_flag_space_form() {
8804 let kernel = Kernel::transient().expect("kernel");
8805 let schema = kj_tree_schema();
8806 // kj context create --type explorer
8807 let args = vec![
8808 pos("context"),
8809 pos("create"),
8810 Arg::LongFlag("type".into()),
8811 pos("explorer"),
8812 ];
8813 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8814 // --type (declared only on the create leaf) binds in space form.
8815 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8816 // The subcommand path survives as positionals for kj to re-parse.
8817 let positionals: Vec<&str> = built
8818 .positional
8819 .iter()
8820 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8821 .collect();
8822 assert_eq!(positionals, vec!["context", "create"]);
8823 }
8824
8825 #[tokio::test]
8826 async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
8827 let kernel = Kernel::transient().expect("kernel");
8828 let schema = kj_tree_schema();
8829 // kj context create --force somearg → --force is a leaf bool flag,
8830 // it must NOT consume `somearg`.
8831 let args = vec![
8832 pos("context"),
8833 pos("create"),
8834 Arg::LongFlag("force".into()),
8835 pos("somearg"),
8836 ];
8837 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8838 assert!(built.flags.contains("force"), "force should be a bare flag");
8839 let positionals: Vec<&str> = built
8840 .positional
8841 .iter()
8842 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
8843 .collect();
8844 assert_eq!(positionals, vec!["context", "create", "somearg"]);
8845 }
8846
8847 #[tokio::test]
8848 async fn build_args_alias_routed_leaf_binds_value_flag() {
8849 let kernel = Kernel::transient().expect("kernel");
8850 let schema = kj_tree_schema();
8851 // kj ctx create -t explorer → command alias + short flag alias.
8852 let args = vec![
8853 pos("ctx"),
8854 pos("create"),
8855 Arg::ShortFlag("t".into()),
8856 pos("explorer"),
8857 ];
8858 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
8859 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
8860 }
8861
8862 #[tokio::test]
8863 async fn build_args_computed_subcommand_selector_fails_loud() {
8864 let kernel = Kernel::transient().expect("kernel");
8865 let schema = kj_tree_schema();
8866 // kj $(echo context) — routing can't see the value; fail loud.
8867 let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
8868 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
8869 )]))];
8870 let err = kernel
8871 .build_args_async(&args, Some(&schema))
8872 .await
8873 .expect_err("computed subcommand selector must error");
8874 assert!(
8875 err.to_string().contains("subcommand name is required"),
8876 "got: {err}"
8877 );
8878 }
8879
8880 // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
8881
8882 #[test]
8883 fn finalize_output_renders_when_kernel_owns_it() {
8884 use crate::interpreter::{OutputData, OutputFormat};
8885 let r = ExecResult::with_output(OutputData::text("RAW"));
8886 let out = finalize_output(r, Some(OutputFormat::Json), false);
8887 // Kernel renders the typed OutputData → JSON; text is no longer bare.
8888 assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
8889 }
8890
8891 #[test]
8892 fn finalize_output_skips_when_tool_owns_output() {
8893 use crate::interpreter::{OutputData, OutputFormat};
8894 let r = ExecResult::with_output(OutputData::text("RAW"));
8895 let out = finalize_output(r, Some(OutputFormat::Json), true);
8896 // owns_output: the tool already rendered; kernel leaves bytes untouched.
8897 assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
8898 }
8899
8900 #[test]
8901 fn finalize_output_no_format_is_noop() {
8902 use crate::interpreter::OutputData;
8903 let r = ExecResult::with_output(OutputData::text("RAW"));
8904 let out = finalize_output(r, None, false);
8905 assert_eq!(out.text_out(), "RAW");
8906 }
8907
8908 // ── initial_vars + execute_with_vars + hermetic env ───────────────────
8909
8910 #[tokio::test]
8911 async fn test_initial_vars_set_and_exported() {
8912 let config = KernelConfig::transient()
8913 .with_var("INIT_FOO", Value::String("bar".into()));
8914 let kernel = Kernel::new(config).expect("failed to create kernel");
8915
8916 assert_eq!(
8917 kernel.get_var("INIT_FOO").await,
8918 Some(Value::String("bar".into()))
8919 );
8920 assert!(
8921 kernel.scope.read().await.is_exported("INIT_FOO"),
8922 "initial_vars entries must be marked exported"
8923 );
8924 }
8925
8926 #[tokio::test]
8927 async fn test_execute_with_vars_overlay_visible() {
8928 let kernel = Kernel::transient().expect("failed to create kernel");
8929 let mut overlay = HashMap::new();
8930 overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
8931
8932 let result = kernel
8933 .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
8934 .await
8935 .expect("execute failed");
8936
8937 assert!(result.ok());
8938 assert_eq!(result.text_out().trim(), "yes");
8939 }
8940
8941 #[tokio::test]
8942 async fn test_execute_with_vars_overlay_cleanup() {
8943 let kernel = Kernel::transient().expect("failed to create kernel");
8944 let mut overlay = HashMap::new();
8945 overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
8946
8947 kernel
8948 .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
8949 .await
8950 .expect("execute failed");
8951
8952 assert_eq!(kernel.get_var("EPHEMERAL").await, None);
8953 assert!(
8954 !kernel.scope.read().await.is_exported("EPHEMERAL"),
8955 "overlay-only export must be cleared on return"
8956 );
8957 }
8958
8959 #[tokio::test]
8960 async fn test_execute_with_vars_does_not_clobber_existing_export() {
8961 let kernel = Kernel::transient().expect("failed to create kernel");
8962 kernel
8963 .execute("export OUTER=outer")
8964 .await
8965 .expect("export failed");
8966
8967 let mut overlay = HashMap::new();
8968 overlay.insert("OUTER".to_string(), Value::String("inner".into()));
8969 let result = kernel
8970 .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
8971 .await
8972 .expect("execute failed");
8973 assert_eq!(result.text_out().trim(), "inner");
8974
8975 assert_eq!(
8976 kernel.get_var("OUTER").await,
8977 Some(Value::String("outer".into())),
8978 "outer value must reappear after pop"
8979 );
8980 assert!(
8981 kernel.scope.read().await.is_exported("OUTER"),
8982 "outer export must survive overlay"
8983 );
8984 }
8985
8986 #[tokio::test]
8987 async fn test_execute_with_vars_inner_assignment_is_local() {
8988 let kernel = Kernel::transient().expect("failed to create kernel");
8989 let mut overlay = HashMap::new();
8990 overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
8991
8992 // Variable assignment inside a single statement uses set() (innermost
8993 // frame), not set_global() — this matches bash function-local semantics.
8994 // We explicitly use `local FOO=...` style by relying on the pushed
8995 // frame; the assignment in the script body modifies the same frame.
8996 let result = kernel
8997 .execute_with_options(
8998 r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
8999 ExecuteOptions::new().with_vars(overlay),
9000 )
9001 .await
9002 .expect("execute failed");
9003 assert!(result.ok());
9004
9005 // After the call the frame is popped, so LOCAL_FOO is gone regardless
9006 // of how the script reassigned it.
9007 assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9008 }
9009
9010 #[tokio::test]
9011 async fn test_external_command_sees_exported_var() {
9012 let kernel = Kernel::transient().expect("failed to create kernel");
9013 // PATH must be in scope to resolve the external `printenv` — the kernel
9014 // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9015 // what a frontend does through initial_vars.
9016 let path = std::env::var("PATH").unwrap_or_default();
9017 let result = kernel
9018 .execute(&format!(
9019 "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9020 ))
9021 .await
9022 .expect("execute failed");
9023
9024 assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9025 assert_eq!(result.text_out().trim(), "bar");
9026 }
9027
9028 #[tokio::test]
9029 async fn test_external_command_does_not_see_unexported_var() {
9030 let kernel = Kernel::transient().expect("failed to create kernel");
9031
9032 // Set without exporting; printenv must not see it (exit code != 0,
9033 // empty stdout per printenv semantics).
9034 let result = kernel
9035 .execute("EXT_BAR=hidden; printenv EXT_BAR")
9036 .await
9037 .expect("execute failed");
9038
9039 assert!(!result.ok(), "printenv should fail when var is unexported");
9040 assert!(
9041 result.text_out().trim().is_empty(),
9042 "no stdout when var is missing, got: {}",
9043 result.text_out()
9044 );
9045 }
9046
9047 #[tokio::test]
9048 async fn test_external_command_does_not_see_os_env() {
9049 // The kernel is hermetic: it never reads std::env::vars() and only
9050 // exports what it has been told to export. Cargo always sets PATH for
9051 // tests, so PATH is reliably present in the OS env — but a transient
9052 // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9053 // inside the kernel must fail.
9054 assert!(
9055 std::env::var_os("PATH").is_some(),
9056 "test precondition: cargo should set PATH"
9057 );
9058
9059 let kernel = Kernel::transient().expect("failed to create kernel");
9060 let result = kernel
9061 .execute("printenv PATH")
9062 .await
9063 .expect("execute failed");
9064
9065 assert!(
9066 !result.ok(),
9067 "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9068 result.text_out()
9069 );
9070 assert!(
9071 result.text_out().trim().is_empty(),
9072 "no PATH in subprocess env, got stdout={:?}",
9073 result.text_out()
9074 );
9075 }
9076
9077 #[tokio::test]
9078 async fn test_execute_with_vars_overlay_reaches_subprocess() {
9079 let kernel = Kernel::transient().expect("failed to create kernel");
9080 let mut overlay = HashMap::new();
9081 overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9082 // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9083 overlay.insert(
9084 "PATH".to_string(),
9085 Value::String(std::env::var("PATH").unwrap_or_default()),
9086 );
9087
9088 let result = kernel
9089 .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9090 .await
9091 .expect("execute failed");
9092
9093 assert!(
9094 result.ok(),
9095 "printenv should succeed: code={} stdout={:?} stderr={:?}",
9096 result.code,
9097 result.text_out(),
9098 result.err
9099 );
9100 assert_eq!(result.text_out().trim(), "subproc");
9101 }
9102
9103 #[tokio::test]
9104 async fn test_classify_command_builtin() {
9105 let kernel = Kernel::transient().expect("failed to create kernel");
9106 assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9107 assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9108 }
9109
9110 #[tokio::test]
9111 async fn test_classify_command_special_forms() {
9112 let kernel = Kernel::transient().expect("failed to create kernel");
9113 for name in ["true", "false", "source", "."] {
9114 assert_eq!(
9115 kernel.classify_command(name).await,
9116 CommandKind::Special,
9117 "{name} should be a special-form",
9118 );
9119 }
9120 }
9121
9122 #[tokio::test]
9123 async fn test_classify_command_dynamic() {
9124 let kernel = Kernel::transient().expect("failed to create kernel");
9125 assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9126 assert_eq!(
9127 kernel.classify_command("$(pick)").await,
9128 CommandKind::Dynamic
9129 );
9130 }
9131
9132 #[tokio::test]
9133 async fn test_classify_command_external() {
9134 let kernel = Kernel::transient().expect("failed to create kernel");
9135 // Not a builtin, user function, or special-form → escapes to PATH.
9136 assert_eq!(
9137 kernel.classify_command("definitely_not_a_kaish_builtin").await,
9138 CommandKind::External
9139 );
9140 // `readonly` is *not* a kaish special-form despite the validator's
9141 // warning heuristic — at runtime it resolves to an external command, so
9142 // a consent gate must see it as External (regression guard against the
9143 // validator/runtime divergence).
9144 assert_eq!(
9145 kernel.classify_command("readonly").await,
9146 CommandKind::External
9147 );
9148 assert!(kernel.classify_command("readonly").await.escapes_kernel());
9149 }
9150
9151 #[tokio::test]
9152 async fn test_classify_command_user_tool_shadows_builtin() {
9153 let kernel = Kernel::transient().expect("failed to create kernel");
9154 kernel
9155 .execute(r#"greet() { echo "hi" }"#)
9156 .await
9157 .expect("function definition failed");
9158 assert_eq!(
9159 kernel.classify_command("greet").await,
9160 CommandKind::UserTool
9161 );
9162
9163 // A user function named after a builtin classifies as UserTool, matching
9164 // the interpreter's user-tools-first resolution.
9165 kernel
9166 .execute(r#"cat() { echo "shadowed" }"#)
9167 .await
9168 .expect("function definition failed");
9169 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9170 }
9171
9172 #[tokio::test]
9173 async fn test_classify_command_alias_to_external_is_external() {
9174 let kernel = Kernel::transient().expect("failed to create kernel");
9175 // An alias whose head is an external binary must NOT report as the
9176 // builtin it shadows — execution expands the alias, so a consent gate
9177 // would otherwise be told an external command is internal.
9178 kernel
9179 .execute("alias cat='/usr/bin/whatever'")
9180 .await
9181 .expect("alias failed");
9182 assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9183 assert!(kernel.classify_command("cat").await.escapes_kernel());
9184 }
9185
9186 #[tokio::test]
9187 async fn test_classify_command_alias_to_builtin() {
9188 let kernel = Kernel::transient().expect("failed to create kernel");
9189 kernel.execute("alias g=grep").await.expect("alias failed");
9190 assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9191 }
9192
9193 #[tokio::test]
9194 async fn test_classify_command_alias_to_special_form() {
9195 let kernel = Kernel::transient().expect("failed to create kernel");
9196 kernel.execute("alias t=true").await.expect("alias failed");
9197 assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9198 }
9199
9200 #[tokio::test]
9201 async fn test_classify_command_braced_var_is_dynamic() {
9202 let kernel = Kernel::transient().expect("failed to create kernel");
9203 // The string API can be handed a `${VAR}` head; it must not be mistaken
9204 // for an external named literally "${VAR}".
9205 assert_eq!(
9206 kernel.classify_command("${CMD}").await,
9207 CommandKind::Dynamic
9208 );
9209 }
9210
9211 /// Drift guard: `classify_command` must agree with what the executor
9212 /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9213 /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9214 /// precedence, alias expansion); without this test those copies could diverge
9215 /// silently — the exact failure class `classify_command` exists to prevent,
9216 /// just moved inside the kernel. Each case asserts the classification AND
9217 /// observes the real resolution, so a future change to one side without the
9218 /// other fails here.
9219 #[tokio::test]
9220 async fn classify_command_matches_executor() {
9221 let kernel = Kernel::transient().expect("failed to create kernel");
9222
9223 // (1) Special-forms. `SpecialForm::from_name` is the single source of
9224 // truth: classify reports Special via it, and the executor matches the
9225 // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9226 // form won't build until both sides handle it). This test pins the other
9227 // half — that each form classifies Special AND actually short-circuits at
9228 // runtime rather than escaping to `PATH`. Every form is executed (not just
9229 // `true`/`false`): an external miss in this PATH-less kernel would be exit
9230 // 127, so a non-127 result that matches the form's own behavior proves the
9231 // short-circuit fired.
9232 for name in ["true", "false", "source", "."] {
9233 assert_eq!(
9234 kernel.classify_command(name).await,
9235 CommandKind::Special,
9236 "{name} should classify Special",
9237 );
9238 }
9239 assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9240 assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9241 // `source`/`.` short-circuit to execute_source, which (no filename) fails
9242 // with its own message — exit 1, never the 127 of an unresolved external.
9243 for name in ["source", "."] {
9244 let r = kernel.execute(name).await.expect("run source form");
9245 assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9246 assert!(
9247 r.err.contains("source: missing filename"),
9248 "{name} did not route to execute_source: {:?}",
9249 r.err,
9250 );
9251 }
9252
9253 // (2) Builtin: classify Builtin AND the executor runs the builtin.
9254 assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9255 let r = kernel.execute("echo hi").await.expect("run echo");
9256 assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9257
9258 // (3) User function shadows a builtin: classify UserTool AND the executor
9259 // runs the function body, not the `cat` builtin.
9260 kernel
9261 .execute(r#"cat() { echo SHADOWED }"#)
9262 .await
9263 .expect("define cat()");
9264 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9265 let r = kernel.execute("cat").await.expect("run shadowed cat");
9266 assert_eq!(
9267 r.text_out().trim(),
9268 "SHADOWED",
9269 "executor ran the builtin instead of the shadowing function",
9270 );
9271
9272 // (4) Alias whose head is external: classify External AND the executor
9273 // resolves through the alias to a missing external (not a builtin).
9274 kernel
9275 .execute("alias x='/nonexistent/binary'")
9276 .await
9277 .expect("define alias x");
9278 assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9279 let r = kernel.execute("x").await.expect("run alias x");
9280 assert!(
9281 !r.ok(),
9282 "alias to a missing external should fail, not resolve internally",
9283 );
9284 }
9285}