kaish_kernel/kernel.rs
1//! The Kernel (核) — the heart of kaish.
2//!
3//! The Kernel owns and coordinates all core components:
4//! - Interpreter state (scope, $?)
5//! - Tool registry (builtins, user tools)
6//! - VFS router (mount points)
7//! - Job manager (background jobs)
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌────────────────────────────────────────────────────────────┐
13//! │ Kernel (核) │
14//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
15//! │ │ Scope │ │ ToolRegistry │ │ VfsRouter │ │
16//! │ │ (variables) │ │ (builtins, │ │ (mount points) │ │
17//! │ │ │ │ user tools)│ │ │ │
18//! │ └──────────────┘ └──────────────┘ └──────────────────┘ │
19//! │ ┌──────────────────────────────┐ ┌──────────────────┐ │
20//! │ │ JobManager (background) │ │ ExecResult ($?) │ │
21//! │ └──────────────────────────────┘ └──────────────────┘ │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
29use std::time::Duration;
30
31use anyhow::{Context, Result};
32use tokio::sync::RwLock;
33
34/// Monotonic counter assigned to each Kernel at construction time, exposed
35/// via `$$` / `${$}`. Starts at 1; each new Kernel gets the next value.
36/// `Kernel::fork()` inherits the parent's value (matching bash's "subshell
37/// keeps parent's $$" semantics) because forks clone the parent's Scope
38/// rather than calling `set_pid` again.
39///
40/// Deliberately *not* the OS PID — kaish runs as a long-lived MCP server
41/// or embedded inside other binaries (kaijutsu), where the host PID is
42/// meaningless to the script. See
43/// `~/.claude/projects/-home-atobey-src-kaish/memory/lang_dollar_dollar_identifier.md`
44/// for the design rationale.
45static KERNEL_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47/// Maximum depth of dynamic statement-engine re-entry — command substitution
48/// (`$(…)`), shell-function calls, and `.kai` script sourcing — before a
49/// **loud** error is returned instead of letting the native call stack
50/// overflow (a `SIGSEGV`/abort with no diagnostic). Mirrors the intent of the
51/// alias re-entry cap (10) and the lexer's `MAX_PAREN_DEPTH` (256): a runaway
52/// or mutually recursive script hits a catchable ceiling, not a signal.
53///
54/// Each level stacks the dispatch chain between re-entries. After the GH #48
55/// allocation pass this measures ~50 KB (release) / ~57 KB (debug at the default
56/// `opt-level = 1` dev profile — see the root `Cargo.toml`) / ~193 KB (a fully
57/// unoptimized debug build) of native stack per level, down from ~80 / ~380 KB
58/// before; the `recursion_stack_cost_tests` probe reports the live figure.
59///
60/// This cap and [`RECOMMENDED_STACK_SIZE`] are a **matched pair**: the cap must
61/// trip *before* `cap × (worst-case per-level stack)` can exceed the floor, so a
62/// runaway is caught, not a `SIGSEGV`. The worst case is the ~193 KB unoptimized
63/// figure — the `opt-level = 1` dev profile above is local to this workspace and
64/// does **not** propagate to embedders, whose own debug builds of the kernel pay
65/// the full unoptimized cost. `48 × 193 KB ≈ 9.3 MB` under the 12 MiB floor
66/// keeps the same ~1.3× margin the pre-#48 pair had (`32 × 380 KB ≈ 12 MB` under
67/// 16 MiB); #48's smaller frames are what let the cap rise 32→48 and the floor
68/// drop 16→12 MiB together. **The guard only fires *before* the stack overflows
69/// on a thread that meets that floor** — this is why the REPL sizes its threads
70/// to it and embedders must too (see `docs/EMBEDDING.md`). Forks (background
71/// jobs, scatter workers, pipeline stages) run on fresh stacks and get a fresh
72/// counter, bounding each chain independently. GH #46 / #47 / #48.
73pub const MAX_RECURSION_DEPTH: usize = 48;
74
75/// Recommended native stack size (12 MiB) for any thread that drives kaish
76/// execution — the REPL sizes its `block_on` thread and tokio worker threads
77/// to this, and embedders that call `Kernel::execute` (directly or via a tokio
78/// runtime) should do the same (`runtime::Builder::thread_stack_size`, and a
79/// `std::thread` stack for a non-worker driver).
80///
81/// The kernel recurses on the native stack (command substitution, shell
82/// functions, `.kai` scripts). [`MAX_RECURSION_DEPTH`] converts a runaway into
83/// a loud error, but only *if the stack is at least this large* — on the
84/// default ~2 MB tokio worker stack the recursion overflows (SIGSEGV) before
85/// reaching the cap. This floor is the companion to that cap (see its docs for
86/// the `cap × per-level < floor` relationship): 12 MiB holds the depth-48 cap
87/// with margin even for an unoptimized embedder build (~193 KB/level), and #48
88/// shrank the per-level cost enough to drop it from 16 MiB. kaish can't set this
89/// itself (it doesn't own the runtime), so it exposes the floor for owners to
90/// apply. See GH #47 / #48.
91pub const RECOMMENDED_STACK_SIZE: usize = 12 * 1024 * 1024;
92
93use async_trait::async_trait;
94
95use crate::ast::{
96 spread_non_list_message, Arg, BinaryOp, Command, Expr, FileTestOp, ListElem, RecordKey, Stmt,
97 StringPart, TestExpr, ToolDef, Value,
98};
99pub use kaish_types::{CommandKind, ExecuteOptions};
100use crate::backend::{BackendError, KernelBackend};
101use kaish_glob::glob_match;
102use crate::dispatch::{CommandDispatcher, PipelinePosition};
103use crate::interpreter::{apply_output_format, eval_expr, expand_tilde, json_to_value_no_envelope, value_to_bool, value_to_string, value_to_text_sink, ControlFlow, ExecResult, LatchRequest, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, BoundedStream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream, DEFAULT_STREAM_MAX_SIZE};
108use crate::tools::{register_builtins, ExecContext, GlobalFlags, ToolArgs, ToolRegistry};
109#[cfg(feature = "subprocess")]
110use crate::tools::{resolve_in_path, virtual_cwd_error};
111use crate::validator::{Severity, Validator};
112#[cfg(feature = "localfs")]
113use crate::vfs::LocalFs;
114use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
115use kaish_vfs::ByteBudget;
116#[cfg(all(feature = "localfs", feature = "overlay"))]
117use kaish_vfs::OverlayFs;
118
119/// VFS mount mode determines how the local filesystem is exposed.
120///
121/// Different modes trade off convenience vs. security:
122/// - `Passthrough` gives native path access (best for human REPL use)
123/// - `Sandboxed` restricts access to a subtree (safer for agents)
124/// - `NoLocal` provides complete isolation (tests, pure memory mode)
125#[derive(Debug, Clone)]
126pub enum VfsMountMode {
127 /// LocalFs at "/" — native paths work directly.
128 ///
129 /// Full filesystem access. Use for human-operated REPL sessions where
130 /// native paths like `/home/user/project` should just work.
131 ///
132 /// Mounts:
133 /// - `/` → LocalFs("/")
134 /// - `/v` → MemoryFs (blob storage)
135 #[cfg(feature = "localfs")]
136 Passthrough,
137
138 /// Transparent sandbox — paths look native but access is restricted.
139 ///
140 /// The local filesystem is mounted at its real path (e.g., `/home/user`),
141 /// so `/home/user/src/project` just works. But paths outside the sandbox
142 /// root are not accessible.
143 ///
144 /// **Note:** This only restricts VFS (builtin) operations. External commands
145 /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
146 ///
147 /// Mounts:
148 /// - `/` → MemoryFs (catches paths outside sandbox)
149 /// - `{root}` → LocalFs(root) (e.g., `/home/user` → LocalFs)
150 /// - `/tmp` → LocalFs("/tmp")
151 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
152 /// - `/v` → MemoryFs (blob storage)
153 #[cfg(feature = "localfs")]
154 Sandboxed {
155 /// Root path for local filesystem. Defaults to `$HOME`.
156 /// Can be restricted further, e.g., `~/src`.
157 root: Option<PathBuf>,
158 },
159
160 /// No local filesystem. Memory only.
161 ///
162 /// Complete isolation — no access to the host filesystem.
163 /// Useful for tests or pure sandboxed execution.
164 ///
165 /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
166 /// for this mode at kernel construction: with no host filesystem mounted,
167 /// large output must not write a host spill file (`paths::spill_dir()`
168 /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
169 ///
170 /// Mounts:
171 /// - `/` → MemoryFs
172 /// - `/tmp` → MemoryFs
173 /// - `/v` → MemoryFs
174 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
175 NoLocal,
176}
177
178#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
179impl Default for VfsMountMode {
180 fn default() -> Self {
181 #[cfg(feature = "localfs")]
182 { VfsMountMode::Sandboxed { root: None } }
183 #[cfg(not(feature = "localfs"))]
184 { VfsMountMode::NoLocal }
185 }
186}
187
188/// Configuration for kernel initialization.
189#[derive(Debug, Clone)]
190pub struct KernelConfig {
191 /// Name of this kernel (for identification).
192 pub name: String,
193
194 /// VFS mount mode — controls how local filesystem is exposed.
195 pub vfs_mode: VfsMountMode,
196
197 /// Initial working directory (VFS path).
198 pub cwd: PathBuf,
199
200 /// Whether to skip pre-execution validation.
201 ///
202 /// When false (default), scripts are validated before execution to catch
203 /// errors early. Set to true to skip validation for performance or to
204 /// allow dynamic/external commands.
205 pub skip_validation: bool,
206
207 /// When true, standalone external commands inherit stdio for real-time output.
208 ///
209 /// Set by script runner and REPL for human-visible output.
210 /// Not set by MCP server (output must be captured for structured responses).
211 pub interactive: bool,
212
213 /// Ignore file configuration for file-walking tools.
214 pub ignore_config: crate::ignore_config::IgnoreConfig,
215
216 /// Output size limit configuration for agent safety.
217 pub output_limit: crate::output_limit::OutputLimitConfig,
218
219 /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
220 ///
221 /// When `true` (default), commands not found as builtins are resolved via PATH
222 /// and executed as child processes. When `false`, only kaish builtins and
223 /// backend-registered tools are available.
224 ///
225 /// **Security:** External commands bypass the VFS sandbox entirely — they see
226 /// the real filesystem, network, and environment. Set to `false` when running
227 /// untrusted input.
228 pub allow_external_commands: bool,
229
230 /// Enable confirmation latch for dangerous operations (set -o latch).
231 ///
232 /// When enabled, destructive operations like `rm` require nonce confirmation.
233 /// Can also be enabled at runtime with `set -o latch` or via `KAISH_LATCH=1`.
234 pub latch_enabled: bool,
235
236 /// Enable trash-on-delete for rm (set -o trash).
237 ///
238 /// When enabled, small files are moved to freedesktop.org Trash instead of
239 /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
240 /// or via `KAISH_TRASH=1`.
241 pub trash_enabled: bool,
242
243 /// Shared nonce store for cross-request confirmation latch.
244 ///
245 /// When `Some`, the kernel uses this store instead of creating a fresh one.
246 /// This allows nonces issued in one MCP `execute()` call to be validated
247 /// in a subsequent call. When `None` (default), a fresh store is created.
248 pub nonce_store: Option<crate::nonce::NonceStore>,
249
250 /// Variables to populate the root scope with at construction, all marked
251 /// for export to child processes.
252 ///
253 /// The kernel itself is hermetic — it never reads `std::env::vars()` —
254 /// so frontends that want OS-env passthrough (REPL, MCP) populate this
255 /// from `std::env::vars()`. Embedders that want isolation pass nothing
256 /// (or only the keys they curate).
257 pub initial_vars: HashMap<String, Value>,
258
259 /// Default per-request timeout. When `Some`, every `execute_with_options`
260 /// call without an explicit `ExecuteOptions::timeout` uses this duration.
261 /// When elapsed, the kernel cancels the request, kills any external
262 /// children with the configured grace, and returns exit code 124.
263 ///
264 /// `None` means no default timeout — only explicit per-call timeouts apply.
265 pub request_timeout: Option<Duration>,
266
267 /// Grace period between SIGTERM and SIGKILL when killing an external
268 /// child on cancellation or timeout.
269 ///
270 /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
271 /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
272 pub kill_grace: Duration,
273
274 /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
275 ///
276 /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
277 /// construction and handed to every `MemoryFs` the kernel builds in
278 /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
279 /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
280 /// `StorageFull` — an in-band error a model reads and adapts to; fail
281 /// loud over quietly eating RAM.
282 ///
283 /// **Why the agent preset is bounded by default:** an agent embedder
284 /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
285 /// is per-call, not per-session. Embedders that know their workload needs
286 /// more opt out with `without_vfs_budget()` or raise the cap with
287 /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
288 /// All other profiles default to `None` (unbounded).
289 ///
290 /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
291 pub vfs_budget_bytes: Option<u64>,
292
293 /// Enable copy-on-write overlay mode (opt-in).
294 ///
295 /// When `true`, the primary local filesystem mount is wrapped in an
296 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
297 /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
298 /// overlay transaction.
299 ///
300 /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
301 /// **Sandboxed{root}:** the `{root}` mount becomes
302 /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
303 /// mounts stay as real `LocalFs` (real writes escape the transaction —
304 /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
305 /// **NoLocal:** incompatible — construction fails loudly (everything is
306 /// already virtual; an overlay adds no value and no lower layer to wrap).
307 /// **with_backend:** incompatible — the embedder controls the VFS; the
308 /// kernel cannot wrap it without bypassing the embedder's semantics.
309 ///
310 /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
311 /// making the overlay a per-call transaction — `kaish-vfs commit` must run
312 /// in the same call as the writes, or the transaction is discarded on drop.
313 /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
314 pub overlay: bool,
315}
316
317/// Get the default sandbox root ($HOME).
318#[cfg(feature = "localfs")]
319fn default_sandbox_root() -> PathBuf {
320 std::env::var("HOME")
321 .map(PathBuf::from)
322 .unwrap_or_else(|_| PathBuf::from("/"))
323}
324
325impl Default for KernelConfig {
326 fn default() -> Self {
327 #[cfg(feature = "localfs")]
328 {
329 let home = default_sandbox_root();
330 Self {
331 name: "default".to_string(),
332 vfs_mode: VfsMountMode::Sandboxed { root: None },
333 cwd: home,
334 skip_validation: false,
335 interactive: false,
336 ignore_config: crate::ignore_config::IgnoreConfig::none(),
337 output_limit: crate::output_limit::OutputLimitConfig::none(),
338 allow_external_commands: cfg!(feature = "subprocess"),
339 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
340 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
341 nonce_store: None,
342 initial_vars: HashMap::new(),
343 request_timeout: None,
344 kill_grace: Duration::from_secs(2),
345 vfs_budget_bytes: None,
346 overlay: false,
347 }
348 }
349 #[cfg(not(feature = "localfs"))]
350 {
351 Self {
352 name: "default".to_string(),
353 vfs_mode: VfsMountMode::NoLocal,
354 cwd: PathBuf::from("/"),
355 skip_validation: false,
356 interactive: false,
357 ignore_config: crate::ignore_config::IgnoreConfig::none(),
358 output_limit: crate::output_limit::OutputLimitConfig::none(),
359 allow_external_commands: false,
360 latch_enabled: false,
361 trash_enabled: false,
362 nonce_store: None,
363 initial_vars: HashMap::new(),
364 request_timeout: None,
365 kill_grace: Duration::from_secs(2),
366 vfs_budget_bytes: None,
367 overlay: false,
368 }
369 }
370 }
371}
372
373impl KernelConfig {
374 /// Create a transient kernel config (sandboxed, for temporary use).
375 #[cfg(feature = "localfs")]
376 pub fn transient() -> Self {
377 let home = default_sandbox_root();
378 Self {
379 name: "transient".to_string(),
380 vfs_mode: VfsMountMode::Sandboxed { root: None },
381 cwd: home,
382 skip_validation: false,
383 interactive: false,
384 ignore_config: crate::ignore_config::IgnoreConfig::none(),
385 output_limit: crate::output_limit::OutputLimitConfig::none(),
386 allow_external_commands: cfg!(feature = "subprocess"),
387 latch_enabled: false,
388 trash_enabled: false,
389 nonce_store: None,
390 initial_vars: HashMap::new(),
391 request_timeout: None,
392 kill_grace: Duration::from_secs(2),
393 vfs_budget_bytes: None,
394 overlay: false,
395 }
396 }
397
398 /// Create a transient kernel config (isolated, no-default-features).
399 #[cfg(not(feature = "localfs"))]
400 pub fn transient() -> Self {
401 Self::isolated()
402 }
403
404 /// Create a kernel config with the given name (sandboxed by default).
405 #[cfg(feature = "localfs")]
406 pub fn named(name: &str) -> Self {
407 let home = default_sandbox_root();
408 Self {
409 name: name.to_string(),
410 vfs_mode: VfsMountMode::Sandboxed { root: None },
411 cwd: home,
412 skip_validation: false,
413 interactive: false,
414 ignore_config: crate::ignore_config::IgnoreConfig::none(),
415 output_limit: crate::output_limit::OutputLimitConfig::none(),
416 allow_external_commands: cfg!(feature = "subprocess"),
417 latch_enabled: false,
418 trash_enabled: false,
419 nonce_store: None,
420 initial_vars: HashMap::new(),
421 request_timeout: None,
422 kill_grace: Duration::from_secs(2),
423 vfs_budget_bytes: None,
424 overlay: false,
425 }
426 }
427
428 /// Create a kernel config with the given name (isolated, no-default-features).
429 #[cfg(not(feature = "localfs"))]
430 pub fn named(name: &str) -> Self {
431 Self {
432 name: name.to_string(),
433 ..Self::isolated()
434 }
435 }
436
437 /// Create a REPL config with passthrough filesystem access.
438 ///
439 /// Native paths like `/home/user/project` work directly.
440 /// The cwd is set to the actual current working directory.
441 #[cfg(feature = "localfs")]
442 pub fn repl() -> Self {
443 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
444 Self {
445 name: "repl".to_string(),
446 vfs_mode: VfsMountMode::Passthrough,
447 cwd,
448 skip_validation: false,
449 interactive: false,
450 // Ignore-aware by default (GH #134): .gitignore + default ignores
451 // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
452 ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
453 output_limit: crate::output_limit::OutputLimitConfig::none(),
454 allow_external_commands: cfg!(feature = "subprocess"),
455 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
456 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
457 nonce_store: None,
458 initial_vars: HashMap::new(),
459 request_timeout: None,
460 kill_grace: Duration::from_secs(2),
461 vfs_budget_bytes: None,
462 overlay: false,
463 }
464 }
465
466 /// Create a sandboxed-agent config with sandboxed filesystem access.
467 ///
468 /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
469 /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
470 /// memory and output. Local filesystem is accessible at its real path (e.g.,
471 /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
472 /// accessible through builtins. External commands still access the real
473 /// filesystem — use `.with_allow_external_commands(false)` to block them.
474 ///
475 /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
476 /// embedder typically creates a fresh kernel per call). Raise or remove with
477 /// `with_vfs_budget` / `without_vfs_budget`.
478 #[cfg(feature = "localfs")]
479 pub fn agent() -> Self {
480 let home = default_sandbox_root();
481 Self {
482 name: "agent".to_string(),
483 vfs_mode: VfsMountMode::Sandboxed { root: None },
484 cwd: home,
485 skip_validation: false,
486 interactive: false,
487 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
488 output_limit: crate::output_limit::OutputLimitConfig::agent(),
489 allow_external_commands: cfg!(feature = "subprocess"),
490 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
491 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
492 nonce_store: None,
493 initial_vars: HashMap::new(),
494 request_timeout: None,
495 kill_grace: Duration::from_secs(2),
496 vfs_budget_bytes: Some(64 * 1024 * 1024),
497 overlay: false,
498 }
499 }
500
501 /// Create a sandboxed-agent config with a custom sandbox root.
502 ///
503 /// Use this to restrict access to a subdirectory like `~/src`.
504 ///
505 /// VFS memory is bounded at 64 MiB per `execute()` call by default.
506 /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
507 #[cfg(feature = "localfs")]
508 pub fn agent_with_root(root: PathBuf) -> Self {
509 Self {
510 name: "agent".to_string(),
511 vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
512 cwd: root,
513 skip_validation: false,
514 interactive: false,
515 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
516 output_limit: crate::output_limit::OutputLimitConfig::agent(),
517 allow_external_commands: cfg!(feature = "subprocess"),
518 latch_enabled: std::env::var("KAISH_LATCH").is_ok_and(|v| v == "1"),
519 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
520 nonce_store: None,
521 initial_vars: HashMap::new(),
522 request_timeout: None,
523 kill_grace: Duration::from_secs(2),
524 vfs_budget_bytes: Some(64 * 1024 * 1024),
525 overlay: false,
526 }
527 }
528
529 /// Create a config with no local filesystem (memory only).
530 ///
531 /// Complete isolation: no local filesystem and external commands are disabled.
532 /// Useful for tests or pure sandboxed execution.
533 pub fn isolated() -> Self {
534 Self {
535 name: "isolated".to_string(),
536 vfs_mode: VfsMountMode::NoLocal,
537 cwd: PathBuf::from("/"),
538 skip_validation: false,
539 interactive: false,
540 ignore_config: crate::ignore_config::IgnoreConfig::none(),
541 output_limit: crate::output_limit::OutputLimitConfig::none(),
542 allow_external_commands: false,
543 latch_enabled: false,
544 trash_enabled: false,
545 nonce_store: None,
546 initial_vars: HashMap::new(),
547 request_timeout: None,
548 kill_grace: Duration::from_secs(2),
549 vfs_budget_bytes: None,
550 overlay: false,
551 }
552 }
553
554 /// Set the VFS mount mode.
555 pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
556 self.vfs_mode = mode;
557 self
558 }
559
560 /// Set the initial working directory.
561 pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
562 self.cwd = cwd;
563 self
564 }
565
566 /// Skip pre-execution validation.
567 pub fn with_skip_validation(mut self, skip: bool) -> Self {
568 self.skip_validation = skip;
569 self
570 }
571
572 /// Enable interactive mode (external commands inherit stdio).
573 pub fn with_interactive(mut self, interactive: bool) -> Self {
574 self.interactive = interactive;
575 self
576 }
577
578 /// Set the ignore file configuration.
579 pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
580 self.ignore_config = config;
581 self
582 }
583
584 /// Set the output limit configuration.
585 pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
586 self.output_limit = config;
587 self
588 }
589
590 /// Set whether external command execution is allowed.
591 ///
592 /// When `false`, commands not found as builtins produce "command not found"
593 /// instead of searching PATH. The `exec` and `spawn` builtins also return
594 /// errors. Use this to prevent VFS sandbox bypass via external binaries.
595 pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
596 self.allow_external_commands = allow;
597 self
598 }
599
600 /// Enable or disable confirmation latch at startup.
601 pub fn with_latch(mut self, enabled: bool) -> Self {
602 self.latch_enabled = enabled;
603 self
604 }
605
606 /// Enable or disable trash-on-delete at startup.
607 pub fn with_trash(mut self, enabled: bool) -> Self {
608 self.trash_enabled = enabled;
609 self
610 }
611
612 /// Use a shared nonce store for cross-request confirmation latch.
613 ///
614 /// Pass a `NonceStore` that outlives individual kernel instances so nonces
615 /// issued in one MCP `execute()` call can be validated in subsequent calls.
616 pub fn with_nonce_store(mut self, store: crate::nonce::NonceStore) -> Self {
617 self.nonce_store = Some(store);
618 self
619 }
620
621 /// Add a single initial variable; marked exported when the kernel boots.
622 ///
623 /// Repeated calls add (last write wins on key collision).
624 pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
625 self.initial_vars.insert(name.into(), value);
626 self
627 }
628
629 /// Replace the entire initial-vars map. All entries are marked exported.
630 pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
631 self.initial_vars = vars;
632 self
633 }
634
635 /// Extend the initial-vars map with the given entries (last write wins).
636 pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
637 self.initial_vars.extend(vars);
638 self
639 }
640
641 /// Set the default per-request timeout (kernel-wide).
642 ///
643 /// Each `execute_with_options` call without an explicit timeout uses
644 /// this. On elapsed, the kernel cancels and returns exit code 124.
645 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
646 self.request_timeout = Some(timeout);
647 self
648 }
649
650 /// Set the SIGTERM-to-SIGKILL grace period for child kills.
651 pub fn with_kill_grace(mut self, grace: Duration) -> Self {
652 self.kill_grace = grace;
653 self
654 }
655
656 /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
657 /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
658 /// created at kernel construction and passed to every `MemoryFs` the
659 /// kernel builds (see `setup_vfs` and `with_backend`).
660 ///
661 /// Writes that would exceed the cap fail loudly with `StorageFull` — an
662 /// in-band error a model reads and adapts to; fail loud over quietly eating
663 /// RAM. Use `without_vfs_budget` to remove the cap entirely.
664 pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
665 self.vfs_budget_bytes = Some(bytes);
666 self
667 }
668
669 /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
670 ///
671 /// Use when the caller knows the workload and the default 64 MiB cap
672 /// (set by `KernelConfig::agent`) is too conservative.
673 pub fn without_vfs_budget(mut self) -> Self {
674 self.vfs_budget_bytes = None;
675 self
676 }
677
678 /// Enable or disable copy-on-write overlay mode.
679 ///
680 /// When `true`, the primary local filesystem mount is wrapped in an
681 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
682 /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
683 /// and `with_backend` kernels (same — the embedder controls the VFS).
684 pub fn with_overlay(mut self, overlay: bool) -> Self {
685 self.overlay = overlay;
686 self
687 }
688}
689
690/// Handle to an active overlay session, kept on the kernel and shared to
691/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
692///
693/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
694/// `/home/user`); `commit_root` is the real filesystem path the overlay's
695/// lower is backed by (used as the target for `kaish-vfs commit`).
696#[cfg(all(feature = "localfs", feature = "overlay"))]
697#[derive(Clone)]
698pub struct OverlayHandle {
699 /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
700 /// methods without holding a VfsRouter lock.
701 pub fs: Arc<OverlayFs>,
702 /// VFS path this overlay is mounted at (e.g. `/home/user`).
703 pub mount_path: PathBuf,
704 /// Real filesystem root to commit into. Same as the lower's root.
705 pub commit_root: PathBuf,
706}
707
708/// The Kernel (核) — executes kaish code.
709///
710/// This is the primary interface for running kaish commands. It owns all
711/// the runtime state: variables, tools, VFS, jobs, and persistence.
712pub struct Kernel {
713 /// Kernel name.
714 name: String,
715 /// Variable scope.
716 scope: RwLock<Scope>,
717 /// Tool registry.
718 tools: Arc<ToolRegistry>,
719 /// User-defined tools (from `tool name { body }` statements).
720 user_tools: RwLock<HashMap<String, ToolDef>>,
721 /// Virtual filesystem router.
722 vfs: Arc<VfsRouter>,
723 /// Background job manager.
724 jobs: Arc<JobManager>,
725 /// Pipeline runner.
726 runner: PipelineRunner,
727 /// Execution context (cwd, stdin, etc.).
728 exec_ctx: RwLock<ExecContext>,
729 /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
730 /// retained past construction so `reset()` can re-seed them into the fresh
731 /// scope instead of silently dropping them.
732 initial_vars: HashMap<String, Value>,
733 /// Whether to skip pre-execution validation.
734 skip_validation: bool,
735 /// When true, standalone external commands inherit stdio for real-time output.
736 interactive: bool,
737 /// Whether external command execution is allowed.
738 allow_external_commands: bool,
739 /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
740 ///
741 /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
742 /// `Some` is Arc-cloned into forks so all concurrent execution draws from
743 /// the same pool — a background job's writes reduce the same cap as
744 /// foreground writes, which is the correct behaviour.
745 vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
746 /// Active overlay session handle, if this kernel was constructed with
747 /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
748 /// `kaish-vfs` builtin) can inspect and mutate the overlay without
749 /// holding a kernel write lock. Propagated to forks via `fork_inner`
750 /// and `child_for_pipeline` so `kaish-vfs` works inside background
751 /// jobs, scatter workers, and pipeline stages.
752 #[cfg(all(feature = "localfs", feature = "overlay"))]
753 overlay_handle: Option<Arc<OverlayHandle>>,
754 /// Default per-request timeout (None = no default).
755 request_timeout: Option<Duration>,
756 /// SIGTERM-to-SIGKILL grace period for child kills.
757 kill_grace: Duration,
758 /// Receiver for the kernel stderr stream.
759 ///
760 /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
761 /// The kernel drains this after each statement in `execute_streaming`.
762 stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
763 /// Cancellation token for interrupting execution (Ctrl-C).
764 ///
765 /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
766 /// needs sync access. Each `execute()` call gets a fresh child token;
767 /// `cancel()` cancels the current token and replaces it.
768 cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
769 /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
770 /// installed for the duration of an `execute_with_options` call and
771 /// cleared on exit. Consulted by `is_cancelled()` so every existing
772 /// cancellation checkpoint gains interrupt awareness without new wiring.
773 /// std Mutex for the same sync-access reason as `cancel_token`.
774 interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
775 /// Terminal state for job control (interactive mode only, Unix only).
776 #[cfg(all(unix, feature = "subprocess"))]
777 terminal_state: Option<Arc<crate::terminal::TerminalState>>,
778 /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
779 ///
780 /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
781 /// through the full Kernel resolution chain.
782 self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
783 /// Background job this kernel (a fork) is executing on behalf of, if any.
784 /// Set on the fork created by `execute_background` and inherited by all its
785 /// sub-forks (pipeline stages, scatter workers), so an external command
786 /// spawned anywhere under a background job can record its process group on
787 /// that job for `kill -<sig> %N`. `None` for foreground execution.
788 bg_job_id: Option<crate::scheduler::JobId>,
789 /// Serializes concurrent `execute()` / `execute_streaming()` callers on
790 /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
791 /// queue. Background jobs, scatter workers, and concurrent pipeline
792 /// stages do NOT take this lock — they run against a *forked* Kernel
793 /// (see [`Kernel::fork`]) so they never contend with the foreground.
794 execute_lock: tokio::sync::Mutex<()>,
795 /// Current dynamic statement-engine re-entry depth — incremented on entry
796 /// to command substitution, a shell-function call, or a `.kai` source, and
797 /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
798 /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
799 /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
800 /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
801 /// the recursion chain is single-threaded (top-level `execute` is
802 /// serialized by `execute_lock`; concurrency happens on forks).
803 recursion_depth: AtomicUsize,
804}
805
806/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
807/// (in `enter_recursion`) and decrements on drop, so a cancelled or
808/// error-unwound re-entry can never leave the counter inflated (which would
809/// spuriously trip later, unrelated recursions).
810struct RecursionGuard<'a> {
811 counter: &'a AtomicUsize,
812}
813
814impl Drop for RecursionGuard<'_> {
815 fn drop(&mut self) {
816 self.counter.fetch_sub(1, Ordering::Relaxed);
817 }
818}
819
820/// Internal result of [`Kernel::setup_vfs`].
821struct VfsSetupResult {
822 vfs: VfsRouter,
823 budget: Option<Arc<ByteBudget>>,
824 #[cfg(all(feature = "localfs", feature = "overlay"))]
825 overlay_handle: Option<Arc<OverlayHandle>>,
826}
827
828impl Kernel {
829 /// Create a new kernel with the given configuration.
830 pub fn new(config: KernelConfig) -> Result<Self> {
831 let mut setup = Self::setup_vfs(&config)?;
832 let jobs = Arc::new(JobManager::new());
833
834 // Mount JobFs for job observability at /v/jobs
835 setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
836
837 #[cfg(all(feature = "localfs", feature = "overlay"))]
838 let overlay_handle = setup.overlay_handle.take();
839
840 // Mode-based construction: the kernel owns its host mounts, so whether
841 // host side channels are allowed is decided by the VFS mode inside
842 // `assemble` (NoLocal forbids them).
843 let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
844 ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
845 })?;
846
847 #[cfg(all(feature = "localfs", feature = "overlay"))]
848 {
849 let mut kernel = kernel;
850 kernel.overlay_handle = overlay_handle;
851 // Also set it on the ExecContext so builtins can access it.
852 if let Some(ref handle) = kernel.overlay_handle {
853 kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
854 }
855 return Ok(kernel);
856 }
857
858 #[allow(unreachable_code)]
859 Ok(kernel)
860 }
861
862 /// Set up VFS based on mount mode.
863 ///
864 /// Returns the router, the budget handle (if bounded), and an optional
865 /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
866 /// every `MemoryFs` the kernel creates here holds a clone of the same
867 /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
868 /// in-memory content across all kernel-owned memory mounts.
869 ///
870 /// # Errors
871 /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
872 /// (overlay is meaningless when everything is already virtual — there is
873 /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
874 /// this as an `anyhow::Error`.
875 fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
876 let mut vfs = VfsRouter::new();
877
878 // One budget for all memory mounts this kernel owns — labeled so the
879 // error message tells the user exactly which knob to raise.
880 let budget: Option<Arc<ByteBudget>> = config
881 .vfs_budget_bytes
882 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
883
884 /// Helper: construct a `MemoryFs` wired to `budget` if present.
885 fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
886 match budget {
887 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
888 None => MemoryFs::new(),
889 }
890 }
891
892 // Overlay handle — populated below if config.overlay is true.
893 #[cfg(all(feature = "localfs", feature = "overlay"))]
894 let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
895
896 match &config.vfs_mode {
897 #[cfg(feature = "localfs")]
898 VfsMountMode::Passthrough => {
899 #[cfg(feature = "overlay")]
900 if config.overlay {
901 // Wrap "/" in an OverlayFs so writes are virtual.
902 let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
903 let overlay_fs = Arc::new(match &budget {
904 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
905 None => OverlayFs::over(lower),
906 });
907 let handle = Arc::new(OverlayHandle {
908 fs: Arc::clone(&overlay_fs),
909 mount_path: PathBuf::from("/"),
910 commit_root: PathBuf::from("/"),
911 });
912 vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
913 overlay_handle = Some(handle);
914 } else {
915 // LocalFs at "/" — native paths work directly
916 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
917 }
918 #[cfg(not(feature = "overlay"))]
919 {
920 if config.overlay {
921 return Err(anyhow::anyhow!(
922 "overlay=true requires the `overlay` feature, but this build \
923 was compiled without it. Recompile with --features overlay \
924 (or the default feature set) to enable overlay mode."
925 ));
926 }
927 // LocalFs at "/" — native paths work directly
928 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
929 }
930 // Memory for blobs
931 vfs.mount("/v", mem(&budget));
932 }
933 #[cfg(feature = "localfs")]
934 VfsMountMode::Sandboxed { root } => {
935 // Memory at root for safety (catches paths outside sandbox).
936 // Note: /tmp and the XDG runtime dir are LocalFs — writes
937 // there escape the VFS budget and are NOT virtual. This is
938 // intentional: /tmp interop with other processes matters more
939 // than accounting for scratch files there.
940 vfs.mount("/", mem(&budget));
941 vfs.mount("/v", mem(&budget));
942
943 // Synthetic /dev: the host's real /dev isn't reachable here, so
944 // /dev/null and /dev/zero are software-backed (see DevFs).
945 vfs.mount("/dev", DevFs::new());
946
947 // Real /tmp for interop with other processes
948 vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
949
950 // Mount XDG runtime dir for spill files and socket access
951 let runtime = crate::paths::xdg_runtime_dir();
952 if runtime.exists() {
953 let runtime_str = runtime.to_string_lossy().to_string();
954 vfs.mount(&runtime_str, LocalFs::new(runtime));
955 }
956
957 // Resolve the sandbox root (defaults to $HOME)
958 let local_root = root.clone().unwrap_or_else(|| {
959 std::env::var("HOME")
960 .map(PathBuf::from)
961 .unwrap_or_else(|_| PathBuf::from("/"))
962 });
963
964 let mount_point = local_root.to_string_lossy().to_string();
965
966 #[cfg(feature = "overlay")]
967 if config.overlay {
968 // Wrap the sandbox root in an OverlayFs.
969 let lower = Arc::new(LocalFs::read_only(local_root.clone()));
970 let overlay_fs = Arc::new(match &budget {
971 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
972 None => OverlayFs::over(lower),
973 });
974 let handle = Arc::new(OverlayHandle {
975 fs: Arc::clone(&overlay_fs),
976 mount_path: PathBuf::from(&mount_point),
977 commit_root: local_root,
978 });
979 vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
980 overlay_handle = Some(handle);
981 } else {
982 // Mount at the real path for transparent access
983 // e.g., /home/atobey → LocalFs("/home/atobey")
984 // so /home/atobey/src/kaish just works
985 vfs.mount(&mount_point, LocalFs::new(local_root));
986 }
987 #[cfg(not(feature = "overlay"))]
988 {
989 if config.overlay {
990 return Err(anyhow::anyhow!(
991 "overlay=true requires the `overlay` feature, but this build \
992 was compiled without it. Recompile with --features overlay \
993 (or the default feature set) to enable overlay mode."
994 ));
995 }
996 // Mount at the real path for transparent access
997 vfs.mount(&mount_point, LocalFs::new(local_root));
998 }
999 }
1000 VfsMountMode::NoLocal => {
1001 if config.overlay {
1002 return Err(anyhow::anyhow!(
1003 "overlay=true is incompatible with VfsMountMode::NoLocal: \
1004 everything is already virtual, there is no real lower layer \
1005 to wrap. Use with_overlay(false) or switch to a Passthrough \
1006 or Sandboxed VFS mode."
1007 ));
1008 }
1009 // Pure memory mode — no local filesystem
1010 vfs.mount("/", mem(&budget));
1011 vfs.mount("/tmp", mem(&budget));
1012 vfs.mount("/v", mem(&budget));
1013 // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1014 vfs.mount("/dev", DevFs::new());
1015 }
1016 }
1017
1018 Ok(VfsSetupResult {
1019 vfs,
1020 budget,
1021 #[cfg(all(feature = "localfs", feature = "overlay"))]
1022 overlay_handle,
1023 })
1024 }
1025
1026 /// Create a transient kernel (no persistence).
1027 pub fn transient() -> Result<Self> {
1028 Self::new(KernelConfig::transient())
1029 }
1030
1031 /// Create a kernel with a custom backend and `/v/*` virtual path support.
1032 ///
1033 /// This is the constructor for embedding kaish in other systems that provide
1034 /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1035 ///
1036 /// A `VirtualOverlayBackend` routes paths automatically:
1037 /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1038 /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1039 /// `/dev/urandom`) — kernel-owned so it works even when your backend is
1040 /// read-only
1041 /// - Everything else → Your custom backend
1042 ///
1043 /// The optional `configure_vfs` closure lets you add additional virtual mounts
1044 /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1045 ///
1046 /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1047 /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1048 /// `skip_validation`, and `interactive`.
1049 ///
1050 /// # Example
1051 ///
1052 /// ```ignore
1053 /// // Simple: default /v/* mounts only
1054 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1055 ///
1056 /// // With custom mounts
1057 /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1058 /// vfs.mount_arc("/v/docs", docs_fs);
1059 /// vfs.mount_arc("/v/g", git_fs);
1060 /// }, |_| {})?;
1061 ///
1062 /// // With custom tools
1063 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1064 /// tools.register(MyCustomTool::new());
1065 /// })?;
1066 /// ```
1067 pub fn with_backend(
1068 backend: Arc<dyn KernelBackend>,
1069 config: KernelConfig,
1070 configure_vfs: impl FnOnce(&mut VfsRouter),
1071 configure_tools: impl FnOnce(&mut ToolRegistry),
1072 ) -> Result<Self> {
1073 use crate::backend::VirtualOverlayBackend;
1074
1075 // overlay=true is incompatible with with_backend: the embedder controls
1076 // the VFS and the kernel cannot wrap it without bypassing the embedder's
1077 // semantics. Fail loudly rather than silently ignoring the flag.
1078 if config.overlay {
1079 return Err(anyhow::anyhow!(
1080 "overlay=true is incompatible with Kernel::with_backend: the embedder \
1081 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1082 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1083 ));
1084 }
1085
1086 let mut vfs = VfsRouter::new();
1087 let jobs = Arc::new(JobManager::new());
1088
1089 // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1090 // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1091 // kernel-owned memory mount here — embedders own the rest of the VFS.
1092 let vfs_budget: Option<Arc<ByteBudget>> = config
1093 .vfs_budget_bytes
1094 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1095
1096 vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1097 let blobs_fs = match &vfs_budget {
1098 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1099 None => MemoryFs::new(),
1100 };
1101 vfs.mount("/v/blobs", blobs_fs);
1102
1103 // /dev/null and friends are software-backed (see DevFs) and must not
1104 // depend on the embedder's backend — a read-only embedder backend
1105 // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1106 // to /dev/null as a filesystem error instead of discarding them.
1107 vfs.mount("/dev", DevFs::new());
1108
1109 // Let caller add custom mounts (e.g., /v/docs, /v/g)
1110 configure_vfs(&mut vfs);
1111
1112 // A custom-backend kernel owns no host mounts — the embedder supplies
1113 // the entire VFS — so any kernel write to a host filesystem via
1114 // `std::fs` (output spill, job output files) bypasses that VFS and its
1115 // read-only guarantees. Forbid host side channels unconditionally.
1116 Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1117 let overlay: Arc<dyn KernelBackend> =
1118 Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1119 ExecContext::with_backend(overlay)
1120 })
1121 }
1122
1123 /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1124 ///
1125 /// The `make_ctx` closure receives the VFS and tools so backends that need
1126 /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1127 /// that already have their own storage can ignore these parameters.
1128 fn assemble(
1129 config: KernelConfig,
1130 mut vfs: VfsRouter,
1131 jobs: Arc<JobManager>,
1132 no_host_filesystem: bool,
1133 vfs_budget: Option<Arc<ByteBudget>>,
1134 configure_tools: impl FnOnce(&mut ToolRegistry),
1135 make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1136 ) -> Result<Self> {
1137 // A kernel with no host filesystem of its own must never write to one
1138 // through a side channel. Two paths bypass the VFS by going straight to
1139 // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1140 // background-job output files (`Job::write_output_file` → host temp).
1141 // Both would punch through the isolation, so force them off:
1142 // in-memory truncation for spill, no host file for job output.
1143 //
1144 // This is true for a `NoLocal` kernel (mounts nothing) and for any
1145 // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1146 // VFS, so the kernel controls no host mounts and any host write is a
1147 // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1148 // when there is no kernel-owned host filesystem to spill to.
1149 let no_host_side_channel =
1150 no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1151
1152 let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, latch_enabled, trash_enabled, nonce_store, initial_vars, request_timeout, kill_grace, .. } = config;
1153
1154 if no_host_side_channel {
1155 output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1156 jobs.set_persist_output_files(false);
1157 }
1158
1159 let mut tools = ToolRegistry::new();
1160 register_builtins(&mut tools);
1161 configure_tools(&mut tools);
1162 let tools = Arc::new(tools);
1163
1164 // Mount BuiltinFs so `ls /v/bin` lists builtins
1165 vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1166
1167 let vfs = Arc::new(vfs);
1168
1169 let runner = PipelineRunner::new(tools.clone());
1170
1171 let (stderr_writer, stderr_receiver) = stderr_stream();
1172
1173 let mut exec_ctx = make_ctx(&vfs, &tools);
1174 exec_ctx.set_cwd(cwd);
1175 exec_ctx.set_job_manager(jobs.clone());
1176 exec_ctx.set_tool_schemas(tools.schemas());
1177 exec_ctx.set_tools(tools.clone());
1178 #[cfg(feature = "os-integration")]
1179 exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1180 exec_ctx.stderr = Some(stderr_writer);
1181 exec_ctx.ignore_config = ignore_config;
1182 exec_ctx.output_limit = output_limit;
1183 exec_ctx.allow_external_commands = allow_external_commands;
1184 exec_ctx.vfs_budget = vfs_budget.clone();
1185 if let Some(store) = nonce_store {
1186 exec_ctx.nonce_store = store;
1187 }
1188
1189 Ok(Self {
1190 name,
1191 scope: RwLock::new({
1192 let mut scope = Scope::new();
1193 scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1194 // HOME is NOT read from the host env here — the kernel is
1195 // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1196 // below (from `std::env::vars()`); a hermetic embedder leaves
1197 // `initial_vars` empty and gets no HOME (tilde stays literal).
1198 // Apply caller-supplied initial variables, all marked exported.
1199 // Frontends (REPL, MCP) populate this from std::env::vars()
1200 // for shell-like UX; embedders that want hermetic behavior
1201 // simply leave it empty.
1202 for (name, value) in initial_vars.clone() {
1203 scope.set_exported(name, value);
1204 }
1205 scope.set_latch_enabled(latch_enabled);
1206 scope.set_trash_enabled(trash_enabled);
1207 scope
1208 }),
1209 initial_vars,
1210 tools,
1211 user_tools: RwLock::new(HashMap::new()),
1212 vfs,
1213 jobs,
1214 runner,
1215 exec_ctx: RwLock::new(exec_ctx),
1216 skip_validation,
1217 interactive,
1218 allow_external_commands,
1219 vfs_budget,
1220 request_timeout,
1221 kill_grace,
1222 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1223 cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1224 interrupt: std::sync::Mutex::new(None),
1225 #[cfg(all(unix, feature = "subprocess"))]
1226 terminal_state: None,
1227 self_weak: std::sync::OnceLock::new(),
1228 execute_lock: tokio::sync::Mutex::new(()),
1229 recursion_depth: AtomicUsize::new(0),
1230 bg_job_id: None,
1231 // Overlay handle is set by Kernel::new after assemble returns;
1232 // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1233 // with_backend always has None (overlay=true is rejected above).
1234 #[cfg(all(feature = "localfs", feature = "overlay"))]
1235 overlay_handle: None,
1236 })
1237 }
1238
1239 /// Get the kernel name.
1240 pub fn name(&self) -> &str {
1241 &self.name
1242 }
1243
1244 /// Wrap this Kernel in an Arc and initialize its self-reference.
1245 ///
1246 /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1247 /// to child contexts, allowing builtins like `timeout` to dispatch inner
1248 /// commands through the full resolution chain (user tools → builtins →
1249 /// .kai scripts → external commands).
1250 pub fn into_arc(self) -> Arc<Self> {
1251 let arc = Arc::new(self);
1252 let _ = arc.self_weak.set(Arc::downgrade(&arc));
1253 arc
1254 }
1255
1256 /// Fork a subsidiary kernel for concurrent execution.
1257 ///
1258 /// The fork is a fully-functional `Kernel` that:
1259 /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1260 /// user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1261 /// the fork do NOT propagate back to the parent — matching bash
1262 /// subshell / background-job semantics.
1263 /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1264 /// registry, the VFS router, and the job manager. A job registered by
1265 /// the fork is visible to the parent's `jobs` builtin, and the fork
1266 /// sees the same VFS mounts.
1267 /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1268 /// `execute_lock`. It is never the TTY owner, so `interactive` is
1269 /// `false` and `terminal_state` is `None`.
1270 ///
1271 /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1272 /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1273 /// routes through the fork itself, not the parent — which is essential
1274 /// for concurrency safety.
1275 ///
1276 /// Use this for **detached** background concurrency where the fork should
1277 /// survive parent cancellation: the `&` background-job operator and any
1278 /// other "fire and forget" worker. The fork gets a fresh, independent
1279 /// cancellation token.
1280 ///
1281 /// For foreground concurrency (scatter workers, concurrent pipeline
1282 /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1283 /// into the fork's external children, use [`Self::fork_attached`].
1284 pub async fn fork(&self) -> Arc<Self> {
1285 self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1286 .await
1287 }
1288
1289 /// Fork attached to the parent's cancellation.
1290 ///
1291 /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1292 /// the parent's. When the parent cancels (request timeout, embedder
1293 /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1294 /// turn kills any external children spawned in the fork via the
1295 /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1296 pub async fn fork_attached(&self) -> Arc<Self> {
1297 let child_token = {
1298 #[allow(clippy::expect_used)]
1299 let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1300 parent.child_token()
1301 };
1302 self.fork_inner(child_token, self.bg_job_id).await
1303 }
1304
1305 /// Fork for a background job, stamping the job id so external commands
1306 /// spawned anywhere beneath it record their process groups on that job
1307 /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1308 /// `JobManager::cancel`.
1309 pub async fn fork_for_background(
1310 &self,
1311 cancel: tokio_util::sync::CancellationToken,
1312 job_id: crate::scheduler::JobId,
1313 ) -> Arc<Self> {
1314 self.fork_inner(cancel, Some(job_id)).await
1315 }
1316
1317 /// Shared fork implementation. Caller decides the cancellation token and
1318 /// which background job (if any) this fork runs on behalf of.
1319 async fn fork_inner(
1320 &self,
1321 cancel: tokio_util::sync::CancellationToken,
1322 bg_job_id: Option<crate::scheduler::JobId>,
1323 ) -> Arc<Self> {
1324 let scope_snapshot = self.scope.read().await.clone();
1325 let user_tools_snapshot = self.user_tools.read().await.clone();
1326
1327 // Snapshot exec_ctx by cloning the cloneable fields, then override
1328 // the ones that should not carry over (stderr channel, dispatcher,
1329 // interactive flag, terminal state, cancel — set from `cancel` arg).
1330 let mut fork_ctx = {
1331 let parent_ctx = self.exec_ctx.read().await;
1332 parent_ctx.child_for_pipeline()
1333 };
1334 let (stderr_writer, stderr_receiver) = stderr_stream();
1335 fork_ctx.stderr = Some(stderr_writer);
1336 // Clear dispatcher; dispatch_command will repopulate it to point at
1337 // the fork on the first dispatch call.
1338 fork_ctx.dispatcher = None;
1339 fork_ctx.interactive = false;
1340 fork_ctx.cancel = cancel.clone();
1341 #[cfg(all(unix, feature = "subprocess"))]
1342 {
1343 fork_ctx.terminal_state = None;
1344 }
1345
1346 let fork = Self {
1347 name: format!("{}:fork", self.name),
1348 scope: RwLock::new(scope_snapshot),
1349 initial_vars: self.initial_vars.clone(),
1350 tools: Arc::clone(&self.tools),
1351 user_tools: RwLock::new(user_tools_snapshot),
1352 vfs: Arc::clone(&self.vfs),
1353 jobs: Arc::clone(&self.jobs),
1354 runner: self.runner.clone(),
1355 exec_ctx: RwLock::new(fork_ctx),
1356 skip_validation: self.skip_validation,
1357 // Forks are never the TTY owner — they run in the background.
1358 interactive: false,
1359 allow_external_commands: self.allow_external_commands,
1360 // Arc-clone the budget so the fork draws from the same pool as the
1361 // parent — background jobs and scatter workers count against the same
1362 // cap as foreground writes.
1363 vfs_budget: self.vfs_budget.clone(),
1364 request_timeout: self.request_timeout,
1365 kill_grace: self.kill_grace,
1366 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1367 cancel_token: std::sync::Mutex::new(cancel),
1368 interrupt: std::sync::Mutex::new(None),
1369 #[cfg(all(unix, feature = "subprocess"))]
1370 terminal_state: None,
1371 self_weak: std::sync::OnceLock::new(),
1372 execute_lock: tokio::sync::Mutex::new(()),
1373 // A fork runs on a fresh stack (spawned task) — its recursion
1374 // budget is independent of the parent's current depth (GH #46).
1375 recursion_depth: AtomicUsize::new(0),
1376 bg_job_id,
1377 // Arc-clone the overlay handle so forks (background jobs, scatter
1378 // workers, pipeline stages) can reach the same overlay transaction
1379 // via `kaish-vfs status/diff/commit/reset`.
1380 #[cfg(all(feature = "localfs", feature = "overlay"))]
1381 overlay_handle: self.overlay_handle.clone(),
1382 };
1383
1384 fork.into_arc()
1385 }
1386
1387 /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1388 ///
1389 /// Returns `None` if the Kernel was not wrapped, or if all strong references
1390 /// have been dropped (the `Weak` can no longer upgrade).
1391 pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1392 self.self_weak
1393 .get()
1394 .and_then(|weak| weak.upgrade())
1395 .map(|arc| arc as Arc<dyn CommandDispatcher>)
1396 }
1397
1398 /// Initialize terminal state for interactive job control.
1399 ///
1400 /// Call this after kernel creation when running as an interactive REPL
1401 /// and stdin is a TTY. Sets up process groups and signal handling.
1402 #[cfg(all(unix, feature = "subprocess"))]
1403 pub fn init_terminal(&mut self) {
1404 if !self.interactive {
1405 return;
1406 }
1407 match crate::terminal::TerminalState::init() {
1408 Ok(state) => {
1409 let state = Arc::new(state);
1410 self.terminal_state = Some(state.clone());
1411 // Set on exec_ctx so builtins (fg, bg, kill) can access it
1412 self.exec_ctx.get_mut().terminal_state = Some(state);
1413 tracing::debug!("terminal job control initialized");
1414 }
1415 Err(e) => {
1416 tracing::warn!("failed to initialize terminal job control: {}", e);
1417 }
1418 }
1419 }
1420
1421 /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1422 ///
1423 /// The kernel installs the OS trash (`SystemTrash`) automatically when
1424 /// built with the `os-integration` feature. Embedders and tests can swap
1425 /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1426 /// it — with trash enabled but no backend present, `rm` fails loud
1427 /// rather than falling through to permanent delete.
1428 pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1429 self.exec_ctx.get_mut().trash_backend = backend;
1430 }
1431
1432 /// Cancel the current execution.
1433 ///
1434 /// This cancels the current cancellation token, causing any execution
1435 /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1436 /// A fresh token is installed for the next `execute()` call.
1437 pub fn cancel(&self) {
1438 #[allow(clippy::expect_used)]
1439 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1440 token.cancel();
1441 }
1442
1443 /// Check if the current execution has been cancelled.
1444 ///
1445 /// Also the polling point for `ExecuteOptions::interrupt`: when the
1446 /// embedder's check reports true, the internal token fires here, so every
1447 /// call site of this method is an interrupt checkpoint for free.
1448 pub fn is_cancelled(&self) -> bool {
1449 let interrupted = {
1450 #[allow(clippy::expect_used)]
1451 let check = self.interrupt.lock().expect("interrupt poisoned");
1452 check.as_ref().is_some_and(|f| f())
1453 };
1454 if interrupted {
1455 self.cancel();
1456 }
1457 #[allow(clippy::expect_used)]
1458 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1459 token.is_cancelled()
1460 }
1461
1462 /// Reset the cancellation token (called at the start of each execute).
1463 fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1464 #[allow(clippy::expect_used)]
1465 let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1466 if token.is_cancelled() {
1467 *token = tokio_util::sync::CancellationToken::new();
1468 }
1469 token.clone()
1470 }
1471
1472 /// Acquire the per-Kernel execute lock, warning on contention.
1473 ///
1474 /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1475 /// the lock is already held, emit a warning so the silent serialization
1476 /// is observable in logs — if you need real parallelism, fork the kernel.
1477 async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1478 match self.execute_lock.try_lock() {
1479 Ok(guard) => guard,
1480 Err(_) => {
1481 tracing::warn!(
1482 target: "kaish::kernel::concurrency",
1483 kernel = %self.name,
1484 "execute() contended — serializing concurrent caller; \
1485 use Kernel::fork() for parallelism instead of sharing"
1486 );
1487 self.execute_lock.lock().await
1488 }
1489 }
1490 }
1491
1492 /// Execute kaish source code with default options.
1493 ///
1494 /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1495 /// Returns the result of the last statement executed.
1496 pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1497 self.run_inner(input, ExecuteOptions::default(), None, None).await
1498 }
1499
1500 /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1501 /// are **already tokenized**.
1502 ///
1503 /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1504 /// that already holds OS/structured argv (a busybox-style multicall binary, a
1505 /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1506 /// into a string just to have the lexer split it apart again — a round-trip
1507 /// that is lossy for typed values, since `to_argv()` stringifies
1508 /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1509 ///
1510 /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1511 /// command substitution, no word splitting — the "single-quoted word"
1512 /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1513 /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1514 /// does still apply, for consistency with the string door: a leading `~` is
1515 /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1516 /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1517 /// non-string `Value`
1518 /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1519 /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1520 /// clap arg model means a builtin that re-parses its own `to_argv()` still
1521 /// sees a stringified value; the typed-passthrough win fully lands only for
1522 /// builtins that read `args.positional` directly — the documented pattern.)
1523 ///
1524 /// This is a *peer*, not a subset: a command string can carry pipelines,
1525 /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1526 /// doors converge **late** (at the shared dispatch chain) rather than one
1527 /// wrapping the other. From argv classification onward `execute_argv` reuses
1528 /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1529 /// tools, `.kai` scripts, externals, backend tools), arg binding, the `--json`
1530 /// transform, and the confirmation latch — so a latched `rm` still emits a
1531 /// nonce and an `ls --json` still applies output formatting. The kernel's
1532 /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1533 /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1534 ///
1535 /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1536 /// and the kernel's configured `request_timeout` applies (a hung builtin or
1537 /// external is interrupted at the deadline with exit code 124, the same as the
1538 /// string door). There is no per-call options surface yet — a future
1539 /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1540 #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1541 pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1542 let _guard = self.acquire_execute_lock().await;
1543 // Fresh cancel surface for this call: `execute_pipeline` reads
1544 // `self.cancel_token`, so a stale cancelled token from a prior call must be
1545 // replaced first. The returned clone is the token the watchdog cancels on
1546 // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1547 // cascading SIGTERM/SIGKILL to any external child.
1548 let cancel = self.reset_cancel();
1549
1550 // Honor the kernel-configured request timeout for parity with `execute`.
1551 let timeout = self.request_timeout;
1552 if timeout == Some(Duration::ZERO) {
1553 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1554 }
1555
1556 let pipeline = crate::ast::Pipeline {
1557 commands: vec![crate::ast::Command {
1558 name: name.to_string(),
1559 args: argv_to_args(argv),
1560 redirects: Vec::new(),
1561 }],
1562 background: false,
1563 };
1564 let result = self
1565 .run_under_watchdog(timeout, &cancel, self.execute_pipeline(&pipeline))
1566 .await?;
1567 self.update_last_result(&result).await;
1568 Ok(result)
1569 }
1570
1571 /// Fulfill a confirmation latch by replaying its exact captured invocation
1572 /// with the nonce — the highest-fidelity approval path.
1573 ///
1574 /// Inspect a gated result with [`ExecResult::latch_request`]; apply whatever
1575 /// policy (allowlist, model review) over `req.command`/`req.paths`; then call
1576 /// this to approve. It replays `execute_argv(req.tool, req.argv)` with
1577 /// `--confirm=<nonce>` prepended — no re-parsing of the human `hint`, so a
1578 /// path with spaces or glob characters round-trips exactly. Share the nonce
1579 /// store ([`KernelConfig::with_nonce_store`]) to confirm from a *later*
1580 /// kernel call than the one that produced the latch.
1581 ///
1582 /// Errors (exit 2) if the latch carries no captured invocation — a latch
1583 /// produced outside a dispatch seam (a direct `tool.execute` in a unit
1584 /// test). Those are confirmable only by re-running with `--confirm=<nonce>`.
1585 ///
1586 /// If `latch.job_id` is set (the gate came from a *backgrounded* job —
1587 /// `rm x &` reaching its gate), a successful replay also retires that job
1588 /// from the `JobManager` (GH #124 part 4) — mirroring the existing manual
1589 /// discard path (`kill --discard %N`), automated. A failed replay leaves
1590 /// the job in place for inspection/retry. Guarded by `is_latched` so a
1591 /// stale/foreign `job_id` can never remove an unrelated running job;
1592 /// idempotent on a repeat confirm (nonces are reusable within TTL, and
1593 /// removing an already-absent job is a no-op).
1594 pub async fn confirm(&self, latch: &LatchRequest) -> Result<ExecResult> {
1595 if latch.tool.is_empty() {
1596 return Ok(ExecResult::failure(
1597 2,
1598 "confirm: latch carries no captured invocation to replay — \
1599 re-run the command with --confirm=<nonce> instead",
1600 ));
1601 }
1602 // Prepend the nonce as a `--confirm=` flag: `to_argv()` trails a `--`
1603 // positional terminator, so appending would let it swallow the flag.
1604 let mut argv: Vec<Value> = Vec::with_capacity(latch.argv.len() + 1);
1605 argv.push(Value::String(format!("--confirm={}", latch.nonce)));
1606 argv.extend(latch.argv.iter().map(|a| Value::String(a.clone())));
1607 let result = self.execute_argv(&latch.tool, &argv).await?;
1608
1609 if result.ok()
1610 && let Some(id) = latch.job_id
1611 {
1612 let job_id = crate::scheduler::JobId(id);
1613 if self.jobs.is_latched(job_id).await {
1614 self.jobs.remove(job_id).await;
1615 }
1616 }
1617
1618 Ok(result)
1619 }
1620
1621 /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1622 /// string door ([`Self::execute_with_options`]) and the argv door
1623 /// ([`Self::execute_argv`]).
1624 ///
1625 /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1626 /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1627 /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1628 /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1629 /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1630 /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1631 /// stale handle would silently suspend nothing). Callers must short-circuit a
1632 /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1633 async fn run_under_watchdog<F>(
1634 &self,
1635 timeout: Option<Duration>,
1636 cancel: &tokio_util::sync::CancellationToken,
1637 work: F,
1638 ) -> Result<ExecResult>
1639 where
1640 F: std::future::Future<Output = Result<ExecResult>>,
1641 {
1642 // Assigned unconditionally (clearing any stale handle); None without a timeout.
1643 let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1644 {
1645 let mut ec = self.exec_ctx.write().await;
1646 ec.watchdog = watchdog.clone();
1647 }
1648
1649 let result = if let Some(d) = timeout {
1650 #[allow(clippy::expect_used)]
1651 let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1652 let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1653 let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1654 let r = work.await;
1655 timer.abort();
1656 match r {
1657 Ok(mut res) => {
1658 if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1659 res.code = 124;
1660 if res.err.is_empty() {
1661 res.err = format!("timeout: timed out after {:?}", d);
1662 }
1663 }
1664 Ok(res)
1665 }
1666 Err(e) => Err(e),
1667 }
1668 } else {
1669 work.await
1670 };
1671
1672 // The timer task is gone (fired or aborted); drop the stale handle.
1673 {
1674 let mut ec = self.exec_ctx.write().await;
1675 ec.watchdog = None;
1676 }
1677 result
1678 }
1679
1680 /// Execute with per-call options. The primary entry point for embedders
1681 /// that don't need per-statement output streaming.
1682 ///
1683 /// `opts` carries timeout, transient vars overlay, optional cwd override,
1684 /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1685 /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1686 ///
1687 /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1688 /// against the kernel's internal token. Either firing cancels and kills
1689 /// external children. The embedder's token is read-only — kernel
1690 /// timeouts do NOT propagate into it. Distinguish via the returned
1691 /// `code`: 124 = timeout, 130 = cancellation.
1692 ///
1693 /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1694 /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1695 ///
1696 /// Concurrent callers on the same Kernel serialize on the kernel-wide
1697 /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1698 /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1699 pub async fn execute_with_options(
1700 &self,
1701 input: &str,
1702 opts: ExecuteOptions,
1703 ) -> Result<ExecResult> {
1704 self.run_inner(input, opts, None, None).await
1705 }
1706
1707 /// Same as [`Self::execute_with_options`] but with a per-statement output
1708 /// callback. The callback fires after each top-level statement so the
1709 /// embedder (REPL, MCP streaming) can flush output incrementally.
1710 pub async fn execute_with_options_streaming(
1711 &self,
1712 input: &str,
1713 opts: ExecuteOptions,
1714 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1715 ) -> Result<ExecResult> {
1716 self.run_inner(input, opts, None, Some(on_output)).await
1717 }
1718
1719 /// Execute with a **lazy** standard input fed as a [`PipeReader`].
1720 ///
1721 /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1722 /// forces the input to be drained before execution: the reader seeds the
1723 /// first top-level command's `pipe_stdin`, and a command that does not read
1724 /// stdin (`echo`) returns without touching it. This is the seam a
1725 /// non-interactive frontend uses to forward an *open* process stdin without
1726 /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1727 ///
1728 /// Embedders that already hold a complete buffer (text or binary) should
1729 /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1730 pub async fn execute_with_pipe_stdin(
1731 &self,
1732 input: &str,
1733 opts: ExecuteOptions,
1734 pipe_stdin: crate::scheduler::PipeReader,
1735 ) -> Result<ExecResult> {
1736 self.run_inner(input, opts, Some(pipe_stdin), None).await
1737 }
1738
1739 /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1740 /// `-c`/script frontend uses this to print output incrementally while
1741 /// feeding a lazy process-stdin pipe.
1742 pub async fn execute_with_pipe_stdin_streaming(
1743 &self,
1744 input: &str,
1745 opts: ExecuteOptions,
1746 pipe_stdin: crate::scheduler::PipeReader,
1747 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1748 ) -> Result<ExecResult> {
1749 self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1750 }
1751
1752 /// Execute kaish source code with a transient overlay of exported variables.
1753 ///
1754 /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1755 /// should use that method directly:
1756 /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1757 #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1758 pub async fn execute_with_vars(
1759 &self,
1760 input: &str,
1761 vars: HashMap<String, Value>,
1762 ) -> Result<ExecResult> {
1763 self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1764 }
1765
1766 /// Execute kaish source code with a per-statement callback.
1767 ///
1768 /// Deprecated thin wrapper. New code should use
1769 /// [`Self::execute_with_options_streaming`].
1770 #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1771 pub async fn execute_streaming(
1772 &self,
1773 input: &str,
1774 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1775 ) -> Result<ExecResult> {
1776 self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1777 }
1778
1779 /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1780 ///
1781 /// The `#[instrument]` execution span resolves its parent from the *current*
1782 /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1783 /// captured when the span is first entered — not when the future is
1784 /// constructed. So a thread-local `attach()` scoped to construction is too
1785 /// early to be seen (the integration test confirms this). `with_context`
1786 /// re-attaches the embedder's context on *every* poll of the inner future,
1787 /// so the context is current at first-enter and survives runtime thread
1788 /// hops. With no embedder trace context, the future runs unwrapped.
1789 async fn run_inner(
1790 &self,
1791 input: &str,
1792 opts: ExecuteOptions,
1793 pipe_stdin: Option<crate::scheduler::PipeReader>,
1794 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1795 ) -> Result<ExecResult> {
1796 use opentelemetry::context::FutureExt;
1797
1798 // Capture the embedder's baggage before `opts` is consumed so it can be
1799 // echoed back onto the result on egress (see `merge_egress_baggage`).
1800 let embedder_baggage = opts.baggage.clone();
1801
1802 let result = match crate::telemetry::extract_parent(&opts) {
1803 Some(parent) => self
1804 .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1805 .with_context(parent)
1806 .await,
1807 None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1808 };
1809
1810 result.map(|mut r| {
1811 crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1812 r
1813 })
1814 }
1815
1816 /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1817 /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1818 /// cwd override, and timeout race.
1819 #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1820 async fn execute_with_options_inner(
1821 &self,
1822 input: &str,
1823 opts: ExecuteOptions,
1824 pipe_stdin: Option<crate::scheduler::PipeReader>,
1825 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1826 ) -> Result<ExecResult> {
1827 let _guard = self.acquire_execute_lock().await;
1828
1829 // Always reset to a fresh internal token; this is the kernel's own
1830 // cancel surface for embedders calling `Kernel::cancel()`. The
1831 // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1832 // is NOT written into `self.cancel_token`, because doing so would
1833 // (a) leak the embedder's token past this call's lifetime,
1834 // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1835 // (c) extend the token's lifetime via the kernel's strong clone.
1836 let internal = self.reset_cancel();
1837
1838 // Install the per-call polled interrupt for `is_cancelled()` to
1839 // consult. The guard clears it on every exit path — a stale check
1840 // must not outlive its call and fire into a later one.
1841 struct ClearInterrupt<'a>(&'a Kernel);
1842 impl Drop for ClearInterrupt<'_> {
1843 fn drop(&mut self) {
1844 if let Ok(mut slot) = self.0.interrupt.lock() {
1845 *slot = None;
1846 }
1847 }
1848 }
1849 {
1850 #[allow(clippy::expect_used)]
1851 let mut slot = self.interrupt.lock().expect("interrupt poisoned");
1852 *slot = opts.interrupt.clone();
1853 }
1854 let _interrupt_guard = ClearInterrupt(self);
1855
1856 // Race the embedder token against the kernel's internal token via a
1857 // tracked watcher task. We hold the JoinHandle so we can abort the
1858 // task at function exit — otherwise it would wait forever for either
1859 // token to fire and leak per call.
1860 let (effective_cancel, watcher_handle): (
1861 tokio_util::sync::CancellationToken,
1862 Option<tokio::task::JoinHandle<()>>,
1863 ) = if let Some(ext) = opts.cancel_token {
1864 let combined = tokio_util::sync::CancellationToken::new();
1865 let combined_writer = combined.clone();
1866 let i = internal.clone();
1867 let handle = tokio::spawn(async move {
1868 tokio::select! {
1869 _ = i.cancelled() => combined_writer.cancel(),
1870 _ = ext.cancelled() => combined_writer.cancel(),
1871 }
1872 });
1873 (combined, Some(handle))
1874 } else {
1875 (internal, None)
1876 };
1877
1878 // Effective timeout: per-call wins over kernel-config default.
1879 let timeout = opts.timeout.or(self.request_timeout);
1880
1881 // ZERO timeout: return 124 immediately without spawning anything.
1882 if timeout == Some(Duration::ZERO) {
1883 if let Some(h) = watcher_handle {
1884 h.abort();
1885 }
1886 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1887 }
1888
1889 // Apply per-call vars overlay (push frame + set_exported), wrapped in
1890 // an RAII guard so a panic inside `execute_streaming_inner` still
1891 // pops the frame and unexports the temporarily-exported names.
1892 struct VarsFrameGuard<'a> {
1893 kernel: &'a Kernel,
1894 newly_exported: Vec<String>,
1895 }
1896 impl Drop for VarsFrameGuard<'_> {
1897 fn drop(&mut self) {
1898 // Best-effort cleanup using try_write. The execute_lock held
1899 // throughout execute_with_options means there is no concurrent
1900 // foreground caller; forks have their own scope and won't
1901 // block this. blocking_write would deadlock the runtime when
1902 // called from a tokio worker thread, so we explicitly do NOT
1903 // fall back to it — if try_write fails (which we've never
1904 // seen in practice), log loudly and accept the leak rather
1905 // than deadlock the entire kernel.
1906 let Ok(mut scope) = self.kernel.scope.try_write() else {
1907 tracing::error!(
1908 "vars frame guard: scope lock unexpectedly busy; \
1909 skipping pop_frame to avoid runtime deadlock — \
1910 transient vars may leak"
1911 );
1912 return;
1913 };
1914 scope.pop_frame();
1915 for name in self.newly_exported.drain(..) {
1916 scope.unexport(&name);
1917 }
1918 }
1919 }
1920
1921 // Per-call cwd override: save current cwd, set the new one, restore
1922 // on Drop so the kernel's persistent cwd doesn't leak between calls.
1923 // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1924 struct CwdGuard<'a> {
1925 kernel: &'a Kernel,
1926 saved: PathBuf,
1927 }
1928 impl Drop for CwdGuard<'_> {
1929 fn drop(&mut self) {
1930 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1931 tracing::error!(
1932 "cwd guard: exec_ctx lock unexpectedly busy; \
1933 skipping cwd restore — kernel cwd may be wrong for next call"
1934 );
1935 return;
1936 };
1937 ec.cwd = std::mem::take(&mut self.saved);
1938 }
1939 }
1940 let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1941 let mut ec = self.exec_ctx.write().await;
1942 let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1943 drop(ec);
1944 Some(CwdGuard { kernel: self, saved })
1945 } else {
1946 None
1947 };
1948
1949 // Per-call stdin: seed the persistent exec_ctx so the first top-level
1950 // command that reads stdin consumes it (it's `take()`n at dispatch).
1951 // Restore the prior value on Drop — normally `None`, so this also drops
1952 // any residual seed an stdin-less program never consumed, keeping it
1953 // from bleeding into the next call. Same RAII pattern as CwdGuard.
1954 struct StdinGuard<'a> {
1955 kernel: &'a Kernel,
1956 saved: Option<Vec<u8>>,
1957 }
1958 impl Drop for StdinGuard<'_> {
1959 fn drop(&mut self) {
1960 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1961 tracing::error!(
1962 "stdin guard: exec_ctx lock unexpectedly busy; \
1963 skipping stdin restore — stale stdin may leak to next call"
1964 );
1965 return;
1966 };
1967 ec.stdin = self.saved.take();
1968 }
1969 }
1970 let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
1971 let mut ec = self.exec_ctx.write().await;
1972 let saved = ec.stdin.replace(stdin);
1973 drop(ec);
1974 Some(StdinGuard { kernel: self, saved })
1975 } else {
1976 None
1977 };
1978
1979 // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
1980 // persistent exec_ctx so the first stdin-reading command drains it (it's
1981 // `take()`n at pipeline build). The RAII guard restores the prior value
1982 // on Drop (normally `None`), so an unread reader doesn't bleed into the
1983 // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
1984 struct PipeStdinGuard<'a> {
1985 kernel: &'a Kernel,
1986 saved: Option<crate::scheduler::PipeReader>,
1987 }
1988 impl Drop for PipeStdinGuard<'_> {
1989 fn drop(&mut self) {
1990 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1991 tracing::error!(
1992 "pipe stdin guard: exec_ctx lock unexpectedly busy; \
1993 skipping restore — stale pipe stdin may leak to next call"
1994 );
1995 return;
1996 };
1997 ec.pipe_stdin = self.saved.take();
1998 }
1999 }
2000 let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2001 let mut ec = self.exec_ctx.write().await;
2002 let saved = ec.pipe_stdin.replace(reader);
2003 drop(ec);
2004 Some(PipeStdinGuard { kernel: self, saved })
2005 } else {
2006 None
2007 };
2008
2009 let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2010 let mut scope = self.scope.write().await;
2011 scope.push_frame();
2012 let mut newly = Vec::with_capacity(opts.vars.len());
2013 for (name, value) in opts.vars {
2014 if !scope.is_exported(&name) {
2015 newly.push(name.clone());
2016 }
2017 scope.set_exported(name, value);
2018 }
2019 drop(scope);
2020 Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2021 } else {
2022 None
2023 };
2024
2025 // Sync the effective cancel into self.exec_ctx so try_execute_external
2026 // (which reads via self.cancel_token) sees cancellation. We also need
2027 // builtins to see it via ctx.cancel — handled in execute_command.
2028 // For simplicity here we mirror effective_cancel into self.cancel_token
2029 // for the duration of this call, then restore the internal token at
2030 // the end (so a later Kernel::cancel still hits our internal surface).
2031 {
2032 #[allow(clippy::expect_used)]
2033 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2034 *cur = effective_cancel.clone();
2035 }
2036
2037 // Run the script under the movable-deadline watchdog (shared with the
2038 // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2039 // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2040 // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2041 // already handled by the early return above.
2042 let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2043 let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2044 Some(cb) => cb,
2045 None => &mut *noop_cb,
2046 };
2047
2048 let result = self
2049 .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2050 .await;
2051
2052 // Restore self.cancel_token to a fresh, uncancelled token so the
2053 // embedder's view of `Kernel::cancel()` stays predictable on the
2054 // next call (it cancels the kernel's own token, not whatever was
2055 // left over from this call's combined token).
2056 {
2057 #[allow(clippy::expect_used)]
2058 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2059 *cur = tokio_util::sync::CancellationToken::new();
2060 }
2061
2062 // Tear down the embedder-token race watcher (if any). Leaving it
2063 // alive would idle forever waiting for tokens that may never fire.
2064 if let Some(h) = watcher_handle {
2065 h.abort();
2066 }
2067
2068 // VarsFrameGuard drops here on the success path and on early-return
2069 // paths above (error path included). Panic safety preserved.
2070 result
2071 }
2072
2073 /// The actual body of `execute_streaming`, run while holding the execute lock.
2074 ///
2075 /// Split out so internal kernel paths that are already under the lock can
2076 /// call this without deadlocking on re-entry. External callers must go
2077 /// through [`Self::execute_streaming`] so they acquire the lock.
2078 async fn execute_streaming_inner(
2079 &self,
2080 input: &str,
2081 on_output: &mut (dyn FnMut(&ExecResult) + Send),
2082 ) -> Result<ExecResult> {
2083 let program = parse(input).map_err(|errors| {
2084 let msg = errors
2085 .iter()
2086 .map(|e| e.format(input))
2087 .collect::<Vec<_>>()
2088 .join("\n");
2089 anyhow::anyhow!("parse error:\n{}", msg)
2090 })?;
2091
2092 // AST display mode: show AST instead of executing
2093 {
2094 let scope = self.scope.read().await;
2095 if scope.show_ast() {
2096 let output = format!("{:#?}\n", program);
2097 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2098 }
2099 }
2100
2101 // Pre-execution validation. Most warnings stay trace-only (every
2102 // external command fires an `UndefinedCommand` warning), but a warning
2103 // whose code opts into agent surfacing is collected here and prepended
2104 // to the result's stderr at each return point below.
2105 let mut surfaced_warnings = String::new();
2106 if !self.skip_validation {
2107 let user_tools = self.user_tools.read().await;
2108 let validator = Validator::new(&self.tools, &user_tools);
2109 let issues = validator.validate(&program);
2110
2111 // Collect errors (warnings are logged but don't prevent execution)
2112 let errors: Vec<_> = issues
2113 .iter()
2114 .filter(|i| i.severity == Severity::Error)
2115 .collect();
2116
2117 if !errors.is_empty() {
2118 let error_msg = errors
2119 .iter()
2120 .map(|e| e.format(input))
2121 .collect::<Vec<_>>()
2122 .join("\n");
2123 return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2124 }
2125
2126 // Log warnings via tracing (trace level to avoid noise); surface the
2127 // opted-in ones to the agent so the guidance is actually seen.
2128 for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2129 tracing::trace!("validation: {}", warning.format(input));
2130 if warning.code.surfaces_to_agent() {
2131 surfaced_warnings.push_str(&warning.format(input));
2132 surfaced_warnings.push('\n');
2133 }
2134 }
2135 }
2136
2137 // Surface opted-in validation warnings to the streaming frontend once,
2138 // before any command output. The streaming consumer (`-c`, REPL) prints
2139 // per `on_output` and ignores the returned aggregate err; non-streaming
2140 // callers (`kernel.execute`) use a noop callback and read the aggregate
2141 // `result.err` (prepended at each return below). The two paths are
2142 // disjoint, so this prints the advisory exactly once on each.
2143 if !surfaced_warnings.is_empty() {
2144 let mut advisory = ExecResult::success("");
2145 advisory.err = surfaced_warnings.clone();
2146 on_output(&advisory);
2147 }
2148
2149 let mut result = ExecResult::success("");
2150
2151 // Reset cancellation token for this execution.
2152 let cancel = self.reset_cancel();
2153
2154 for stmt in program.statements {
2155 if matches!(stmt, Stmt::Empty) {
2156 continue;
2157 }
2158
2159 // Cancellation checkpoint
2160 if cancel.is_cancelled() {
2161 result.code = 130;
2162 return Ok(result);
2163 }
2164
2165 let flow = self.execute_stmt_flow(&stmt).await?;
2166
2167 // Drain any stderr written by pipeline stages during this statement.
2168 // This captures stderr from intermediate pipeline stages that would
2169 // otherwise be lost (only the last stage's result is returned).
2170 let drained_stderr = {
2171 let mut receiver = self.stderr_receiver.lock().await;
2172 receiver.drain_lossy()
2173 };
2174
2175 match flow {
2176 ControlFlow::Normal(mut r) => {
2177 if !drained_stderr.is_empty() {
2178 if !r.err.is_empty() && !r.err.ends_with('\n') {
2179 r.err.push('\n');
2180 }
2181 // Prepend pipeline stderr before the last stage's stderr
2182 let combined = format!("{}{}", drained_stderr, r.err);
2183 r.err = combined;
2184 }
2185 on_output(&r);
2186 // Carry the last statement's structured output for MCP TOON encoding.
2187 // Must be done here (not in accumulate_result) because accumulate_result
2188 // is also used in loops where per-iteration output would be wrong.
2189 let last_output = r.output().cloned();
2190 accumulate_result(&mut result, &r);
2191 result.set_output(last_output);
2192 }
2193 ControlFlow::Exit { code } => {
2194 if !drained_stderr.is_empty() {
2195 result.err.push_str(&drained_stderr);
2196 }
2197 result.code = code;
2198 if !surfaced_warnings.is_empty() {
2199 result.err = format!("{surfaced_warnings}{}", result.err);
2200 }
2201 return Ok(result);
2202 }
2203 ControlFlow::Return { mut value } => {
2204 if !drained_stderr.is_empty() {
2205 value.err = format!("{}{}", drained_stderr, value.err);
2206 }
2207 on_output(&value);
2208 // A top-level `return` stops the script, like `exit` —
2209 // it must not discard prior statements' accumulated
2210 // output nor let execution continue past it.
2211 accumulate_result(&mut result, &value);
2212 if !surfaced_warnings.is_empty() {
2213 result.err = format!("{surfaced_warnings}{}", result.err);
2214 }
2215 return Ok(result);
2216 }
2217 ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2218 if !drained_stderr.is_empty() {
2219 r.err = format!("{}{}", drained_stderr, r.err);
2220 }
2221 on_output(&r);
2222 accumulate_result(&mut result, &r);
2223 }
2224 }
2225 }
2226
2227 if !surfaced_warnings.is_empty() {
2228 result.err = format!("{surfaced_warnings}{}", result.err);
2229 }
2230 Ok(result)
2231 }
2232
2233 /// Execute a single statement, returning control flow information.
2234 fn execute_stmt_flow<'a>(
2235 &'a self,
2236 stmt: &'a Stmt,
2237 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2238 // No per-statement span here: `execute_stmt_flow` is the largest future
2239 // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2240 // the span's state through every `.await` at every level, costing native
2241 // stack per level (GH #48). Coarser spans on the outer execute entries
2242 // remain. See item 3 of the #48 burndown.
2243 Box::pin(async move {
2244 match stmt {
2245 Stmt::Assignment(assign) => {
2246 // Use async evaluator to support command substitution
2247 let value = self.eval_expr_async(&assign.value).await
2248 .context("failed to evaluate assignment")?;
2249 let mut scope = self.scope.write().await;
2250 if assign.path.segments.len() == 1 {
2251 // Plain `NAME=value` — no subscript, so `local` applies.
2252 if assign.local {
2253 // local: set in innermost (current function) frame
2254 scope.set(assign.name(), value.clone());
2255 } else {
2256 // non-local: update existing or create in root frame
2257 scope.set_global(assign.name(), value.clone());
2258 }
2259 } else {
2260 // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2261 // mutates the existing root wherever it lives — `local` is
2262 // meaningless here (see docs/arrays-and-hashes.md, "Assignment
2263 // lvalues").
2264 scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2265 PathError::UndefinedRoot(name) => anyhow::anyhow!(
2266 "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2267 ),
2268 PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2269 })?;
2270 }
2271 drop(scope);
2272
2273 // Assignments don't produce output (like sh)
2274 Ok(ControlFlow::ok(ExecResult::success("")))
2275 }
2276 Stmt::Command(cmd) => {
2277 // Route single commands through execute_pipeline for a unified path.
2278 // This ensures all commands go through the dispatcher chain.
2279 let pipeline = crate::ast::Pipeline {
2280 commands: vec![cmd.clone()],
2281 background: false,
2282 };
2283 let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2284 self.update_last_result(&result).await;
2285
2286 // Check for error exit mode (set -e)
2287 if !result.ok() {
2288 let scope = self.scope.read().await;
2289 if scope.error_exit_enabled() {
2290 return Ok(ControlFlow::exit_code(result.code));
2291 }
2292 }
2293
2294 Ok(ControlFlow::ok(result))
2295 }
2296 Stmt::Pipeline(pipeline) => {
2297 let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2298 self.update_last_result(&result).await;
2299
2300 // Check for error exit mode (set -e)
2301 if !result.ok() {
2302 let scope = self.scope.read().await;
2303 if scope.error_exit_enabled() {
2304 return Ok(ControlFlow::exit_code(result.code));
2305 }
2306 }
2307
2308 Ok(ControlFlow::ok(result))
2309 }
2310 Stmt::If(if_stmt) => {
2311 // Use async evaluator to support command substitution in conditions
2312 let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2313
2314 let branch = if is_truthy(&cond_value) {
2315 &if_stmt.then_branch
2316 } else {
2317 if_stmt.else_branch.as_deref().unwrap_or(&[])
2318 };
2319
2320 let mut result = ExecResult::success("");
2321 for stmt in branch {
2322 let flow = self.execute_stmt_flow(stmt).await?;
2323 match flow {
2324 ControlFlow::Normal(r) => {
2325 accumulate_result(&mut result, &r);
2326 self.drain_stderr_into(&mut result).await;
2327 }
2328 other => {
2329 self.drain_stderr_into(&mut result).await;
2330 return Ok(other);
2331 }
2332 }
2333 }
2334 Ok(ControlFlow::ok(result))
2335 }
2336 Stmt::For(for_loop) => {
2337 // Evaluate all items and collect values for iteration
2338 // Use async evaluator to support command substitution like $(seq 1 5)
2339 let mut items: Vec<Value> = Vec::new();
2340 for item_expr in &for_loop.items {
2341 // Glob expansion in for-loop items: `for f in *.txt`
2342 if let Expr::GlobPattern(pattern) = item_expr {
2343 let glob_enabled = {
2344 let scope = self.scope.read().await;
2345 scope.glob_enabled()
2346 };
2347 if glob_enabled {
2348 let (paths, cwd) = {
2349 let ctx = self.exec_ctx.read().await;
2350 let paths = ctx.expand_glob(pattern).await
2351 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2352 let cwd = ctx.resolve_path(".");
2353 (paths, cwd)
2354 };
2355 if paths.is_empty() {
2356 return Err(anyhow::anyhow!("no matches: {}", pattern));
2357 }
2358 for path in paths {
2359 let display = if !pattern.starts_with('/') {
2360 path.strip_prefix(&cwd)
2361 .unwrap_or(&path)
2362 .to_string_lossy().into_owned()
2363 } else {
2364 path.to_string_lossy().into_owned()
2365 };
2366 items.push(Value::String(display));
2367 }
2368 continue;
2369 }
2370 }
2371 // Track whether this item came from $(cmd); that's the
2372 // only position where multi-line stdout auto-splits per
2373 // line. Arrays still spread element-by-element; bare
2374 // $VAR is rejected upstream by validator E012. See
2375 // docs/LANGUAGE.md.
2376 let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2377 let item = self.eval_expr_async(item_expr).await?;
2378 match item {
2379 // JSON arrays iterate over elements (preferred path
2380 // when builtins emit .data — seq, jq, cut, find, …)
2381 Value::Json(serde_json::Value::Array(arr)) => {
2382 for elem in arr {
2383 // Envelope-free: an element that happens to be
2384 // envelope-shaped (e.g. from `fromjson`) is
2385 // external data, not an internal bytes round-trip,
2386 // so it must NOT be re-decoded to Value::Bytes.
2387 items.push(json_to_value_no_envelope(elem));
2388 }
2389 }
2390 // Strings from $(cmd): empty → 0 iterations,
2391 // multi-line → split per line (trimming trailing
2392 // newlines and per-line trailing \r), single-line
2393 // → one iteration. Whitespace within a line is
2394 // NOT split — the "$VAR with spaces just works"
2395 // promise is preserved because this only fires
2396 // in CommandSubst position.
2397 Value::String(s) if from_command_subst => {
2398 let trimmed = s.trim_end_matches(['\n', '\r']);
2399 if trimmed.is_empty() {
2400 continue;
2401 }
2402 if trimmed.contains('\n') {
2403 for line in trimmed.split('\n') {
2404 let line = line.trim_end_matches('\r');
2405 items.push(Value::String(line.to_string()));
2406 }
2407 } else {
2408 items.push(Value::String(trimmed.to_string()));
2409 }
2410 }
2411 // Binary isn't iterable — fail loud rather than loop
2412 // once over an opaque byte blob.
2413 Value::Bytes(_) => {
2414 anyhow::bail!(
2415 "for: cannot iterate over binary data — decode it \
2416 (base64/xxd) first"
2417 );
2418 }
2419 // Strings not from $(cmd) stay as one value.
2420 other => items.push(other),
2421 }
2422 }
2423
2424 let mut result = ExecResult::success("");
2425 {
2426 let mut scope = self.scope.write().await;
2427 scope.push_frame();
2428 }
2429
2430 'outer: for item in items {
2431 // Cancellation checkpoint per iteration
2432 if self.is_cancelled() {
2433 let mut scope = self.scope.write().await;
2434 scope.pop_frame();
2435 result.code = 130;
2436 return Ok(ControlFlow::ok(result));
2437 }
2438 {
2439 let mut scope = self.scope.write().await;
2440 scope.set(&for_loop.variable, item);
2441 }
2442 for stmt in &for_loop.body {
2443 let mut flow = match self.execute_stmt_flow(stmt).await {
2444 Ok(f) => f,
2445 Err(e) => {
2446 let mut scope = self.scope.write().await;
2447 scope.pop_frame();
2448 return Err(e);
2449 }
2450 };
2451 self.drain_stderr_into(&mut result).await;
2452 match &mut flow {
2453 ControlFlow::Normal(r) => {
2454 accumulate_result(&mut result, r);
2455 if !r.ok() {
2456 let scope = self.scope.read().await;
2457 if scope.error_exit_enabled() {
2458 drop(scope);
2459 let mut scope = self.scope.write().await;
2460 scope.pop_frame();
2461 return Ok(ControlFlow::exit_code(r.code));
2462 }
2463 }
2464 }
2465 ControlFlow::Break { .. } => {
2466 if flow.decrement_level() {
2467 accumulate_flow_output(&mut result, &flow);
2468 break 'outer;
2469 }
2470 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2471 let mut scope = self.scope.write().await;
2472 scope.pop_frame();
2473 return Ok(flow);
2474 }
2475 ControlFlow::Continue { .. } => {
2476 if flow.decrement_level() {
2477 accumulate_flow_output(&mut result, &flow);
2478 continue 'outer;
2479 }
2480 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2481 let mut scope = self.scope.write().await;
2482 scope.pop_frame();
2483 return Ok(flow);
2484 }
2485 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2486 let mut scope = self.scope.write().await;
2487 scope.pop_frame();
2488 return Ok(flow);
2489 }
2490 }
2491 }
2492 }
2493
2494 {
2495 let mut scope = self.scope.write().await;
2496 scope.pop_frame();
2497 }
2498 Ok(ControlFlow::ok(result))
2499 }
2500 Stmt::While(while_loop) => {
2501 let mut result = ExecResult::success("");
2502
2503 'outer: loop {
2504 // Evaluate condition - use async to support command substitution
2505 // Cancellation checkpoint per iteration
2506 if self.is_cancelled() {
2507 result.code = 130;
2508 return Ok(ControlFlow::ok(result));
2509 }
2510
2511 let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2512
2513 if !is_truthy(&cond_value) {
2514 break;
2515 }
2516
2517 // Execute body
2518 for stmt in &while_loop.body {
2519 let mut flow = self.execute_stmt_flow(stmt).await?;
2520 self.drain_stderr_into(&mut result).await;
2521 match &mut flow {
2522 ControlFlow::Normal(r) => {
2523 accumulate_result(&mut result, r);
2524 if !r.ok() {
2525 let scope = self.scope.read().await;
2526 if scope.error_exit_enabled() {
2527 return Ok(ControlFlow::exit_code(r.code));
2528 }
2529 }
2530 }
2531 ControlFlow::Break { .. } => {
2532 if flow.decrement_level() {
2533 accumulate_flow_output(&mut result, &flow);
2534 break 'outer;
2535 }
2536 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2537 return Ok(flow);
2538 }
2539 ControlFlow::Continue { .. } => {
2540 if flow.decrement_level() {
2541 accumulate_flow_output(&mut result, &flow);
2542 continue 'outer;
2543 }
2544 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2545 return Ok(flow);
2546 }
2547 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2548 return Ok(flow);
2549 }
2550 }
2551 }
2552 }
2553
2554 Ok(ControlFlow::ok(result))
2555 }
2556 Stmt::Case(case_stmt) => {
2557 // Evaluate the expression to match against. Text sink: a
2558 // `case $bin in ...)` pattern match on binary goes loud
2559 // rather than glob-matching against the `[binary: N bytes]`
2560 // placeholder (Decision E — same class as `==`/`in`).
2561 let match_value = {
2562 let value = self.eval_expr_async(&case_stmt.expr).await?;
2563 value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2564 };
2565
2566 // Try each branch until we find a match
2567 for branch in &case_stmt.branches {
2568 let matched = branch.patterns.iter().any(|pattern| {
2569 glob_match(pattern, &match_value)
2570 });
2571
2572 if matched {
2573 // Execute the branch body
2574 let mut result = ExecResult::success("");
2575 for stmt in &branch.body {
2576 let flow = self.execute_stmt_flow(stmt).await?;
2577 match flow {
2578 ControlFlow::Normal(r) => {
2579 accumulate_result(&mut result, &r);
2580 self.drain_stderr_into(&mut result).await;
2581 }
2582 other => {
2583 self.drain_stderr_into(&mut result).await;
2584 return Ok(other);
2585 }
2586 }
2587 }
2588 return Ok(ControlFlow::ok(result));
2589 }
2590 }
2591
2592 // No match - return success with empty output (like sh)
2593 Ok(ControlFlow::ok(ExecResult::success("")))
2594 }
2595 Stmt::Break(levels) => {
2596 Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2597 }
2598 Stmt::Continue(levels) => {
2599 Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2600 }
2601 Stmt::Return(expr) => {
2602 // return [N] - N becomes the exit code, NOT stdout
2603 // Shell semantics: return sets exit code, doesn't produce output
2604 let result = if let Some(e) = expr {
2605 let val = self.eval_expr_async(e).await?;
2606 let code = crate::interpreter::value_to_exit_code(&val)
2607 .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2608 ExecResult::from_parts(code, String::new(), String::new(), None)
2609 } else {
2610 ExecResult::success("")
2611 };
2612 Ok(ControlFlow::return_value(result))
2613 }
2614 Stmt::Exit(expr) => {
2615 let code = if let Some(e) = expr {
2616 let val = self.eval_expr_async(e).await?;
2617 crate::interpreter::value_to_exit_code(&val)
2618 .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2619 } else {
2620 0
2621 };
2622 Ok(ControlFlow::exit_code(code))
2623 }
2624 Stmt::ToolDef(tool_def) => {
2625 let mut user_tools = self.user_tools.write().await;
2626 user_tools.insert(tool_def.name.clone(), tool_def.clone());
2627 Ok(ControlFlow::ok(ExecResult::success("")))
2628 }
2629 Stmt::AndChain { left, right } => {
2630 // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2631 // Suppress errexit for the left side — && handles failure itself.
2632 {
2633 let mut scope = self.scope.write().await;
2634 scope.suppress_errexit();
2635 }
2636 let left_flow = match self.execute_stmt_flow(left).await {
2637 Ok(f) => f,
2638 Err(e) => {
2639 let mut scope = self.scope.write().await;
2640 scope.unsuppress_errexit();
2641 return Err(e);
2642 }
2643 };
2644 {
2645 let mut scope = self.scope.write().await;
2646 scope.unsuppress_errexit();
2647 }
2648 match left_flow {
2649 ControlFlow::Normal(mut left_result) => {
2650 self.drain_stderr_into(&mut left_result).await;
2651 self.update_last_result(&left_result).await;
2652 if left_result.ok() {
2653 let right_flow = self.execute_stmt_flow(right).await?;
2654 match right_flow {
2655 ControlFlow::Normal(mut right_result) => {
2656 self.drain_stderr_into(&mut right_result).await;
2657 self.update_last_result(&right_result).await;
2658 let mut combined = left_result;
2659 accumulate_result(&mut combined, &right_result);
2660 Ok(ControlFlow::ok(combined))
2661 }
2662 other => Ok(other),
2663 }
2664 } else {
2665 Ok(ControlFlow::ok(left_result))
2666 }
2667 }
2668 _ => Ok(left_flow),
2669 }
2670 }
2671 Stmt::OrChain { left, right } => {
2672 // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2673 // Suppress errexit for the left side — || handles failure itself.
2674 {
2675 let mut scope = self.scope.write().await;
2676 scope.suppress_errexit();
2677 }
2678 let left_flow = match self.execute_stmt_flow(left).await {
2679 Ok(f) => f,
2680 Err(e) => {
2681 let mut scope = self.scope.write().await;
2682 scope.unsuppress_errexit();
2683 return Err(e);
2684 }
2685 };
2686 {
2687 let mut scope = self.scope.write().await;
2688 scope.unsuppress_errexit();
2689 }
2690 match left_flow {
2691 ControlFlow::Normal(mut left_result) => {
2692 self.drain_stderr_into(&mut left_result).await;
2693 self.update_last_result(&left_result).await;
2694 if !left_result.ok() {
2695 let right_flow = self.execute_stmt_flow(right).await?;
2696 match right_flow {
2697 ControlFlow::Normal(mut right_result) => {
2698 self.drain_stderr_into(&mut right_result).await;
2699 self.update_last_result(&right_result).await;
2700 let mut combined = left_result;
2701 accumulate_result(&mut combined, &right_result);
2702 Ok(ControlFlow::ok(combined))
2703 }
2704 other => Ok(other),
2705 }
2706 } else {
2707 Ok(ControlFlow::ok(left_result))
2708 }
2709 }
2710 _ => Ok(left_flow), // Propagate non-normal flow
2711 }
2712 }
2713 Stmt::Test(test_expr) => {
2714 let is_true = self.eval_test_async(test_expr).await?;
2715 let result = if is_true {
2716 ExecResult::success("")
2717 } else {
2718 ExecResult::failure(1, "")
2719 };
2720 // A bare test writes `$?` and honors `set -e` like any command
2721 // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2722 // safe: the chain arms suppress errexit around their left side,
2723 // and `if`/`while` conditions evaluate as expressions, never
2724 // through this statement arm.
2725 self.update_last_result(&result).await;
2726 if !result.ok() {
2727 let scope = self.scope.read().await;
2728 if scope.error_exit_enabled() {
2729 return Ok(ControlFlow::exit_code(result.code));
2730 }
2731 }
2732 Ok(ControlFlow::ok(result))
2733 }
2734 Stmt::EnvScoped { assignments, body } => {
2735 // Inline env prefix (`NAME=value ... command`): apply the
2736 // assignments as EXPORTED vars in a fresh frame so the command
2737 // — and its subprocess environment — sees them, then unwind so
2738 // they do NOT persist (bash-style command-scoped env). Values
2739 // evaluate left-to-right with earlier ones already in scope, so
2740 // `A=1 B=$A cmd` works.
2741 {
2742 let mut scope = self.scope.write().await;
2743 scope.push_frame();
2744 }
2745 let mut prior_export: Vec<(String, bool)> =
2746 Vec::with_capacity(assignments.len());
2747 let mut setup_err: Option<anyhow::Error> = None;
2748 for assign in assignments {
2749 match self.eval_expr_async(&assign.value).await {
2750 Ok(value) => {
2751 let mut scope = self.scope.write().await;
2752 prior_export
2753 .push((assign.name().to_string(), scope.is_exported(assign.name())));
2754 scope.set_exported(assign.name(), value);
2755 }
2756 Err(e) => {
2757 setup_err = Some(e);
2758 break;
2759 }
2760 }
2761 }
2762
2763 let flow = if setup_err.is_none() {
2764 self.execute_stmt_flow(body).await
2765 } else {
2766 Ok(ControlFlow::ok(ExecResult::success("")))
2767 };
2768
2769 // Unwind the env frame and restore export marks unconditionally
2770 // (names that were not exported before must not stay exported).
2771 {
2772 let mut scope = self.scope.write().await;
2773 scope.pop_frame();
2774 for (name, was_exported) in &prior_export {
2775 if !*was_exported {
2776 scope.unexport(name);
2777 }
2778 }
2779 }
2780
2781 match setup_err {
2782 Some(e) => Err(e),
2783 None => flow,
2784 }
2785 }
2786 Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2787 }
2788 })
2789 }
2790
2791 /// Build a boxed per-command `ExecContext` snapshot from the persistent
2792 /// kernel state (`ec`/`scope`, both already locked by the caller).
2793 ///
2794 /// Sync on purpose: the ~30 field clones live in this transient frame rather
2795 /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2796 /// — not the 960-byte struct — rides the dispatch await at every recursion
2797 /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2798 /// per-site differences (the pipeline runner uses the kernel's own cancel
2799 /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2800 /// they're parameters; every other field is snapshotted identically.
2801 fn snapshot_exec_ctx(
2802 &self,
2803 ec: &ExecContext,
2804 scope: &Scope,
2805 pipeline_position: PipelinePosition,
2806 cancel: tokio_util::sync::CancellationToken,
2807 ) -> Box<ExecContext> {
2808 Box::new(ExecContext {
2809 backend: ec.backend.clone(),
2810 scope: scope.clone(),
2811 cwd: ec.cwd.clone(),
2812 prev_cwd: ec.prev_cwd.clone(),
2813 stdin: ec.stdin.clone(),
2814 stdin_data: ec.stdin_data.clone(),
2815 stdin_data_rx: None,
2816 pipe_stdin: None,
2817 pipe_stdout: None,
2818 stderr: ec.stderr.clone(),
2819 tool_schemas: ec.tool_schemas.clone(),
2820 tools: ec.tools.clone(),
2821 job_manager: ec.job_manager.clone(),
2822 pipeline_position,
2823 interactive: self.interactive,
2824 aliases: ec.aliases.clone(),
2825 ignore_config: ec.ignore_config.clone(),
2826 output_limit: ec.output_limit.clone(),
2827 allow_external_commands: self.allow_external_commands,
2828 nonce_store: ec.nonce_store.clone(),
2829 trash_backend: ec.trash_backend.clone(),
2830 #[cfg(all(unix, feature = "subprocess"))]
2831 terminal_state: ec.terminal_state.clone(),
2832 dispatcher: self.dispatcher(),
2833 cancel,
2834 output_format: None,
2835 current_invocation: None,
2836 vfs_budget: self.vfs_budget.clone(),
2837 watchdog: ec.watchdog.clone(),
2838 #[cfg(all(feature = "localfs", feature = "overlay"))]
2839 overlay_handle: self.overlay_handle.clone(),
2840 })
2841 }
2842
2843 /// Execute a pipeline.
2844 async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2845 if pipeline.commands.is_empty() {
2846 return Ok(ExecResult::success(""));
2847 }
2848
2849 // Handle background execution (`&` operator)
2850 if pipeline.background {
2851 return self.execute_background(pipeline).await;
2852 }
2853
2854 // All commands go through the runner with the Kernel as dispatcher.
2855 // This is the single execution path — no fast path for single commands.
2856 //
2857 // IMPORTANT: We snapshot exec_ctx into a local context and release the
2858 // lock before running. This prevents deadlocks when dispatch_command
2859 // is called from within the pipeline and recursively triggers another
2860 // pipeline (e.g., via user-defined tools).
2861 let (mut ctx, has_pipe_stdin) = {
2862 let ec = self.exec_ctx.read().await;
2863 let scope = self.scope.read().await;
2864 // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2865 // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2866 // the consume-once block below, so note its presence here.
2867 let has_pipe_stdin = ec.pipe_stdin.is_some();
2868 // The pipeline runner drives stage 0 with the first stage's stdin
2869 // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2870 // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2871 // and uses the kernel's own cancel token so a `cancel()` reaches the
2872 // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2873 let cancel = {
2874 #[allow(clippy::expect_used)]
2875 let token = self.cancel_token.lock().expect("cancel_token poisoned");
2876 token.clone()
2877 };
2878 (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2879 }; // locks released
2880
2881 // Consume-once: move/clear the seeded stdin sources from the persistent
2882 // exec_ctx now that this pipeline's ctx owns them, so a later statement
2883 // in the same call (`cat ; cat`) does not re-receive them — matching
2884 // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2885 // (the ctx above was built with `pipe_stdin: None`).
2886 if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2887 let mut ec = self.exec_ctx.write().await;
2888 ctx.pipe_stdin = ec.pipe_stdin.take();
2889 ec.stdin = None;
2890 ec.stdin_data = None;
2891 }
2892
2893 let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2894
2895 // Post-hoc spill check (catches builtins and fast external commands)
2896 if ctx.output_limit.is_enabled() {
2897 let _ = crate::output_limit::spill_if_needed(&mut result, &ctx.output_limit).await;
2898 }
2899
2900 // Signal spill with exit 3; agent reads the spill file directly
2901 // (use `set +o output-limit` before cat/head/tail to bypass the limit)
2902 if result.did_spill {
2903 result.original_code = Some(result.code);
2904 result.code = 3;
2905 }
2906
2907 // Sync changes back from context
2908 {
2909 let mut ec = self.exec_ctx.write().await;
2910 ec.cwd = ctx.cwd.clone();
2911 ec.prev_cwd = ctx.prev_cwd.clone();
2912 ec.aliases = ctx.aliases.clone();
2913 ec.ignore_config = ctx.ignore_config.clone();
2914 ec.output_limit = ctx.output_limit.clone();
2915 }
2916 {
2917 let mut scope = self.scope.write().await;
2918 *scope = ctx.scope.clone();
2919 }
2920
2921 Ok(result)
2922 }
2923
2924 /// Execute a pipeline in the background.
2925 ///
2926 /// The command is spawned as a tokio task, registered with the JobManager,
2927 /// and its output is captured via BoundedStreams. The job is observable via
2928 /// `/v/jobs/{id}/stdout`, `/v/jobs/{id}/stderr`, and `/v/jobs/{id}/status`.
2929 ///
2930 /// Returns immediately with a job ID like "[1]".
2931 #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2932 async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2933 use tokio::sync::oneshot;
2934
2935 // Format the command for display in /v/jobs/{id}/command
2936 let command_str = self.format_pipeline(pipeline);
2937
2938 // Create bounded streams for output capture
2939 let stdout = Arc::new(BoundedStream::default_size());
2940 let stderr = Arc::new(BoundedStream::default_size());
2941
2942 // Create channel for result notification
2943 let (tx, rx) = oneshot::channel();
2944
2945 // Register with JobManager to get job ID and create VFS entries
2946 let job_id = self.jobs.register_with_streams(
2947 command_str.clone(),
2948 rx,
2949 stdout.clone(),
2950 stderr.clone(),
2951 ).await;
2952
2953 // Fork the kernel for this background job. The fork snapshots the
2954 // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
2955 // while sharing the job manager, VFS, and tool registry. The fork's
2956 // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
2957 // is available here — something BackendDispatcher couldn't provide.
2958 //
2959 // The fork gets its own cancellation token (recorded on the job so
2960 // `kill %N` can stop the job — including a pure-builtin job with no OS
2961 // process group) and is stamped with the job id so any external
2962 // command it spawns records its process group for `kill -<sig> %N`.
2963 let cancel = tokio_util::sync::CancellationToken::new();
2964 self.jobs.set_cancel_token(job_id, cancel.clone()).await;
2965 let fork = self.fork_for_background(cancel, job_id).await;
2966 let runner = self.runner.clone();
2967 let commands = pipeline.commands.clone();
2968
2969 // Snapshot the fork's exec_ctx for the spawned task. We have to do
2970 // this before tokio::spawn because the fork's exec_ctx is behind a
2971 // tokio RwLock and we want the spawned task to own its ctx.
2972 let mut bg_ctx = {
2973 let ec = fork.exec_ctx.read().await;
2974 ec.child_for_pipeline()
2975 };
2976 bg_ctx.scope = fork.scope.read().await.clone();
2977 // The fork's dispatcher points at the fork itself; set it here so
2978 // builtins inside the background task (e.g. timeout) re-dispatch
2979 // through the fork, not the parent.
2980 bg_ctx.dispatcher = fork.dispatcher();
2981
2982 // Spawn the background task. Propagate the embedder's trace context
2983 // across the spawn boundary so the job's spans stay in the same trace.
2984 tokio::spawn(crate::telemetry::bind_current_context(async move {
2985 // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
2986 // gives us that (Kernel implements CommandDispatcher).
2987 let result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
2988
2989 // Write output to streams
2990 let text = result.text_out();
2991 if !text.is_empty() {
2992 stdout.write(text.as_bytes()).await;
2993 }
2994 if !result.err.is_empty() {
2995 stderr.write(result.err.as_bytes()).await;
2996 }
2997
2998 // Close streams
2999 stdout.close().await;
3000 stderr.close().await;
3001
3002 // Send result to JobManager (ignore error if receiver dropped)
3003 let _ = tx.send(result);
3004 }));
3005
3006 Ok(ExecResult::success(format!("[{}]", job_id)))
3007 }
3008
3009 /// Format a pipeline as a command string for display.
3010 fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3011 pipeline.commands
3012 .iter()
3013 .map(|cmd| {
3014 let mut parts = vec![cmd.name.clone()];
3015 for arg in &cmd.args {
3016 match arg {
3017 Arg::Positional(expr) => {
3018 parts.push(self.format_expr(expr));
3019 }
3020 Arg::Named { key, value } => {
3021 parts.push(format!("--{}={}", key, self.format_expr(value)));
3022 }
3023 Arg::WordAssign { key, value } => {
3024 parts.push(format!("{}={}", key, self.format_expr(value)));
3025 }
3026 Arg::ShortFlag(name) => {
3027 parts.push(format!("-{}", name));
3028 }
3029 Arg::LongFlag(name) => {
3030 parts.push(format!("--{}", name));
3031 }
3032 Arg::DoubleDash => {
3033 parts.push("--".to_string());
3034 }
3035 }
3036 }
3037 parts.join(" ")
3038 })
3039 .collect::<Vec<_>>()
3040 .join(" | ")
3041 }
3042
3043 /// Format an expression as a string for display.
3044 fn format_expr(&self, expr: &Expr) -> String {
3045 match expr {
3046 Expr::Literal(Value::String(s)) => {
3047 if s.contains(' ') || s.contains('"') {
3048 format!("'{}'", s.replace('\'', "\\'"))
3049 } else {
3050 s.clone()
3051 }
3052 }
3053 Expr::Literal(Value::Int(i)) => i.to_string(),
3054 Expr::Literal(Value::Float(f)) => f.to_string(),
3055 Expr::Literal(Value::Bool(b)) => b.to_string(),
3056 Expr::Literal(Value::Null) => "null".to_string(),
3057 Expr::VarRef(path) => {
3058 let mut name = String::new();
3059 for (i, seg) in path.segments.iter().enumerate() {
3060 match seg {
3061 crate::ast::VarSegment::Field(f) => {
3062 if i > 0 {
3063 name.push('.');
3064 }
3065 name.push_str(f);
3066 }
3067 crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3068 crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3069 crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3070 crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3071 "[{}:{}]",
3072 a.map(|n| n.to_string()).unwrap_or_default(),
3073 b.map(|n| n.to_string()).unwrap_or_default()
3074 )),
3075 }
3076 }
3077 format!("${{{}}}", name)
3078 }
3079 Expr::Interpolated(_) => "\"...\"".to_string(),
3080 Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3081 _ => "...".to_string(),
3082 }
3083 }
3084
3085 /// Execute a single command.
3086 async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3087 self.execute_command_depth(name, args, 0).await
3088 }
3089
3090 async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3091 // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3092 // most-recursed function on the ring, so wrapping its future in
3093 // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3094 // every level (GH #48, item 3). A `trace!` event records the command name
3095 // without living in the future.
3096 tracing::trace!(command = %name, alias_depth, "dispatch");
3097 // Special built-ins. `SpecialForm::from_name` is the single source of
3098 // truth (shared with `classify_command` via `is_runtime_special_form`),
3099 // and this match on the enum is *exhaustive* — adding a special-form is a
3100 // compile error until both the name mapping and the behavior here are
3101 // updated. A name that is not a special-form falls through to alias /
3102 // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3103 if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3104 return match form {
3105 crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3106 crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3107 crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3108 };
3109 }
3110
3111 // Alias expansion (with recursion limit)
3112 if alias_depth < 10 {
3113 let alias_value = {
3114 let ctx = self.exec_ctx.read().await;
3115 ctx.aliases.get(name).cloned()
3116 };
3117 if let Some(alias_val) = alias_value {
3118 // Split alias value into command + args
3119 let parts: Vec<&str> = alias_val.split_whitespace().collect();
3120 if let Some((alias_cmd, alias_args)) = parts.split_first() {
3121 let mut new_args: Vec<Arg> = alias_args
3122 .iter()
3123 .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3124 .collect();
3125 new_args.extend_from_slice(args);
3126 return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3127 }
3128 }
3129 }
3130
3131 // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3132 if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3133 return match self.tools.get(builtin_name) {
3134 Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3135 None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3136 };
3137 }
3138
3139 // Check user-defined tools first
3140 {
3141 let user_tools = self.user_tools.read().await;
3142 if let Some(tool_def) = user_tools.get(name) {
3143 let tool_def = tool_def.clone();
3144 drop(user_tools);
3145 return Box::pin(self.execute_user_tool(tool_def, args)).await;
3146 }
3147 }
3148
3149 // Look up builtin tool
3150 let tool = match self.tools.get(name) {
3151 Some(t) => t,
3152 None => {
3153 // Try executing as .kai script from PATH
3154 if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3155 return Ok(result);
3156 }
3157 // Try executing as external command from PATH — boxed because its
3158 // future is the heaviest branch here (holds a `tokio::process::Command`,
3159 // argv, the child's stdio streams, and kill/reap drop guards); leaving
3160 // it inline fattens every `execute_command_depth` frame on the recursion
3161 // ring even when the command is a builtin.
3162 if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3163 return Ok(result);
3164 }
3165
3166 // Try backend-registered tools (embedder engines, etc.)
3167 // Look up tool schema for positional→named mapping.
3168 // Clone backend and drop read lock before awaiting (may involve network I/O).
3169 // Backend tools expect named JSON params, so enable positional mapping.
3170 let backend = self.exec_ctx.read().await.backend.clone();
3171 let tool_schema = backend
3172 .get_tool(name)
3173 .await
3174 .unwrap_or_else(|e| {
3175 // Schema lookup failing just means positionals won't
3176 // get name-mapped below — `call_tool` is still
3177 // attempted. Trace it so the degradation is visible
3178 // rather than silently swallowed.
3179 tracing::debug!("backend get_tool error for {name}: {e}");
3180 None
3181 })
3182 .map(|t| {
3183 let mut s = t.schema;
3184 // Flat backend/MCP tools expect named JSON params, so map
3185 // bare positionals onto named params. Subcommand-aware tools
3186 // route positionals through the subcommand path and declare
3187 // map_positionals per leaf (kj keeps it false so it re-parses
3188 // the argv with its own clap) — don't blanket-override them.
3189 if s.subcommands.is_empty() {
3190 s.map_positionals = true;
3191 }
3192 s
3193 });
3194 let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3195 let mut ctx = self.exec_ctx.write().await;
3196 {
3197 let scope = self.scope.read().await;
3198 ctx.scope = scope.clone();
3199 }
3200 let backend = ctx.backend.clone();
3201 match backend.call_tool(name, tool_args, &mut *ctx).await {
3202 Ok(tool_result) => {
3203 let mut scope = self.scope.write().await;
3204 *scope = ctx.scope.clone();
3205 // Preserve every field (data/content_type/baggage/latch,
3206 // not just stdout text) — this is the embedder seam:
3207 // `x=$(embedder_tool)` and structured iteration over
3208 // its result depend on `.data` surviving the crossing
3209 // back into the kernel.
3210 return Ok(ExecResult::from(tool_result));
3211 }
3212 Err(BackendError::ToolNotFound(_)) => {
3213 // The backend confirms no such tool exists — fall
3214 // through to "command not found" below.
3215 }
3216 Err(e) => {
3217 // The tool was found (dispatch reached real
3218 // execution) but running it failed — a genuine
3219 // execution error, not "command not found". Surface
3220 // it loudly instead of masking it as exit-127.
3221 return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3222 }
3223 }
3224
3225 return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3226 }
3227 };
3228
3229 // Build arguments (async to support command substitution, schema-aware for flag values)
3230 let schema = tool.schema();
3231 let tool_args = self.build_args_async(args, Some(&schema)).await?;
3232
3233 // --help / -h: show the generic whole-tool help, unless either the tool's
3234 // root schema claims that flag OR the tool owns its output. Owned-output
3235 // tools re-parse their own argv and route their own `--help` — including
3236 // leaf/subcommand help — through their internal (clap) parser, so the root
3237 // schema can't express "this leaf claims help" and intercepting here would
3238 // render top-level help and return before `execute()` ever sees the
3239 // request (#51). Pass it through and let the tool render its own help.
3240 let schema_claims = |flag: &str| -> bool {
3241 let bare = flag.trim_start_matches('-');
3242 schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3243 };
3244 let wants_help = !schema.owns_output
3245 && ((tool_args.flags.contains("help") && !schema_claims("help"))
3246 || (tool_args.flags.contains("h") && !schema_claims("-h")));
3247 if wants_help {
3248 let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3249 let ctx = self.exec_ctx.read().await;
3250 let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3251 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3252 }
3253
3254 // `owns_output` is the only thing read from `schema` after the recursive
3255 // `tool.execute` await below; capture the bool and drop the (heap-backed)
3256 // `ToolSchema` now so it doesn't ride that await in every command's frame
3257 // (GH #48, item 7).
3258 let owns_output = schema.owns_output;
3259 drop(schema);
3260
3261 // Snapshot exec_ctx into a local context and release the write lock
3262 // before calling tool.execute. Holding the write across tool execution
3263 // would deadlock any builtin that re-dispatches through ctx.dispatcher
3264 // (timeout, scatter) — the inner dispatch_command needs its own
3265 // exec_ctx.write() and would block forever.
3266 let mut ctx = {
3267 let ec = self.exec_ctx.write().await;
3268 let scope = self.scope.read().await;
3269 // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3270 // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3271 // child token — e.g. timeout's — reaches the spawned external via
3272 // wait_or_kill; it falls back to the kernel's own token on a
3273 // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3274 self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3275 }; // both locks released — tool.execute can re-dispatch safely
3276
3277 // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3278 // semantics): take() so a later dispatch doesn't see stale stdin.
3279 // Done after the snapshot above so we hold the write briefly.
3280 {
3281 let mut ec = self.exec_ctx.write().await;
3282 ctx.stdin = ec.stdin.take();
3283 ctx.stdin_data = ec.stdin_data.take();
3284 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3285 ctx.pipe_stdin = ec.pipe_stdin.take();
3286 ctx.pipe_stdout = ec.pipe_stdout.take();
3287 }
3288
3289 // Honor --json before the builtin runs so its setting survives a clap
3290 // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3291 // --json on the floor when `try_parse_from` returns Err early).
3292 // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3293 GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3294
3295 // Capture the exact invocation at the dispatch seam so a latch producer
3296 // (`latch_result`/`gate_overwrites`) can stamp it into the LatchRequest
3297 // for a precise `Kernel::confirm` replay — no re-parsing of the human
3298 // `hint`. `to_argv()` is computed before `tool_args` moves into execute.
3299 //
3300 // Captured unconditionally, NOT gated on `latch_enabled`: `kaish-trash
3301 // empty` gates every time (it's inherently destructive, independent of
3302 // `set -o latch`), so a `latch_enabled`-only gate would leave its
3303 // `tool`/`argv` empty and break `confirm`. The cost is a small argv
3304 // clone per command — marginal beside the per-command ExecContext
3305 // snapshot above — and it does NOT reintroduce the deep-`$()` stack
3306 // overflow (that was the inline `LatchRequest` in `ExecResult`, now
3307 // boxed; the capture's temporaries don't survive into the recursive
3308 // `tool.execute` below).
3309 //
3310 // `to_argv()` can now fail loud on a `Value::Bytes` named argument (GH
3311 // #164). This capture is best-effort bookkeeping only, so a failure
3312 // here must NOT gate whether the tool runs: not every builtin routes
3313 // its own named args through `to_argv()` — `export`'s `NAME=VALUE`
3314 // pairs are arbitrary user variable names, not schema flags, so
3315 // `export` reads `args.named` directly and a Bytes value there is
3316 // completely legitimate (see `export.rs`). A builtin that DOES call
3317 // `to_argv()` internally (nearly all of them) will raise the
3318 // identical loud, tool-prefixed error a few lines below inside
3319 // `tool.execute()`; dropping the error here only empties this
3320 // side-channel capture, never the command's real result.
3321 let argv = tool_args.to_argv().unwrap_or_default();
3322 ctx.current_invocation = Some(Box::new((name.to_string(), argv)));
3323
3324 let result = tool.execute(tool_args, &mut *ctx).await;
3325
3326 // Sync mutations back. Tools may have changed scope (set/cd),
3327 // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3328 // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3329 // hands them back to the pipeline runner — the runner uses
3330 // stage_ctx.pipe_stdout to write the result to the next stage when
3331 // the tool itself didn't take and write to it.
3332 {
3333 let mut scope = self.scope.write().await;
3334 *scope = ctx.scope.clone();
3335 }
3336 {
3337 let mut ec = self.exec_ctx.write().await;
3338 ec.cwd = ctx.cwd;
3339 ec.prev_cwd = ctx.prev_cwd;
3340 ec.aliases = ctx.aliases;
3341 // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3342 // mutate the runtime output limit; without this sync the change is
3343 // dropped here and never reaches dispatch_command's read-back, so
3344 // it would not survive past the current statement.
3345 ec.output_limit = ctx.output_limit.clone();
3346 // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3347 // was missing from this sync, so every runtime ignore mutation
3348 // silently died at the end of its own statement — including the
3349 // documented `kaish-ignore add .gitignore` rc-file recipe.
3350 ec.ignore_config = ctx.ignore_config.clone();
3351 ec.pipe_stdin = ctx.pipe_stdin.take();
3352 ec.pipe_stdout = ctx.pipe_stdout.take();
3353 }
3354
3355 // Builtins parse --json via the GlobalFlags flatten in their clap
3356 // struct and write ctx.output_format. The kernel applies it — unless the
3357 // tool owns its own output (renders --json itself), in which case we
3358 // leave its bytes untouched.
3359 let result = finalize_output(result, ctx.output_format, owns_output);
3360
3361 Ok(result)
3362 }
3363
3364 /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3365 /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3366 /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3367 /// unexpanded rather than leaking the host home directory.
3368 async fn scope_home(&self) -> Option<String> {
3369 match self.scope.read().await.get("HOME") {
3370 Some(Value::String(s)) => Some(s.clone()),
3371 _ => None,
3372 }
3373 }
3374
3375 /// Build tool arguments from AST args.
3376 ///
3377 /// Uses async evaluation to support command substitution in arguments.
3378 /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3379 /// now only supplies the evaluator — `self` implements `ArgValueSource`
3380 /// against the kernel's own session state (full recursion through the
3381 /// async pipeline, real glob expansion, tilde expansion). Before this,
3382 /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3383 /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3384 /// scatter/gather's own option parsing and the `#[cfg(test)]`
3385 /// `BackendDispatcher`) that could — and did — drift from this method,
3386 /// the same drift-class GH #133 fixed for the external-command spawn
3387 /// sites. Now both paths call the one `bind_tool_args` core, differing
3388 /// only in which `ArgValueSource` they hand it.
3389 async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3390 bind_tool_args(args, schema, self).await
3391 }
3392
3393 /// Build arguments as flat string list for external commands.
3394 ///
3395 /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3396 /// this preserves the original flag format as strings for external commands:
3397 /// - `-l` stays as `-l`
3398 /// - `--verbose` stays as `--verbose`
3399 /// - `key=value` stays as `key=value`
3400 ///
3401 /// This is what external commands expect in their argv.
3402 #[cfg(feature = "subprocess")]
3403 async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3404 let mut argv = Vec::new();
3405 let home = self.scope_home().await;
3406 for arg in args {
3407 match arg {
3408 Arg::Positional(expr) => {
3409 // Glob expansion for external commands
3410 if let Expr::GlobPattern(pattern) = expr {
3411 let glob_enabled = {
3412 let scope = self.scope.read().await;
3413 scope.glob_enabled()
3414 };
3415 if glob_enabled {
3416 let (paths, cwd) = {
3417 let ctx = self.exec_ctx.read().await;
3418 let paths = ctx.expand_glob(pattern).await
3419 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3420 let cwd = ctx.resolve_path(".");
3421 (paths, cwd)
3422 };
3423 if paths.is_empty() {
3424 return Err(anyhow::anyhow!("no matches: {}", pattern));
3425 }
3426 for path in paths {
3427 let display = if !pattern.starts_with('/') {
3428 path.strip_prefix(&cwd)
3429 .unwrap_or(&path)
3430 .to_string_lossy().into_owned()
3431 } else {
3432 path.to_string_lossy().into_owned()
3433 };
3434 argv.push(display);
3435 }
3436 continue;
3437 }
3438 }
3439 let value = self.eval_expr_async(expr).await?;
3440 // Decision D: a bare collection can't cross the external
3441 // process boundary as an argv element — refuse rather than
3442 // silently JSON-serializing it. A quoted `"$x"` already
3443 // reduced to a `Value::String` above (via `Expr::Interpolated`),
3444 // so only a live, un-interpolated `$x` trips this.
3445 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3446 return Err(anyhow::anyhow!(msg));
3447 }
3448 let value = apply_tilde_expansion(value, home.as_deref());
3449 // External-command argv is a text sink: a bare `$BIN` binary
3450 // word goes loud, never the `[binary: N bytes]` placeholder.
3451 argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3452 }
3453 Arg::Named { key, value } => {
3454 let val = self.eval_expr_async(value).await?;
3455 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3456 return Err(anyhow::anyhow!(msg));
3457 }
3458 let val = apply_tilde_expansion(val, home.as_deref());
3459 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3460 argv.push(format!("--{key}={val_str}"));
3461 }
3462 Arg::WordAssign { key, value } => {
3463 let val = self.eval_expr_async(value).await?;
3464 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3465 return Err(anyhow::anyhow!(msg));
3466 }
3467 let val = apply_tilde_expansion(val, home.as_deref());
3468 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3469 argv.push(format!("{key}={val_str}"));
3470 }
3471 Arg::ShortFlag(name) => {
3472 // Preserve original format: -l, -la (combined flags)
3473 argv.push(format!("-{}", name));
3474 }
3475 Arg::LongFlag(name) => {
3476 // Preserve original format: --verbose
3477 argv.push(format!("--{}", name));
3478 }
3479 Arg::DoubleDash => {
3480 // Preserve the -- marker
3481 argv.push("--".to_string());
3482 }
3483 }
3484 }
3485 Ok(argv)
3486 }
3487
3488 /// Async expression evaluator that supports command substitution.
3489 ///
3490 /// This is used for contexts where expressions may contain `$(...)` command
3491 /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3492 fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3493 Box::pin(async move {
3494 match expr {
3495 Expr::Literal(value) => Ok(value.clone()),
3496 Expr::VarRef(path) => {
3497 let scope = self.scope.read().await;
3498 match scope.resolve_path(path) {
3499 Ok(v) => Ok(v),
3500 Err(PathError::UndefinedRoot(_)) => {
3501 Err(anyhow::anyhow!("undefined variable"))
3502 }
3503 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3504 Err(anyhow::anyhow!(msg))
3505 }
3506 }
3507 }
3508 Expr::Interpolated(parts) => {
3509 let mut result = String::new();
3510 for part in parts {
3511 result.push_str(&self.eval_string_part_async(part).await?);
3512 }
3513 Ok(Value::String(result))
3514 }
3515 Expr::HereDocBody { parts, strip_tabs } => {
3516 // Assemble part-by-part so `<<-` tab stripping applies to the
3517 // literal source, not to tabs from a `$var` value (bash strips
3518 // source-line tabs before parameter expansion).
3519 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3520 for sp in parts {
3521 match &sp.part {
3522 StringPart::Literal(s) => asm.push_literal(s),
3523 other => {
3524 asm.push_interpolated(&self.eval_string_part_async(other).await?)
3525 }
3526 }
3527 }
3528 Ok(Value::String(asm.into_string()))
3529 }
3530 Expr::BinaryOp { left, op, right } => match op {
3531 BinaryOp::And => {
3532 let left_val = self.eval_expr_async(left).await?;
3533 if !is_truthy(&left_val) {
3534 return Ok(left_val);
3535 }
3536 self.eval_expr_async(right).await
3537 }
3538 BinaryOp::Or => {
3539 let left_val = self.eval_expr_async(left).await?;
3540 if is_truthy(&left_val) {
3541 return Ok(left_val);
3542 }
3543 self.eval_expr_async(right).await
3544 }
3545 },
3546 Expr::CommandSubst(stmts) => {
3547 // Snapshot scope, cwd, and session config before running —
3548 // only output escapes, not side effects like `cd`, variable
3549 // assignments, or config mutations (`kaish-ignore`,
3550 // `kaish-output-limit`, `alias`/`unalias`) — matching how
3551 // every other execution context (background forks, scatter
3552 // workers) already isolates mutations (GH #139).
3553 // Boxed: this ~470 B scope snapshot is held across the nested
3554 // `$(…)` recursion await below, so inlining it grows every
3555 // command-substitution level's future (GH #48, item 4).
3556 let saved_scope = Box::new(self.scope.read().await.clone());
3557 let saved_ec = {
3558 let ec = self.exec_ctx.read().await;
3559 (
3560 ec.cwd.clone(),
3561 ec.prev_cwd.clone(),
3562 ec.aliases.clone(),
3563 ec.ignore_config.clone(),
3564 ec.output_limit.clone(),
3565 )
3566 };
3567
3568 // Capture result without `?` — restore state unconditionally
3569 let run_result = self.execute_block_capturing(stmts).await;
3570
3571 // Restore scope and cwd regardless of success/failure
3572 {
3573 let mut scope = self.scope.write().await;
3574 *scope = *saved_scope;
3575 if let Ok(ref r) = run_result {
3576 scope.set_last_result(r.clone());
3577 }
3578 }
3579 {
3580 let mut ec = self.exec_ctx.write().await;
3581 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3582 ec.cwd = cwd;
3583 ec.prev_cwd = prev_cwd;
3584 ec.aliases = aliases;
3585 ec.ignore_config = ignore_config;
3586 ec.output_limit = output_limit;
3587 }
3588
3589 // Now propagate the error
3590 let result = run_result?;
3591
3592 // A binary result is preserved as bytes — never lossy-decoded to
3593 // a string. No trailing-newline trim (every byte is significant).
3594 if let Some(bytes) = result.out_bytes() {
3595 Ok(Value::Bytes(bytes.to_vec()))
3596 // Prefer structured data (enables `for i in $(cmd)` iteration)
3597 } else if let Some(data) = &result.data {
3598 Ok(data.clone())
3599 } else if let Some(output) = result.output() {
3600 // Flat non-text node lists (glob, ls, tree) → iterable array
3601 if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3602 let items: Vec<serde_json::Value> = output.root.iter()
3603 .map(|n| serde_json::Value::String(n.display_name().to_string()))
3604 .collect();
3605 Ok(Value::Json(serde_json::Value::Array(items)))
3606 } else {
3607 // Strip trailing newlines only (POSIX command-subst),
3608 // not all trailing whitespace — spaces/tabs are
3609 // significant. Use the exact same trim as the quoted
3610 // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3611 // `trim_end_matches('\n')`) so bare and quoted command
3612 // substitution agree.
3613 Ok(Value::String(
3614 result.text_out().trim_end_matches('\n').to_string(),
3615 ))
3616 }
3617 } else {
3618 // Otherwise return stdout as single string (NO implicit splitting)
3619 Ok(Value::String(
3620 result.text_out().trim_end_matches('\n').to_string(),
3621 ))
3622 }
3623 }
3624 Expr::Test(test_expr) => {
3625 Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3626 }
3627 Expr::Positional(n) => {
3628 let scope = self.scope.read().await;
3629 match scope.get_positional(*n) {
3630 Some(s) => Ok(Value::String(s.to_string())),
3631 None => Ok(Value::String(String::new())),
3632 }
3633 }
3634 Expr::AllArgs => {
3635 let scope = self.scope.read().await;
3636 Ok(Value::String(scope.all_args().join(" ")))
3637 }
3638 Expr::ArgCount => {
3639 let scope = self.scope.read().await;
3640 Ok(Value::Int(scope.arg_count() as i64))
3641 }
3642 Expr::VarLength(path) => {
3643 let scope = self.scope.read().await;
3644 crate::interpreter::resolve_length(&scope, path)
3645 .map(Value::Int)
3646 .map_err(|msg| anyhow::anyhow!(msg))
3647 }
3648 Expr::VarWithDefault { path, default } => {
3649 // Resolve inside a scoped guard so the lock is released before the
3650 // recursive default evaluation.
3651 let resolved = {
3652 let scope = self.scope.read().await;
3653 crate::interpreter::resolve_default(&scope, path)
3654 .map_err(|msg| anyhow::anyhow!(msg))?
3655 };
3656 match resolved {
3657 Some(value) => Ok(value),
3658 None => self.eval_string_parts_async(default).await.map(Value::String),
3659 }
3660 }
3661 Expr::Arithmetic(expr_str) => {
3662 let scope = self.scope.read().await;
3663 crate::arithmetic::eval_arithmetic(expr_str, &scope)
3664 .map(Value::Int)
3665 .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3666 }
3667 Expr::Command(cmd) => {
3668 // Execute command and return boolean based on exit code
3669 let result = self.execute_command(&cmd.name, &cmd.args).await?;
3670 Ok(Value::Bool(result.code == 0))
3671 }
3672 Expr::LastExitCode => {
3673 let scope = self.scope.read().await;
3674 Ok(Value::Int(scope.last_result().code))
3675 }
3676 Expr::CurrentPid => {
3677 let scope = self.scope.read().await;
3678 Ok(Value::Int(scope.pid() as i64))
3679 }
3680 Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3681 Expr::ListLiteral(elems) => {
3682 // Spread must itself be a list — a scalar/record spread is a
3683 // loud error, never silently coerced or dropped (mirrors the
3684 // sync `Evaluator::eval_list_literal`; wording shared via
3685 // `spread_non_list_message` so the two paths can't diverge).
3686 let mut out = Vec::with_capacity(elems.len());
3687 for elem in elems {
3688 match elem {
3689 ListElem::Item(e) => {
3690 let value = self.eval_expr_async(e).await?;
3691 out.push(crate::interpreter::value_to_json(&value));
3692 }
3693 ListElem::Spread(e) => {
3694 let value = self.eval_expr_async(e).await?;
3695 match value {
3696 Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3697 other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3698 }
3699 }
3700 }
3701 }
3702 Ok(Value::Json(serde_json::Value::Array(out)))
3703 }
3704 Expr::RecordLiteral(entries) => {
3705 // Insertion order preserved (workspace serde_json has
3706 // `preserve_order`); a duplicate key keeps the last value
3707 // written, matching plain map-insert semantics.
3708 let mut map = serde_json::Map::new();
3709 for entry in entries {
3710 let key = match &entry.key {
3711 RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3712 // `{"$k": v}` resolves like any double-quoted string
3713 // (used to silently create a literal "$k" key).
3714 RecordKey::Interpolated(parts) => {
3715 self.eval_string_parts_async(parts).await?
3716 }
3717 };
3718 let value = self.eval_expr_async(&entry.value).await?;
3719 map.insert(key, crate::interpreter::value_to_json(&value));
3720 }
3721 Ok(Value::Json(serde_json::Value::Object(map)))
3722 }
3723 }
3724 })
3725 }
3726
3727 /// Async helper to evaluate multiple StringParts into a single string.
3728 fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3729 Box::pin(async move {
3730 let mut result = String::new();
3731 for part in parts {
3732 result.push_str(&self.eval_string_part_async(part).await?);
3733 }
3734 Ok(result)
3735 })
3736 }
3737
3738 /// Async helper to evaluate a StringPart.
3739 /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3740 /// through the VFS backend instead of using raw `std::path`.
3741 fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3742 Box::pin(async move {
3743 match test_expr {
3744 TestExpr::FileTest { op, path } => {
3745 let path_value = self.eval_expr_async(path).await?;
3746 // Expand `~` against the session HOME before stat'ing, the
3747 // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3748 // stats the literal `~/x` and is always false.
3749 let home = self.scope_home().await;
3750 let path_value = apply_tilde_expansion(path_value, home.as_deref());
3751 // A binary `[[ -f $bin ]]` operand goes loud rather than
3752 // silently stat'ing a file literally named
3753 // `[binary: N bytes]` (the same path-positional guard
3754 // builtins like `stat`/`cp` use).
3755 let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
3756 .map_err(|e| anyhow::anyhow!("{e}"))?;
3757 // Resolve against the *session* cwd, not the process cwd, so a
3758 // relative `[[ -f rel ]]` honors `cd` and agrees with the
3759 // VFS-aware `test` builtin (GH #101). Backend stats a raw
3760 // relative path against the process cwd otherwise.
3761 let (resolved, backend) = {
3762 let ctx = self.exec_ctx.read().await;
3763 (ctx.resolve_path(&path_str), ctx.backend.clone())
3764 };
3765 let entry = backend.stat(&resolved).await.ok();
3766 Ok(match op {
3767 FileTestOp::Exists => entry.is_some(),
3768 FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3769 FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3770 FileTestOp::Readable => entry.is_some(),
3771 FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3772 e.permissions.is_none_or(|p| p & 0o222 != 0)
3773 }),
3774 FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3775 e.permissions.is_some_and(|p| p & 0o111 != 0)
3776 }),
3777 })
3778 }
3779 TestExpr::StringTest { op, value } => match op {
3780 crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
3781 let val = self.eval_expr_async(value).await?;
3782 // Decision E: a collection operand is a loud Shape error
3783 // here too — must not diverge from the sync path in
3784 // interpreter/eval.rs (shared `scalar_test_operand_error`).
3785 let symbol = match op {
3786 crate::ast::StringTestOp::IsEmpty => "-z",
3787 crate::ast::StringTestOp::IsNonEmpty => "-n",
3788 crate::ast::StringTestOp::IsList
3789 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3790 };
3791 if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
3792 anyhow::bail!(msg);
3793 }
3794 let s = value_to_string(&val);
3795 Ok(match op {
3796 crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3797 crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3798 crate::ast::StringTestOp::IsList
3799 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3800 })
3801 }
3802 // Shape guard: propagates eval errors like -z/-n (a bare
3803 // `$unset` is an undefined-variable error, not a silent
3804 // false). A defined-but-wrong-shaped value is false. Must
3805 // not diverge from the sync path in interpreter/eval.rs.
3806 crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
3807 let val = self.eval_expr_async(value).await?;
3808 Ok(op.matches_shape(&val))
3809 }
3810 },
3811 TestExpr::Comparison { left, op, right } => {
3812 // Evaluate operands async (handles $(cmd)), then compare sync
3813 let left_val = self.eval_expr_async(left).await?;
3814 let right_val = self.eval_expr_async(right).await?;
3815 let resolved = TestExpr::Comparison {
3816 left: Box::new(Expr::Literal(left_val)),
3817 op: *op,
3818 right: Box::new(Expr::Literal(right_val)),
3819 };
3820 let expr = Expr::Test(Box::new(resolved));
3821 let mut scope = self.scope.write().await;
3822 let value = eval_expr(&expr, &mut scope)
3823 .map_err(|e| anyhow::anyhow!("{}", e))?;
3824 Ok(value_to_bool(&value))
3825 }
3826 TestExpr::And { left, right } => {
3827 if !self.eval_test_async(left).await? {
3828 Ok(false)
3829 } else {
3830 self.eval_test_async(right).await
3831 }
3832 }
3833 TestExpr::Or { left, right } => {
3834 if self.eval_test_async(left).await? {
3835 Ok(true)
3836 } else {
3837 self.eval_test_async(right).await
3838 }
3839 }
3840 TestExpr::Not { expr } => {
3841 Ok(!self.eval_test_async(expr).await?)
3842 }
3843 TestExpr::In { left, right } => {
3844 let left_val = self.eval_expr_async(left).await?;
3845 let right_val = self.eval_expr_async(right).await?;
3846 let resolved = TestExpr::In {
3847 left: Box::new(Expr::Literal(left_val)),
3848 right: Box::new(Expr::Literal(right_val)),
3849 };
3850 let expr = Expr::Test(Box::new(resolved));
3851 let mut scope = self.scope.write().await;
3852 let value = eval_expr(&expr, &mut scope)
3853 .map_err(|e| anyhow::anyhow!("{}", e))?;
3854 Ok(value_to_bool(&value))
3855 }
3856 TestExpr::NotIn { left, right } => {
3857 let left_val = self.eval_expr_async(left).await?;
3858 let right_val = self.eval_expr_async(right).await?;
3859 let resolved = TestExpr::NotIn {
3860 left: Box::new(Expr::Literal(left_val)),
3861 right: Box::new(Expr::Literal(right_val)),
3862 };
3863 let expr = Expr::Test(Box::new(resolved));
3864 let mut scope = self.scope.write().await;
3865 let value = eval_expr(&expr, &mut scope)
3866 .map_err(|e| anyhow::anyhow!("{}", e))?;
3867 Ok(value_to_bool(&value))
3868 }
3869 }
3870 })
3871 }
3872
3873 fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3874 Box::pin(async move {
3875 match part {
3876 StringPart::Literal(s) => Ok(s.clone()),
3877 StringPart::Var(path) => {
3878 let scope = self.scope.read().await;
3879 match scope.resolve_path(path) {
3880 // Text sink: binary goes loud, never the placeholder —
3881 // a `b=$(cat blob)` capture holds real bytes; splicing
3882 // `[binary: N bytes]` into "$b" would be silent loss.
3883 Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3884 // Unset vars expand to empty; loud path errors surface.
3885 Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
3886 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3887 Err(anyhow::anyhow!(msg))
3888 }
3889 }
3890 }
3891 StringPart::VarWithDefault { path, default } => {
3892 let resolved = {
3893 let scope = self.scope.read().await;
3894 crate::interpreter::resolve_default(&scope, path)
3895 .map_err(|msg| anyhow::anyhow!(msg))?
3896 };
3897 match resolved {
3898 Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3899 None => self.eval_string_parts_async(default).await,
3900 }
3901 }
3902 StringPart::VarLength(path) => {
3903 let scope = self.scope.read().await;
3904 crate::interpreter::resolve_length(&scope, path)
3905 .map(|n| n.to_string())
3906 .map_err(|msg| anyhow::anyhow!(msg))
3907 }
3908 StringPart::Positional(n) => {
3909 let scope = self.scope.read().await;
3910 match scope.get_positional(*n) {
3911 Some(s) => Ok(s.to_string()),
3912 None => Ok(String::new()),
3913 }
3914 }
3915 StringPart::AllArgs => {
3916 let scope = self.scope.read().await;
3917 Ok(scope.all_args().join(" "))
3918 }
3919 StringPart::ArgCount => {
3920 let scope = self.scope.read().await;
3921 Ok(scope.arg_count().to_string())
3922 }
3923 StringPart::Arithmetic(expr) => {
3924 // Loud on purpose (GH #183): this used to be `Err(_) =>
3925 // Ok(String::new())`, silently splicing in "" for e.g.
3926 // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
3927 // at exit 0 instead of failing. Matches the bare (non-string)
3928 // `Expr::Arithmetic` arm above, which already propagates.
3929 let scope = self.scope.read().await;
3930 crate::arithmetic::eval_arithmetic(expr, &scope)
3931 .map(|value| value.to_string())
3932 .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
3933 }
3934 StringPart::CommandSubst(stmts) => {
3935 // Snapshot scope, cwd, and session config — command
3936 // substitution in strings must not leak side effects (e.g.,
3937 // `"dir: $(cd /; pwd)"` must not change cwd, and
3938 // `"$(kaish-ignore clear)"` must not change the session's
3939 // ignore config) — matching how every other execution
3940 // context (background forks, scatter workers) already
3941 // isolates mutations (GH #139).
3942 // Boxed: this ~470 B scope snapshot is held across the nested
3943 // `$(…)` recursion await below, so inlining it grows every
3944 // command-substitution level's future (GH #48, item 4).
3945 let saved_scope = Box::new(self.scope.read().await.clone());
3946 let saved_ec = {
3947 let ec = self.exec_ctx.read().await;
3948 (
3949 ec.cwd.clone(),
3950 ec.prev_cwd.clone(),
3951 ec.aliases.clone(),
3952 ec.ignore_config.clone(),
3953 ec.output_limit.clone(),
3954 )
3955 };
3956
3957 // Capture result without `?` — restore state unconditionally
3958 let run_result = self.execute_block_capturing(stmts).await;
3959
3960 // Restore scope and cwd regardless of success/failure
3961 {
3962 let mut scope = self.scope.write().await;
3963 *scope = *saved_scope;
3964 if let Ok(ref r) = run_result {
3965 scope.set_last_result(r.clone());
3966 }
3967 }
3968 {
3969 let mut ec = self.exec_ctx.write().await;
3970 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3971 ec.cwd = cwd;
3972 ec.prev_cwd = prev_cwd;
3973 ec.aliases = aliases;
3974 ec.ignore_config = ignore_config;
3975 ec.output_limit = output_limit;
3976 }
3977
3978 // Now propagate the error
3979 let result = run_result?;
3980
3981 // Embedding binary into a string is a text context: fail loud
3982 // rather than splice in U+FFFD garbage.
3983 match result.try_text_out() {
3984 // Text wins when present — unchanged behavior.
3985 Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
3986 // `.out` is empty: a builtin/tool that set only structured
3987 // `.data` must not silently evaporate to "" (SILENT DATA
3988 // LOSS). Render it the same way a bare `"$x"`
3989 // collection-valued variable renders — compact JSON for
3990 // lists/records, plain form for scalars — by reusing
3991 // `value_to_string` (the exact `StringPart::Var` helper
3992 // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
3993 // identically. No trailing-newline trim here: that's a
3994 // text-path artifact, not applicable to a freshly
3995 // rendered JSON/scalar string.
3996 Ok(_) => match &result.data {
3997 Some(data) => Ok(value_to_string(data)),
3998 None => Ok(String::new()),
3999 },
4000 Err(e) => anyhow::bail!(
4001 "command substitution in a string produced binary data ({e}) — \
4002 pipe through base64/xxd"
4003 ),
4004 }
4005 }
4006 StringPart::LastExitCode => {
4007 let scope = self.scope.read().await;
4008 Ok(scope.last_result().code.to_string())
4009 }
4010 StringPart::CurrentPid => {
4011 let scope = self.scope.read().await;
4012 Ok(scope.pid().to_string())
4013 }
4014 }
4015 })
4016 }
4017
4018 /// Update the last result in scope.
4019 async fn update_last_result(&self, result: &ExecResult) {
4020 let mut scope = self.scope.write().await;
4021 scope.set_last_result(result.clone());
4022 }
4023
4024 /// Drain accumulated pipeline stderr into a result.
4025 ///
4026 /// Called after each sub-statement inside control structures (`if`, `for`,
4027 /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4028 /// than batching until the entire structure finishes.
4029 async fn drain_stderr_into(&self, result: &mut ExecResult) {
4030 let drained = {
4031 let mut receiver = self.stderr_receiver.lock().await;
4032 receiver.drain_lossy()
4033 };
4034 if !drained.is_empty() {
4035 if !result.err.is_empty() && !result.err.ends_with('\n') {
4036 result.err.push('\n');
4037 }
4038 result.err.push_str(&drained);
4039 }
4040 }
4041
4042 /// Execute a user-defined function with local variable scoping.
4043 ///
4044 /// Functions push a new scope frame for local variables. Variables declared
4045 /// with `local` are scoped to the function; other assignments modify outer
4046 /// scopes (or create in root if new).
4047 async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4048 let _depth = self.enter_recursion("a shell function")?;
4049
4050 // 1. Build function args from AST args (async to support command substitution)
4051 let tool_args = self.build_args_async(args, None).await?;
4052
4053 // 2. Push a new scope frame for local variables
4054 {
4055 let mut scope = self.scope.write().await;
4056 scope.push_frame();
4057 }
4058
4059 // 3. Save current positional parameters and set new ones for this function
4060 let saved_positional = {
4061 let mut scope = self.scope.write().await;
4062 let saved = scope.save_positional();
4063
4064 // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4065 let positional_args: Vec<String> = tool_args.positional
4066 .iter()
4067 .map(value_to_string)
4068 .collect();
4069 scope.set_positional(&def.name, positional_args);
4070
4071 saved
4072 };
4073
4074 // 3. Execute body statements with control flow handling
4075 // Accumulate output across statements (like sh)
4076 // Accumulate stdout as raw bytes so a binary-producing statement in a
4077 // function body survives instead of being lossy-decoded here.
4078 let mut accumulated_out: Vec<u8> = Vec::new();
4079 let mut accumulated_err = String::new();
4080 let mut last_code = 0i64;
4081 let mut last_data: Option<Value> = None;
4082
4083 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4084 match r.out_bytes() {
4085 Some(b) => buf.extend_from_slice(b),
4086 None => buf.extend_from_slice(r.text_out().as_bytes()),
4087 }
4088 }
4089
4090 // Track execution error for propagation after cleanup
4091 let mut exec_error: Option<anyhow::Error> = None;
4092 let mut exit_code: Option<i64> = None;
4093
4094 for stmt in &def.body {
4095 match self.execute_stmt_flow(stmt).await {
4096 Ok(flow) => {
4097 // Drain pipeline stderr after each sub-statement.
4098 let drained = {
4099 let mut receiver = self.stderr_receiver.lock().await;
4100 receiver.drain_lossy()
4101 };
4102 if !drained.is_empty() {
4103 accumulated_err.push_str(&drained);
4104 }
4105
4106 match flow {
4107 ControlFlow::Normal(r) => {
4108 push_out(&mut accumulated_out, &r);
4109 accumulated_err.push_str(&r.err);
4110 last_code = r.code;
4111 last_data = r.data;
4112 }
4113 ControlFlow::Return { value } => {
4114 push_out(&mut accumulated_out, &value);
4115 accumulated_err.push_str(&value.err);
4116 last_code = value.code;
4117 last_data = value.data;
4118 break;
4119 }
4120 ControlFlow::Exit { code } => {
4121 exit_code = Some(code);
4122 break;
4123 }
4124 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4125 push_out(&mut accumulated_out, &r);
4126 accumulated_err.push_str(&r.err);
4127 last_code = r.code;
4128 last_data = r.data;
4129 }
4130 }
4131 }
4132 Err(e) => {
4133 exec_error = Some(e);
4134 break;
4135 }
4136 }
4137 }
4138
4139 // 4. Pop scope frame and restore original positional parameters (unconditionally)
4140 {
4141 let mut scope = self.scope.write().await;
4142 scope.pop_frame();
4143 scope.set_positional(saved_positional.0, saved_positional.1);
4144 }
4145
4146 // 5. Propagate error or exit after cleanup
4147 if let Some(e) = exec_error {
4148 return Err(e);
4149 }
4150 let code = exit_code.unwrap_or(last_code);
4151 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4152 result.err = accumulated_err;
4153 result.data = last_data;
4154 Ok(result)
4155 }
4156
4157 /// Execute a command-substitution body — a block of statements — and return
4158 /// the combined result. Stdout/stderr accumulate across statements with **no
4159 /// inserted separator** (matching bash and the `;`/`&&`/`||` output model),
4160 /// and the last statement's exit code and structured `.data` ride through,
4161 /// so `for x in $(seq 3)` still iterates the array and `$(printf a; printf b)`
4162 /// captures `ab`. Scope/cwd snapshotting (so `$(cd / && pwd)` cannot leak the
4163 /// cwd) is the caller's responsibility.
4164 /// Enter one level of dynamic statement-engine re-entry (command
4165 /// substitution / function call / script source), returning an RAII guard
4166 /// that releases the level on drop. Past [`MAX_RECURSION_DEPTH`] it returns
4167 /// a loud, catchable error instead of letting the native stack overflow
4168 /// (GH #46). `what` names the re-entry kind for the message.
4169 ///
4170 /// The guard is constructed *before* the ceiling check so the error path
4171 /// unwinds it too — the counter is always balanced, even when we reject.
4172 fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4173 let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4174 let guard = RecursionGuard { counter: &self.recursion_depth };
4175 if depth > MAX_RECURSION_DEPTH {
4176 return Err(anyhow::anyhow!(
4177 "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4178 a runaway or mutually recursive script (deeply nested $(…), or \
4179 functions/scripts that call each other without a base case) was \
4180 stopped before it could overflow the stack"
4181 ));
4182 }
4183 Ok(guard)
4184 }
4185
4186 async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4187 let _depth = self.enter_recursion("command substitution")?;
4188 // Accumulate stdout as raw bytes so a binary-producing statement
4189 // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4190 // caller can preserve it. The final result is text iff valid UTF-8.
4191 let mut accumulated_out: Vec<u8> = Vec::new();
4192 let mut accumulated_err = String::new();
4193 let mut last_code = 0i64;
4194 let mut last_data: Option<Value> = None;
4195
4196 // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4197 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4198 match r.out_bytes() {
4199 Some(b) => buf.extend_from_slice(b),
4200 None => buf.extend_from_slice(r.text_out().as_bytes()),
4201 }
4202 }
4203
4204 for stmt in stmts {
4205 let flow = self.execute_stmt_flow(stmt).await?;
4206
4207 // Drain pipeline stderr after each sub-statement (incremental, like
4208 // the control-structure and function-body executors).
4209 let drained = {
4210 let mut receiver = self.stderr_receiver.lock().await;
4211 receiver.drain_lossy()
4212 };
4213 if !drained.is_empty() {
4214 accumulated_err.push_str(&drained);
4215 }
4216
4217 match flow {
4218 ControlFlow::Normal(r)
4219 | ControlFlow::Break { result: r, .. }
4220 | ControlFlow::Continue { result: r, .. } => {
4221 push_out(&mut accumulated_out, &r);
4222 accumulated_err.push_str(&r.err);
4223 last_code = r.code;
4224 last_data = r.data;
4225 }
4226 ControlFlow::Return { value } => {
4227 push_out(&mut accumulated_out, &value);
4228 accumulated_err.push_str(&value.err);
4229 last_code = value.code;
4230 last_data = value.data;
4231 break;
4232 }
4233 ControlFlow::Exit { code } => {
4234 last_code = code;
4235 break;
4236 }
4237 }
4238 }
4239
4240 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4241 result.err = accumulated_err;
4242 result.data = last_data;
4243 Ok(result)
4244 }
4245
4246 /// Execute the `source` / `.` command to include and run a script.
4247 ///
4248 /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4249 /// allowing the sourced script to set variables and modify shell state.
4250 async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4251 // `source`/`.` is the fourth dynamic re-entry point: it runs the
4252 // sourced file's statements inline via `execute_stmt_flow`, so a file
4253 // that sources itself recurses unbounded just like a runaway function
4254 // (GH #46). It's intercepted as a special form *before* the other
4255 // guarded paths, so it needs its own guard.
4256 let _depth = self.enter_recursion("source")?;
4257
4258 // Get the file path from the first positional argument
4259 let tool_args = self.build_args_async(args, None).await?;
4260 let path = match tool_args.positional.first() {
4261 Some(Value::String(s)) => s.clone(),
4262 Some(v) => value_to_string(v),
4263 None => {
4264 return Ok(ExecResult::failure(1, "source: missing filename"));
4265 }
4266 };
4267
4268 // Resolve path relative to cwd
4269 let full_path = {
4270 let ctx = self.exec_ctx.read().await;
4271 if path.starts_with('/') {
4272 std::path::PathBuf::from(&path)
4273 } else {
4274 ctx.cwd.join(&path)
4275 }
4276 };
4277
4278 // Read file content via backend
4279 let content = {
4280 let ctx = self.exec_ctx.read().await;
4281 match ctx.backend.read(&full_path, None).await {
4282 Ok(bytes) => {
4283 String::from_utf8(bytes).map_err(|e| {
4284 anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4285 })?
4286 }
4287 Err(e) => {
4288 return Ok(ExecResult::failure(
4289 1,
4290 format!("source: {}: {}", path, e),
4291 ));
4292 }
4293 }
4294 };
4295
4296 // Parse the content
4297 let program = match crate::parser::parse(&content) {
4298 Ok(p) => p,
4299 Err(errors) => {
4300 let msg = errors
4301 .iter()
4302 .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4303 .collect::<Vec<_>>()
4304 .join("\n");
4305 return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4306 }
4307 };
4308
4309 // Execute each statement in the CURRENT scope (not isolated), accumulating
4310 // stdout/stderr across statements like `execute_user_tool` — a sourced
4311 // script's earlier statements must not be silently dropped in favor of
4312 // just the last one.
4313 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4314 match r.out_bytes() {
4315 Some(b) => buf.extend_from_slice(b),
4316 None => buf.extend_from_slice(r.text_out().as_bytes()),
4317 }
4318 }
4319
4320 let mut accumulated_out: Vec<u8> = Vec::new();
4321 let mut accumulated_err = String::new();
4322 let mut last_code = 0i64;
4323 let mut last_data: Option<Value> = None;
4324
4325 for stmt in program.statements {
4326 if matches!(stmt, crate::ast::Stmt::Empty) {
4327 continue;
4328 }
4329
4330 match self.execute_stmt_flow(&stmt).await {
4331 Ok(flow) => {
4332 let drained = {
4333 let mut receiver = self.stderr_receiver.lock().await;
4334 receiver.drain_lossy()
4335 };
4336 if !drained.is_empty() {
4337 accumulated_err.push_str(&drained);
4338 }
4339 match flow {
4340 ControlFlow::Normal(r) => {
4341 push_out(&mut accumulated_out, &r);
4342 accumulated_err.push_str(&r.err);
4343 last_code = r.code;
4344 last_data = r.data.clone();
4345 self.update_last_result(&r).await;
4346 }
4347 ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4348 return Err(anyhow::anyhow!(
4349 "source: {}: unexpected break/continue outside loop",
4350 path
4351 ));
4352 }
4353 ControlFlow::Return { value } => {
4354 push_out(&mut accumulated_out, &value);
4355 accumulated_err.push_str(&value.err);
4356 let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4357 .with_code(value.code);
4358 result.err = accumulated_err;
4359 result.data = value.data;
4360 return Ok(result);
4361 }
4362 ControlFlow::Exit { code } => {
4363 let mut result =
4364 ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4365 result.err = accumulated_err;
4366 result.data = last_data;
4367 return Ok(result);
4368 }
4369 }
4370 }
4371 Err(e) => {
4372 return Err(e.context(format!("source: {}", path)));
4373 }
4374 }
4375 }
4376
4377 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4378 result.err = accumulated_err;
4379 result.data = last_data;
4380 Ok(result)
4381 }
4382
4383 /// Try to execute a script from PATH directories.
4384 ///
4385 /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4386 /// (like user-defined tools). Returns None if no script is found.
4387 async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4388 // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4389 // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4390 // A non-script command pays only a transient, balanced increment during
4391 // the probe before falling through to the external path.
4392 let _depth = self.enter_recursion("a .kai script")?;
4393
4394 // Get PATH from scope (default to "/bin")
4395 let path_value = {
4396 let scope = self.scope.read().await;
4397 scope
4398 .get("PATH")
4399 .map(value_to_string)
4400 .unwrap_or_else(|| "/bin".to_string())
4401 };
4402
4403 // Search PATH directories for script
4404 for dir in path_value.split(':') {
4405 if dir.is_empty() {
4406 continue;
4407 }
4408
4409 // Build script path: {dir}/{name}.kai
4410 let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4411
4412 // Check if script exists
4413 let exists = {
4414 let ctx = self.exec_ctx.read().await;
4415 ctx.backend.exists(&script_path).await
4416 };
4417
4418 if !exists {
4419 continue;
4420 }
4421
4422 // Read script content
4423 let content = {
4424 let ctx = self.exec_ctx.read().await;
4425 match ctx.backend.read(&script_path, None).await {
4426 Ok(bytes) => match String::from_utf8(bytes) {
4427 Ok(s) => s,
4428 Err(e) => {
4429 return Ok(Some(ExecResult::failure(
4430 1,
4431 format!("{}: invalid UTF-8: {}", script_path.display(), e),
4432 )));
4433 }
4434 },
4435 Err(e) => {
4436 return Ok(Some(ExecResult::failure(
4437 1,
4438 format!("{}: {}", script_path.display(), e),
4439 )));
4440 }
4441 }
4442 };
4443
4444 // Parse the script
4445 let program = match crate::parser::parse(&content) {
4446 Ok(p) => p,
4447 Err(errors) => {
4448 let msg = errors
4449 .iter()
4450 .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4451 .collect::<Vec<_>>()
4452 .join("\n");
4453 return Ok(Some(ExecResult::failure(1, msg)));
4454 }
4455 };
4456
4457 // Build tool_args from args (async for command substitution support)
4458 let tool_args = self.build_args_async(args, None).await?;
4459
4460 // Create isolated scope (like user tools)
4461 let mut isolated_scope = Scope::new();
4462
4463 // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4464 let positional_args: Vec<String> = tool_args.positional
4465 .iter()
4466 .map(value_to_string)
4467 .collect();
4468 isolated_scope.set_positional(name, positional_args);
4469
4470 // Save current scope and swap with isolated scope
4471 let original_scope = {
4472 let mut scope = self.scope.write().await;
4473 std::mem::replace(&mut *scope, isolated_scope)
4474 };
4475
4476 // Execute script statements — accumulate stdout/stderr across
4477 // statements like `execute_user_tool`, rather than keeping only the
4478 // last one's result.
4479 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4480 match r.out_bytes() {
4481 Some(b) => buf.extend_from_slice(b),
4482 None => buf.extend_from_slice(r.text_out().as_bytes()),
4483 }
4484 }
4485
4486 let mut accumulated_out: Vec<u8> = Vec::new();
4487 let mut accumulated_err = String::new();
4488 let mut last_code = 0i64;
4489 let mut last_data: Option<Value> = None;
4490 let mut exec_error: Option<anyhow::Error> = None;
4491 let mut exit_code: Option<i64> = None;
4492
4493 for stmt in program.statements {
4494 if matches!(stmt, crate::ast::Stmt::Empty) {
4495 continue;
4496 }
4497
4498 match self.execute_stmt_flow(&stmt).await {
4499 Ok(flow) => {
4500 let drained = {
4501 let mut receiver = self.stderr_receiver.lock().await;
4502 receiver.drain_lossy()
4503 };
4504 if !drained.is_empty() {
4505 accumulated_err.push_str(&drained);
4506 }
4507 match flow {
4508 ControlFlow::Normal(r) => {
4509 push_out(&mut accumulated_out, &r);
4510 accumulated_err.push_str(&r.err);
4511 last_code = r.code;
4512 last_data = r.data;
4513 }
4514 ControlFlow::Return { value } => {
4515 push_out(&mut accumulated_out, &value);
4516 accumulated_err.push_str(&value.err);
4517 last_code = value.code;
4518 last_data = value.data;
4519 break;
4520 }
4521 ControlFlow::Exit { code } => {
4522 exit_code = Some(code);
4523 break;
4524 }
4525 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4526 push_out(&mut accumulated_out, &r);
4527 accumulated_err.push_str(&r.err);
4528 last_code = r.code;
4529 last_data = r.data;
4530 }
4531 }
4532 }
4533 Err(e) => {
4534 exec_error = Some(e);
4535 break;
4536 }
4537 }
4538 }
4539
4540 // Restore original scope unconditionally
4541 {
4542 let mut scope = self.scope.write().await;
4543 *scope = original_scope;
4544 }
4545
4546 // Propagate error or exit after cleanup
4547 if let Some(e) = exec_error {
4548 return Err(e.context(format!("script: {}", script_path.display())));
4549 }
4550 let code = exit_code.unwrap_or(last_code);
4551 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4552 result.err = accumulated_err;
4553 result.data = last_data;
4554 return Ok(Some(result));
4555 }
4556
4557 // No script found
4558 Ok(None)
4559 }
4560
4561 /// Try to execute an external command from PATH.
4562 ///
4563 /// This is the fallback when no builtin or user-defined tool matches.
4564 /// External commands receive a clean argv (flags preserved in their original format).
4565 ///
4566 /// # Requirements
4567 /// - Command must be found in PATH
4568 /// - Current working directory must be on a real filesystem (not virtual like /v)
4569 ///
4570 /// # Returns
4571 /// - `Ok(Some(result))` if command was found and executed
4572 /// - `Ok(None)` if command was not found in PATH
4573 /// - `Err` on execution errors
4574 #[cfg(not(feature = "subprocess"))]
4575 async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4576 Ok(None)
4577 }
4578
4579 /// Try to execute an external command from PATH.
4580 #[cfg(feature = "subprocess")]
4581 #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4582 async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4583 // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4584 // populates from the inbound ctx.cancel on every dispatch. This is
4585 // what makes the `timeout` builtin's swapped child token reach the
4586 // wait_or_kill discipline below — reading `self.cancel_token` would
4587 // give the kernel-wide token and miss the timeout's child cascade.
4588 let cancel = {
4589 let ec = self.exec_ctx.read().await;
4590 ec.cancel.clone()
4591 };
4592 let kill_grace = self.kill_grace;
4593 if !self.allow_external_commands {
4594 return Ok(None);
4595 }
4596
4597 // Get the shell's cwd and its real filesystem location, if any. A
4598 // `None` real path means the cwd is virtual (a CoW overlay, an
4599 // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4600 // process to run. Don't bail out here: a bare command name that isn't
4601 // in PATH at all is a genuine "not found" regardless of cwd, and the
4602 // virtual-cwd error would blame the wrong thing for that case. Once
4603 // the command actually resolves, `real_cwd` is checked again below
4604 // and the honest reason is given then (issue #181).
4605 let (cwd, real_cwd) = {
4606 let ctx = self.exec_ctx.read().await;
4607 (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4608 };
4609
4610 let executable = if name.contains('/') {
4611 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4612 let resolved = if std::path::Path::new(name).is_absolute() {
4613 std::path::PathBuf::from(name)
4614 } else {
4615 match &real_cwd {
4616 Some(real_cwd) => real_cwd.join(name),
4617 // A relative path can't be resolved without a real cwd to
4618 // join against, so we can't even tell whether it would
4619 // exist — name the actual blocker instead of a
4620 // misleading "No such file or directory".
4621 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4622 }
4623 };
4624 if !resolved.exists() {
4625 return Ok(Some(ExecResult::failure(
4626 127,
4627 format!("{}: No such file or directory", name),
4628 )));
4629 }
4630 if !resolved.is_file() {
4631 return Ok(Some(ExecResult::failure(
4632 126,
4633 format!("{}: Is a directory", name),
4634 )));
4635 }
4636 #[cfg(unix)]
4637 {
4638 use std::os::unix::fs::PermissionsExt;
4639 let mode = std::fs::metadata(&resolved)
4640 .map(|m| m.permissions().mode())
4641 .unwrap_or(0);
4642 if mode & 0o111 == 0 {
4643 return Ok(Some(ExecResult::failure(
4644 126,
4645 format!("{}: Permission denied", name),
4646 )));
4647 }
4648 }
4649 resolved.to_string_lossy().into_owned()
4650 } else {
4651 // Get PATH from scope only. The kernel never reads OS env: a
4652 // frontend that wants host PATH seeds it via initial_vars (the REPL
4653 // does, with os_env_vars()). No PATH in scope → nothing resolves.
4654 let path_var = {
4655 let scope = self.scope.read().await;
4656 scope.get("PATH").map(value_to_string).unwrap_or_default()
4657 };
4658
4659 // Resolve command in PATH
4660 match resolve_in_path(name, &path_var) {
4661 Some(path) => path,
4662 None => return Ok(None), // Not found - let caller handle error
4663 }
4664 };
4665
4666 // The executable resolved — found in PATH, or a path that exists and
4667 // is executable — but there's still nowhere to run it without a real
4668 // cwd to spawn the child process in.
4669 let real_cwd = match real_cwd {
4670 Some(p) => p,
4671 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4672 };
4673
4674 tracing::debug!(executable = %executable, "resolved external command");
4675
4676 // Build flat argv (preserves flag format)
4677 let argv = self.build_args_flat(args).await?;
4678
4679 // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4680 // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4681 // byte vector. Take both out under the lock but do NOT drain here — a
4682 // pipe read can block on its producer (a still-running upstream stage),
4683 // so draining before spawn would serialize the pipeline (deadlocking
4684 // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4685 // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
4686 // are mutually exclusive in practice; prefer the pipe.
4687 let (pipe_stdin, stdin_bytes) = {
4688 let mut ctx = self.exec_ctx.write().await;
4689 (ctx.pipe_stdin.take(), ctx.take_stdin())
4690 };
4691 let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
4692
4693 // Build and spawn the command
4694 use tokio::process::Command;
4695
4696 let mut cmd = Command::new(&executable);
4697 cmd.args(&argv);
4698 cmd.current_dir(&real_cwd);
4699
4700 // Hermetic env: child sees only kaish's exported vars, not the kaish
4701 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4702 // populate it via KernelConfig::initial_vars at construction.
4703 cmd.env_clear();
4704 {
4705 let scope = self.scope.read().await;
4706 let exported = scope.exported_vars();
4707 // A structured value can't cross the process boundary; refuse rather
4708 // than silently JSON-serialize it into the child's environment.
4709 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4710 return Err(anyhow::anyhow!(msg));
4711 }
4712 for (var_name, value) in exported {
4713 // Binary can't cross the process boundary as an env var value
4714 // either — loud, not the `[binary: N bytes]` placeholder
4715 // silently exported in its place (kept in sync with
4716 // dispatch.rs::try_external and env.rs::execute_with_env).
4717 let value_str = crate::interpreter::value_to_text_sink_named(
4718 &value,
4719 "an exported environment variable value",
4720 )
4721 .map_err(|e| anyhow::anyhow!("{e}"))?;
4722 cmd.env(var_name, value_str);
4723 }
4724 }
4725
4726 // Handle stdin
4727 cmd.stdin(if has_stdin {
4728 std::process::Stdio::piped()
4729 } else if self.interactive {
4730 std::process::Stdio::inherit()
4731 } else {
4732 std::process::Stdio::null()
4733 });
4734
4735 // In interactive mode, standalone or last-in-pipeline commands inherit
4736 // the terminal's stdout/stderr so output streams in real-time.
4737 // First/middle commands must capture stdout for the pipe — same as bash.
4738 let pipeline_position = {
4739 let ctx = self.exec_ctx.read().await;
4740 ctx.pipeline_position
4741 };
4742 let inherit_output = self.interactive
4743 && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4744
4745 if inherit_output {
4746 cmd.stdout(std::process::Stdio::inherit());
4747 cmd.stderr(std::process::Stdio::inherit());
4748 } else {
4749 cmd.stdout(std::process::Stdio::piped());
4750 cmd.stderr(std::process::Stdio::piped());
4751 }
4752
4753 // On Unix, always put the child in its own process group so cancellation
4754 // can `killpg` the whole tree (the child plus any grandchildren).
4755 // Restoring default tty-related signal handlers stays gated on
4756 // job-control mode — those only matter when the child has a controlling
4757 // terminal.
4758 #[cfg(unix)]
4759 {
4760 let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4761 // SAFETY: setpgid and sigaction(SIG_DFL) are async-signal-safe per POSIX
4762 #[allow(unsafe_code)]
4763 unsafe {
4764 cmd.pre_exec(move || {
4765 // Own process group — for kill scope.
4766 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4767 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4768 if restore_jc_signals {
4769 use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4770 let mut sa: nix::libc::sigaction = std::mem::zeroed();
4771 sa.sa_sigaction = SIG_DFL;
4772 if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4773 return Err(std::io::Error::last_os_error());
4774 }
4775 if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4776 return Err(std::io::Error::last_os_error());
4777 }
4778 if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4779 return Err(std::io::Error::last_os_error());
4780 }
4781 if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4782 return Err(std::io::Error::last_os_error());
4783 }
4784 }
4785 Ok(())
4786 });
4787 }
4788 }
4789
4790 // Backstop for kill on drop in case our explicit kill path is bypassed
4791 // (panic, early return, etc) on the **capture** wait path. We do NOT
4792 // set this on the JC inherit path: that uses sync `waitpid` outside
4793 // tokio's view of the child, so on drop tokio would try to kill an
4794 // already-reaped (possibly-reused) PID. The JC path has its own
4795 // cancel handling via the side-task watcher.
4796 let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4797 if !in_jc_inherit_path {
4798 cmd.kill_on_drop(true);
4799 }
4800
4801 // Spawn the process. Capture a `KillTarget` immediately so cancel/
4802 // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4803 // to this process's generation, immune to PID reuse if the OS reaps
4804 // the child before our kill syscalls fire.
4805 let mut child = match cmd.spawn() {
4806 Ok(child) => child,
4807 Err(e) => {
4808 return Ok(Some(ExecResult::failure(
4809 127,
4810 format!("{}: {}", name, e),
4811 )));
4812 }
4813 };
4814 let kill_target = crate::pidfd::KillTarget::from_child(&child);
4815
4816 // If this external runs on behalf of a background job, record its
4817 // process group on the job so `kill -<sig> %N` can signal the real
4818 // process directly (STOP/CONT/USR1/…, not just terminate). The child
4819 // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4820 if let Some(job_id) = self.bg_job_id
4821 && let Some(pid) = child.id()
4822 {
4823 self.jobs.add_pgid(job_id, pid).await;
4824 }
4825
4826 // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4827 // detached task (bounded memory, no pre-drain) so an upstream stage and
4828 // this child run concurrently — and a child that never reads stdin (or
4829 // is killed) just breaks the copy, which stops. A buffered byte vector
4830 // is written verbatim (no text detour), so binary stdin survives.
4831 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4832 child.stdin.take().map(|mut child_stdin| {
4833 tokio::spawn(async move {
4834 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4835 let mut buf = [0u8; 8192];
4836 loop {
4837 match pipe_in.read(&mut buf).await {
4838 Ok(0) => break, // EOF
4839 Ok(n) => {
4840 if child_stdin.write_all(&buf[..n]).await.is_err() {
4841 break; // child closed stdin
4842 }
4843 }
4844 Err(_) => break,
4845 }
4846 }
4847 // Dropping child_stdin signals EOF to the child.
4848 })
4849 })
4850 } else if let Some(data) = stdin_bytes {
4851 // Write the buffered bytes from a detached task too — NOT inline.
4852 // An inline write blocks once the stdin pipe fills, and the output
4853 // drain hasn't spawned yet, so a child that emits a lot before
4854 // consuming all its input (every pipe buffer full) deadlocks. A
4855 // write error here is normal, not a failure: a child that closes
4856 // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
4857 // signals EOF.
4858 child.stdin.take().map(|mut child_stdin| {
4859 tokio::spawn(async move {
4860 use tokio::io::AsyncWriteExt;
4861 let _ = child_stdin.write_all(&data).await;
4862 })
4863 })
4864 } else {
4865 None
4866 };
4867
4868 // Abort the stdin-copy task on EVERY exit path (the capture path, both
4869 // interactive `inherit_output` returns, and any early error return).
4870 // Once the child is reaped the copy has nothing left to deliver; if it
4871 // were left parked on `pipe_in.read()` it would leak and hold the
4872 // upstream pipe reader open. A drop guard is the single place that
4873 // covers all returns — explicit per-return aborts were error-prone (an
4874 // earlier version missed the two inherit_output returns).
4875 struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
4876 impl Drop for AbortStdinCopyOnDrop {
4877 fn drop(&mut self) {
4878 if let Some(t) = self.0.take() {
4879 t.abort();
4880 }
4881 }
4882 }
4883 let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
4884
4885 if inherit_output {
4886 // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
4887 #[cfg(unix)]
4888 if let Some(ref term) = self.terminal_state {
4889 let child_id = child.id().unwrap_or(0);
4890 let pid = nix::unistd::Pid::from_raw(child_id as i32);
4891 let pgid = pid; // child is its own pgid leader
4892
4893 // Give the terminal to the child's process group
4894 if let Err(e) = term.give_terminal_to(pgid) {
4895 tracing::warn!("failed to give terminal to child: {}", e);
4896 }
4897
4898 let term_clone = term.clone();
4899 let cmd_name = name.to_string();
4900 let cmd_display = format!("{} {}", name, argv.join(" "));
4901 let jobs = self.jobs.clone();
4902
4903 // Side task that watches for cancellation while the blocking
4904 // waitpid runs. On cancel, it SIGTERMs the process group, waits
4905 // the grace period, then SIGKILLs. The blocking waitpid returns
4906 // when the child dies. AbortOnDrop guard cancels the watcher
4907 // on the success path so it doesn't keep running after wait
4908 // returns naturally.
4909 //
4910 // `wait_complete` shrinks the PID-reuse race: the watcher
4911 // checks it before each kill syscall and bails out if
4912 // wait_for_foreground has already reaped the child. This
4913 // doesn't fully eliminate the race (atomic load + kill is
4914 // not atomic with the OS reap+reuse), but narrows the window
4915 // to nanoseconds — enough to be ignorable in practice.
4916 let wait_complete = std::sync::Arc::new(
4917 std::sync::atomic::AtomicBool::new(false)
4918 );
4919 let cancel_watcher = {
4920 let cancel = cancel.clone();
4921 let wc = wait_complete.clone();
4922 // Ownership transfer: the JC path's sync wait inside
4923 // block_in_place owns the child's reaping, so the
4924 // cancel_watcher drives the kill side via KillTarget
4925 // (pidfd-bound on Linux). When kill_target is None
4926 // (older kernel + open failure, or non-Linux), falls
4927 // through to the older PID-based path the closure
4928 // captures from `pid`.
4929 let target = kill_target.as_ref().map(|t| {
4930 // Re-borrow the components we need into Owned-ish form
4931 // so the spawned task is 'static. We can't move
4932 // KillTarget directly because try_execute_external
4933 // still uses it after the spawn — but on the JC path
4934 // there is no further use after the watcher spawn,
4935 // so a clone-of-pid + owned None pidfd is safe.
4936 // Simpler: signal via the existing target by cloning
4937 // a fresh pidfd; the original keeps its handle.
4938 // Pidfd is just an OwnedFd — not Clone — so do it
4939 // by re-opening from the pid. Fall back if reopen
4940 // fails (race already reaped → best-effort kill).
4941 crate::pidfd::KillTarget::from_pid(t.pid())
4942 });
4943 tokio::spawn(async move {
4944 cancel.cancelled().await;
4945 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4946 use nix::sys::signal::Signal;
4947 if let Some(t) = &target {
4948 t.signal(Signal::SIGTERM);
4949 t.signal_pg(Signal::SIGTERM);
4950 } else {
4951 let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
4952 let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
4953 }
4954 if kill_grace > Duration::ZERO {
4955 tokio::time::sleep(kill_grace).await;
4956 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
4957 }
4958 if let Some(t) = &target {
4959 t.signal(Signal::SIGKILL);
4960 t.signal_pg(Signal::SIGKILL);
4961 } else {
4962 let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
4963 let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
4964 }
4965 })
4966 };
4967 struct AbortOnDrop(tokio::task::JoinHandle<()>);
4968 impl Drop for AbortOnDrop {
4969 fn drop(&mut self) {
4970 self.0.abort();
4971 }
4972 }
4973 let _watcher_guard = AbortOnDrop(cancel_watcher);
4974
4975 let wait_complete_setter = wait_complete.clone();
4976 let code = tokio::task::block_in_place(move || {
4977 let result = term_clone.wait_for_foreground(pid);
4978 // Mark wait done before the watcher might fire.
4979 wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
4980
4981 // Always reclaim the terminal
4982 if let Err(e) = term_clone.reclaim_terminal() {
4983 tracing::warn!("failed to reclaim terminal: {}", e);
4984 }
4985
4986 match result {
4987 crate::terminal::WaitResult::Exited(code) => code as i64,
4988 crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
4989 crate::terminal::WaitResult::Stopped(_sig) => {
4990 // Register as a stopped job
4991 let rt = tokio::runtime::Handle::current();
4992 let job_id = rt.block_on(jobs.register_stopped(
4993 cmd_display,
4994 child_id,
4995 child_id, // pgid = pid for group leader
4996 ));
4997 eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
4998 148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
4999 }
5000 }
5001 });
5002
5003 return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5004 }
5005
5006 // Non-job-control path with inherited stdio.
5007 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5008 Ok(s) => s,
5009 Err(e) => {
5010 return Ok(Some(ExecResult::failure(
5011 1,
5012 format!("{}: failed to wait: {}", name, e),
5013 )));
5014 }
5015 };
5016
5017 let code = exit_code_from_status(&status);
5018
5019 // stdout/stderr already went to the terminal
5020 Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5021 } else {
5022 // Capture output via bounded streams
5023 let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5024 let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5025
5026 let stdout_pipe = child.stdout.take();
5027 let stderr_pipe = child.stderr.take();
5028
5029 let stdout_clone = stdout_stream.clone();
5030 let stderr_clone = stderr_stream.clone();
5031
5032 let stdout_task = stdout_pipe.map(|pipe| {
5033 tokio::spawn(async move {
5034 drain_to_stream(pipe, stdout_clone).await;
5035 })
5036 });
5037
5038 let stderr_task = stderr_pipe.map(|pipe| {
5039 tokio::spawn(async move {
5040 drain_to_stream(pipe, stderr_clone).await;
5041 })
5042 });
5043
5044 let cancelled_before_wait = cancel.is_cancelled();
5045 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5046 Ok(s) => s,
5047 Err(e) => {
5048 // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5049 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5050 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5051 return Ok(Some(ExecResult::failure(
5052 1,
5053 format!("{}: failed to wait: {}", name, e),
5054 )));
5055 }
5056 };
5057
5058 // On cancel, abort the drain tasks (the child's pipes are gone;
5059 // late output is lost but predictable death beats partial capture).
5060 // On normal exit, await drains so we don't lose buffered output.
5061 if cancelled_before_wait || cancel.is_cancelled() {
5062 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5063 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5064 } else {
5065 if let Some(task) = stdout_task {
5066 // Ignore join error — the drain task logs its own errors
5067 let _ = task.await;
5068 }
5069 if let Some(task) = stderr_task {
5070 let _ = task.await;
5071 }
5072 }
5073
5074 let code = exit_code_from_status(&status);
5075
5076 // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5077 // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5078 // intact. stderr stays text. See docs/binary-data.md.
5079 let stdout = stdout_stream.read().await;
5080 let mut stderr = stderr_stream.read_string().await;
5081 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5082
5083 // Both streams are fixed-size rings regardless of `ctx.output_limit`
5084 // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5085 // when enabled). With the limit disabled — the repl/embedded/test
5086 // default — an overflow here used to be silent: `write` evicted the
5087 // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5088 // counter, so a >10MB stdout reported clean success with its head
5089 // quietly gone (GH #191). Surface it loudly instead.
5090 if stderr_stream.has_overflowed().await {
5091 let stats = stderr_stream.stats().await;
5092 stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5093 }
5094 if stdout_stream.has_overflowed().await {
5095 // The marker goes in stderr, never prepended into `result`'s
5096 // stdout payload: stdout may be binary
5097 // (`success_text_or_bytes` yields a `Bytes` result for
5098 // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5099 // string-formatting a marker into it would lossily reinterpret
5100 // bytes as text, introducing a SECOND, different kind of
5101 // corruption on top of the eviction itself.
5102 //
5103 // Only stdout overflow flips `did_spill` — exit-code integrity
5104 // tracks stdout, matching the enabled-limit path's contract
5105 // (stderr overflow alone doesn't remap the exit code).
5106 let stats = stdout_stream.stats().await;
5107 stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5108 result.did_spill = true;
5109 }
5110 result.err = stderr;
5111 Ok(Some(result))
5112 }
5113 }
5114
5115 // --- Variable Access ---
5116
5117 /// Get a variable value.
5118 pub async fn get_var(&self, name: &str) -> Option<Value> {
5119 let scope = self.scope.read().await;
5120 scope.get(name).cloned()
5121 }
5122
5123 /// Check if error-exit mode is enabled (for testing).
5124 #[cfg(test)]
5125 pub async fn error_exit_enabled(&self) -> bool {
5126 let scope = self.scope.read().await;
5127 scope.error_exit_enabled()
5128 }
5129
5130 /// Set a variable value.
5131 pub async fn set_var(&self, name: &str, value: Value) {
5132 let mut scope = self.scope.write().await;
5133 scope.set(name.to_string(), value);
5134 }
5135
5136 /// Set positional parameters ($0 script name and $1-$9 args).
5137 pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5138 let mut scope = self.scope.write().await;
5139 scope.set_positional(script_name, args);
5140 }
5141
5142 /// List all variables.
5143 pub async fn list_vars(&self) -> Vec<(String, Value)> {
5144 let scope = self.scope.read().await;
5145 scope.all()
5146 }
5147
5148 /// List exported variables (name, value), sorted by name. These are the
5149 /// vars a child process would see (see `dispatch`'s hermetic env build).
5150 pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5151 let scope = self.scope.read().await;
5152 scope.exported_vars()
5153 }
5154
5155 // --- CWD ---
5156
5157 /// Get current working directory.
5158 pub async fn cwd(&self) -> PathBuf {
5159 self.exec_ctx.read().await.cwd.clone()
5160 }
5161
5162 /// Set current working directory.
5163 pub async fn set_cwd(&self, path: PathBuf) {
5164 let mut ctx = self.exec_ctx.write().await;
5165 ctx.set_cwd(path);
5166 }
5167
5168 /// Set the working directory only if `path` resolves to a directory in the
5169 /// kernel's backend — the same namespace `cd` validates against. Unlike a
5170 /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5171 /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5172 /// disappeared. Returns whether the cwd was changed.
5173 pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5174 // Clone the backend Arc out before the stat so we never hold the
5175 // exec_ctx lock across the await.
5176 let backend = self.exec_ctx.read().await.backend.clone();
5177 let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5178 if is_dir {
5179 self.exec_ctx.write().await.set_cwd(path);
5180 }
5181 is_dir
5182 }
5183
5184 // --- Last Result ---
5185
5186 /// Get the last result ($?).
5187 pub async fn last_result(&self) -> ExecResult {
5188 let scope = self.scope.read().await;
5189 scope.last_result().clone()
5190 }
5191
5192 // --- Tools ---
5193
5194 /// Check if a user-defined function exists.
5195 pub async fn has_function(&self, name: &str) -> bool {
5196 self.user_tools.read().await.contains_key(name)
5197 }
5198
5199 /// Get available tool schemas.
5200 pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5201 self.tools.schemas()
5202 }
5203
5204 /// Classify how the kernel will resolve a command name.
5205 ///
5206 /// This is the supported, single source of truth for command resolution that
5207 /// embedders should call instead of re-deriving the rules. Walk a parsed
5208 /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5209 /// this per command name to bucket each into builtin / user-function /
5210 /// special-form / dynamic / external — for example a consent gate that blocks
5211 /// a script until external commands are approved.
5212 ///
5213 /// The classification mirrors the interpreter's real resolution order
5214 /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5215 /// short-circuit first, then **aliases are expanded** (bounded recursion,
5216 /// re-checking special-forms each step, exactly as execution does), then user
5217 /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5218 /// name that is a variable or command-substitution expansion (`$cmd`,
5219 /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5220 /// be resolved statically.
5221 ///
5222 /// Aliases are resolved against the kernel's current alias table, so an
5223 /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5224 /// thing it would actually run. The safe direction of any residual imprecision
5225 /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5226 /// `.kai`/backend-tool resolution are reported `External` even though some of
5227 /// those resolve in-process, so a consent gate over-gates rather than letting
5228 /// a `PATH` escape slip through.
5229 pub async fn classify_command(&self, name: &str) -> CommandKind {
5230 // Resolve the command head the way `execute_command_depth` does: a
5231 // special-form short-circuits before any alias lookup, otherwise expand
5232 // aliases (bounded, recursive) and re-check from the top. A dynamic name
5233 // can't be resolved at all.
5234 let mut name = name.to_string();
5235 let mut alias_depth = 0u8;
5236 loop {
5237 if !crate::validator::is_static_command_name(&name) {
5238 return CommandKind::Dynamic;
5239 }
5240 if crate::validator::is_runtime_special_form(&name) {
5241 return CommandKind::Special;
5242 }
5243 if alias_depth >= 10 {
5244 break;
5245 }
5246 let alias_value = {
5247 let ctx = self.exec_ctx.read().await;
5248 ctx.aliases.get(&name).cloned()
5249 };
5250 // Expand to the alias's head command. An empty alias value (no head)
5251 // is ignored by execution, so resolution continues with this name.
5252 match alias_value
5253 .as_deref()
5254 .and_then(|v| v.split_whitespace().next())
5255 {
5256 Some(head) => {
5257 name = head.to_string();
5258 alias_depth += 1;
5259 }
5260 None => break,
5261 }
5262 }
5263
5264 let is_user_tool = self.user_tools.read().await.contains_key(&name);
5265 let is_builtin = self.tools.contains(&name);
5266 crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5267 }
5268
5269 // --- Jobs ---
5270
5271 /// Get job manager.
5272 pub fn jobs(&self) -> Arc<JobManager> {
5273 self.jobs.clone()
5274 }
5275
5276 // --- VFS ---
5277
5278 /// Get VFS router.
5279 pub fn vfs(&self) -> Arc<VfsRouter> {
5280 self.vfs.clone()
5281 }
5282
5283 // --- State ---
5284
5285 /// Reset kernel to initial state.
5286 ///
5287 /// Clears in-memory variables and resets cwd to root. History is not
5288 /// cleared (it persists across resets). The kernel's `$$` identity, the
5289 /// confirmation latch / trash-on-delete configuration, and any
5290 /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
5291 /// are re-applied to the fresh scope rather than silently reverting to
5292 /// defaults — an embedder that opted into `with_latch(true)` must not
5293 /// find the gate quietly disabled after a `reset()` between requests.
5294 pub async fn reset(&self) -> Result<()> {
5295 {
5296 let mut scope = self.scope.write().await;
5297 let pid = scope.pid();
5298 let latch_enabled = scope.latch_enabled();
5299 let trash_enabled = scope.trash_enabled();
5300 let mut fresh = Scope::new();
5301 fresh.set_pid(pid);
5302 for (name, value) in self.initial_vars.clone() {
5303 fresh.set_exported(name, value);
5304 }
5305 fresh.set_latch_enabled(latch_enabled);
5306 fresh.set_trash_enabled(trash_enabled);
5307 *scope = fresh;
5308 }
5309 {
5310 let mut ctx = self.exec_ctx.write().await;
5311 ctx.cwd = PathBuf::from("/");
5312 }
5313 Ok(())
5314 }
5315
5316 /// Shutdown the kernel.
5317 pub async fn shutdown(self) -> Result<()> {
5318 // Wait for all background jobs
5319 self.jobs.wait_all().await;
5320 Ok(())
5321 }
5322
5323 /// Dispatch a single command using the full resolution chain.
5324 ///
5325 /// This is the core of `CommandDispatcher` — it syncs state between the
5326 /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5327 /// then delegates to `execute_command` for the actual dispatch.
5328 ///
5329 /// State flow:
5330 /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5331 /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5332 /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5333 async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5334 // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5335 // its inner command via ctx.dispatcher) routes through THIS kernel,
5336 // not a stale parent. Critical for forks: the fork's builtins must
5337 // use the fork's dispatcher, not the parent's.
5338 if let Some(d) = self.dispatcher() {
5339 ctx.dispatcher = Some(d);
5340 }
5341
5342 // 1. Sync ctx → self internals
5343 {
5344 let mut scope = self.scope.write().await;
5345 *scope = ctx.scope.clone();
5346 }
5347 {
5348 let mut ec = self.exec_ctx.write().await;
5349 ec.cwd = ctx.cwd.clone();
5350 ec.prev_cwd = ctx.prev_cwd.clone();
5351 ec.stdin = ctx.stdin.take();
5352 ec.stdin_data = ctx.stdin_data.take();
5353 // The structured-data sideband receiver (set by the concurrent
5354 // pipeline runner on the stage ctx) must reach the tool's snapshot
5355 // too — same reason as the pipe endpoints below. Without this a
5356 // pipeline consumer never sees the producer's `.data`.
5357 ec.stdin_data_rx = ctx.stdin_data_rx.take();
5358 // Streaming pipe endpoints and kernel stderr must flow to the
5359 // tool via self.exec_ctx — execute_command reads that, not the
5360 // passed-in ctx. Without moving these, concurrent pipeline
5361 // stages dispatched via a fork get pipe_stdin = None and
5362 // silently read nothing.
5363 ec.pipe_stdin = ctx.pipe_stdin.take();
5364 ec.pipe_stdout = ctx.pipe_stdout.take();
5365 if let Some(stderr) = ctx.stderr.clone() {
5366 ec.stderr = Some(stderr);
5367 }
5368 ec.aliases = ctx.aliases.clone();
5369 ec.ignore_config = ctx.ignore_config.clone();
5370 ec.output_limit = ctx.output_limit.clone();
5371 ec.pipeline_position = ctx.pipeline_position;
5372 // Sync the cancel token from ctx → ec. Builtins like `timeout`
5373 // swap ctx.cancel to a derived child token before re-dispatching;
5374 // execute_command's snapshot reads ec.cancel (kept aligned by
5375 // this sync), so try_execute_external sees the right token.
5376 ec.cancel = ctx.cancel.clone();
5377 // Same alignment for the watchdog: a fork dispatching through its
5378 // own kernel must hand the shared script clock to the snapshot so
5379 // patient holds in forked stages suspend the right timer.
5380 ec.watchdog = ctx.watchdog.clone();
5381 }
5382
5383 // 2. Execute via the full dispatch chain
5384 let result = self.execute_command(&cmd.name, &cmd.args).await?;
5385
5386 // 3. Sync self → ctx
5387 {
5388 let scope = self.scope.read().await;
5389 ctx.scope = scope.clone();
5390 }
5391 {
5392 let mut ec = self.exec_ctx.write().await;
5393 ctx.cwd = ec.cwd.clone();
5394 ctx.prev_cwd = ec.prev_cwd.clone();
5395 ctx.aliases = ec.aliases.clone();
5396 ctx.ignore_config = ec.ignore_config.clone();
5397 ctx.output_limit = ec.output_limit.clone();
5398 // Return any pipe endpoints that the tool didn't consume.
5399 // `take()` here keeps the fork's exec_ctx in a clean state for
5400 // the next dispatch — these are per-command and shouldn't leak
5401 // between calls.
5402 ctx.pipe_stdin = ec.pipe_stdin.take();
5403 ctx.pipe_stdout = ec.pipe_stdout.take();
5404 }
5405
5406 Ok(result)
5407 }
5408}
5409
5410/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5411/// shared arg-binding core behind both `Kernel::build_args_async`
5412/// (production: full recursion through the async pipeline, command
5413/// substitution, real glob expansion) and the reduced sync evaluator behind
5414/// scatter/gather's own option parsing and the `#[cfg(test)]`
5415/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5416/// `SyncEvalSource`). GH #188 closes the drift class between those two
5417/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5418/// is now the ONLY implementation; only expression evaluation, which is
5419/// capability-bound (recursing into command substitution needs a live async
5420/// pipeline the reduced context doesn't have), still has two providers.
5421#[async_trait]
5422pub(crate) trait ArgValueSource: Send + Sync {
5423 /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5424 /// this evaluator" — the reduced sync evaluator's bash-compatible
5425 /// "coalesce" convention for an unset bare variable, or an expression
5426 /// form it doesn't support (a binary op) — and the caller drops the
5427 /// argument the same way an unset bare variable always has. The real
5428 /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5429 /// evaluate.
5430 async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5431
5432 /// Expand a bare glob-pattern positional to display strings, or `None`
5433 /// if this evaluator doesn't expand globs here (disabled, or the reduced
5434 /// sync context, which never has — matching its documented "no
5435 /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5436 /// back to `eval` (which hands back the pattern text as a literal
5437 /// string) when this returns `None`. An enabled expansion that matches
5438 /// nothing is a genuine error, not `Ok(None)`.
5439 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5440
5441 /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5442 /// — the reduced sync evaluator's existing behavior (it never expanded
5443 /// `~`).
5444 async fn home(&self) -> Option<String>;
5445}
5446
5447#[async_trait]
5448impl ArgValueSource for Kernel {
5449 async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5450 Ok(Some(self.eval_expr_async(expr).await?))
5451 }
5452
5453 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5454 let glob_enabled = self.scope.read().await.glob_enabled();
5455 if !glob_enabled {
5456 return Ok(None);
5457 }
5458 let (paths, cwd) = {
5459 let ctx = self.exec_ctx.read().await;
5460 let paths = ctx
5461 .expand_glob(pattern)
5462 .await
5463 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
5464 let cwd = ctx.resolve_path(".");
5465 (paths, cwd)
5466 };
5467 if paths.is_empty() {
5468 anyhow::bail!("no matches: {}", pattern);
5469 }
5470 let display = paths
5471 .into_iter()
5472 .map(|path| {
5473 if !pattern.starts_with('/') {
5474 path.strip_prefix(&cwd)
5475 .unwrap_or(&path)
5476 .to_string_lossy()
5477 .into_owned()
5478 } else {
5479 path.to_string_lossy().into_owned()
5480 }
5481 })
5482 .collect();
5483 Ok(Some(display))
5484 }
5485
5486 async fn home(&self) -> Option<String> {
5487 self.scope_home().await
5488 }
5489}
5490
5491/// Pull `consumes` positional args after a non-bool flag and stash them on
5492/// `tool_args.named` under the canonical param name. Shared core behind
5493/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
5494/// function's doc comment for the unification story (GH #188).
5495///
5496/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
5497/// single scalar value (last write wins on the rare duplicate).
5498/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
5499/// inside `named[canonical] = Value::Json(Array(...))`, preserving
5500/// invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
5501/// a repeated single-value flag must keep every value, not silently drop
5502/// all but the last (a "no silent corruption" violation).
5503/// - `consumes > 1` accumulates each occurrence as an inner
5504/// `serde_json::Value::Array` inside `named[canonical] =
5505/// Value::Json(Array(...))`, preserving invocation order. This is the
5506/// shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
5507///
5508/// Errors loudly if the flag is missing required positionals — matches
5509/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
5510/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
5511/// can't represent — Kernel's evaluator never returns this) falls back to a
5512/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
5513/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
5514/// error rather than a silently-partial array.
5515#[allow(clippy::too_many_arguments)]
5516async fn consume_flag_positionals(
5517 source: &dyn ArgValueSource,
5518 home: Option<&str>,
5519 args: &[Arg],
5520 flag_name: &str,
5521 canonical: &str,
5522 consumes: usize,
5523 repeatable: bool,
5524 positional_indices: &[usize],
5525 consumed: &mut std::collections::HashSet<usize>,
5526 current_idx: usize,
5527 tool_args: &mut ToolArgs,
5528) -> Result<()> {
5529 let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
5530 for _ in 0..consumes.max(1) {
5531 // A `key=value` (WordAssign) token is consumable only by a
5532 // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
5533 // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
5534 // filter` would reassemble `x=1` into the first slot and steal the
5535 // filter into the second. Multi-value flags take plain positionals.
5536 let allow_word_assign = consumes <= 1;
5537 let next_pos = positional_indices
5538 .iter()
5539 .find(|idx| {
5540 **idx > current_idx
5541 && !consumed.contains(idx)
5542 && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
5543 })
5544 .copied();
5545 match next_pos {
5546 Some(pos_idx) => match &args[pos_idx] {
5547 Arg::Positional(expr) => match source.eval(expr).await? {
5548 Some(value) => {
5549 let value = apply_tilde_expansion(value, home);
5550 collected.push(value);
5551 consumed.insert(pos_idx);
5552 }
5553 None if collected.is_empty() => {
5554 tool_args.flags.insert(flag_name.to_string());
5555 return Ok(());
5556 }
5557 None => anyhow::bail!(
5558 "--{flag_name}: could not evaluate argument {} in this context",
5559 collected.len() + 1
5560 ),
5561 },
5562 // `-v a=1`: reassemble the `key=value` token as the flag's
5563 // scalar value (see `positional_indices` construction).
5564 Arg::WordAssign { key, value } => match source.eval(value).await? {
5565 Some(val) => {
5566 let val = apply_tilde_expansion(val, home);
5567 // Loud on binary (GH #116): `-v a=$BIN` must not silently
5568 // reassemble the `[binary: N bytes]` placeholder into the
5569 // flag's value — same text-sink boundary as the primary
5570 // sinks fixed in #93 item 1.
5571 let val_str = crate::interpreter::value_to_text_sink_named(
5572 &val,
5573 "a key=value argument",
5574 )
5575 .map_err(|e| anyhow::anyhow!("{e}"))?;
5576 collected.push(Value::String(format!("{key}={val_str}")));
5577 consumed.insert(pos_idx);
5578 }
5579 None if collected.is_empty() => {
5580 tool_args.flags.insert(flag_name.to_string());
5581 return Ok(());
5582 }
5583 None => anyhow::bail!(
5584 "--{flag_name}: could not evaluate argument {} in this context",
5585 collected.len() + 1
5586 ),
5587 },
5588 _ => {}
5589 },
5590 None => {
5591 if consumes <= 1 && collected.is_empty() {
5592 // Back-compat: a flag with no follow-up positional
5593 // becomes a bare flag. `--path` with nothing after
5594 // lands in `flags`, same as before this refactor.
5595 tool_args.flags.insert(flag_name.to_string());
5596 return Ok(());
5597 }
5598 anyhow::bail!(
5599 "--{flag_name} requires {consumes} argument{}, got {}",
5600 if consumes == 1 { "" } else { "s" },
5601 collected.len()
5602 );
5603 }
5604 }
5605 }
5606
5607 if consumes <= 1 {
5608 if let Some(v) = collected.pop() {
5609 if repeatable {
5610 push_repeatable_value(tool_args, flag_name, canonical, v)?;
5611 } else {
5612 tool_args.named.insert(canonical.to_string(), v);
5613 }
5614 }
5615 return Ok(());
5616 }
5617
5618 // Multi-consume: accumulate under named[canonical] as array-of-arrays.
5619 let occ: Vec<serde_json::Value> = collected
5620 .into_iter()
5621 .map(|v| crate::interpreter::value_to_json(&v))
5622 .collect();
5623 let entry = tool_args
5624 .named
5625 .entry(canonical.to_string())
5626 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5627 if let Value::Json(serde_json::Value::Array(outer)) = entry {
5628 outer.push(serde_json::Value::Array(occ));
5629 } else {
5630 anyhow::bail!(
5631 "--{flag_name}: named[{canonical}] already holds a non-array value"
5632 );
5633 }
5634 Ok(())
5635}
5636
5637/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
5638/// (GH #188) shared by `Kernel::build_args_async` (production) and the
5639/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
5640/// scatter/gather's own option parsing and the `#[cfg(test)]`
5641/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
5642/// pass: Kernel's evaluates full expressions (including `$(...)` command
5643/// substitution) and expands real globs/tilde; the reduced one can't recurse
5644/// into the async pipeline this early (scatter/gather's own flags bind
5645/// before any worker forks) so it evaluates a smaller expression subset and
5646/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
5647///
5648/// If a schema is provided, uses it to determine argument types:
5649/// - For `--flag` where schema says type is non-bool: consume next
5650/// positional(s) as value(s) (`consumes`/`repeatable`-aware).
5651/// - For `--flag` where schema says type is bool (or unknown): treat as a
5652/// boolean flag.
5653///
5654/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
5655pub(crate) async fn bind_tool_args(
5656 args: &[Arg],
5657 schema: Option<&crate::tools::ToolSchema>,
5658 source: &dyn ArgValueSource,
5659) -> Result<ToolArgs> {
5660 let mut tool_args = ToolArgs::new();
5661 let home = source.home().await;
5662
5663 // A glob-passthrough tool (`glob`) consumes patterns as data: skip
5664 // argv glob expansion so the pattern reaches the tool as written —
5665 // otherwise `glob **/*.rs` binds the first *matching path* as its
5666 // pattern. The eval fallback turns `Expr::GlobPattern` into its
5667 // literal string.
5668 let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
5669
5670 // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
5671 // in source order with types preserved — operators (`-f`, `=`, `!`) as
5672 // strings, operands keeping their `Value` — leaving `flags`/`named`
5673 // empty. A position-sensitive command needs the *true* argv: an operand
5674 // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
5675 // hoisted into the unordered flag set the normal binder splits into.
5676 // Globs still expand and `~` still resolves, matching normal positional
5677 // binding — so `test -f *.rs` errors on too many args, not a literal
5678 // pattern stat.
5679 if schema.is_some_and(|s| s.raw_argv) {
5680 for arg in args {
5681 match arg {
5682 Arg::Positional(expr) => {
5683 let glob = if let Expr::GlobPattern(p) = expr {
5684 (!glob_passthrough).then(|| p.clone())
5685 } else {
5686 None
5687 };
5688 if let Some(pattern) = glob {
5689 match source.expand_glob(&pattern).await? {
5690 Some(paths) => {
5691 for path in paths {
5692 tool_args.positional.push(Value::String(path));
5693 }
5694 }
5695 None => {
5696 let value = source.eval(expr).await?.ok_or_else(|| {
5697 anyhow::anyhow!(
5698 "raw-argv positional could not be evaluated in this context"
5699 )
5700 })?;
5701 let value = apply_tilde_expansion(value, home.as_deref());
5702 tool_args.positional.push(value);
5703 }
5704 }
5705 } else {
5706 let value = source.eval(expr).await?.ok_or_else(|| {
5707 anyhow::anyhow!(
5708 "raw-argv positional could not be evaluated in this context"
5709 )
5710 })?;
5711 let value = apply_tilde_expansion(value, home.as_deref());
5712 tool_args.positional.push(value);
5713 }
5714 }
5715 Arg::ShortFlag(name) => {
5716 tool_args.positional.push(Value::String(format!("-{name}")));
5717 }
5718 Arg::LongFlag(name) => {
5719 tool_args.positional.push(Value::String(format!("--{name}")));
5720 }
5721 Arg::Named { key, value } => {
5722 let val = source.eval(value).await?.ok_or_else(|| {
5723 anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
5724 })?;
5725 let val = apply_tilde_expansion(val, home.as_deref());
5726 // Loud on binary (GH #116): `test --k=$BIN` must not
5727 // silently reassemble the placeholder into the raw-argv
5728 // positional stream `test` binds against.
5729 let val_str = crate::interpreter::value_to_text_sink_named(
5730 &val,
5731 "a --key=value argument",
5732 )
5733 .map_err(|e| anyhow::anyhow!("{e}"))?;
5734 tool_args
5735 .positional
5736 .push(Value::String(format!("--{key}={val_str}")));
5737 }
5738 Arg::WordAssign { key, value } => {
5739 let val = source.eval(value).await?.ok_or_else(|| {
5740 anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
5741 })?;
5742 let val = apply_tilde_expansion(val, home.as_deref());
5743 // Loud on binary (GH #116): same reasoning as the Named
5744 // arm above, for the bare `key=value` raw-argv form.
5745 let val_str = crate::interpreter::value_to_text_sink_named(
5746 &val,
5747 "a key=value argument",
5748 )
5749 .map_err(|e| anyhow::anyhow!("{e}"))?;
5750 tool_args
5751 .positional
5752 .push(Value::String(format!("{key}={val_str}")));
5753 }
5754 Arg::DoubleDash => {
5755 tool_args.positional.push(Value::String("--".to_string()));
5756 }
5757 }
5758 }
5759 return Ok(tool_args);
5760 }
5761
5762 // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
5763 // schemas; pick the leaf the leading positionals route to and bind
5764 // flags against *its* params. Flat tools return the root. select_leaf
5765 // errors (fail loud) if a computed positional sits where a subcommand
5766 // selector is required.
5767 let leaf = match schema {
5768 Some(s) => Some(select_leaf(s, args)?),
5769 None => None,
5770 };
5771 // Bind against the leaf's params, but MERGE the root schema's params on
5772 // top as "global" flags: a value-flag declared at the tool's top level
5773 // (e.g. kj's `--confirm <nonce>`) must bind at every leaf, including when
5774 // it trails the subcommand path (`kj context retag a b --confirm <n>`).
5775 // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
5776 // merge is a harmless no-op.
5777 let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
5778 if let Some(l) = leaf {
5779 param_lookup.extend(schema_param_lookup(l));
5780 }
5781 // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
5782 // not the leaf — it's a property of the command, not the subcommand.
5783 let accepts_word_assign = schema
5784 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
5785 .unwrap_or(false);
5786
5787 // Track which positional indices have been consumed as flag values
5788 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
5789 let mut past_double_dash = false;
5790
5791 // Indices a value-flag may consume as its value. Positionals always
5792 // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
5793 // itself treat `key=value` as an assignment (everything but
5794 // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
5795 // `-v`, rather than skipping it and grabbing the next positional (the
5796 // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
5797 // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
5798 let positional_indices: Vec<usize> = args
5799 .iter()
5800 .enumerate()
5801 .filter_map(|(i, a)| {
5802 let consumable = matches!(a, Arg::Positional(_))
5803 || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
5804 consumable.then_some(i)
5805 })
5806 .collect();
5807
5808 let mut i = 0;
5809 while i < args.len() {
5810 match &args[i] {
5811 Arg::DoubleDash => {
5812 past_double_dash = true;
5813 }
5814 Arg::Positional(expr) => {
5815 if !consumed.contains(&i) {
5816 // Glob expansion: bare glob patterns expand to matching files
5817 if let Expr::GlobPattern(pattern) = expr {
5818 if !glob_passthrough {
5819 if let Some(paths) = source.expand_glob(pattern).await? {
5820 for path in paths {
5821 tool_args.positional.push(Value::String(path));
5822 }
5823 i += 1;
5824 continue;
5825 }
5826 }
5827 }
5828 if let Some(value) = source.eval(expr).await? {
5829 let value = apply_tilde_expansion(value, home.as_deref());
5830 tool_args.positional.push(value);
5831 }
5832 }
5833 }
5834 Arg::Named { key, value } => {
5835 if let Some(val) = source.eval(value).await? {
5836 let val = apply_tilde_expansion(val, home.as_deref());
5837 // A repeatable flag in `--flag=value` form must accumulate too,
5838 // not overwrite — otherwise `--expression=A --expression=B`
5839 // would silently keep only B, and mixing with the `-e` space
5840 // form would clobber the array. Route it through the same
5841 // accumulator the space form uses.
5842 let is_declared_value_flag = param_lookup
5843 .get(key.as_str())
5844 .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
5845 if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
5846 push_repeatable_value(&mut tool_args, key, canonical, val)?;
5847 } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
5848 // Flagify at bind time (GH #189): `--flag=true`/
5849 // `--flag=false` binds the same way the bare
5850 // `--flag`/its absence already do (true → flag
5851 // presence, false → dropped) instead of landing in
5852 // `named` as a literal `Value::Bool` that a clap
5853 // `bool` field's `SetTrue` action rejects
5854 // (`seq --json=true` used to exit 2 with a clap
5855 // parse error). Covers both a schema-declared bool
5856 // param AND an undeclared flag — `--json` itself is
5857 // deliberately excluded from every builtin's schema
5858 // (`clap_schema::is_skipped`), so this is what makes
5859 // `--json=true` work universally instead of only for
5860 // the builtins that happen to call
5861 // `ToolArgs::flagify_bool_named` themselves. A
5862 // declared VALUE-taking flag's own `=true` literal
5863 // (`spawn --command=true`) is excluded by
5864 // `is_declared_value_flag` and still falls to
5865 // `named` below.
5866 if let Value::Bool(true) = val {
5867 tool_args.flags.insert(key.clone());
5868 }
5869 // Value::Bool(false): absent == false, nothing to insert.
5870 } else {
5871 tool_args.named.insert(key.clone(), val);
5872 }
5873 }
5874 }
5875 Arg::WordAssign { key, value } => {
5876 // Already pulled in as a preceding value-flag's argument
5877 // (`awk -v a=1`); don't also emit it as a positional.
5878 if consumed.contains(&i) {
5879 i += 1;
5880 continue;
5881 }
5882 if let Some(val) = source.eval(value).await? {
5883 let val = apply_tilde_expansion(val, home.as_deref());
5884 // Past `--`, EVERY token is raw data — including for
5885 // export/alias, whose `key=value` is normally a shell
5886 // assignment (GH #189). `export -- A=1` must bind `A=1`
5887 // as a literal positional, not silently re-enter the
5888 // named-assignment path `past_double_dash` exists to
5889 // suppress for flags right above this arm.
5890 if accepts_word_assign && !past_double_dash {
5891 tool_args.named.insert(key.clone(), val);
5892 } else {
5893 // Stringify "key=value" and pass as a positional.
5894 // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
5895 // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
5896 // must not silently become a path/operand literally named
5897 // `foo=[binary: N bytes]`.
5898 let val_str = crate::interpreter::value_to_text_sink_named(
5899 &val,
5900 "a key=value argument",
5901 )
5902 .map_err(|e| anyhow::anyhow!("{e}"))?;
5903 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
5904 }
5905 }
5906 }
5907 Arg::ShortFlag(name) => {
5908 if past_double_dash {
5909 tool_args.positional.push(Value::String(format!("-{name}")));
5910 } else if name.len() == 1 {
5911 let flag_name = name.as_str();
5912 let lookup = param_lookup.get(flag_name);
5913
5914 // Same ambiguity guard as the `LongFlag` arm below (GH
5915 // #189 item 4): an undeclared short flag immediately
5916 // followed by an unconsumed positional under a
5917 // map_positionals (backend/MCP) schema is exactly as
5918 // ambiguous as the long-flag case — kaish can't tell a
5919 // space-form value (`-t explorer`) from a bool flag
5920 // sitting before a real positional (`-f file.txt`).
5921 // Unlike `--flag`, there is no `-f=value` escape hatch to
5922 // suggest: a glued `-f=val` is two tokens with a dangling
5923 // `=` that the parser's no-token-pasting guard already
5924 // rejects — the only fix is declaring the flag.
5925 let ambiguous_value = (lookup.is_none()
5926 && leaf.is_some_and(|s| s.map_positionals)
5927 && !consumed.contains(&(i + 1)))
5928 .then(|| match args.get(i + 1) {
5929 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
5930 Some(s.clone())
5931 }
5932 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
5933 _ => None,
5934 })
5935 .flatten();
5936 if let Some(val) = ambiguous_value {
5937 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
5938 anyhow::bail!(
5939 "{tool}: -{name} is not a declared flag, so the \
5940 space-separated value ({val:?}) would be silently \
5941 dropped. Have {tool} declare -{name} in its schema \
5942 (short flags have no -{name}=value form to fall \
5943 back on)."
5944 );
5945 }
5946
5947 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
5948
5949 if is_bool {
5950 tool_args.flags.insert(flag_name.to_string());
5951 } else {
5952 // Non-bool: consume `consumes` positionals as value(s)
5953 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
5954 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
5955 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
5956 consume_flag_positionals(
5957 source,
5958 home.as_deref(),
5959 args,
5960 name,
5961 canonical,
5962 consumes,
5963 repeatable,
5964 &positional_indices,
5965 &mut consumed,
5966 i,
5967 &mut tool_args,
5968 )
5969 .await?;
5970 }
5971 } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
5972 // Multi-char short flag matches a schema param (POSIX style: -name value)
5973 if is_bool_type(typ) {
5974 tool_args.flags.insert(canonical.to_string());
5975 } else {
5976 consume_flag_positionals(
5977 source,
5978 home.as_deref(),
5979 args,
5980 name,
5981 canonical,
5982 consumes,
5983 repeatable,
5984 &positional_indices,
5985 &mut consumed,
5986 i,
5987 &mut tool_args,
5988 )
5989 .await?;
5990 }
5991 } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
5992 .get(&name[..1])
5993 .filter(|(_, typ, ..)| !is_bool_type(typ))
5994 {
5995 // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
5996 // `grep -A1`, `sed -e1d`. The first char is a declared
5997 // value-taking short flag, so the rest of the token is its
5998 // value — the coreutils idiom. The lexer's flag char class is
5999 // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6000 // (safe to slice) and the tail is a plain literal.
6001 bind_glued_short_value(
6002 &mut tool_args,
6003 &name[..1],
6004 canonical,
6005 consumes,
6006 repeatable,
6007 name[1..].to_string(),
6008 )?;
6009 } else {
6010 // Multi-char combined short flags. Bool flags stack
6011 // (`-la`), but the FIRST value-taking flag reached
6012 // consumes the rest of the token as its glued value
6013 // (`-ivC3` → C=3) or, if it is the last char, the next
6014 // positional (`grep -ivC 3` → C=3). Before this, a
6015 // trailing value-flag was silently treated as a bool,
6016 // stranding its argument as a stray positional (arity
6017 // error). Undeclared/bool chars stay bare flags, so a
6018 // schemaless tool keeps the old all-boolean behavior.
6019 // The first char being value-taking is handled by the
6020 // glued arm above, so it never reaches here. The flag
6021 // char class is ASCII, so byte indexing is char indexing
6022 // (no `Vec<char>` allocation needed).
6023 let bytes = name.as_bytes();
6024 let mut p = 0;
6025 while p < bytes.len() {
6026 let key = &name[p..p + 1];
6027 match param_lookup.get(key) {
6028 Some(&(canonical, typ, consumes, repeatable))
6029 if !is_bool_type(typ) =>
6030 {
6031 let glued = name[p + 1..].to_string();
6032 if glued.is_empty() {
6033 // Value flag is the last char: take the
6034 // next positional. `consume_flag_positionals`
6035 // respects `consumes`.
6036 consume_flag_positionals(
6037 source,
6038 home.as_deref(),
6039 args,
6040 key,
6041 canonical,
6042 consumes,
6043 repeatable,
6044 &positional_indices,
6045 &mut consumed,
6046 i,
6047 &mut tool_args,
6048 )
6049 .await?;
6050 } else {
6051 bind_glued_short_value(
6052 &mut tool_args,
6053 key,
6054 canonical,
6055 consumes,
6056 repeatable,
6057 glued,
6058 )?;
6059 }
6060 break;
6061 }
6062 _ => {
6063 tool_args.flags.insert(key.to_string());
6064 p += 1;
6065 }
6066 }
6067 }
6068 }
6069 }
6070 Arg::LongFlag(name) => {
6071 if past_double_dash {
6072 tool_args.positional.push(Value::String(format!("--{name}")));
6073 } else {
6074 let lookup = param_lookup.get(name.as_str());
6075 // An *undeclared* long flag under a `map_positionals`
6076 // (backend/MCP) schema that is immediately followed by an
6077 // unconsumed positional is ambiguous: kaish can't tell the
6078 // space-form value (`--type explorer`) from a bool flag
6079 // before a real positional (`--force file.txt`). Defaulting
6080 // to bool here silently divorces the value and misroutes it
6081 // — a privilege-escalation-by-typo against deny-by-default
6082 // embedders (docs/issues.md). Fail loud instead of guessing.
6083 let ambiguous_value = (lookup.is_none()
6084 && leaf.is_some_and(|s| s.map_positionals)
6085 && !consumed.contains(&(i + 1)))
6086 .then(|| match args.get(i + 1) {
6087 // Echo a concrete value for a copy-pasteable fix
6088 // when it's a plain literal; fall back to VALUE.
6089 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6090 Some(s.clone())
6091 }
6092 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6093 _ => None,
6094 })
6095 .flatten();
6096 if let Some(val) = ambiguous_value {
6097 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6098 anyhow::bail!(
6099 "{tool}: --{name} is not a declared flag, so the \
6100 space-separated value would be silently dropped. \
6101 Use --{name}={val}, or have {tool} declare --{name} \
6102 in its schema."
6103 );
6104 }
6105 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6106
6107 if is_bool {
6108 tool_args.flags.insert(name.clone());
6109 } else {
6110 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6111 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6112 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6113 consume_flag_positionals(
6114 source,
6115 home.as_deref(),
6116 args,
6117 name,
6118 canonical,
6119 consumes,
6120 repeatable,
6121 &positional_indices,
6122 &mut consumed,
6123 i,
6124 &mut tool_args,
6125 )
6126 .await?;
6127 }
6128 }
6129 }
6130 }
6131 i += 1;
6132 }
6133
6134 // Map remaining positionals to unfilled non-bool schema params (in order).
6135 // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6136 // Positionals that appeared after `--` are never mapped (they're raw data).
6137 // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6138 // Keyed off the routed leaf so a subcommand tool maps against the active
6139 // leaf's params (kj leaves keep map_positionals=false → block skipped).
6140 if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6141 let pre_dash_count = if past_double_dash {
6142 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6143 positional_indices.iter()
6144 .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6145 .count()
6146 } else {
6147 tool_args.positional.len()
6148 };
6149
6150 let mut remaining = Vec::new();
6151 let mut positional_iter = tool_args.positional.drain(..).enumerate();
6152
6153 for param in &schema.params {
6154 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
6155 continue;
6156 }
6157 if is_bool_type(¶m.param_type) {
6158 continue;
6159 }
6160 loop {
6161 match positional_iter.next() {
6162 Some((idx, val)) if idx < pre_dash_count => {
6163 tool_args.named.insert(param.name.clone(), val);
6164 break;
6165 }
6166 Some((_, val)) => {
6167 remaining.push(val);
6168 }
6169 None => break,
6170 }
6171 }
6172 }
6173
6174 remaining.extend(positional_iter.map(|(_, v)| v));
6175 tool_args.positional = remaining;
6176 }
6177
6178 Ok(tool_args)
6179}
6180
6181#[async_trait]
6182impl CommandDispatcher for Kernel {
6183 /// Dispatch a command through the Kernel's full resolution chain.
6184 ///
6185 /// This is the single path for all command execution when called from
6186 /// the pipeline runner. It provides the full dispatch chain:
6187 /// user tools → builtins → .kai scripts → external commands → backend tools.
6188 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6189 self.dispatch_command(cmd, ctx).await
6190 }
6191
6192 /// Evaluate an expression through the kernel's async chain, including
6193 /// command substitution. Delegates to `eval_expr_async`, which snapshots
6194 /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6195 /// only command output escapes. The `ctx` is unused here because the
6196 /// kernel evaluates against its own session state (a fork carries the
6197 /// pipeline stage's snapshot); var refs resolve against that scope.
6198 async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6199 self.eval_expr_async(expr).await
6200 }
6201
6202 /// Produce a forked dispatcher with independent mutable state (detached).
6203 ///
6204 /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6205 /// recursing into the trait method we're defining) and coerces the
6206 /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6207 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6208 let fork: Arc<Kernel> = Kernel::fork(self).await;
6209 fork
6210 }
6211
6212 /// Produce a forked dispatcher with cancellation cascading from this kernel.
6213 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6214 let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6215 fork
6216 }
6217}
6218
6219/// Apply the requested output format to a builtin's result, unless the tool
6220/// owns its own output — and even then, only on success.
6221///
6222/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6223/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6224/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6225/// gather never render a structured error themselves, so a failure
6226/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6227/// never "already rendered" by the tool. Skipping `apply_output_format` on
6228/// that path just leaked the raw diagnostic under `--json` instead of the
6229/// uniform `{"error","code"}` envelope every other builtin's failure gets
6230/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6231/// whole owns_output error-path class). Gating the skip on `result.ok()`
6232/// keeps the intentional success-path opt-out while closing that gap.
6233fn finalize_output(
6234 result: ExecResult,
6235 format: Option<crate::interpreter::OutputFormat>,
6236 owns_output: bool,
6237) -> ExecResult {
6238 match format {
6239 Some(_) if owns_output && result.ok() => result,
6240 Some(format) => apply_output_format(result, format),
6241 None => result,
6242 }
6243}
6244
6245/// Accumulate output from one result into another.
6246///
6247/// Appends stdout and stderr verbatim and updates the exit code to match the
6248/// new result. Used to preserve output from multiple statements, loop
6249/// iterations, and command chains. No separator is inserted between outputs —
6250/// each command's output concatenates raw, matching bash (`printf a; printf b`
6251/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6252/// when a command emits its own, as `echo` does).
6253fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6254 // Materialize lazy OutputData into .out before accumulating.
6255 // Without this, the first command's output stays in .output while
6256 // the second's text gets appended to .out, losing the first.
6257 accumulated.materialize();
6258 match new.out_bytes() {
6259 // A binary result must not be lossy-decoded by text_out(): concatenate
6260 // raw bytes so the combined output stays binary (this is the path every
6261 // top-level statement's result flows through). See docs/binary-data.md.
6262 Some(new_bytes) => {
6263 let mut combined: Vec<u8> = match accumulated.out_bytes() {
6264 Some(b) => b.to_vec(),
6265 None => accumulated.text_out().into_owned().into_bytes(),
6266 };
6267 combined.extend_from_slice(new_bytes);
6268 accumulated.set_out_bytes(combined);
6269 }
6270 None => accumulated.push_out(&new.text_out()),
6271 }
6272 accumulated.err.push_str(&new.err);
6273 accumulated.code = new.code;
6274 accumulated.data = new.data.clone();
6275 accumulated.did_spill = new.did_spill;
6276 accumulated.original_code = new.original_code;
6277 accumulated.content_type = new.content_type.clone();
6278 accumulated.baggage.clone_from(&new.baggage);
6279 // A latch gate (exit-2 + nonce) is the last statement's result; carry its
6280 // control-plane field through accumulation or the confirmation is lost.
6281 accumulated.latch = new.latch.clone();
6282}
6283
6284/// Fold a loop's accumulated output into a break/continue signal that is
6285/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
6286/// (with `N > 1`) would otherwise be discarded when the signal replaces the
6287/// loop's result on its way up. The loop's output comes first (it ran before
6288/// the signal was raised), then the signal's already-carried output.
6289fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
6290 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6291 let mut merged = loop_output;
6292 accumulate_result(&mut merged, result);
6293 *result = merged;
6294 }
6295}
6296
6297/// Accumulate the output a break/continue signal carried (from inner loops it
6298/// propagated through) into the loop that finally handles it, so it survives
6299/// into that loop's result.
6300fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6301 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6302 accumulate_result(accumulated, result);
6303 }
6304}
6305
6306/// Check if a value is truthy.
6307fn is_truthy(value: &Value) -> bool {
6308 match value {
6309 Value::Null => false,
6310 Value::Bool(b) => *b,
6311 Value::Int(i) => *i != 0,
6312 Value::Float(f) => *f != 0.0,
6313 Value::String(s) => !s.is_empty(),
6314 Value::Json(json) => match json {
6315 serde_json::Value::Null => false,
6316 serde_json::Value::Array(arr) => !arr.is_empty(),
6317 serde_json::Value::Object(obj) => !obj.is_empty(),
6318 serde_json::Value::Bool(b) => *b,
6319 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6320 serde_json::Value::String(s) => !s.is_empty(),
6321 },
6322 Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6323 }
6324}
6325
6326/// Apply tilde expansion to a value.
6327///
6328/// Only string values starting with `~` are expanded. `home` is the session
6329/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6330/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6331fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6332 match value {
6333 Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6334 _ => value,
6335 }
6336}
6337
6338/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6339/// how the lexer tokenizes the equivalent minimally-quoted command string —
6340/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6341/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6342/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6343/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6344///
6345/// Classification matches the lexer's word classes:
6346/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6347/// the binder's `past_double_dash` arms, exactly as for the string door).
6348/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6349/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6350/// (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6351/// they fall through to a positional, not a flag).
6352/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6353/// binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6354/// `key=value` positional, per the command's word-assign allowlist).
6355/// - everything else → a literal [`Arg::Positional`].
6356///
6357/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6358/// positional — it can never be a flag — and rides through as-is. That is the
6359/// typed passthrough the string-native door cannot offer.
6360pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6361 argv.iter().map(classify_argv_token).collect()
6362}
6363
6364fn classify_argv_token(token: &Value) -> Arg {
6365 let Value::String(s) = token else {
6366 return Arg::Positional(Expr::Literal(token.clone()));
6367 };
6368
6369 if s == "--" {
6370 return Arg::DoubleDash;
6371 }
6372
6373 // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6374 // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6375 // literal word (GH #137), matching this classifier's own literal
6376 // fallback — so they fall through to a literal positional rather than a
6377 // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6378 if let Some(rest) = s.strip_prefix("--") {
6379 if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6380 return match rest.split_once('=') {
6381 Some((key, val)) => Arg::Named {
6382 key: key.to_string(),
6383 value: Expr::Literal(Value::String(val.to_string())),
6384 },
6385 None => Arg::LongFlag(rest.to_string()),
6386 };
6387 }
6388 } else if let Some(rest) = s.strip_prefix('-') {
6389 // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6390 // token carrying any other char — notably `=` (`-k=v` is a parse error in
6391 // the string door) — or a leading digit (`-1` lexes as a number) is not a
6392 // short-flag word, so it falls through to a literal positional instead of
6393 // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6394 if is_short_flag_body(rest) {
6395 return Arg::ShortFlag(rest.to_string());
6396 }
6397 }
6398
6399 if let Some((key, val)) = s.split_once('=') {
6400 if is_shell_identifier(key) {
6401 return Arg::WordAssign {
6402 key: key.to_string(),
6403 value: Expr::Literal(Value::String(val.to_string())),
6404 };
6405 }
6406 }
6407
6408 Arg::Positional(Expr::Literal(Value::String(s.clone())))
6409}
6410
6411/// A short-flag word: a leading ASCII letter, then only ASCII
6412/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6413/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6414/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6415/// (`=` is the assignment operator — a parse error in the string door), and
6416/// any non-ASCII tail (never produced by the lexer, and not safe for the
6417/// combined-short-flag binder's byte-index slicing) do not, so they fall
6418/// through to a literal positional instead of a malformed `ShortFlag`.
6419fn is_short_flag_body(s: &str) -> bool {
6420 s.starts_with(|c: char| c.is_ascii_alphabetic())
6421 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6422}
6423
6424/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6425fn is_shell_identifier(s: &str) -> bool {
6426 let mut chars = s.chars();
6427 match chars.next() {
6428 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6429 _ => return false,
6430 }
6431 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6432}
6433
6434/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6435/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6436/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6437/// must keep every value, not silently drop all but the last. Used by every flag
6438/// surface that can carry the same flag twice — the space form
6439/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6440/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6441/// ordered array.
6442pub(crate) fn push_repeatable_value(
6443 tool_args: &mut ToolArgs,
6444 flag_name: &str,
6445 canonical: &str,
6446 v: Value,
6447) -> anyhow::Result<()> {
6448 let occ = crate::interpreter::value_to_json(&v);
6449 let entry = tool_args
6450 .named
6451 .entry(canonical.to_string())
6452 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6453 if let Value::Json(serde_json::Value::Array(items)) = entry {
6454 items.push(occ);
6455 Ok(())
6456 } else {
6457 anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6458 }
6459}
6460
6461/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6462/// is one token, so it carries a single value: a repeatable flag accumulates
6463/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6464/// first-char glued arm and the combined-bundle arm so the two can't drift on
6465/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6466/// is a loud error, not a silent single-value bind.
6467pub(crate) fn bind_glued_short_value(
6468 tool_args: &mut ToolArgs,
6469 flag_name: &str,
6470 canonical: &str,
6471 consumes: usize,
6472 repeatable: bool,
6473 value: String,
6474) -> anyhow::Result<()> {
6475 if consumes > 1 {
6476 anyhow::bail!(
6477 "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6478 );
6479 }
6480 if repeatable {
6481 push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6482 } else {
6483 tool_args
6484 .named
6485 .insert(canonical.to_string(), Value::String(value));
6486 Ok(())
6487 }
6488}
6489
6490/// Map a child's exit status to a shell-style exit code.
6491///
6492/// `ExitStatus::code()` is `None` when the process died from a signal rather
6493/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6494/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6495/// number. Shared by both external-command spawn sites — production
6496/// (`try_execute_external`, below) and the test-only twin
6497/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6498/// this mapping again (GH #133 item 1).
6499#[cfg(feature = "subprocess")]
6500pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6501 status.code().unwrap_or_else(|| {
6502 #[cfg(unix)]
6503 {
6504 use std::os::unix::process::ExitStatusExt;
6505 128 + status.signal().unwrap_or(0)
6506 }
6507 #[cfg(not(unix))]
6508 {
6509 -1
6510 }
6511 }) as i64
6512}
6513
6514/// Wait for a child to exit, killing it if `cancel` fires first.
6515///
6516/// `target` carries a Linux pidfd (when available) for race-free direct-child
6517/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6518/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6519#[cfg(all(unix, feature = "subprocess"))]
6520pub(crate) async fn wait_or_kill(
6521 child: &mut tokio::process::Child,
6522 target: Option<&crate::pidfd::KillTarget>,
6523 cancel: &tokio_util::sync::CancellationToken,
6524 grace: Duration,
6525) -> std::io::Result<std::process::ExitStatus> {
6526 tokio::select! {
6527 biased;
6528 status = child.wait() => status,
6529 _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6530 }
6531}
6532
6533#[cfg(all(not(unix), feature = "subprocess"))]
6534pub(crate) async fn wait_or_kill(
6535 child: &mut tokio::process::Child,
6536 _target: Option<&()>,
6537 cancel: &tokio_util::sync::CancellationToken,
6538 _grace: Duration,
6539) -> std::io::Result<std::process::ExitStatus> {
6540 tokio::select! {
6541 biased;
6542 status = child.wait() => status,
6543 _ = cancel.cancelled() => {
6544 let _ = child.start_kill();
6545 child.wait().await
6546 }
6547 }
6548}
6549
6550/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6551///
6552/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6553/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6554/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6555#[cfg(all(unix, feature = "subprocess"))]
6556pub(crate) async fn kill_with_grace(
6557 child: &mut tokio::process::Child,
6558 target: Option<&crate::pidfd::KillTarget>,
6559 grace: Duration,
6560) -> std::io::Result<std::process::ExitStatus> {
6561 use nix::sys::signal::Signal;
6562
6563 if let Some(t) = target {
6564 t.signal(Signal::SIGTERM);
6565 t.signal_pg(Signal::SIGTERM);
6566 if grace > Duration::ZERO
6567 && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6568 {
6569 return status;
6570 }
6571 t.signal(Signal::SIGKILL);
6572 t.signal_pg(Signal::SIGKILL);
6573 }
6574 child.wait().await
6575}
6576
6577#[cfg(test)]
6578#[allow(clippy::unwrap_used, clippy::expect_used)]
6579mod argv_classify_tests {
6580 use super::*;
6581
6582 /// A normalized, comparable view of one `Arg` representing its *logical
6583 /// argument* (what the command observably receives), not its exact AST shape:
6584 ///
6585 /// - Value-bearing arms compare by *stringified* value, so the parser's
6586 /// number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6587 /// (`String("-1")`) count as the same argument.
6588 /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6589 /// For every command except the `export`/`alias` allowlist, a bareword
6590 /// `key=value` is stringified straight back to a `"key=value"` positional
6591 /// (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6592 /// converge observably even when they disagree on the AST tag — e.g. the
6593 /// lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6594 /// `WordAssign`, where the classifier (bash-correctly) makes a positional.
6595 /// The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6596 /// separately by `classifies_each_word_class`.
6597 ///
6598 /// Returns `None` for shapes we deliberately don't compare:
6599 /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6600 /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6601 /// lex to `Int`, dropping the literal text, where the classifier keeps the
6602 /// string. That divergence is *intentional* — `execute_argv` preserves a
6603 /// literal numeric string (pass `Value::Int` for a number), the string door
6604 /// can only guess — so the property skips it rather than demanding the
6605 /// classifier replicate a lossy coercion. Numeric edges are pinned exactly
6606 /// by `classifies_each_word_class`.
6607 fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6608 // Only a *string*-valued literal is comparable; a coerced number is not.
6609 let lit = |e: &Expr| match e {
6610 Expr::Literal(Value::String(s)) => Some(s.clone()),
6611 _ => None,
6612 };
6613 Some(match arg {
6614 Arg::DoubleDash => ("dash", String::new(), String::new()),
6615 Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6616 Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6617 Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6618 Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6619 Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6620 })
6621 }
6622
6623 /// Classify a single string token the way `execute_argv` would.
6624 fn classify(token: &str) -> Arg {
6625 classify_argv_token(&Value::String(token.to_string()))
6626 }
6627
6628 #[test]
6629 fn classifies_each_word_class() {
6630 assert_eq!(classify("--"), Arg::DoubleDash);
6631 assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6632 assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6633 assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6634 assert_eq!(
6635 classify("--key=value"),
6636 Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6637 );
6638 assert_eq!(
6639 classify("NAME=val"),
6640 Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6641 );
6642 // Digits after the first flag char are ordinary (kept verbatim).
6643 assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6644 assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6645 // Leading-digit dash is a number to the lexer, not a flag → positional.
6646 assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6647 // Numeric strings keep their literal text — `execute_argv` does NOT
6648 // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6649 // who wants a number passes `Value::Int`; a string stays the string.
6650 assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6651 assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6652 // A lone dash (stdin convention) is a positional, not a flag.
6653 assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6654 // Non-identifier LHS is not an assignment.
6655 assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6656 assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6657 }
6658
6659 #[test]
6660 fn typed_values_pass_through_as_literal_positionals() {
6661 // The whole point of the `&[Value]` signature: a non-string value is a
6662 // literal positional carrying the *exact* value, never stringified and
6663 // never flag-interpreted.
6664 let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6665 assert_eq!(
6666 classify_argv_token(&bytes),
6667 Arg::Positional(Expr::Literal(bytes.clone()))
6668 );
6669 let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6670 assert_eq!(
6671 classify_argv_token(&json),
6672 Arg::Positional(Expr::Literal(json.clone()))
6673 );
6674 // An integer token that *looks* like a flag is still a positional value
6675 // (only strings are inspected for a leading dash).
6676 assert_eq!(
6677 classify_argv_token(&Value::Int(-9)),
6678 Arg::Positional(Expr::Literal(Value::Int(-9)))
6679 );
6680 }
6681
6682 #[test]
6683 fn double_dash_only_matches_exactly() {
6684 // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6685 // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6686 // as a single argv token here it's likewise literal.
6687 assert_eq!(classify("--"), Arg::DoubleDash);
6688 assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6689 assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6690 }
6691
6692 #[test]
6693 fn malformed_flag_words_fall_back_to_literal_positionals() {
6694 // A token that isn't a well-formed flag word must NOT be silently misbound
6695 // into the arg binder (house rule: loud/visible over silent-wrong). Each
6696 // of these is a parse error or different tokenization in the string door,
6697 // so the argv door keeps them as literal positionals.
6698 let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6699 // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6700 // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6701 assert_eq!(classify("-k=v"), pos("-k=v"));
6702 assert_eq!(classify("-="), pos("-="));
6703 // Empty long-flag key.
6704 assert_eq!(classify("--=v"), pos("--=v"));
6705 // `--` followed by a non-letter is not a long flag.
6706 assert_eq!(classify("--1"), pos("--1"));
6707 // A bare dash and a number-dash are positionals (covered above too).
6708 assert_eq!(classify("-"), pos("-"));
6709 assert_eq!(classify("-9"), pos("-9"));
6710 // A non-ASCII tail is not part of the lexer's short-flag char class
6711 // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6712 // absorbs) — classifying it as `ShortFlag` would hand the combined
6713 // short-flag binder a byte string it (correctly, for real ASCII flag
6714 // words) slices by *byte* index, panicking on a multi-byte char
6715 // boundary. Fall back to a literal positional instead.
6716 assert_eq!(classify("-lé"), pos("-lé"));
6717 assert_eq!(classify("-é"), pos("-é"));
6718 }
6719
6720 #[tokio::test]
6721 async fn non_ascii_short_flag_bundle_does_not_panic() {
6722 // Regression: `execute_argv`'s combined-short-flag loop assumed the
6723 // flag body was ASCII (safe to byte-slice) because the lexer's
6724 // grammar guarantees that on the *string* door. The argv door's
6725 // classifier let a non-ASCII tail through as `ShortFlag`, so
6726 // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6727 let kernel = Kernel::transient().expect("failed to create kernel");
6728 let result = kernel
6729 .execute_argv("ls", &[Value::String("-lé".into())])
6730 .await
6731 .expect("execute_argv must not panic on a non-ASCII short-flag token");
6732 // Not a well-formed flag word, so it's a literal positional — `ls`
6733 // then reports it as a missing path rather than mangling flags.
6734 assert_ne!(result.code, 0);
6735 }
6736
6737 proptest::proptest! {
6738 /// The core correctness claim: the classifier mirrors the lexer/parser
6739 /// on metacharacter-free tokens. For any such single token, the `Arg`
6740 /// the classifier produces matches the one the real parser produces for
6741 /// the equivalent one-word command — so `execute_argv` reusing the
6742 /// string door's binder is sound. (First proptest in the workspace.)
6743 #[test]
6744 fn classifier_matches_parser_on_clean_tokens(
6745 // No digits: this property tests the *classification* boundary
6746 // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6747 // positional), not numeric coercion. The lexer coerces digit runs to
6748 // `Int`/`Float` and drops the literal text (even inside a colon-merged
6749 // word: `00:` → `0:`); the classifier intentionally preserves the raw
6750 // string. Those numeric edges are pinned exactly by the unit tests.
6751 token in "[a-zA-Z_=./@:+-]{1,8}"
6752 ) {
6753 let parsed = match parse(&format!("cmd {token}")) {
6754 Ok(p) => p,
6755 Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6756 };
6757 let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6758 return Ok(());
6759 };
6760 // Only compare when the token lexed as exactly one argument.
6761 let [arg] = cmd.args.as_slice() else { return Ok(()); };
6762
6763 let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6764 return Ok(()); // a non-literal parsed Expr we don't model — skip
6765 };
6766 proptest::prop_assert_eq!(
6767 ours, theirs,
6768 "classifier diverged from parser on token {:?}", token
6769 );
6770 }
6771 }
6772}
6773
6774#[cfg(all(test, feature = "subprocess"))]
6775#[allow(clippy::expect_used)]
6776mod tests {
6777 use super::*;
6778
6779 #[tokio::test]
6780 async fn test_kernel_transient() {
6781 let kernel = Kernel::transient().expect("failed to create kernel");
6782 assert_eq!(kernel.name(), "transient");
6783 }
6784
6785 #[tokio::test]
6786 async fn test_kernel_execute_echo() {
6787 let kernel = Kernel::transient().expect("failed to create kernel");
6788 let result = kernel.execute("echo hello").await.expect("execution failed");
6789 assert!(result.ok());
6790 assert_eq!(result.text_out().trim(), "hello");
6791 }
6792
6793 #[tokio::test]
6794 async fn test_multiple_statements_accumulate_output() {
6795 let kernel = Kernel::transient().expect("failed to create kernel");
6796 let result = kernel
6797 .execute("echo one\necho two\necho three")
6798 .await
6799 .expect("execution failed");
6800 assert!(result.ok());
6801 // Should have all three outputs separated by newlines
6802 assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6803 assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6804 assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6805 }
6806
6807 #[tokio::test]
6808 async fn test_and_chain_accumulates_output() {
6809 let kernel = Kernel::transient().expect("failed to create kernel");
6810 let result = kernel
6811 .execute("echo first && echo second")
6812 .await
6813 .expect("execution failed");
6814 assert!(result.ok());
6815 assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6816 assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6817 }
6818
6819 #[tokio::test]
6820 async fn test_for_loop_accumulates_output() {
6821 let kernel = Kernel::transient().expect("failed to create kernel");
6822 let result = kernel
6823 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6824 .await
6825 .expect("execution failed");
6826 assert!(result.ok());
6827 assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6828 assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6829 assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6830 }
6831
6832 #[tokio::test]
6833 async fn test_while_loop_accumulates_output() {
6834 let kernel = Kernel::transient().expect("failed to create kernel");
6835 let result = kernel
6836 .execute(r#"
6837 N=3
6838 while [[ ${N} -gt 0 ]]; do
6839 echo "N=${N}"
6840 N=$((N - 1))
6841 done
6842 "#)
6843 .await
6844 .expect("execution failed");
6845 assert!(result.ok());
6846 assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
6847 assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
6848 assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
6849 }
6850
6851 #[tokio::test]
6852 async fn test_kernel_set_var() {
6853 let kernel = Kernel::transient().expect("failed to create kernel");
6854
6855 kernel.execute("X=42").await.expect("set failed");
6856
6857 let value = kernel.get_var("X").await;
6858 assert_eq!(value, Some(Value::Int(42)));
6859 }
6860
6861 #[tokio::test]
6862 async fn test_kernel_var_expansion() {
6863 let kernel = Kernel::transient().expect("failed to create kernel");
6864
6865 kernel.execute("NAME=\"world\"").await.expect("set failed");
6866 let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
6867
6868 assert!(result.ok());
6869 assert_eq!(result.text_out().trim(), "hello world");
6870 }
6871
6872 #[tokio::test]
6873 async fn test_kernel_last_result() {
6874 let kernel = Kernel::transient().expect("failed to create kernel");
6875
6876 kernel.execute("echo test").await.expect("echo failed");
6877
6878 let last = kernel.last_result().await;
6879 assert!(last.ok());
6880 assert_eq!(last.text_out().trim(), "test");
6881 }
6882
6883 #[tokio::test]
6884 async fn test_kernel_tool_not_found() {
6885 let kernel = Kernel::transient().expect("failed to create kernel");
6886
6887 let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
6888 assert!(!result.ok());
6889 assert_eq!(result.code, 127);
6890 assert!(result.err.contains("command not found"));
6891 }
6892
6893 #[tokio::test]
6894 async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
6895 // The embedder seam: a backend-registered tool (kaijutsu, an MCP
6896 // engine, …) returns a `ToolResult` with structured `data` — this
6897 // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
6898 // and `for r in $(embedder_tool)` see the typed value, not just
6899 // stdout text.
6900 use crate::backend::testing::MockBackend;
6901 use crate::backend::ToolResult;
6902 let (mock, _calls) = MockBackend::new();
6903 let backend = mock.with_tool_result(|_name| {
6904 let mut baggage = std::collections::BTreeMap::new();
6905 baggage.insert("trace_id".to_string(), "abc123".to_string());
6906 // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
6907 // construct via with_data + the with_* setters, not a struct literal.
6908 Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
6909 .with_content_type("application/json")
6910 .with_baggage(baggage))
6911 });
6912 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6913 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6914 .expect("with_backend kernel");
6915
6916 let result = kernel
6917 .execute("embedder_tool")
6918 .await
6919 .expect("execution failed");
6920 assert!(result.ok(), "backend tool call should succeed: {result:?}");
6921 assert_eq!(
6922 result.data,
6923 Some(Value::Json(serde_json::json!({"key": "value"}))),
6924 "backend tool's structured data must survive into ExecResult, not be dropped"
6925 );
6926 assert_eq!(
6927 result.content_type.as_deref(),
6928 Some("application/json"),
6929 "backend tool's content_type must survive into ExecResult"
6930 );
6931 assert_eq!(
6932 result.baggage.get("trace_id").map(String::as_str),
6933 Some("abc123"),
6934 "backend tool's baggage must survive into ExecResult"
6935 );
6936 }
6937
6938 #[tokio::test]
6939 async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
6940 // A backend tool that IS found but fails during execution (`Io`,
6941 // `PermissionDenied`, …) must surface its real error, not get
6942 // misreported as exit-127 "command not found" — that masks a genuine
6943 // failure as a lookup miss.
6944 use crate::backend::testing::MockBackend;
6945 let (mock, _calls) = MockBackend::new();
6946 let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
6947 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
6948 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
6949 .expect("with_backend kernel");
6950
6951 let result = kernel
6952 .execute("embedder_tool")
6953 .await
6954 .expect("execution failed");
6955 assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
6956 assert!(!result.ok());
6957 assert!(
6958 result.err.contains("disk exploded"),
6959 "the real backend error must be visible, not masked: {result:?}"
6960 );
6961 }
6962
6963 #[tokio::test]
6964 async fn test_external_command_true() {
6965 // Use REPL config for passthrough filesystem access
6966 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6967
6968 // /bin/true should be available on any Unix system
6969 let result = kernel.execute("true").await.expect("execution failed");
6970 // This should use the builtin true, which returns 0
6971 assert!(result.ok(), "true should succeed: {:?}", result);
6972 }
6973
6974 #[tokio::test]
6975 async fn test_external_command_basic() {
6976 // Use REPL config for passthrough filesystem access
6977 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
6978
6979 // Test with /bin/echo which is external
6980 // Note: kaish has a builtin echo, so this will use the builtin
6981 // Let's test with a command that's not a builtin
6982 // Actually, let's just test that PATH resolution works by checking the PATH var
6983 let path_var = std::env::var("PATH").unwrap_or_default();
6984 eprintln!("System PATH: {}", path_var);
6985
6986 // Set PATH in kernel to ensure it's available
6987 kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
6988
6989 // Now try an external command like /usr/bin/env
6990 // But env is also a builtin... let's try uname
6991 let result = kernel.execute("uname").await.expect("execution failed");
6992 eprintln!("uname result: {:?}", result);
6993 // uname should succeed if external commands work
6994 assert!(result.ok() || result.code == 127, "uname: {:?}", result);
6995 }
6996
6997 #[tokio::test]
6998 async fn test_kernel_reset() {
6999 let kernel = Kernel::transient().expect("failed to create kernel");
7000
7001 kernel.execute("X=1").await.expect("set failed");
7002 assert!(kernel.get_var("X").await.is_some());
7003
7004 kernel.reset().await.expect("reset failed");
7005 assert!(kernel.get_var("X").await.is_none());
7006 }
7007
7008 #[tokio::test]
7009 async fn test_kernel_reset_preserves_latch_and_trash_config() {
7010 // An embedder configuring `with_latch(true)` must not have the
7011 // confirmation gate silently disabled by a later `reset()` — that
7012 // would let a destructive command through with no nonce and no
7013 // error, exactly the "silent fallback" the latch exists to prevent.
7014 let kernel = Kernel::new(
7015 KernelConfig::transient()
7016 .with_latch(true)
7017 .with_skip_validation(true),
7018 )
7019 .expect("failed to create kernel");
7020
7021 // Write and rm relative to `/` (reset()'s post-reset cwd) so the file
7022 // is reachable identically before and after reset.
7023 kernel.execute("cd /; echo hi > latch-probe.txt").await.expect("setup write failed");
7024
7025 let before = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
7026 assert_eq!(before.code, 2, "latch should require confirmation before reset: {before:?}");
7027
7028 kernel.reset().await.expect("reset failed");
7029
7030 // reset() only clears scope/cwd (to `/`), not the VFS — the
7031 // un-deleted probe file (the latch blocked the delete above) is
7032 // still there.
7033 let after = kernel.execute("rm latch-probe.txt").await.expect("execute failed");
7034 assert_eq!(
7035 after.code, 2,
7036 "latch must still require confirmation after reset, not silently disable: {after:?}"
7037 );
7038 }
7039
7040 #[tokio::test]
7041 async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7042 let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7043 .expect("failed to create kernel");
7044
7045 let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7046 assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7047
7048 kernel.reset().await.expect("reset failed");
7049
7050 let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7051 assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7052 assert_eq!(
7053 kernel.get_var("HOME").await,
7054 Some(Value::String("/home/probe".into())),
7055 "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7056 );
7057 }
7058
7059 #[tokio::test]
7060 async fn test_kernel_cwd() {
7061 let kernel = Kernel::transient().expect("failed to create kernel");
7062
7063 // Transient kernel uses sandboxed mode with cwd=$HOME
7064 let cwd = kernel.cwd().await;
7065 let home = std::env::var("HOME")
7066 .map(PathBuf::from)
7067 .unwrap_or_else(|_| PathBuf::from("/"));
7068 assert_eq!(cwd, home);
7069
7070 kernel.set_cwd(PathBuf::from("/tmp")).await;
7071 assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7072 }
7073
7074 #[tokio::test]
7075 async fn test_kernel_list_vars() {
7076 let kernel = Kernel::transient().expect("failed to create kernel");
7077
7078 kernel.execute("A=1").await.ok();
7079 kernel.execute("B=2").await.ok();
7080
7081 let vars = kernel.list_vars().await;
7082 assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7083 assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7084 }
7085
7086 #[tokio::test]
7087 async fn test_is_truthy() {
7088 assert!(!is_truthy(&Value::Null));
7089 assert!(!is_truthy(&Value::Bool(false)));
7090 assert!(is_truthy(&Value::Bool(true)));
7091 assert!(!is_truthy(&Value::Int(0)));
7092 assert!(is_truthy(&Value::Int(1)));
7093 assert!(!is_truthy(&Value::String("".into())));
7094 assert!(is_truthy(&Value::String("x".into())));
7095 }
7096
7097 #[tokio::test]
7098 async fn test_jq_in_pipeline() {
7099 let kernel = Kernel::transient().expect("failed to create kernel");
7100 // kaish uses double quotes only; escape inner quotes
7101 let result = kernel
7102 .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7103 .await
7104 .expect("execution failed");
7105 assert!(result.ok(), "jq pipeline failed: {}", result.err);
7106 assert_eq!(result.text_out().trim(), "Alice");
7107 }
7108
7109 #[tokio::test]
7110 async fn test_user_defined_tool() {
7111 let kernel = Kernel::transient().expect("failed to create kernel");
7112
7113 // Define a function
7114 kernel
7115 .execute(r#"greet() { echo "Hello, $1!" }"#)
7116 .await
7117 .expect("function definition failed");
7118
7119 // Call the function
7120 let result = kernel
7121 .execute(r#"greet "World""#)
7122 .await
7123 .expect("function call failed");
7124
7125 assert!(result.ok(), "greet failed: {}", result.err);
7126 assert_eq!(result.text_out().trim(), "Hello, World!");
7127 }
7128
7129 #[tokio::test]
7130 async fn test_user_tool_positional_args() {
7131 let kernel = Kernel::transient().expect("failed to create kernel");
7132
7133 // Define a function with positional param
7134 kernel
7135 .execute(r#"greet() { echo "Hi $1" }"#)
7136 .await
7137 .expect("function definition failed");
7138
7139 // Call with positional argument
7140 let result = kernel
7141 .execute(r#"greet "Amy""#)
7142 .await
7143 .expect("function call failed");
7144
7145 assert!(result.ok(), "greet failed: {}", result.err);
7146 assert_eq!(result.text_out().trim(), "Hi Amy");
7147 }
7148
7149 #[tokio::test]
7150 async fn test_function_shared_scope() {
7151 let kernel = Kernel::transient().expect("failed to create kernel");
7152
7153 // Set a variable in parent scope
7154 kernel
7155 .execute(r#"SECRET="hidden""#)
7156 .await
7157 .expect("set failed");
7158
7159 // Define a function that accesses and modifies parent variable
7160 kernel
7161 .execute(r#"access_parent() {
7162 echo "${SECRET}"
7163 SECRET="modified"
7164 }"#)
7165 .await
7166 .expect("function definition failed");
7167
7168 // Call the function - it SHOULD see SECRET (shared scope like sh)
7169 let result = kernel.execute("access_parent").await.expect("function call failed");
7170
7171 // Function should have access to parent scope
7172 assert!(
7173 result.text_out().contains("hidden"),
7174 "Function should access parent scope, got: {}",
7175 result.text_out()
7176 );
7177
7178 // Function should have modified the parent variable
7179 let secret = kernel.get_var("SECRET").await;
7180 assert_eq!(
7181 secret,
7182 Some(Value::String("modified".into())),
7183 "Function should modify parent scope"
7184 );
7185 }
7186
7187 #[tokio::test]
7188 #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7189 async fn test_exec_builtin() {
7190 let kernel = Kernel::transient().expect("failed to create kernel");
7191 // argv is now a space-separated string or JSON array string
7192 let result = kernel
7193 .execute(r#"exec command="/bin/echo" argv="hello world""#)
7194 .await
7195 .expect("exec failed");
7196
7197 assert!(result.ok(), "exec failed: {}", result.err);
7198 assert_eq!(result.text_out().trim(), "hello world");
7199 }
7200
7201 #[tokio::test]
7202 async fn test_while_false_never_runs() {
7203 let kernel = Kernel::transient().expect("failed to create kernel");
7204
7205 // A while loop with false condition should never run
7206 let result = kernel
7207 .execute(r#"
7208 while false; do
7209 echo "should not run"
7210 done
7211 "#)
7212 .await
7213 .expect("while false failed");
7214
7215 assert!(result.ok());
7216 assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7217 }
7218
7219 #[tokio::test]
7220 async fn test_while_string_comparison() {
7221 let kernel = Kernel::transient().expect("failed to create kernel");
7222
7223 // Set a flag
7224 kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7225
7226 // Use string comparison as condition (shell-compatible [[ ]] syntax)
7227 // Note: Put echo last so we can check the output
7228 let result = kernel
7229 .execute(r#"
7230 while [[ ${FLAG} == "go" ]]; do
7231 FLAG="stop"
7232 echo "running"
7233 done
7234 "#)
7235 .await
7236 .expect("while with string cmp failed");
7237
7238 assert!(result.ok());
7239 assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7240
7241 // Verify flag was changed
7242 let flag = kernel.get_var("FLAG").await;
7243 assert_eq!(flag, Some(Value::String("stop".into())));
7244 }
7245
7246 #[tokio::test]
7247 async fn test_while_numeric_comparison() {
7248 let kernel = Kernel::transient().expect("failed to create kernel");
7249
7250 // Test > comparison (shell-compatible [[ ]] with -gt)
7251 kernel.execute("N=5").await.expect("set failed");
7252
7253 // Note: Put echo last so we can check the output
7254 let result = kernel
7255 .execute(r#"
7256 while [[ ${N} -gt 3 ]]; do
7257 N=3
7258 echo "N was greater"
7259 done
7260 "#)
7261 .await
7262 .expect("while with > failed");
7263
7264 assert!(result.ok());
7265 assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7266 }
7267
7268 #[tokio::test]
7269 async fn test_break_in_while_loop() {
7270 let kernel = Kernel::transient().expect("failed to create kernel");
7271
7272 let result = kernel
7273 .execute(r#"
7274 I=0
7275 while true; do
7276 I=1
7277 echo "before break"
7278 break
7279 echo "after break"
7280 done
7281 "#)
7282 .await
7283 .expect("while with break failed");
7284
7285 assert!(result.ok());
7286 assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7287 assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7288
7289 // Verify we exited the loop
7290 let i = kernel.get_var("I").await;
7291 assert_eq!(i, Some(Value::Int(1)));
7292 }
7293
7294 #[tokio::test]
7295 async fn test_continue_in_while_loop() {
7296 let kernel = Kernel::transient().expect("failed to create kernel");
7297
7298 // Test continue in a while loop where variables persist
7299 // We use string state transition: "start" -> "middle" -> "end"
7300 // continue on "middle" should skip to next iteration
7301 // Shell-compatible: use [[ ]] for comparisons
7302 let result = kernel
7303 .execute(r#"
7304 STATE="start"
7305 AFTER_CONTINUE="no"
7306 while [[ ${STATE} != "done" ]]; do
7307 if [[ ${STATE} == "start" ]]; then
7308 STATE="middle"
7309 continue
7310 AFTER_CONTINUE="yes"
7311 fi
7312 if [[ ${STATE} == "middle" ]]; then
7313 STATE="done"
7314 fi
7315 done
7316 "#)
7317 .await
7318 .expect("while with continue failed");
7319
7320 assert!(result.ok());
7321
7322 // STATE should be "done" (we completed the loop)
7323 let state = kernel.get_var("STATE").await;
7324 assert_eq!(state, Some(Value::String("done".into())));
7325
7326 // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7327 let after = kernel.get_var("AFTER_CONTINUE").await;
7328 assert_eq!(after, Some(Value::String("no".into())));
7329 }
7330
7331 #[tokio::test]
7332 async fn test_break_with_level() {
7333 let kernel = Kernel::transient().expect("failed to create kernel");
7334
7335 // Nested loop with break 2 to exit both loops
7336 // We verify by checking OUTER value:
7337 // - If break 2 works, OUTER stays at 1 (set before for loop)
7338 // - If break 2 fails, OUTER becomes 2 (set after for loop)
7339 let result = kernel
7340 .execute(r#"
7341 OUTER=0
7342 while true; do
7343 OUTER=1
7344 for X in "1 2"; do
7345 break 2
7346 done
7347 OUTER=2
7348 done
7349 "#)
7350 .await
7351 .expect("nested break failed");
7352
7353 assert!(result.ok());
7354
7355 // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7356 let outer = kernel.get_var("OUTER").await;
7357 assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7358 }
7359
7360 #[tokio::test]
7361 async fn test_return_from_tool() {
7362 let kernel = Kernel::transient().expect("failed to create kernel");
7363
7364 // Define a function that returns early
7365 kernel
7366 .execute(r#"early_return() {
7367 if [[ $1 == 1 ]]; then
7368 return 42
7369 fi
7370 echo "not returned"
7371 }"#)
7372 .await
7373 .expect("function definition failed");
7374
7375 // Call with arg=1 should return with exit code 42
7376 // (POSIX shell behavior: return N sets exit code, doesn't output N)
7377 let result = kernel
7378 .execute("early_return 1")
7379 .await
7380 .expect("function call failed");
7381
7382 // Exit code should be 42 (non-zero, so not ok())
7383 assert_eq!(result.code, 42);
7384 // Output should be empty (we returned before echo)
7385 assert!(result.text_out().is_empty());
7386 }
7387
7388 #[tokio::test]
7389 async fn test_return_without_value() {
7390 let kernel = Kernel::transient().expect("failed to create kernel");
7391
7392 // Define a function that returns without a value
7393 kernel
7394 .execute(r#"early_exit() {
7395 if [[ $1 == "stop" ]]; then
7396 return
7397 fi
7398 echo "continued"
7399 }"#)
7400 .await
7401 .expect("function definition failed");
7402
7403 // Call with arg="stop" should return early
7404 let result = kernel
7405 .execute(r#"early_exit "stop""#)
7406 .await
7407 .expect("function call failed");
7408
7409 assert!(result.ok());
7410 assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7411 }
7412
7413 #[tokio::test]
7414 async fn test_exit_stops_execution() {
7415 let kernel = Kernel::transient().expect("failed to create kernel");
7416
7417 // exit should stop further execution
7418 kernel
7419 .execute(r#"
7420 BEFORE="yes"
7421 exit 0
7422 AFTER="yes"
7423 "#)
7424 .await
7425 .expect("execution failed");
7426
7427 // BEFORE should be set, AFTER should not
7428 let before = kernel.get_var("BEFORE").await;
7429 assert_eq!(before, Some(Value::String("yes".into())));
7430
7431 let after = kernel.get_var("AFTER").await;
7432 assert!(after.is_none(), "AFTER should not be set after exit");
7433 }
7434
7435 #[tokio::test]
7436 async fn test_exit_with_code() {
7437 let kernel = Kernel::transient().expect("failed to create kernel");
7438
7439 // exit with code should propagate the exit code
7440 let result = kernel
7441 .execute("exit 42")
7442 .await
7443 .expect("exit failed");
7444
7445 assert_eq!(result.code, 42);
7446 assert!(result.text_out().is_empty(), "exit should not produce stdout");
7447 }
7448
7449 #[tokio::test]
7450 async fn test_set_e_stops_on_failure() {
7451 let kernel = Kernel::transient().expect("failed to create kernel");
7452
7453 // Enable error-exit mode
7454 kernel.execute("set -e").await.expect("set -e failed");
7455
7456 // Run a sequence where the middle command fails
7457 kernel
7458 .execute(r#"
7459 STEP1="done"
7460 false
7461 STEP2="done"
7462 "#)
7463 .await
7464 .expect("execution failed");
7465
7466 // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7467 let step1 = kernel.get_var("STEP1").await;
7468 assert_eq!(step1, Some(Value::String("done".into())));
7469
7470 let step2 = kernel.get_var("STEP2").await;
7471 assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7472 }
7473
7474 #[tokio::test]
7475 async fn test_set_plus_e_disables_error_exit() {
7476 let kernel = Kernel::transient().expect("failed to create kernel");
7477
7478 // Enable then disable error-exit mode
7479 kernel.execute("set -e").await.expect("set -e failed");
7480 kernel.execute("set +e").await.expect("set +e failed");
7481
7482 // Now failure should NOT stop execution
7483 kernel
7484 .execute(r#"
7485 STEP1="done"
7486 false
7487 STEP2="done"
7488 "#)
7489 .await
7490 .expect("execution failed");
7491
7492 // Both should be set since +e disables error exit
7493 let step1 = kernel.get_var("STEP1").await;
7494 assert_eq!(step1, Some(Value::String("done".into())));
7495
7496 let step2 = kernel.get_var("STEP2").await;
7497 assert_eq!(step2, Some(Value::String("done".into())));
7498 }
7499
7500 #[tokio::test]
7501 async fn test_set_ignores_unknown_options() {
7502 let kernel = Kernel::transient().expect("failed to create kernel");
7503
7504 // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7505 let result = kernel
7506 .execute("set -e -u -o pipefail")
7507 .await
7508 .expect("set with unknown options failed");
7509
7510 assert!(result.ok(), "set should succeed with unknown options");
7511
7512 // -e should still be enabled
7513 kernel
7514 .execute(r#"
7515 BEFORE="yes"
7516 false
7517 AFTER="yes"
7518 "#)
7519 .await
7520 .ok();
7521
7522 let after = kernel.get_var("AFTER").await;
7523 assert!(after.is_none(), "-e should be enabled despite unknown options");
7524 }
7525
7526 #[tokio::test]
7527 async fn test_set_no_args_shows_settings() {
7528 let kernel = Kernel::transient().expect("failed to create kernel");
7529
7530 // Enable -e
7531 kernel.execute("set -e").await.expect("set -e failed");
7532
7533 // Call set with no args to see settings
7534 let result = kernel.execute("set").await.expect("set failed");
7535
7536 assert!(result.ok());
7537 assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7538 }
7539
7540 #[tokio::test]
7541 async fn test_set_e_in_pipeline() {
7542 let kernel = Kernel::transient().expect("failed to create kernel");
7543
7544 kernel.execute("set -e").await.expect("set -e failed");
7545
7546 // Pipeline failure should trigger exit
7547 kernel
7548 .execute(r#"
7549 BEFORE="yes"
7550 false | cat
7551 AFTER="yes"
7552 "#)
7553 .await
7554 .ok();
7555
7556 let before = kernel.get_var("BEFORE").await;
7557 assert_eq!(before, Some(Value::String("yes".into())));
7558
7559 // AFTER should not be set if pipeline failure triggers exit
7560 // Note: The exit code of a pipeline is the exit code of the last command
7561 // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7562 // To test pipeline failure, we need the last command to fail.
7563 }
7564
7565 #[tokio::test]
7566 async fn test_set_e_with_and_chain() {
7567 let kernel = Kernel::transient().expect("failed to create kernel");
7568
7569 kernel.execute("set -e").await.expect("set -e failed");
7570
7571 // Commands in && chain should not trigger -e on the first failure
7572 // because && explicitly handles the error
7573 kernel
7574 .execute(r#"
7575 RESULT="initial"
7576 false && RESULT="chained"
7577 RESULT="continued"
7578 "#)
7579 .await
7580 .ok();
7581
7582 // In bash, commands in && don't trigger -e. The chain handles the failure.
7583 // Our implementation may differ - let's verify current behavior.
7584 let result = kernel.get_var("RESULT").await;
7585 // If we follow bash semantics, RESULT should be "continued"
7586 // If we trigger -e on the false, RESULT stays "initial"
7587 assert!(result.is_some(), "RESULT should be set");
7588 }
7589
7590 #[tokio::test]
7591 async fn test_set_e_exits_in_for_loop() {
7592 let kernel = Kernel::transient().expect("failed to create kernel");
7593
7594 kernel.execute("set -e").await.expect("set -e failed");
7595
7596 kernel
7597 .execute(r#"
7598 REACHED="no"
7599 for x in 1 2 3; do
7600 false
7601 REACHED="yes"
7602 done
7603 "#)
7604 .await
7605 .ok();
7606
7607 // With set -e, false should trigger exit; REACHED should remain "no"
7608 let reached = kernel.get_var("REACHED").await;
7609 assert_eq!(reached, Some(Value::String("no".into())),
7610 "set -e should exit on failure in for loop body");
7611 }
7612
7613 #[tokio::test]
7614 async fn test_for_loop_continues_without_set_e() {
7615 let kernel = Kernel::transient().expect("failed to create kernel");
7616
7617 // Without set -e, for loop should continue normally
7618 kernel
7619 .execute(r#"
7620 COUNT=0
7621 for x in 1 2 3; do
7622 false
7623 COUNT=$((COUNT + 1))
7624 done
7625 "#)
7626 .await
7627 .ok();
7628
7629 let count = kernel.get_var("COUNT").await;
7630 // Arithmetic produces Int values; accept either Int or String representation
7631 let count_val = match &count {
7632 Some(Value::Int(n)) => *n,
7633 Some(Value::String(s)) => s.parse().unwrap_or(-1),
7634 _ => -1,
7635 };
7636 assert_eq!(count_val, 3,
7637 "without set -e, loop should complete all iterations (got {:?})", count);
7638 }
7639
7640 // ═══════════════════════════════════════════════════════════════════════════
7641 // Source Tests
7642 // ═══════════════════════════════════════════════════════════════════════════
7643
7644 #[tokio::test]
7645 async fn test_source_sets_variables() {
7646 let kernel = Kernel::transient().expect("failed to create kernel");
7647
7648 // Write a script to the VFS
7649 kernel
7650 .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7651 .await
7652 .expect("write failed");
7653
7654 // Source the script
7655 let result = kernel
7656 .execute(r#"source "/test.kai""#)
7657 .await
7658 .expect("source failed");
7659
7660 assert!(result.ok(), "source should succeed");
7661
7662 // Variable should be set in current scope
7663 let foo = kernel.get_var("FOO").await;
7664 assert_eq!(foo, Some(Value::String("bar".into())));
7665 }
7666
7667 #[tokio::test]
7668 async fn test_source_with_dot_alias() {
7669 let kernel = Kernel::transient().expect("failed to create kernel");
7670
7671 // Write a script to the VFS
7672 kernel
7673 .execute(r#"write "/vars.kai" 'X=42'"#)
7674 .await
7675 .expect("write failed");
7676
7677 // Source using . alias
7678 let result = kernel
7679 .execute(r#". "/vars.kai""#)
7680 .await
7681 .expect(". failed");
7682
7683 assert!(result.ok(), ". should succeed");
7684
7685 // Variable should be set in current scope
7686 let x = kernel.get_var("X").await;
7687 assert_eq!(x, Some(Value::Int(42)));
7688 }
7689
7690 #[tokio::test]
7691 async fn test_source_not_found() {
7692 let kernel = Kernel::transient().expect("failed to create kernel");
7693
7694 // Try to source a non-existent file
7695 let result = kernel
7696 .execute(r#"source "/nonexistent.kai""#)
7697 .await
7698 .expect("source should not fail with error");
7699
7700 assert!(!result.ok(), "source of non-existent file should fail");
7701 assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7702 }
7703
7704 #[tokio::test]
7705 async fn test_source_missing_filename() {
7706 let kernel = Kernel::transient().expect("failed to create kernel");
7707
7708 // Call source with no arguments
7709 let result = kernel
7710 .execute("source")
7711 .await
7712 .expect("source should not fail with error");
7713
7714 assert!(!result.ok(), "source without filename should fail");
7715 assert!(result.err.contains("missing filename"), "error should mention missing filename");
7716 }
7717
7718 #[tokio::test]
7719 async fn test_source_executes_multiple_statements() {
7720 let kernel = Kernel::transient().expect("failed to create kernel");
7721
7722 // Write a script with multiple statements
7723 kernel
7724 .execute(r#"write "/multi.kai" 'A=1
7725B=2
7726C=3'"#)
7727 .await
7728 .expect("write failed");
7729
7730 // Source it
7731 kernel
7732 .execute(r#"source "/multi.kai""#)
7733 .await
7734 .expect("source failed");
7735
7736 // All variables should be set
7737 assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7738 assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7739 assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7740 }
7741
7742 #[tokio::test]
7743 async fn test_source_can_define_functions() {
7744 let kernel = Kernel::transient().expect("failed to create kernel");
7745
7746 // Write a script that defines a function
7747 kernel
7748 .execute(r#"write "/functions.kai" 'greet() {
7749 echo "Hello, $1!"
7750}'"#)
7751 .await
7752 .expect("write failed");
7753
7754 // Source it
7755 kernel
7756 .execute(r#"source "/functions.kai""#)
7757 .await
7758 .expect("source failed");
7759
7760 // Use the defined function
7761 let result = kernel
7762 .execute(r#"greet "World""#)
7763 .await
7764 .expect("greet failed");
7765
7766 assert!(result.ok());
7767 assert!(result.text_out().contains("Hello, World!"));
7768 }
7769
7770 #[tokio::test]
7771 async fn test_source_inherits_error_exit() {
7772 let kernel = Kernel::transient().expect("failed to create kernel");
7773
7774 // Enable error exit
7775 kernel.execute("set -e").await.expect("set -e failed");
7776
7777 // Write a script that has a failure
7778 kernel
7779 .execute(r#"write "/fail.kai" 'BEFORE="yes"
7780false
7781AFTER="yes"'"#)
7782 .await
7783 .expect("write failed");
7784
7785 // Source it (should exit on false due to set -e)
7786 kernel
7787 .execute(r#"source "/fail.kai""#)
7788 .await
7789 .ok();
7790
7791 // BEFORE should be set, AFTER should NOT be set due to error exit
7792 let before = kernel.get_var("BEFORE").await;
7793 assert_eq!(before, Some(Value::String("yes".into())));
7794
7795 // Note: This test depends on whether error exit is checked within source
7796 // Currently our implementation checks per-statement in the main kernel
7797 }
7798
7799 // ═══════════════════════════════════════════════════════════════════════════
7800 // set -e with && / || chains
7801 // ═══════════════════════════════════════════════════════════════════════════
7802
7803 #[tokio::test]
7804 async fn test_set_e_and_chain_left_fails() {
7805 // set -e; false && echo hi; REACHED=1 → REACHED should be set
7806 let kernel = Kernel::transient().expect("failed to create kernel");
7807 kernel.execute("set -e").await.expect("set -e failed");
7808
7809 kernel
7810 .execute("false && echo hi; REACHED=1")
7811 .await
7812 .expect("execution failed");
7813
7814 let reached = kernel.get_var("REACHED").await;
7815 assert_eq!(
7816 reached,
7817 Some(Value::Int(1)),
7818 "set -e should not trigger on left side of &&"
7819 );
7820 }
7821
7822 #[tokio::test]
7823 async fn test_set_e_and_chain_right_fails() {
7824 // set -e; true && false; REACHED=1 → REACHED should NOT be set
7825 let kernel = Kernel::transient().expect("failed to create kernel");
7826 kernel.execute("set -e").await.expect("set -e failed");
7827
7828 kernel
7829 .execute("true && false; REACHED=1")
7830 .await
7831 .expect("execution failed");
7832
7833 let reached = kernel.get_var("REACHED").await;
7834 assert!(
7835 reached.is_none(),
7836 "set -e should trigger when right side of && fails"
7837 );
7838 }
7839
7840 #[tokio::test]
7841 async fn test_set_e_or_chain_recovers() {
7842 // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7843 let kernel = Kernel::transient().expect("failed to create kernel");
7844 kernel.execute("set -e").await.expect("set -e failed");
7845
7846 kernel
7847 .execute("false || echo recovered; REACHED=1")
7848 .await
7849 .expect("execution failed");
7850
7851 let reached = kernel.get_var("REACHED").await;
7852 assert_eq!(
7853 reached,
7854 Some(Value::Int(1)),
7855 "set -e should not trigger when || recovers the failure"
7856 );
7857 }
7858
7859 #[tokio::test]
7860 async fn test_set_e_or_chain_both_fail() {
7861 // set -e; false || false; REACHED=1 → REACHED should NOT be set
7862 let kernel = Kernel::transient().expect("failed to create kernel");
7863 kernel.execute("set -e").await.expect("set -e failed");
7864
7865 kernel
7866 .execute("false || false; REACHED=1")
7867 .await
7868 .expect("execution failed");
7869
7870 let reached = kernel.get_var("REACHED").await;
7871 assert!(
7872 reached.is_none(),
7873 "set -e should trigger when || chain ultimately fails"
7874 );
7875 }
7876
7877 // ═══════════════════════════════════════════════════════════════════════════
7878 // Cancellation Tests
7879 // ═══════════════════════════════════════════════════════════════════════════
7880
7881 /// Helper: schedule a cancel after a delay from a background thread.
7882 /// Uses std::thread because cancel() is sync and Kernel is not Send.
7883 fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
7884 let k = Arc::clone(kernel);
7885 std::thread::spawn(move || {
7886 std::thread::sleep(delay);
7887 k.cancel();
7888 });
7889 }
7890
7891 #[tokio::test]
7892 async fn test_cancel_interrupts_for_loop() {
7893 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7894
7895 // Schedule cancel after a short delay from a background OS thread
7896 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7897
7898 // #149: a bare `X=$i` body has no await point, so the for-loop's
7899 // cancellation checkpoint (checked once per iteration, see the
7900 // `Stmt::For` arm above) never gets a chance to run mid-body — under
7901 // host load, 100_000 trivial iterations could complete and return
7902 // before the background thread's 10ms sleep ever elapsed, racing a
7903 // natural exit-0 completion against the scheduled cancel. Rather than
7904 // widen the margin (there's no bound on how slow "under load" can be),
7905 // make completion deterministically impossible inside the test
7906 // window: `sleep` is a real interruptible await point (it races
7907 // `tokio::time::sleep` against the same cancellation token — see
7908 // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
7909 // cancellation somewhere to land almost immediately AND, at enough
7910 // iterations, makes natural completion take far longer than the
7911 // bounded wait below. The outer timeout is the "must not hang CI if
7912 // cancellation is broken" backstop: it fails loudly well before the
7913 // loop could ever finish on its own.
7914 const ITERATIONS: u32 = 2000;
7915 const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
7916 let bound = std::time::Duration::from_secs(10);
7917 let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
7918
7919 let result = tokio::time::timeout(bound, kernel.execute(&script))
7920 .await
7921 .unwrap_or_else(|_| {
7922 panic!(
7923 "for-loop did not return within {bound:?} — cancellation support looks \
7924 broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
7925 longer than this bound)",
7926 ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
7927 )
7928 })
7929 .expect("execute failed");
7930
7931 assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
7932
7933 // The loop variable should be set to something well short of the full
7934 // iteration count — i.e. cancellation landed long before the loop
7935 // could complete on its own.
7936 let x = kernel.get_var("X").await;
7937 if let Some(Value::Int(n)) = x {
7938 assert!(
7939 n < i64::from(ITERATIONS),
7940 "loop should have been interrupted before finishing, got X={n}"
7941 );
7942 }
7943 }
7944
7945 #[tokio::test]
7946 async fn test_cancel_interrupts_while_loop() {
7947 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7948 kernel.execute("COUNT=0").await.expect("init failed");
7949
7950 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
7951
7952 let result = kernel
7953 .execute("while true; do COUNT=$((COUNT + 1)); done")
7954 .await
7955 .expect("execute failed");
7956
7957 assert_eq!(result.code, 130);
7958
7959 let count = kernel.get_var("COUNT").await;
7960 if let Some(Value::Int(n)) = count {
7961 assert!(n > 0, "loop should have run at least once");
7962 }
7963 }
7964
7965 #[tokio::test]
7966 async fn test_reset_after_cancel() {
7967 // After cancellation, the next execute() should work normally
7968 let kernel = Kernel::transient().expect("failed to create kernel");
7969 kernel.cancel(); // cancel with nothing running
7970
7971 let result = kernel.execute("echo hello").await.expect("execute failed");
7972 assert!(result.ok(), "execute after cancel should succeed");
7973 assert_eq!(result.text_out().trim(), "hello");
7974 }
7975
7976 #[tokio::test]
7977 async fn test_cancel_interrupts_statement_sequence() {
7978 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
7979
7980 // Schedule cancel after the first statement runs but before sleep finishes
7981 schedule_cancel(&kernel, std::time::Duration::from_millis(50));
7982
7983 let result = kernel
7984 .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
7985 .await
7986 .expect("execute failed");
7987
7988 assert_eq!(result.code, 130);
7989
7990 // STEP should be 1 (set before sleep), not 2 or 3
7991 let step = kernel.get_var("STEP").await;
7992 assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
7993 }
7994
7995 // ═══════════════════════════════════════════════════════════════════════════
7996 // Case Statement Tests
7997 // ═══════════════════════════════════════════════════════════════════════════
7998
7999 #[tokio::test]
8000 async fn test_case_simple_match() {
8001 let kernel = Kernel::transient().expect("failed to create kernel");
8002
8003 let result = kernel
8004 .execute(r#"
8005 case "hello" in
8006 hello) echo "matched hello" ;;
8007 world) echo "matched world" ;;
8008 esac
8009 "#)
8010 .await
8011 .expect("case failed");
8012
8013 assert!(result.ok());
8014 assert_eq!(result.text_out().trim(), "matched hello");
8015 }
8016
8017 #[tokio::test]
8018 async fn test_case_wildcard_match() {
8019 let kernel = Kernel::transient().expect("failed to create kernel");
8020
8021 let result = kernel
8022 .execute(r#"
8023 case "main.rs" in
8024 *.py) echo "Python" ;;
8025 *.rs) echo "Rust" ;;
8026 *) echo "Unknown" ;;
8027 esac
8028 "#)
8029 .await
8030 .expect("case failed");
8031
8032 assert!(result.ok());
8033 assert_eq!(result.text_out().trim(), "Rust");
8034 }
8035
8036 #[tokio::test]
8037 async fn test_case_default_match() {
8038 let kernel = Kernel::transient().expect("failed to create kernel");
8039
8040 let result = kernel
8041 .execute(r#"
8042 case "unknown.xyz" in
8043 *.py) echo "Python" ;;
8044 *.rs) echo "Rust" ;;
8045 *) echo "Default" ;;
8046 esac
8047 "#)
8048 .await
8049 .expect("case failed");
8050
8051 assert!(result.ok());
8052 assert_eq!(result.text_out().trim(), "Default");
8053 }
8054
8055 #[tokio::test]
8056 async fn test_case_no_match() {
8057 let kernel = Kernel::transient().expect("failed to create kernel");
8058
8059 // Case with no default branch and no match
8060 let result = kernel
8061 .execute(r#"
8062 case "nope" in
8063 "yes") echo "yes" ;;
8064 "no") echo "no" ;;
8065 esac
8066 "#)
8067 .await
8068 .expect("case failed");
8069
8070 assert!(result.ok());
8071 assert!(result.text_out().is_empty(), "no match should produce empty output");
8072 }
8073
8074 #[tokio::test]
8075 async fn test_case_with_variable() {
8076 let kernel = Kernel::transient().expect("failed to create kernel");
8077
8078 kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8079
8080 let result = kernel
8081 .execute(r#"
8082 case ${LANG} in
8083 python) echo "snake" ;;
8084 rust) echo "crab" ;;
8085 go) echo "gopher" ;;
8086 esac
8087 "#)
8088 .await
8089 .expect("case failed");
8090
8091 assert!(result.ok());
8092 assert_eq!(result.text_out().trim(), "crab");
8093 }
8094
8095 #[tokio::test]
8096 async fn test_case_multiple_patterns() {
8097 let kernel = Kernel::transient().expect("failed to create kernel");
8098
8099 let result = kernel
8100 .execute(r#"
8101 case "yes" in
8102 "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8103 "n"|"no"|"N"|"NO") echo "negative" ;;
8104 esac
8105 "#)
8106 .await
8107 .expect("case failed");
8108
8109 assert!(result.ok());
8110 assert_eq!(result.text_out().trim(), "affirmative");
8111 }
8112
8113 #[tokio::test]
8114 async fn test_case_glob_question_mark() {
8115 let kernel = Kernel::transient().expect("failed to create kernel");
8116
8117 let result = kernel
8118 .execute(r#"
8119 case "test1" in
8120 test?) echo "matched test?" ;;
8121 *) echo "default" ;;
8122 esac
8123 "#)
8124 .await
8125 .expect("case failed");
8126
8127 assert!(result.ok());
8128 assert_eq!(result.text_out().trim(), "matched test?");
8129 }
8130
8131 #[tokio::test]
8132 async fn test_case_char_class() {
8133 let kernel = Kernel::transient().expect("failed to create kernel");
8134
8135 let result = kernel
8136 .execute(r#"
8137 case "Yes" in
8138 [Yy]*) echo "yes-like" ;;
8139 [Nn]*) echo "no-like" ;;
8140 esac
8141 "#)
8142 .await
8143 .expect("case failed");
8144
8145 assert!(result.ok());
8146 assert_eq!(result.text_out().trim(), "yes-like");
8147 }
8148
8149 // ═══════════════════════════════════════════════════════════════════════════
8150 // Cat Stdin Tests
8151 // ═══════════════════════════════════════════════════════════════════════════
8152
8153 #[tokio::test]
8154 async fn test_cat_from_pipeline() {
8155 let kernel = Kernel::transient().expect("failed to create kernel");
8156
8157 let result = kernel
8158 .execute(r#"echo "piped text" | cat"#)
8159 .await
8160 .expect("cat pipeline failed");
8161
8162 assert!(result.ok(), "cat failed: {}", result.err);
8163 assert_eq!(result.text_out().trim(), "piped text");
8164 }
8165
8166 #[tokio::test]
8167 async fn test_cat_from_pipeline_multiline() {
8168 let kernel = Kernel::transient().expect("failed to create kernel");
8169
8170 let result = kernel
8171 .execute(r#"echo "line1\nline2" | cat -n"#)
8172 .await
8173 .expect("cat pipeline failed");
8174
8175 assert!(result.ok(), "cat failed: {}", result.err);
8176 assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8177 }
8178
8179 // ═══════════════════════════════════════════════════════════════════════════
8180 // Heredoc Tests
8181 // ═══════════════════════════════════════════════════════════════════════════
8182
8183 #[tokio::test]
8184 async fn test_heredoc_basic() {
8185 let kernel = Kernel::transient().expect("failed to create kernel");
8186
8187 let result = kernel
8188 .execute("cat <<EOF\nhello\nEOF")
8189 .await
8190 .expect("heredoc failed");
8191
8192 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8193 assert_eq!(result.text_out().trim(), "hello");
8194 }
8195
8196 #[tokio::test]
8197 async fn test_arithmetic_in_string() {
8198 let kernel = Kernel::transient().expect("failed to create kernel");
8199
8200 let result = kernel
8201 .execute(r#"echo "result: $((1 + 2))""#)
8202 .await
8203 .expect("arithmetic in string failed");
8204
8205 assert!(result.ok(), "echo failed: {}", result.err);
8206 assert_eq!(result.text_out().trim(), "result: 3");
8207 }
8208
8209 #[tokio::test]
8210 async fn test_heredoc_multiline() {
8211 let kernel = Kernel::transient().expect("failed to create kernel");
8212
8213 let result = kernel
8214 .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8215 .await
8216 .expect("heredoc failed");
8217
8218 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8219 assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8220 assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8221 assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8222 }
8223
8224 #[tokio::test]
8225 async fn test_heredoc_variable_expansion() {
8226 // Bug N: unquoted heredoc should expand variables
8227 let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229 kernel.execute("GREETING=hello").await.expect("set var");
8230
8231 let result = kernel
8232 .execute("cat <<EOF\n$GREETING world\nEOF")
8233 .await
8234 .expect("heredoc expansion failed");
8235
8236 assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8237 assert_eq!(result.text_out().trim(), "hello world");
8238 }
8239
8240 #[tokio::test]
8241 async fn test_heredoc_quoted_no_expansion() {
8242 // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8243 let kernel = Kernel::transient().expect("failed to create kernel");
8244
8245 kernel.execute("GREETING=hello").await.expect("set var");
8246
8247 let result = kernel
8248 .execute("cat <<'EOF'\n$GREETING world\nEOF")
8249 .await
8250 .expect("quoted heredoc failed");
8251
8252 assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8253 assert_eq!(result.text_out().trim(), "$GREETING world");
8254 }
8255
8256 #[tokio::test]
8257 async fn test_heredoc_default_value_expansion() {
8258 // Bug N: ${VAR:-default} should expand in unquoted heredocs
8259 let kernel = Kernel::transient().expect("failed to create kernel");
8260
8261 let result = kernel
8262 .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8263 .await
8264 .expect("heredoc default expansion failed");
8265
8266 assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8267 assert_eq!(result.text_out().trim(), "fallback");
8268 }
8269
8270 // ═══════════════════════════════════════════════════════════════════════════
8271 // Read Builtin Tests
8272 // ═══════════════════════════════════════════════════════════════════════════
8273
8274 #[tokio::test]
8275 async fn test_read_from_pipeline() {
8276 let kernel = Kernel::transient().expect("failed to create kernel");
8277
8278 // Pipe input to read
8279 let result = kernel
8280 .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8281 .await
8282 .expect("read pipeline failed");
8283
8284 assert!(result.ok(), "read failed: {}", result.err);
8285 assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8286 }
8287
8288 #[tokio::test]
8289 async fn test_read_multiple_vars_from_pipeline() {
8290 let kernel = Kernel::transient().expect("failed to create kernel");
8291
8292 let result = kernel
8293 .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8294 .await
8295 .expect("read pipeline failed");
8296
8297 assert!(result.ok(), "read failed: {}", result.err);
8298 assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8299 }
8300
8301 // ═══════════════════════════════════════════════════════════════════════════
8302 // Shell-Style Function Tests
8303 // ═══════════════════════════════════════════════════════════════════════════
8304
8305 #[tokio::test]
8306 async fn test_posix_function_with_positional_params() {
8307 let kernel = Kernel::transient().expect("failed to create kernel");
8308
8309 // Define POSIX-style function
8310 kernel
8311 .execute(r#"greet() { echo "Hello, $1!" }"#)
8312 .await
8313 .expect("function definition failed");
8314
8315 // Call the function
8316 let result = kernel
8317 .execute(r#"greet "Amy""#)
8318 .await
8319 .expect("function call failed");
8320
8321 assert!(result.ok(), "greet failed: {}", result.err);
8322 assert_eq!(result.text_out().trim(), "Hello, Amy!");
8323 }
8324
8325 #[tokio::test]
8326 async fn test_posix_function_multiple_args() {
8327 let kernel = Kernel::transient().expect("failed to create kernel");
8328
8329 // Define function using $1 and $2
8330 kernel
8331 .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8332 .await
8333 .expect("function definition failed");
8334
8335 // Call the function
8336 let result = kernel
8337 .execute(r#"add_greeting "Hello" "World""#)
8338 .await
8339 .expect("function call failed");
8340
8341 assert!(result.ok(), "function failed: {}", result.err);
8342 assert_eq!(result.text_out().trim(), "Hello World!");
8343 }
8344
8345 #[tokio::test]
8346 async fn test_bash_function_with_positional_params() {
8347 let kernel = Kernel::transient().expect("failed to create kernel");
8348
8349 // Define bash-style function (function keyword, no parens)
8350 kernel
8351 .execute(r#"function greet { echo "Hi $1" }"#)
8352 .await
8353 .expect("function definition failed");
8354
8355 // Call the function
8356 let result = kernel
8357 .execute(r#"greet "Bob""#)
8358 .await
8359 .expect("function call failed");
8360
8361 assert!(result.ok(), "greet failed: {}", result.err);
8362 assert_eq!(result.text_out().trim(), "Hi Bob");
8363 }
8364
8365 #[tokio::test]
8366 async fn test_shell_function_with_all_args() {
8367 let kernel = Kernel::transient().expect("failed to create kernel");
8368
8369 // Define function using $@ (all args)
8370 kernel
8371 .execute(r#"echo_all() { echo "args: $@" }"#)
8372 .await
8373 .expect("function definition failed");
8374
8375 // Call with multiple args
8376 let result = kernel
8377 .execute(r#"echo_all "a" "b" "c""#)
8378 .await
8379 .expect("function call failed");
8380
8381 assert!(result.ok(), "function failed: {}", result.err);
8382 assert_eq!(result.text_out().trim(), "args: a b c");
8383 }
8384
8385 #[tokio::test]
8386 async fn test_shell_function_with_arg_count() {
8387 let kernel = Kernel::transient().expect("failed to create kernel");
8388
8389 // Define function using $# (arg count)
8390 kernel
8391 .execute(r#"count_args() { echo "count: $#" }"#)
8392 .await
8393 .expect("function definition failed");
8394
8395 // Call with three args
8396 let result = kernel
8397 .execute(r#"count_args "x" "y" "z""#)
8398 .await
8399 .expect("function call failed");
8400
8401 assert!(result.ok(), "function failed: {}", result.err);
8402 assert_eq!(result.text_out().trim(), "count: 3");
8403 }
8404
8405 #[tokio::test]
8406 async fn test_shell_function_shared_scope() {
8407 let kernel = Kernel::transient().expect("failed to create kernel");
8408
8409 // Set a variable in parent scope
8410 kernel
8411 .execute(r#"PARENT_VAR="visible""#)
8412 .await
8413 .expect("set failed");
8414
8415 // Define shell function that reads and writes parent variable
8416 kernel
8417 .execute(r#"modify_parent() {
8418 echo "saw: ${PARENT_VAR}"
8419 PARENT_VAR="changed by function"
8420 }"#)
8421 .await
8422 .expect("function definition failed");
8423
8424 // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8425 let result = kernel.execute("modify_parent").await.expect("function failed");
8426
8427 assert!(
8428 result.text_out().contains("visible"),
8429 "Shell function should access parent scope, got: {}",
8430 result.text_out()
8431 );
8432
8433 // Parent variable should be modified
8434 let var = kernel.get_var("PARENT_VAR").await;
8435 assert_eq!(
8436 var,
8437 Some(Value::String("changed by function".into())),
8438 "Shell function should modify parent scope"
8439 );
8440 }
8441
8442 // ═══════════════════════════════════════════════════════════════════════════
8443 // Script Execution via PATH Tests
8444 // ═══════════════════════════════════════════════════════════════════════════
8445
8446 #[tokio::test]
8447 async fn test_script_execution_from_path() {
8448 let kernel = Kernel::transient().expect("failed to create kernel");
8449
8450 // Create /bin directory and script
8451 kernel.execute(r#"mkdir "/bin""#).await.ok();
8452 kernel
8453 .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8454 .await
8455 .expect("write script failed");
8456
8457 // Set PATH to /bin
8458 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8459
8460 // Call script by name (without .kai extension)
8461 let result = kernel
8462 .execute("hello")
8463 .await
8464 .expect("script execution failed");
8465
8466 assert!(result.ok(), "script failed: {}", result.err);
8467 assert_eq!(result.text_out().trim(), "Hello from script!");
8468 }
8469
8470 #[tokio::test]
8471 async fn test_script_with_args() {
8472 let kernel = Kernel::transient().expect("failed to create kernel");
8473
8474 // Create script that uses positional params
8475 kernel.execute(r#"mkdir "/bin""#).await.ok();
8476 kernel
8477 .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8478 .await
8479 .expect("write script failed");
8480
8481 // Set PATH
8482 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8483
8484 // Call script with arg
8485 let result = kernel
8486 .execute(r#"greet "World""#)
8487 .await
8488 .expect("script execution failed");
8489
8490 assert!(result.ok(), "script failed: {}", result.err);
8491 assert_eq!(result.text_out().trim(), "Hello, World!");
8492 }
8493
8494 #[tokio::test]
8495 async fn test_script_not_found() {
8496 let kernel = Kernel::transient().expect("failed to create kernel");
8497
8498 // Set empty PATH
8499 kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8500
8501 // Call non-existent script
8502 let result = kernel
8503 .execute("noscript")
8504 .await
8505 .expect("execution failed");
8506
8507 assert!(!result.ok(), "should fail with command not found");
8508 assert_eq!(result.code, 127);
8509 assert!(result.err.contains("command not found"));
8510 }
8511
8512 #[tokio::test]
8513 async fn test_script_path_search_order() {
8514 let kernel = Kernel::transient().expect("failed to create kernel");
8515
8516 // Create two directories with same-named script
8517 // Note: using "myscript" not "test" to avoid conflict with test builtin
8518 kernel.execute(r#"mkdir "/first""#).await.ok();
8519 kernel.execute(r#"mkdir "/second""#).await.ok();
8520 kernel
8521 .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8522 .await
8523 .expect("write failed");
8524 kernel
8525 .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8526 .await
8527 .expect("write failed");
8528
8529 // Set PATH with first before second
8530 kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8531
8532 // Should find first one
8533 let result = kernel
8534 .execute("myscript")
8535 .await
8536 .expect("script execution failed");
8537
8538 assert!(result.ok(), "script failed: {}", result.err);
8539 assert_eq!(result.text_out().trim(), "from first");
8540 }
8541
8542 // ═══════════════════════════════════════════════════════════════════════════
8543 // Special Variable Tests ($?, $$, unset vars)
8544 // ═══════════════════════════════════════════════════════════════════════════
8545
8546 #[tokio::test]
8547 async fn test_last_exit_code_success() {
8548 let kernel = Kernel::transient().expect("failed to create kernel");
8549
8550 // true exits with 0
8551 let result = kernel.execute("true; echo $?").await.expect("execution failed");
8552 assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8553 }
8554
8555 #[tokio::test]
8556 async fn test_last_exit_code_failure() {
8557 let kernel = Kernel::transient().expect("failed to create kernel");
8558
8559 // false exits with 1
8560 let result = kernel.execute("false; echo $?").await.expect("execution failed");
8561 assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8562 }
8563
8564 #[tokio::test]
8565 async fn test_current_pid() {
8566 let kernel = Kernel::transient().expect("failed to create kernel");
8567
8568 let result = kernel.execute("echo $$").await.expect("execution failed");
8569 // PID should be a positive number
8570 let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8571 assert!(pid > 0, "PID should be positive");
8572 }
8573
8574 #[tokio::test]
8575 async fn test_unset_variable_expands_to_empty() {
8576 let kernel = Kernel::transient().expect("failed to create kernel");
8577
8578 // Unset variable in interpolation should be empty
8579 let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8580 assert_eq!(result.text_out().trim(), "prefix::suffix");
8581 }
8582
8583 #[tokio::test]
8584 async fn test_eq_ne_operators() {
8585 let kernel = Kernel::transient().expect("failed to create kernel");
8586
8587 // Test -eq operator
8588 let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8589 assert_eq!(result.text_out().trim(), "eq works");
8590
8591 // Test -ne operator
8592 let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8593 assert_eq!(result.text_out().trim(), "ne works");
8594
8595 // Test -eq with different values
8596 let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8597 assert_eq!(result.text_out().trim(), "correct");
8598 }
8599
8600 #[tokio::test]
8601 async fn test_escaped_dollar_in_string() {
8602 let kernel = Kernel::transient().expect("failed to create kernel");
8603
8604 // \$ should produce literal $
8605 let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8606 assert_eq!(result.text_out().trim(), "$100");
8607 }
8608
8609 #[tokio::test]
8610 async fn test_special_vars_in_interpolation() {
8611 let kernel = Kernel::transient().expect("failed to create kernel");
8612
8613 // Test $? in string interpolation
8614 let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8615 assert_eq!(result.text_out().trim(), "exit: 0");
8616
8617 // Test $$ in string interpolation
8618 let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8619 assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8620 let text = result.text_out();
8621 let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8622 let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8623 }
8624
8625 // ═══════════════════════════════════════════════════════════════════════════
8626 // Command Substitution Tests
8627 // ═══════════════════════════════════════════════════════════════════════════
8628
8629 #[tokio::test]
8630 async fn test_command_subst_assignment() {
8631 let kernel = Kernel::transient().expect("failed to create kernel");
8632
8633 // Command substitution in assignment
8634 let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8635 assert_eq!(result.text_out().trim(), "hello");
8636 }
8637
8638 #[tokio::test]
8639 async fn test_command_subst_with_args() {
8640 let kernel = Kernel::transient().expect("failed to create kernel");
8641
8642 // Command substitution with string argument
8643 let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8644 assert_eq!(result.text_out().trim(), "a b c");
8645 }
8646
8647 #[tokio::test]
8648 async fn test_command_subst_nested_vars() {
8649 let kernel = Kernel::transient().expect("failed to create kernel");
8650
8651 // Variables inside command substitution
8652 let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8653 assert_eq!(result.text_out().trim(), "hello world");
8654 }
8655
8656 #[tokio::test]
8657 async fn test_background_job_basic() {
8658 use std::time::Duration;
8659
8660 let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8661
8662 // Run a simple background command
8663 let result = kernel.execute("echo hello &").await.expect("execution failed");
8664 assert!(result.ok(), "background command should succeed: {}", result.err);
8665 assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8666
8667 // Give the job time to complete
8668 tokio::time::sleep(Duration::from_millis(100)).await;
8669
8670 // Check job status
8671 let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8672 assert!(status.ok(), "status should succeed: {}", status.err);
8673 assert!(
8674 status.text_out().contains("done:") || status.text_out().contains("running"),
8675 "should have valid status: {}",
8676 status.text_out()
8677 );
8678
8679 // Check stdout
8680 let stdout = kernel.execute("cat /v/jobs/1/stdout").await.expect("stdout check failed");
8681 assert!(stdout.ok());
8682 assert!(stdout.text_out().contains("hello"));
8683 }
8684
8685 #[tokio::test]
8686 async fn test_heredoc_piped_to_command() {
8687 // Bug 4: heredoc content should pipe through to next command
8688 let kernel = Kernel::transient().expect("kernel");
8689 let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8690 assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8691 assert_eq!(result.text_out().trim(), "hello world");
8692 }
8693
8694 /// A transient kernel paired with a real, auto-cleaning tempdir. The
8695 /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8696 /// tests need actual files on disk. Hold the returned `TempDir` for the
8697 /// test's lifetime: it removes the directory tree on drop — including on
8698 /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8699 /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8700 /// as a string for interpolation into scripts.
8701 fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8702 let kernel = Kernel::transient().expect("kernel");
8703 let tmp = tempfile::tempdir().expect("tempdir");
8704 let dir = tmp.path().display().to_string();
8705 (kernel, tmp, dir)
8706 }
8707
8708 #[tokio::test]
8709 async fn test_for_loop_glob_iterates() {
8710 // Bug 1: for F in $(glob ...) should iterate per file, not once
8711 let (kernel, _tmp, dir) = transient_with_tempdir();
8712 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8713 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8714 let result = kernel.execute(&format!(r#"
8715 N=0
8716 for F in $(glob "{dir}/*.txt"); do
8717 N=$((N + 1))
8718 done
8719 echo $N
8720 "#)).await.unwrap();
8721 assert!(result.ok(), "for glob failed: {}", result.err);
8722 assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8723 }
8724
8725 #[tokio::test]
8726 async fn test_bare_glob_expansion_echo() {
8727 let (kernel, _tmp, dir) = transient_with_tempdir();
8728 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8729 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8730 kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8731 kernel.execute(&format!("cd {dir}")).await.unwrap();
8732 let result = kernel.execute("echo *.txt").await.unwrap();
8733 assert!(result.ok(), "echo *.txt failed: {}", result.err);
8734 let out = result.text_out();
8735 let out = out.trim();
8736 // Should contain both .txt files (order may vary)
8737 assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8738 assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8739 assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8740 }
8741
8742 #[tokio::test]
8743 async fn test_bare_glob_no_matches_errors() {
8744 let (kernel, _tmp, dir) = transient_with_tempdir();
8745 kernel.execute(&format!("cd {dir}")).await.unwrap();
8746 let result = kernel.execute("echo *.nonexistent").await;
8747 match &result {
8748 Ok(exec) => {
8749 // No-match glob should produce a non-zero exit code
8750 assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8751 assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8752 }
8753 Err(e) => {
8754 assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8755 }
8756 }
8757 }
8758
8759 #[tokio::test]
8760 async fn test_bare_glob_disabled_with_set() {
8761 let (kernel, _tmp, dir) = transient_with_tempdir();
8762 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8763 kernel.execute(&format!("cd {dir}")).await.unwrap();
8764 // Disable glob expansion
8765 kernel.execute("set +o glob").await.unwrap();
8766 let result = kernel.execute("echo *.txt").await.unwrap();
8767 // With glob disabled, *.txt should be passed as literal string
8768 assert!(result.ok(), "echo should succeed: {}", result.err);
8769 assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8770 }
8771
8772 #[tokio::test]
8773 async fn test_bare_glob_quoted_not_expanded() {
8774 let (kernel, _tmp, dir) = transient_with_tempdir();
8775 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8776 kernel.execute(&format!("cd {dir}")).await.unwrap();
8777 // Quoted globs should NOT expand
8778 let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8779 assert!(result.ok(), "echo should succeed: {}", result.err);
8780 assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8781 }
8782
8783 #[tokio::test]
8784 async fn test_bare_glob_for_loop() {
8785 let (kernel, _tmp, dir) = transient_with_tempdir();
8786 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8787 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8788 kernel.execute(&format!("cd {dir}")).await.unwrap();
8789 let result = kernel.execute(r#"
8790 N=0
8791 for f in *.txt; do
8792 N=$((N + 1))
8793 done
8794 echo $N
8795 "#).await.unwrap();
8796 assert!(result.ok(), "for loop failed: {}", result.err);
8797 assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8798 }
8799
8800 #[tokio::test]
8801 async fn test_glob_in_assignment_is_literal() {
8802 let kernel = Kernel::transient().expect("kernel");
8803 let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8804 assert!(result.ok());
8805 assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8806 }
8807
8808 #[tokio::test]
8809 async fn test_glob_in_test_expr_is_literal() {
8810 let kernel = Kernel::transient().expect("kernel");
8811 let result = kernel.execute(r#"
8812 if [[ *.txt == "*.txt" ]]; then
8813 echo "match"
8814 else
8815 echo "no"
8816 fi
8817 "#).await.unwrap();
8818 assert!(result.ok());
8819 assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8820 }
8821
8822 #[tokio::test]
8823 async fn test_command_subst_echo_not_iterable() {
8824 // Regression guard: $(echo "a b c") must remain a single string
8825 let kernel = Kernel::transient().expect("kernel");
8826 let result = kernel.execute(r#"
8827 N=0
8828 for X in $(echo "a b c"); do N=$((N + 1)); done
8829 echo $N
8830 "#).await.unwrap();
8831 assert!(result.ok());
8832 assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8833 }
8834
8835 // -- accumulate_result / newline tests --
8836
8837 #[test]
8838 fn test_accumulate_preserves_own_newlines() {
8839 // Outputs concatenate verbatim — a command's own trailing newline is
8840 // kept, none is invented.
8841 let mut acc = ExecResult::success("line1\n");
8842 let new = ExecResult::success("line2\n");
8843 accumulate_result(&mut acc, &new);
8844 assert_eq!(&*acc.text_out(), "line1\nline2\n");
8845 assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8846 }
8847
8848 #[test]
8849 fn test_accumulate_inserts_no_separator() {
8850 // No artificial separator: `printf a; printf b` style concatenates to
8851 // `ab`, matching bash (regression for the 2026-06-09 finding).
8852 let mut acc = ExecResult::success("line1");
8853 let new = ExecResult::success("line2");
8854 accumulate_result(&mut acc, &new);
8855 assert_eq!(&*acc.text_out(), "line1line2");
8856 }
8857
8858 #[test]
8859 fn test_accumulate_empty_into_nonempty() {
8860 let mut acc = ExecResult::success("");
8861 let new = ExecResult::success("hello\n");
8862 accumulate_result(&mut acc, &new);
8863 assert_eq!(&*acc.text_out(), "hello\n");
8864 }
8865
8866 #[test]
8867 fn test_accumulate_nonempty_into_empty() {
8868 let mut acc = ExecResult::success("hello\n");
8869 let new = ExecResult::success("");
8870 accumulate_result(&mut acc, &new);
8871 assert_eq!(&*acc.text_out(), "hello\n");
8872 }
8873
8874 #[test]
8875 fn test_accumulate_stderr_no_double_newlines() {
8876 let mut acc = ExecResult::failure(1, "err1\n");
8877 let new = ExecResult::failure(1, "err2\n");
8878 accumulate_result(&mut acc, &new);
8879 assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
8880 }
8881
8882 #[tokio::test]
8883 async fn test_multiple_echo_no_blank_lines() {
8884 let kernel = Kernel::transient().expect("kernel");
8885 let result = kernel
8886 .execute("echo one\necho two\necho three")
8887 .await
8888 .expect("execution failed");
8889 assert!(result.ok());
8890 assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
8891 }
8892
8893 #[tokio::test]
8894 async fn test_for_loop_no_blank_lines() {
8895 let kernel = Kernel::transient().expect("kernel");
8896 let result = kernel
8897 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
8898 .await
8899 .expect("execution failed");
8900 assert!(result.ok());
8901 assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
8902 }
8903
8904 #[tokio::test]
8905 async fn test_for_command_subst_no_blank_lines() {
8906 let kernel = Kernel::transient().expect("kernel");
8907 let result = kernel
8908 .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
8909 .await
8910 .expect("execution failed");
8911 assert!(result.ok());
8912 assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
8913 }
8914
8915 // ------------------------------------------------------------------
8916 // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
8917 // ------------------------------------------------------------------
8918
8919 /// Helper: a throwaway schema with one `--pair` param declared as
8920 /// consuming two positionals per occurrence. Modelled after what
8921 /// jq_native will declare for `--arg` / `--argjson`.
8922 fn multi_consume_schema() -> crate::tools::ToolSchema {
8923 use crate::tools::{ParamSchema, ToolSchema};
8924 ToolSchema::new("test", "multi-consume smoke")
8925 .param(
8926 ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
8927 .consumes(2),
8928 )
8929 }
8930
8931 fn pos(s: &str) -> Arg {
8932 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
8933 }
8934
8935 #[tokio::test]
8936 async fn build_args_multi_consume_single_occurrence() {
8937 let kernel = Kernel::transient().expect("kernel");
8938 let schema = multi_consume_schema();
8939 // Simulates: test --pair NAME VALUE filter
8940 let args = vec![
8941 Arg::LongFlag("pair".into()),
8942 pos("NAME"),
8943 pos("VALUE"),
8944 pos("filter"),
8945 ];
8946 let built = kernel
8947 .build_args_async(&args, Some(&schema))
8948 .await
8949 .expect("build_args should succeed");
8950
8951 // `--pair` + its two positionals are consumed into named["pair"],
8952 // which becomes an outer array of one inner 2-element array.
8953 let pair = built.named.get("pair").expect("named[pair] missing");
8954 match pair {
8955 Value::Json(serde_json::Value::Array(occurrences)) => {
8956 assert_eq!(occurrences.len(), 1, "expected one occurrence");
8957 match &occurrences[0] {
8958 serde_json::Value::Array(values) => {
8959 assert_eq!(values.len(), 2, "pair must have 2 values");
8960 assert_eq!(values[0], serde_json::Value::String("NAME".into()));
8961 assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
8962 }
8963 other => panic!("expected inner array, got {other:?}"),
8964 }
8965 }
8966 other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
8967 }
8968
8969 // The un-consumed positional ("filter") remains in `positional`.
8970 assert_eq!(built.positional.len(), 1);
8971 assert_eq!(built.positional[0], Value::String("filter".into()));
8972 }
8973 #[tokio::test]
8974 async fn build_args_multi_consume_two_occurrences_accumulate() {
8975 let kernel = Kernel::transient().expect("kernel");
8976 let schema = multi_consume_schema();
8977 // Simulates: test --pair A 1 --pair B 2 filter
8978 let args = vec![
8979 Arg::LongFlag("pair".into()),
8980 pos("A"),
8981 pos("1"),
8982 Arg::LongFlag("pair".into()),
8983 pos("B"),
8984 pos("2"),
8985 pos("filter"),
8986 ];
8987 let built = kernel
8988 .build_args_async(&args, Some(&schema))
8989 .await
8990 .expect("build_args should succeed");
8991
8992 let pair = built.named.get("pair").expect("named[pair] missing");
8993 match pair {
8994 Value::Json(serde_json::Value::Array(occurrences)) => {
8995 assert_eq!(occurrences.len(), 2, "expected two occurrences");
8996 // Preserved in invocation order.
8997 match &occurrences[0] {
8998 serde_json::Value::Array(values) => {
8999 assert_eq!(values[0], serde_json::Value::String("A".into()));
9000 assert_eq!(values[1], serde_json::Value::String("1".into()));
9001 }
9002 other => panic!("expected inner array, got {other:?}"),
9003 }
9004 match &occurrences[1] {
9005 serde_json::Value::Array(values) => {
9006 assert_eq!(values[0], serde_json::Value::String("B".into()));
9007 assert_eq!(values[1], serde_json::Value::String("2".into()));
9008 }
9009 other => panic!("expected inner array, got {other:?}"),
9010 }
9011 }
9012 other => panic!("expected Json(Array(...)), got {other:?}"),
9013 }
9014 }
9015
9016 // ── undeclared space-form flag under map_positionals (kj --type val) ──
9017 //
9018 // A backend/MCP tool whose schema does NOT declare a flag must not let
9019 // `--flag value` (space form) silently divorce the value: that was a
9020 // privilege-escalation-by-typo against kaijutsu (see docs/issues.md).
9021 // kaish fails loud rather than guessing.
9022
9023 use crate::tools::{ParamSchema, ToolSchema};
9024
9025 /// Backend-style schema (map_positionals) declaring only a `name`
9026 /// positional — `--type` is intentionally undeclared.
9027 fn kj_like_schema() -> ToolSchema {
9028 ToolSchema::new("kj", "incomplete backend schema")
9029 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9030 .with_positional_mapping()
9031 }
9032
9033 #[tokio::test]
9034 async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9035 let kernel = Kernel::transient().expect("kernel");
9036 let schema = kj_like_schema();
9037 // kj context create exp --type explorer
9038 let args = vec![
9039 pos("context"),
9040 pos("create"),
9041 pos("exp"),
9042 Arg::LongFlag("type".into()),
9043 pos("explorer"),
9044 ];
9045 let err = kernel
9046 .build_args_async(&args, Some(&schema))
9047 .await
9048 .expect_err("undeclared --type with a space value must fail loud");
9049 let msg = err.to_string();
9050 assert!(msg.contains("--type"), "message should name the flag: {msg}");
9051 assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9052 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9053 }
9054
9055 #[tokio::test]
9056 async fn build_args_declared_space_flag_still_binds() {
9057 let kernel = Kernel::transient().expect("kernel");
9058 // Same tool, but now the schema DECLARES --type as a string param.
9059 let schema = ToolSchema::new("kj", "complete schema")
9060 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9061 .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9062 .with_positional_mapping();
9063 let args = vec![
9064 pos("exp"),
9065 Arg::LongFlag("type".into()),
9066 pos("explorer"),
9067 ];
9068 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9069 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9070 }
9071
9072 #[tokio::test]
9073 async fn build_args_equals_form_binds_for_undeclared_flag() {
9074 let kernel = Kernel::transient().expect("kernel");
9075 let schema = kj_like_schema();
9076 // The unambiguous `=` form must keep working even when undeclared.
9077 let args = vec![
9078 pos("exp"),
9079 Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9080 ];
9081 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9082 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9083 }
9084
9085 #[tokio::test]
9086 async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9087 let kernel = Kernel::transient().expect("kernel");
9088 let schema = kj_like_schema();
9089 // No positional follows --force → unambiguously a bare flag.
9090 let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9091 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9092 assert!(built.flags.contains("force"));
9093 }
9094
9095 #[tokio::test]
9096 async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9097 let kernel = Kernel::transient().expect("kernel");
9098 let schema = kj_like_schema();
9099 // --verbose is followed by a flag, not a positional → not ambiguous.
9100 let args = vec![
9101 Arg::LongFlag("verbose".into()),
9102 Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9103 ];
9104 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9105 assert!(built.flags.contains("verbose"));
9106 }
9107
9108 #[tokio::test]
9109 async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9110 let kernel = Kernel::transient().expect("kernel");
9111 // Builtins set map_positionals=false; the ambiguity guard must not
9112 // fire there (clap validates their flags separately).
9113 let schema = ToolSchema::new("frobnicate", "builtin-style")
9114 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9115 let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9116 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9117 assert!(built.flags.contains("frob"));
9118 }
9119
9120 // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9121 //
9122 // The long-flag guard above was closed by GH #188; an undeclared SHORT
9123 // flag under a map_positionals schema was still silently defaulting to
9124 // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9125 // same way the long-flag case used to.
9126
9127 #[tokio::test]
9128 async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9129 let kernel = Kernel::transient().expect("kernel");
9130 let schema = kj_like_schema();
9131 // kj exp -t explorer
9132 let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9133 let err = kernel
9134 .build_args_async(&args, Some(&schema))
9135 .await
9136 .expect_err("undeclared -t with a space value must fail loud");
9137 let msg = err.to_string();
9138 assert!(msg.contains("-t"), "message should name the flag: {msg}");
9139 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9140 }
9141
9142 #[tokio::test]
9143 async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9144 let kernel = Kernel::transient().expect("kernel");
9145 // Builtins set map_positionals=false; the ambiguity guard must not
9146 // fire there.
9147 let schema = ToolSchema::new("frobnicate", "builtin-style")
9148 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9149 let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9150 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9151 assert!(built.flags.contains("t"));
9152 }
9153
9154 // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9155 //
9156 // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9157 // params, not the root's. The subcommand-path positionals stay positional
9158 // (kj re-parses them with its own clap), and a value flag declared only on
9159 // a deep leaf still binds in space form.
9160
9161 /// kj → context (alias ctx) → create{--type value, --force bool}.
9162 /// map_positionals defaults false on every node (builtin/kj style).
9163 fn kj_tree_schema() -> ToolSchema {
9164 ToolSchema::new("kj", "subcommand tool").subcommand(
9165 ToolSchema::new("context", "context ops")
9166 .with_command_aliases(["ctx"])
9167 .subcommand(
9168 ToolSchema::new("create", "create context")
9169 .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9170 .param(ParamSchema::new("force", "bool")),
9171 ),
9172 )
9173 }
9174
9175 #[tokio::test]
9176 async fn build_args_binds_deep_leaf_value_flag_space_form() {
9177 let kernel = Kernel::transient().expect("kernel");
9178 let schema = kj_tree_schema();
9179 // kj context create --type explorer
9180 let args = vec![
9181 pos("context"),
9182 pos("create"),
9183 Arg::LongFlag("type".into()),
9184 pos("explorer"),
9185 ];
9186 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9187 // --type (declared only on the create leaf) binds in space form.
9188 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9189 // The subcommand path survives as positionals for kj to re-parse.
9190 let positionals: Vec<&str> = built
9191 .positional
9192 .iter()
9193 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9194 .collect();
9195 assert_eq!(positionals, vec!["context", "create"]);
9196 }
9197
9198 #[tokio::test]
9199 async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9200 let kernel = Kernel::transient().expect("kernel");
9201 let schema = kj_tree_schema();
9202 // kj context create --force somearg → --force is a leaf bool flag,
9203 // it must NOT consume `somearg`.
9204 let args = vec![
9205 pos("context"),
9206 pos("create"),
9207 Arg::LongFlag("force".into()),
9208 pos("somearg"),
9209 ];
9210 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9211 assert!(built.flags.contains("force"), "force should be a bare flag");
9212 let positionals: Vec<&str> = built
9213 .positional
9214 .iter()
9215 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9216 .collect();
9217 assert_eq!(positionals, vec!["context", "create", "somearg"]);
9218 }
9219
9220 #[tokio::test]
9221 async fn build_args_alias_routed_leaf_binds_value_flag() {
9222 let kernel = Kernel::transient().expect("kernel");
9223 let schema = kj_tree_schema();
9224 // kj ctx create -t explorer → command alias + short flag alias.
9225 let args = vec![
9226 pos("ctx"),
9227 pos("create"),
9228 Arg::ShortFlag("t".into()),
9229 pos("explorer"),
9230 ];
9231 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9232 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9233 }
9234
9235 #[tokio::test]
9236 async fn build_args_computed_subcommand_selector_fails_loud() {
9237 let kernel = Kernel::transient().expect("kernel");
9238 let schema = kj_tree_schema();
9239 // kj $(echo context) — routing can't see the value; fail loud.
9240 let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9241 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9242 )]))];
9243 let err = kernel
9244 .build_args_async(&args, Some(&schema))
9245 .await
9246 .expect_err("computed subcommand selector must error");
9247 assert!(
9248 err.to_string().contains("subcommand name is required"),
9249 "got: {err}"
9250 );
9251 }
9252
9253 // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9254
9255 #[test]
9256 fn finalize_output_renders_when_kernel_owns_it() {
9257 use crate::interpreter::{OutputData, OutputFormat};
9258 let r = ExecResult::with_output(OutputData::text("RAW"));
9259 let out = finalize_output(r, Some(OutputFormat::Json), false);
9260 // Kernel renders the typed OutputData → JSON; text is no longer bare.
9261 assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9262 }
9263
9264 #[test]
9265 fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9266 use crate::interpreter::{OutputData, OutputFormat};
9267 let r = ExecResult::with_output(OutputData::text("RAW"));
9268 let out = finalize_output(r, Some(OutputFormat::Json), true);
9269 // owns_output + success: the tool already rendered; kernel leaves bytes
9270 // untouched.
9271 assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9272 }
9273
9274 #[test]
9275 fn finalize_output_renders_owns_output_failure() {
9276 // scatter/gather (the only owns_output tools) never render their own
9277 // JSONL/array on a FAILURE path — their error returns are plain-text
9278 // `ExecResult::failure(code, msg)`, identical in shape to any other
9279 // builtin's. owns_output means "the tool already rendered its own
9280 // SUCCESS output", not "never touch this tool's bytes" — a failure
9281 // must still get the uniform --json error envelope like every other
9282 // builtin (kaibo review finding on merged PR #215; confirmed
9283 // pre-existing for scatter/gather's whole error-path class, including
9284 // the clap-parse-failure path).
9285 use crate::interpreter::OutputFormat;
9286 let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9287 let out = finalize_output(r, Some(OutputFormat::Json), true);
9288 let parsed: serde_json::Value =
9289 serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9290 assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9291 assert_eq!(parsed["code"], 2);
9292 }
9293
9294 #[test]
9295 fn finalize_output_no_format_is_noop() {
9296 use crate::interpreter::OutputData;
9297 let r = ExecResult::with_output(OutputData::text("RAW"));
9298 let out = finalize_output(r, None, false);
9299 assert_eq!(out.text_out(), "RAW");
9300 }
9301
9302 // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9303
9304 #[tokio::test]
9305 async fn test_initial_vars_set_and_exported() {
9306 let config = KernelConfig::transient()
9307 .with_var("INIT_FOO", Value::String("bar".into()));
9308 let kernel = Kernel::new(config).expect("failed to create kernel");
9309
9310 assert_eq!(
9311 kernel.get_var("INIT_FOO").await,
9312 Some(Value::String("bar".into()))
9313 );
9314 assert!(
9315 kernel.scope.read().await.is_exported("INIT_FOO"),
9316 "initial_vars entries must be marked exported"
9317 );
9318 }
9319
9320 #[tokio::test]
9321 async fn test_execute_with_vars_overlay_visible() {
9322 let kernel = Kernel::transient().expect("failed to create kernel");
9323 let mut overlay = HashMap::new();
9324 overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9325
9326 let result = kernel
9327 .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9328 .await
9329 .expect("execute failed");
9330
9331 assert!(result.ok());
9332 assert_eq!(result.text_out().trim(), "yes");
9333 }
9334
9335 #[tokio::test]
9336 async fn test_execute_with_vars_overlay_cleanup() {
9337 let kernel = Kernel::transient().expect("failed to create kernel");
9338 let mut overlay = HashMap::new();
9339 overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9340
9341 kernel
9342 .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9343 .await
9344 .expect("execute failed");
9345
9346 assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9347 assert!(
9348 !kernel.scope.read().await.is_exported("EPHEMERAL"),
9349 "overlay-only export must be cleared on return"
9350 );
9351 }
9352
9353 #[tokio::test]
9354 async fn test_execute_with_vars_does_not_clobber_existing_export() {
9355 let kernel = Kernel::transient().expect("failed to create kernel");
9356 kernel
9357 .execute("export OUTER=outer")
9358 .await
9359 .expect("export failed");
9360
9361 let mut overlay = HashMap::new();
9362 overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9363 let result = kernel
9364 .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9365 .await
9366 .expect("execute failed");
9367 assert_eq!(result.text_out().trim(), "inner");
9368
9369 assert_eq!(
9370 kernel.get_var("OUTER").await,
9371 Some(Value::String("outer".into())),
9372 "outer value must reappear after pop"
9373 );
9374 assert!(
9375 kernel.scope.read().await.is_exported("OUTER"),
9376 "outer export must survive overlay"
9377 );
9378 }
9379
9380 #[tokio::test]
9381 async fn test_execute_with_vars_inner_assignment_is_local() {
9382 let kernel = Kernel::transient().expect("failed to create kernel");
9383 let mut overlay = HashMap::new();
9384 overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9385
9386 // Variable assignment inside a single statement uses set() (innermost
9387 // frame), not set_global() — this matches bash function-local semantics.
9388 // We explicitly use `local FOO=...` style by relying on the pushed
9389 // frame; the assignment in the script body modifies the same frame.
9390 let result = kernel
9391 .execute_with_options(
9392 r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9393 ExecuteOptions::new().with_vars(overlay),
9394 )
9395 .await
9396 .expect("execute failed");
9397 assert!(result.ok());
9398
9399 // After the call the frame is popped, so LOCAL_FOO is gone regardless
9400 // of how the script reassigned it.
9401 assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9402 }
9403
9404 #[tokio::test]
9405 async fn test_external_command_sees_exported_var() {
9406 let kernel = Kernel::transient().expect("failed to create kernel");
9407 // PATH must be in scope to resolve the external `printenv` — the kernel
9408 // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9409 // what a frontend does through initial_vars.
9410 let path = std::env::var("PATH").unwrap_or_default();
9411 let result = kernel
9412 .execute(&format!(
9413 "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9414 ))
9415 .await
9416 .expect("execute failed");
9417
9418 assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9419 assert_eq!(result.text_out().trim(), "bar");
9420 }
9421
9422 #[tokio::test]
9423 async fn test_external_command_does_not_see_unexported_var() {
9424 let kernel = Kernel::transient().expect("failed to create kernel");
9425
9426 // Set without exporting; printenv must not see it (exit code != 0,
9427 // empty stdout per printenv semantics).
9428 let result = kernel
9429 .execute("EXT_BAR=hidden; printenv EXT_BAR")
9430 .await
9431 .expect("execute failed");
9432
9433 assert!(!result.ok(), "printenv should fail when var is unexported");
9434 assert!(
9435 result.text_out().trim().is_empty(),
9436 "no stdout when var is missing, got: {}",
9437 result.text_out()
9438 );
9439 }
9440
9441 #[tokio::test]
9442 async fn test_external_command_does_not_see_os_env() {
9443 // The kernel is hermetic: it never reads std::env::vars() and only
9444 // exports what it has been told to export. Cargo always sets PATH for
9445 // tests, so PATH is reliably present in the OS env — but a transient
9446 // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9447 // inside the kernel must fail.
9448 assert!(
9449 std::env::var_os("PATH").is_some(),
9450 "test precondition: cargo should set PATH"
9451 );
9452
9453 let kernel = Kernel::transient().expect("failed to create kernel");
9454 let result = kernel
9455 .execute("printenv PATH")
9456 .await
9457 .expect("execute failed");
9458
9459 assert!(
9460 !result.ok(),
9461 "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9462 result.text_out()
9463 );
9464 assert!(
9465 result.text_out().trim().is_empty(),
9466 "no PATH in subprocess env, got stdout={:?}",
9467 result.text_out()
9468 );
9469 }
9470
9471 #[tokio::test]
9472 async fn test_execute_with_vars_overlay_reaches_subprocess() {
9473 let kernel = Kernel::transient().expect("failed to create kernel");
9474 let mut overlay = HashMap::new();
9475 overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9476 // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9477 overlay.insert(
9478 "PATH".to_string(),
9479 Value::String(std::env::var("PATH").unwrap_or_default()),
9480 );
9481
9482 let result = kernel
9483 .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9484 .await
9485 .expect("execute failed");
9486
9487 assert!(
9488 result.ok(),
9489 "printenv should succeed: code={} stdout={:?} stderr={:?}",
9490 result.code,
9491 result.text_out(),
9492 result.err
9493 );
9494 assert_eq!(result.text_out().trim(), "subproc");
9495 }
9496
9497 #[tokio::test]
9498 async fn test_classify_command_builtin() {
9499 let kernel = Kernel::transient().expect("failed to create kernel");
9500 assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9501 assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9502 }
9503
9504 #[tokio::test]
9505 async fn test_classify_command_special_forms() {
9506 let kernel = Kernel::transient().expect("failed to create kernel");
9507 for name in ["true", "false", "source", "."] {
9508 assert_eq!(
9509 kernel.classify_command(name).await,
9510 CommandKind::Special,
9511 "{name} should be a special-form",
9512 );
9513 }
9514 }
9515
9516 #[tokio::test]
9517 async fn test_classify_command_dynamic() {
9518 let kernel = Kernel::transient().expect("failed to create kernel");
9519 assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9520 assert_eq!(
9521 kernel.classify_command("$(pick)").await,
9522 CommandKind::Dynamic
9523 );
9524 }
9525
9526 #[tokio::test]
9527 async fn test_classify_command_external() {
9528 let kernel = Kernel::transient().expect("failed to create kernel");
9529 // Not a builtin, user function, or special-form → escapes to PATH.
9530 assert_eq!(
9531 kernel.classify_command("definitely_not_a_kaish_builtin").await,
9532 CommandKind::External
9533 );
9534 // `readonly` is *not* a kaish special-form despite the validator's
9535 // warning heuristic — at runtime it resolves to an external command, so
9536 // a consent gate must see it as External (regression guard against the
9537 // validator/runtime divergence).
9538 assert_eq!(
9539 kernel.classify_command("readonly").await,
9540 CommandKind::External
9541 );
9542 assert!(kernel.classify_command("readonly").await.escapes_kernel());
9543 }
9544
9545 #[tokio::test]
9546 async fn test_classify_command_user_tool_shadows_builtin() {
9547 let kernel = Kernel::transient().expect("failed to create kernel");
9548 kernel
9549 .execute(r#"greet() { echo "hi" }"#)
9550 .await
9551 .expect("function definition failed");
9552 assert_eq!(
9553 kernel.classify_command("greet").await,
9554 CommandKind::UserTool
9555 );
9556
9557 // A user function named after a builtin classifies as UserTool, matching
9558 // the interpreter's user-tools-first resolution.
9559 kernel
9560 .execute(r#"cat() { echo "shadowed" }"#)
9561 .await
9562 .expect("function definition failed");
9563 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9564 }
9565
9566 #[tokio::test]
9567 async fn test_classify_command_alias_to_external_is_external() {
9568 let kernel = Kernel::transient().expect("failed to create kernel");
9569 // An alias whose head is an external binary must NOT report as the
9570 // builtin it shadows — execution expands the alias, so a consent gate
9571 // would otherwise be told an external command is internal.
9572 kernel
9573 .execute("alias cat='/usr/bin/whatever'")
9574 .await
9575 .expect("alias failed");
9576 assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9577 assert!(kernel.classify_command("cat").await.escapes_kernel());
9578 }
9579
9580 #[tokio::test]
9581 async fn test_classify_command_alias_to_builtin() {
9582 let kernel = Kernel::transient().expect("failed to create kernel");
9583 kernel.execute("alias g=grep").await.expect("alias failed");
9584 assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9585 }
9586
9587 #[tokio::test]
9588 async fn test_classify_command_alias_to_special_form() {
9589 let kernel = Kernel::transient().expect("failed to create kernel");
9590 kernel.execute("alias t=true").await.expect("alias failed");
9591 assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9592 }
9593
9594 #[tokio::test]
9595 async fn test_classify_command_braced_var_is_dynamic() {
9596 let kernel = Kernel::transient().expect("failed to create kernel");
9597 // The string API can be handed a `${VAR}` head; it must not be mistaken
9598 // for an external named literally "${VAR}".
9599 assert_eq!(
9600 kernel.classify_command("${CMD}").await,
9601 CommandKind::Dynamic
9602 );
9603 }
9604
9605 /// Drift guard: `classify_command` must agree with what the executor
9606 /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9607 /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9608 /// precedence, alias expansion); without this test those copies could diverge
9609 /// silently — the exact failure class `classify_command` exists to prevent,
9610 /// just moved inside the kernel. Each case asserts the classification AND
9611 /// observes the real resolution, so a future change to one side without the
9612 /// other fails here.
9613 #[tokio::test]
9614 async fn classify_command_matches_executor() {
9615 let kernel = Kernel::transient().expect("failed to create kernel");
9616
9617 // (1) Special-forms. `SpecialForm::from_name` is the single source of
9618 // truth: classify reports Special via it, and the executor matches the
9619 // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9620 // form won't build until both sides handle it). This test pins the other
9621 // half — that each form classifies Special AND actually short-circuits at
9622 // runtime rather than escaping to `PATH`. Every form is executed (not just
9623 // `true`/`false`): an external miss in this PATH-less kernel would be exit
9624 // 127, so a non-127 result that matches the form's own behavior proves the
9625 // short-circuit fired.
9626 for name in ["true", "false", "source", "."] {
9627 assert_eq!(
9628 kernel.classify_command(name).await,
9629 CommandKind::Special,
9630 "{name} should classify Special",
9631 );
9632 }
9633 assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9634 assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9635 // `source`/`.` short-circuit to execute_source, which (no filename) fails
9636 // with its own message — exit 1, never the 127 of an unresolved external.
9637 for name in ["source", "."] {
9638 let r = kernel.execute(name).await.expect("run source form");
9639 assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9640 assert!(
9641 r.err.contains("source: missing filename"),
9642 "{name} did not route to execute_source: {:?}",
9643 r.err,
9644 );
9645 }
9646
9647 // (2) Builtin: classify Builtin AND the executor runs the builtin.
9648 assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9649 let r = kernel.execute("echo hi").await.expect("run echo");
9650 assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9651
9652 // (3) User function shadows a builtin: classify UserTool AND the executor
9653 // runs the function body, not the `cat` builtin.
9654 kernel
9655 .execute(r#"cat() { echo SHADOWED }"#)
9656 .await
9657 .expect("define cat()");
9658 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9659 let r = kernel.execute("cat").await.expect("run shadowed cat");
9660 assert_eq!(
9661 r.text_out().trim(),
9662 "SHADOWED",
9663 "executor ran the builtin instead of the shadowing function",
9664 );
9665
9666 // (4) Alias whose head is external: classify External AND the executor
9667 // resolves through the alias to a missing external (not a builtin).
9668 kernel
9669 .execute("alias x='/nonexistent/binary'")
9670 .await
9671 .expect("define alias x");
9672 assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9673 let r = kernel.execute("x").await.expect("run alias x");
9674 assert!(
9675 !r.ok(),
9676 "alias to a missing external should fail, not resolve internally",
9677 );
9678 }
9679}