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, PathError, Scope};
104use crate::parser::parse;
105use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
106#[cfg(feature = "subprocess")]
107use crate::scheduler::{drain_to_stream_teed, BoundedStream, 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(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
231 /// Enable trash-on-delete for rm (set -o trash).
232 ///
233 /// When enabled, small files are moved to freedesktop.org Trash instead of
234 /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
235 /// or via `KAISH_TRASH=1`.
236 pub trash_enabled: bool,
237
238 /// Variables to populate the root scope with at construction, all marked
239 /// for export to child processes.
240 ///
241 /// The kernel itself is hermetic — it never reads `std::env::vars()` —
242 /// so frontends that want OS-env passthrough (REPL, MCP) populate this
243 /// from `std::env::vars()`. Embedders that want isolation pass nothing
244 /// (or only the keys they curate).
245 pub initial_vars: HashMap<String, Value>,
246
247 /// Default per-request timeout. When `Some`, every `execute_with_options`
248 /// call without an explicit `ExecuteOptions::timeout` uses this duration.
249 /// When elapsed, the kernel cancels the request, kills any external
250 /// children with the configured grace, and returns exit code 124.
251 ///
252 /// `None` means no default timeout — only explicit per-call timeouts apply.
253 pub request_timeout: Option<Duration>,
254
255 /// Grace period between SIGTERM and SIGKILL when killing an external
256 /// child on cancellation or timeout.
257 ///
258 /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
259 /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
260 pub kill_grace: Duration,
261
262 /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
263 ///
264 /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
265 /// construction and handed to every `MemoryFs` the kernel builds in
266 /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
267 /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
268 /// `StorageFull` — an in-band error a model reads and adapts to; fail
269 /// loud over quietly eating RAM.
270 ///
271 /// **Why the agent preset is bounded by default:** an agent embedder
272 /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
273 /// is per-call, not per-session. Embedders that know their workload needs
274 /// more opt out with `without_vfs_budget()` or raise the cap with
275 /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
276 /// All other profiles default to `None` (unbounded).
277 ///
278 /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
279 pub vfs_budget_bytes: Option<u64>,
280
281 /// Enable copy-on-write overlay mode (opt-in).
282 ///
283 /// When `true`, the primary local filesystem mount is wrapped in an
284 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
285 /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
286 /// overlay transaction.
287 ///
288 /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
289 /// **Sandboxed{root}:** the `{root}` mount becomes
290 /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
291 /// mounts stay as real `LocalFs` (real writes escape the transaction —
292 /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
293 /// **NoLocal:** incompatible — construction fails loudly (everything is
294 /// already virtual; an overlay adds no value and no lower layer to wrap).
295 /// **with_backend:** incompatible — the embedder controls the VFS; the
296 /// kernel cannot wrap it without bypassing the embedder's semantics.
297 ///
298 /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
299 /// making the overlay a per-call transaction — `kaish-vfs commit` must run
300 /// in the same call as the writes, or the transaction is discarded on drop.
301 /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
302 pub overlay: bool,
303
304 /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
305 /// fresh one, so every kernel owns its own job table.
306 ///
307 /// Supply one to share a single job table across kernels. An embedder that
308 /// builds a kernel per request (kaijutsu builds one per tool call) has no
309 /// other way to keep a `cmd &` job reachable: ids, status, and output
310 /// streams all live on the manager, so a per-kernel manager takes them
311 /// down with the kernel that made it. One manager held by the embedder and
312 /// handed to every kernel keeps `&` usable across calls, and keeps job ids
313 /// unique because they are minted from the manager's own counter.
314 ///
315 /// **A shared manager carries shared settings.** `kill_grace` and
316 /// `persist_output_files` are stamped onto the manager at kernel
317 /// construction, so the last kernel built wins for both: a hermetic kernel
318 /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
319 /// off for every kernel on that manager, and each kernel's
320 /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
321 /// between kernels configured alike, or accept the last writer.
322 ///
323 /// Set through [`Self::with_job_manager`].
324 pub job_manager: Option<Arc<JobManager>>,
325
326 /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
327 /// spawns, so the OS kills the child the instant this process dies —
328 /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
329 ///
330 /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
331 /// the same "protection on by default for the agent preset, opt in
332 /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
333 ///
334 /// **Why not unconditional.** kaish already puts every child in its own
335 /// process group and kills through a pidfd on cancel, and drops it with
336 /// `kill_on_drop`. All three need this process to still be running code,
337 /// so none of them survive a hard kill — that is the gap this closes. But
338 /// closing it costs something a human at a REPL may not want: an armed
339 /// child cannot outlive its shell, at all, and the child has no way to
340 /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
341 /// escape). A REPL user who backgrounds a long download and exits expects
342 /// it to keep going. An agent embedder expects the opposite — an
343 /// invisible orphaned `cargo build` is the failure — so the presets
344 /// differ rather than one behavior being forced on both.
345 ///
346 /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
347 /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
348 /// process). This flag is accepted and has no effect there, rather than
349 /// being faked with something weaker.
350 ///
351 /// Set through [`Self::with_kill_children_on_parent_death`].
352 pub kill_children_on_parent_death: bool,
353}
354
355/// Get the default sandbox root ($HOME).
356#[cfg(feature = "localfs")]
357fn default_sandbox_root() -> PathBuf {
358 std::env::var("HOME")
359 .map(PathBuf::from)
360 .unwrap_or_else(|_| PathBuf::from("/"))
361}
362
363impl Default for KernelConfig {
364 fn default() -> Self {
365 #[cfg(feature = "localfs")]
366 {
367 let home = default_sandbox_root();
368 Self {
369 name: "default".to_string(),
370 vfs_mode: VfsMountMode::Sandboxed { root: None },
371 cwd: home,
372 skip_validation: false,
373 interactive: false,
374 ignore_config: crate::ignore_config::IgnoreConfig::none(),
375 output_limit: crate::output_limit::OutputLimitConfig::none(),
376 allow_external_commands: cfg!(feature = "subprocess"),
377 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
378 initial_vars: HashMap::new(),
379 request_timeout: None,
380 kill_grace: Duration::from_secs(2),
381 vfs_budget_bytes: None,
382 overlay: false,
383 job_manager: None,
384 kill_children_on_parent_death: false,
385 }
386 }
387 #[cfg(not(feature = "localfs"))]
388 {
389 Self {
390 name: "default".to_string(),
391 vfs_mode: VfsMountMode::NoLocal,
392 cwd: PathBuf::from("/"),
393 skip_validation: false,
394 interactive: false,
395 ignore_config: crate::ignore_config::IgnoreConfig::none(),
396 output_limit: crate::output_limit::OutputLimitConfig::none(),
397 allow_external_commands: false,
398 trash_enabled: false,
399 initial_vars: HashMap::new(),
400 request_timeout: None,
401 kill_grace: Duration::from_secs(2),
402 vfs_budget_bytes: None,
403 overlay: false,
404 job_manager: None,
405 kill_children_on_parent_death: false,
406 }
407 }
408 }
409}
410
411impl KernelConfig {
412 /// Create a transient kernel config (sandboxed, for temporary use).
413 #[cfg(feature = "localfs")]
414 pub fn transient() -> Self {
415 let home = default_sandbox_root();
416 Self {
417 name: "transient".to_string(),
418 vfs_mode: VfsMountMode::Sandboxed { root: None },
419 cwd: home,
420 skip_validation: false,
421 interactive: false,
422 ignore_config: crate::ignore_config::IgnoreConfig::none(),
423 output_limit: crate::output_limit::OutputLimitConfig::none(),
424 allow_external_commands: cfg!(feature = "subprocess"),
425 trash_enabled: false,
426 initial_vars: HashMap::new(),
427 request_timeout: None,
428 kill_grace: Duration::from_secs(2),
429 vfs_budget_bytes: None,
430 overlay: false,
431 job_manager: None,
432 kill_children_on_parent_death: false,
433 }
434 }
435
436 /// Create a transient kernel config (isolated, no-default-features).
437 #[cfg(not(feature = "localfs"))]
438 pub fn transient() -> Self {
439 Self::isolated()
440 }
441
442 /// Create a kernel config with the given name (sandboxed by default).
443 #[cfg(feature = "localfs")]
444 pub fn named(name: &str) -> Self {
445 let home = default_sandbox_root();
446 Self {
447 name: name.to_string(),
448 vfs_mode: VfsMountMode::Sandboxed { root: None },
449 cwd: home,
450 skip_validation: false,
451 interactive: false,
452 ignore_config: crate::ignore_config::IgnoreConfig::none(),
453 output_limit: crate::output_limit::OutputLimitConfig::none(),
454 allow_external_commands: cfg!(feature = "subprocess"),
455 trash_enabled: false,
456 initial_vars: HashMap::new(),
457 request_timeout: None,
458 kill_grace: Duration::from_secs(2),
459 vfs_budget_bytes: None,
460 overlay: false,
461 job_manager: None,
462 kill_children_on_parent_death: false,
463 }
464 }
465
466 /// Create a kernel config with the given name (isolated, no-default-features).
467 #[cfg(not(feature = "localfs"))]
468 pub fn named(name: &str) -> Self {
469 Self {
470 name: name.to_string(),
471 ..Self::isolated()
472 }
473 }
474
475 /// Create a REPL config with passthrough filesystem access.
476 ///
477 /// Native paths like `/home/user/project` work directly.
478 /// The cwd is set to the actual current working directory.
479 #[cfg(feature = "localfs")]
480 pub fn repl() -> Self {
481 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
482 Self {
483 name: "repl".to_string(),
484 vfs_mode: VfsMountMode::Passthrough,
485 cwd,
486 skip_validation: false,
487 interactive: false,
488 // Ignore-aware by default (GH #134): .gitignore + default ignores
489 // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
490 ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
491 output_limit: crate::output_limit::OutputLimitConfig::none(),
492 allow_external_commands: cfg!(feature = "subprocess"),
493 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
494 initial_vars: HashMap::new(),
495 request_timeout: None,
496 kill_grace: Duration::from_secs(2),
497 vfs_budget_bytes: None,
498 overlay: false,
499 job_manager: None,
500 kill_children_on_parent_death: false,
501 }
502 }
503
504 /// Create a sandboxed-agent config with sandboxed filesystem access.
505 ///
506 /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
507 /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
508 /// memory and output. Local filesystem is accessible at its real path (e.g.,
509 /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
510 /// accessible through builtins. External commands still access the real
511 /// filesystem — use `.with_allow_external_commands(false)` to block them.
512 ///
513 /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
514 /// embedder typically creates a fresh kernel per call). Raise or remove with
515 /// `with_vfs_budget` / `without_vfs_budget`.
516 #[cfg(feature = "localfs")]
517 pub fn agent() -> Self {
518 let home = default_sandbox_root();
519 Self {
520 name: "agent".to_string(),
521 vfs_mode: VfsMountMode::Sandboxed { root: None },
522 cwd: home,
523 skip_validation: false,
524 interactive: false,
525 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
526 output_limit: crate::output_limit::OutputLimitConfig::agent(),
527 allow_external_commands: cfg!(feature = "subprocess"),
528 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
529 initial_vars: HashMap::new(),
530 request_timeout: None,
531 kill_grace: Duration::from_secs(2),
532 vfs_budget_bytes: Some(64 * 1024 * 1024),
533 overlay: false,
534 job_manager: None,
535 // An agent embedder must never leave an invisible `cargo build` running
536 // after its process is hard-killed; see the field doc for why this is
537 // not the default everywhere.
538 kill_children_on_parent_death: true,
539 }
540 }
541
542 /// Create a sandboxed-agent config with a custom sandbox root.
543 ///
544 /// Use this to restrict access to a subdirectory like `~/src`.
545 ///
546 /// VFS memory is bounded at 64 MiB per `execute()` call by default.
547 /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
548 #[cfg(feature = "localfs")]
549 pub fn agent_with_root(root: PathBuf) -> Self {
550 Self {
551 name: "agent".to_string(),
552 vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
553 cwd: root,
554 skip_validation: false,
555 interactive: false,
556 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
557 output_limit: crate::output_limit::OutputLimitConfig::agent(),
558 allow_external_commands: cfg!(feature = "subprocess"),
559 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
560 initial_vars: HashMap::new(),
561 request_timeout: None,
562 kill_grace: Duration::from_secs(2),
563 vfs_budget_bytes: Some(64 * 1024 * 1024),
564 overlay: false,
565 job_manager: None,
566 // Same reasoning as `agent()`.
567 kill_children_on_parent_death: true,
568 }
569 }
570
571 /// Create a config with no local filesystem (memory only).
572 ///
573 /// Complete isolation: no local filesystem and external commands are disabled.
574 /// Useful for tests or pure sandboxed execution.
575 pub fn isolated() -> Self {
576 Self {
577 name: "isolated".to_string(),
578 vfs_mode: VfsMountMode::NoLocal,
579 cwd: PathBuf::from("/"),
580 skip_validation: false,
581 interactive: false,
582 ignore_config: crate::ignore_config::IgnoreConfig::none(),
583 output_limit: crate::output_limit::OutputLimitConfig::none(),
584 allow_external_commands: false,
585 trash_enabled: false,
586 initial_vars: HashMap::new(),
587 request_timeout: None,
588 kill_grace: Duration::from_secs(2),
589 vfs_budget_bytes: None,
590 overlay: false,
591 job_manager: None,
592 kill_children_on_parent_death: false,
593 }
594 }
595
596 /// Set the VFS mount mode.
597 pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
598 self.vfs_mode = mode;
599 self
600 }
601
602 /// Set the initial working directory.
603 pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
604 self.cwd = cwd;
605 self
606 }
607
608 /// Skip pre-execution validation.
609 pub fn with_skip_validation(mut self, skip: bool) -> Self {
610 self.skip_validation = skip;
611 self
612 }
613
614 /// Enable interactive mode (external commands inherit stdio).
615 pub fn with_interactive(mut self, interactive: bool) -> Self {
616 self.interactive = interactive;
617 self
618 }
619
620 /// Set the ignore file configuration.
621 pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
622 self.ignore_config = config;
623 self
624 }
625
626 /// Set the output limit configuration.
627 pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
628 self.output_limit = config;
629 self
630 }
631
632 /// Set whether external command execution is allowed.
633 ///
634 /// When `false`, commands not found as builtins produce "command not found"
635 /// instead of searching PATH. The `exec` and `spawn` builtins also return
636 /// errors. Use this to prevent VFS sandbox bypass via external binaries.
637 pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
638 self.allow_external_commands = allow;
639 self
640 }
641
642 /// Enable or disable trash-on-delete at startup.
643 pub fn with_trash(mut self, enabled: bool) -> Self {
644 self.trash_enabled = enabled;
645 self
646 }
647
648 /// Add a single initial variable; marked exported when the kernel boots.
649 ///
650 /// Repeated calls add (last write wins on key collision).
651 pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
652 self.initial_vars.insert(name.into(), value);
653 self
654 }
655
656 /// Replace the entire initial-vars map. All entries are marked exported.
657 pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
658 self.initial_vars = vars;
659 self
660 }
661
662 /// Extend the initial-vars map with the given entries (last write wins).
663 pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
664 self.initial_vars.extend(vars);
665 self
666 }
667
668 /// Set the default per-request timeout (kernel-wide).
669 ///
670 /// Each `execute_with_options` call without an explicit timeout uses
671 /// this. On elapsed, the kernel cancels and returns exit code 124.
672 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
673 self.request_timeout = Some(timeout);
674 self
675 }
676
677 /// Set the SIGTERM-to-SIGKILL grace period for child kills.
678 pub fn with_kill_grace(mut self, grace: Duration) -> Self {
679 self.kill_grace = grace;
680 self
681 }
682
683 /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
684 /// kaish process cannot orphan them (Linux only — read
685 /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
686 /// gap).
687 pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
688 self.kill_children_on_parent_death = on;
689 self
690 }
691
692 /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
693 /// so background jobs outlive the kernel that started them. Read
694 /// [`Self::job_manager`] before sharing one manager between kernels that
695 /// are configured differently.
696 pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
697 self.job_manager = Some(jobs);
698 self
699 }
700
701 /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
702 /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
703 /// created at kernel construction and passed to every `MemoryFs` the
704 /// kernel builds (see `setup_vfs` and `with_backend`).
705 ///
706 /// Writes that would exceed the cap fail loudly with `StorageFull` — an
707 /// in-band error a model reads and adapts to; fail loud over quietly eating
708 /// RAM. Use `without_vfs_budget` to remove the cap entirely.
709 pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
710 self.vfs_budget_bytes = Some(bytes);
711 self
712 }
713
714 /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
715 ///
716 /// Use when the caller knows the workload and the default 64 MiB cap
717 /// (set by `KernelConfig::agent`) is too conservative.
718 pub fn without_vfs_budget(mut self) -> Self {
719 self.vfs_budget_bytes = None;
720 self
721 }
722
723 /// Enable or disable copy-on-write overlay mode.
724 ///
725 /// When `true`, the primary local filesystem mount is wrapped in an
726 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
727 /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
728 /// and `with_backend` kernels (same — the embedder controls the VFS).
729 pub fn with_overlay(mut self, overlay: bool) -> Self {
730 self.overlay = overlay;
731 self
732 }
733
734}
735
736
737/// Handle to an active overlay session, kept on the kernel and shared to
738/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
739///
740/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
741/// `/home/user`); `commit_root` is the real filesystem path the overlay's
742/// lower is backed by (used as the target for `kaish-vfs commit`).
743#[cfg(all(feature = "localfs", feature = "overlay"))]
744#[derive(Clone)]
745pub struct OverlayHandle {
746 /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
747 /// methods without holding a VfsRouter lock.
748 pub fs: Arc<OverlayFs>,
749 /// VFS path this overlay is mounted at (e.g. `/home/user`).
750 pub mount_path: PathBuf,
751 /// Real filesystem root to commit into. Same as the lower's root.
752 pub commit_root: PathBuf,
753}
754
755/// The Kernel (核) — executes kaish code.
756///
757/// This is the primary interface for running kaish commands. It owns all
758/// the runtime state: variables, tools, VFS, jobs, and persistence.
759pub struct Kernel {
760 /// Kernel name.
761 name: String,
762 /// Variable scope.
763 scope: RwLock<Scope>,
764 /// Tool registry.
765 tools: Arc<ToolRegistry>,
766 /// User-defined tools (from `tool name { body }` statements).
767 user_tools: RwLock<HashMap<String, ToolDef>>,
768 /// Virtual filesystem router.
769 vfs: Arc<VfsRouter>,
770 /// Background job manager.
771 jobs: Arc<JobManager>,
772 /// Pipeline runner.
773 runner: PipelineRunner,
774 /// Execution context (cwd, stdin, etc.).
775 exec_ctx: RwLock<ExecContext>,
776 /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
777 /// retained past construction so `reset()` can re-seed them into the fresh
778 /// scope instead of silently dropping them.
779 initial_vars: HashMap<String, Value>,
780 /// Whether to skip pre-execution validation.
781 skip_validation: bool,
782 /// When true, standalone external commands inherit stdio for real-time output.
783 interactive: bool,
784 /// Whether external command execution is allowed.
785 allow_external_commands: bool,
786 /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
787 ///
788 /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
789 /// `Some` is Arc-cloned into forks so all concurrent execution draws from
790 /// the same pool — a background job's writes reduce the same cap as
791 /// foreground writes, which is the correct behaviour.
792 vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
793 /// Active overlay session handle, if this kernel was constructed with
794 /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
795 /// `kaish-vfs` builtin) can inspect and mutate the overlay without
796 /// holding a kernel write lock. Propagated to forks via `fork_inner`
797 /// and `child_for_pipeline` so `kaish-vfs` works inside background
798 /// jobs, scatter workers, and pipeline stages.
799 #[cfg(all(feature = "localfs", feature = "overlay"))]
800 overlay_handle: Option<Arc<OverlayHandle>>,
801 /// Default per-request timeout (None = no default).
802 request_timeout: Option<Duration>,
803 /// SIGTERM-to-SIGKILL grace period for child kills.
804 kill_grace: Duration,
805 /// Receiver for the kernel stderr stream.
806 ///
807 /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
808 /// The kernel drains this after each statement in `execute_streaming`.
809 stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
810 /// Cancellation token for interrupting execution (Ctrl-C).
811 ///
812 /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
813 /// needs sync access. Each `execute()` call gets a fresh child token;
814 /// `cancel()` cancels the current token and replaces it.
815 cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
816 /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
817 /// installed for the duration of an `execute_with_options` call and
818 /// cleared on exit. Consulted by `is_cancelled()` so every existing
819 /// cancellation checkpoint gains interrupt awareness without new wiring.
820 /// std Mutex for the same sync-access reason as `cancel_token`.
821 interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
822 /// Terminal state for job control (interactive mode only, Unix only).
823 #[cfg(all(unix, feature = "subprocess"))]
824 terminal_state: Option<Arc<crate::terminal::TerminalState>>,
825 /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
826 ///
827 /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
828 /// through the full Kernel resolution chain.
829 self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
830 /// Background job this kernel (a fork) is executing on behalf of, if any.
831 /// Set on the fork created by `execute_background` and inherited by all its
832 /// sub-forks (pipeline stages, scatter workers), so an external command
833 /// spawned anywhere under a background job can record its process group on
834 /// that job for `kill -<sig> %N`. `None` for foreground execution.
835 bg_job_id: Option<crate::scheduler::JobId>,
836 /// Serializes concurrent `execute()` / `execute_streaming()` callers on
837 /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
838 /// queue. Background jobs, scatter workers, and concurrent pipeline
839 /// stages do NOT take this lock — they run against a *forked* Kernel
840 /// (see [`Kernel::fork`]) so they never contend with the foreground.
841 execute_lock: tokio::sync::Mutex<()>,
842 /// Current dynamic statement-engine re-entry depth — incremented on entry
843 /// to command substitution, a shell-function call, or a `.kai` source, and
844 /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
845 /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
846 /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
847 /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
848 /// the recursion chain is single-threaded (top-level `execute` is
849 /// serialized by `execute_lock`; concurrency happens on forks).
850 recursion_depth: AtomicUsize,
851}
852
853/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
854/// (in `enter_recursion`) and decrements on drop, so a cancelled or
855/// error-unwound re-entry can never leave the counter inflated (which would
856/// spuriously trip later, unrelated recursions).
857struct RecursionGuard<'a> {
858 counter: &'a AtomicUsize,
859}
860
861impl Drop for RecursionGuard<'_> {
862 fn drop(&mut self) {
863 self.counter.fetch_sub(1, Ordering::Relaxed);
864 }
865}
866
867/// Internal result of [`Kernel::setup_vfs`].
868struct VfsSetupResult {
869 vfs: VfsRouter,
870 budget: Option<Arc<ByteBudget>>,
871 #[cfg(all(feature = "localfs", feature = "overlay"))]
872 overlay_handle: Option<Arc<OverlayHandle>>,
873}
874
875impl Kernel {
876 /// Create a new kernel with the given configuration.
877 pub fn new(config: KernelConfig) -> Result<Self> {
878 let mut setup = Self::setup_vfs(&config)?;
879 // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
880 // (see `KernelConfig::job_manager`); with none, this kernel owns its
881 // own job table exactly as before.
882 let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
883 // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
884 // kill builtin bounds its wait-for-death on the same number (GH #244).
885 jobs.set_kill_grace(config.kill_grace);
886
887 // Mount JobFs for job observability at /v/jobs
888 setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
889
890 #[cfg(all(feature = "localfs", feature = "overlay"))]
891 let overlay_handle = setup.overlay_handle.take();
892
893 // Mode-based construction: the kernel owns its host mounts, so whether
894 // host side channels are allowed is decided by the VFS mode inside
895 // `assemble` (NoLocal forbids them).
896 let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
897 ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
898 })?;
899
900 #[cfg(all(feature = "localfs", feature = "overlay"))]
901 {
902 let mut kernel = kernel;
903 kernel.overlay_handle = overlay_handle;
904 // Also set it on the ExecContext so builtins can access it.
905 if let Some(ref handle) = kernel.overlay_handle {
906 kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
907 }
908 return Ok(kernel);
909 }
910
911 #[allow(unreachable_code)]
912 Ok(kernel)
913 }
914
915 /// Set up VFS based on mount mode.
916 ///
917 /// Returns the router, the budget handle (if bounded), and an optional
918 /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
919 /// every `MemoryFs` the kernel creates here holds a clone of the same
920 /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
921 /// in-memory content across all kernel-owned memory mounts.
922 ///
923 /// # Errors
924 /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
925 /// (overlay is meaningless when everything is already virtual — there is
926 /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
927 /// this as an `anyhow::Error`.
928 fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
929 let mut vfs = VfsRouter::new();
930
931 // One budget for all memory mounts this kernel owns — labeled so the
932 // error message tells the user exactly which knob to raise.
933 let budget: Option<Arc<ByteBudget>> = config
934 .vfs_budget_bytes
935 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
936
937 /// Helper: construct a `MemoryFs` wired to `budget` if present.
938 fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
939 match budget {
940 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
941 None => MemoryFs::new(),
942 }
943 }
944
945 // Overlay handle — populated below if config.overlay is true.
946 #[cfg(all(feature = "localfs", feature = "overlay"))]
947 let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
948
949 match &config.vfs_mode {
950 #[cfg(feature = "localfs")]
951 VfsMountMode::Passthrough => {
952 #[cfg(feature = "overlay")]
953 if config.overlay {
954 // Wrap "/" in an OverlayFs so writes are virtual.
955 let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
956 let overlay_fs = Arc::new(match &budget {
957 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
958 None => OverlayFs::over(lower),
959 });
960 let handle = Arc::new(OverlayHandle {
961 fs: Arc::clone(&overlay_fs),
962 mount_path: PathBuf::from("/"),
963 commit_root: PathBuf::from("/"),
964 });
965 vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
966 overlay_handle = Some(handle);
967 } else {
968 // LocalFs at "/" — native paths work directly
969 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
970 }
971 #[cfg(not(feature = "overlay"))]
972 {
973 if config.overlay {
974 return Err(anyhow::anyhow!(
975 "overlay=true requires the `overlay` feature, but this build \
976 was compiled without it. Recompile with --features overlay \
977 (or the default feature set) to enable overlay mode."
978 ));
979 }
980 // LocalFs at "/" — native paths work directly
981 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
982 }
983 // Memory for blobs
984 vfs.mount("/v", mem(&budget));
985 }
986 #[cfg(feature = "localfs")]
987 VfsMountMode::Sandboxed { root } => {
988 // Memory at root for safety (catches paths outside sandbox).
989 // Note: /tmp and the XDG runtime dir are LocalFs — writes
990 // there escape the VFS budget and are NOT virtual. This is
991 // intentional: /tmp interop with other processes matters more
992 // than accounting for scratch files there.
993 vfs.mount("/", mem(&budget));
994 vfs.mount("/v", mem(&budget));
995
996 // Synthetic /dev: the host's real /dev isn't reachable here, so
997 // /dev/null and /dev/zero are software-backed (see DevFs).
998 vfs.mount("/dev", DevFs::new());
999
1000 // Real /tmp for interop with other processes
1001 vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1002
1003 // Mount XDG runtime dir for spill files and socket access
1004 let runtime = crate::paths::xdg_runtime_dir();
1005 if runtime.exists() {
1006 let runtime_str = runtime.to_string_lossy().to_string();
1007 vfs.mount(&runtime_str, LocalFs::new(runtime));
1008 }
1009
1010 // Resolve the sandbox root (defaults to $HOME)
1011 let local_root = root.clone().unwrap_or_else(|| {
1012 std::env::var("HOME")
1013 .map(PathBuf::from)
1014 .unwrap_or_else(|_| PathBuf::from("/"))
1015 });
1016
1017 let mount_point = local_root.to_string_lossy().to_string();
1018
1019 #[cfg(feature = "overlay")]
1020 if config.overlay {
1021 // Wrap the sandbox root in an OverlayFs.
1022 let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1023 let overlay_fs = Arc::new(match &budget {
1024 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1025 None => OverlayFs::over(lower),
1026 });
1027 let handle = Arc::new(OverlayHandle {
1028 fs: Arc::clone(&overlay_fs),
1029 mount_path: PathBuf::from(&mount_point),
1030 commit_root: local_root,
1031 });
1032 vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1033 overlay_handle = Some(handle);
1034 } else {
1035 // Mount at the real path for transparent access
1036 // e.g., /home/atobey → LocalFs("/home/atobey")
1037 // so /home/atobey/src/kaish just works
1038 vfs.mount(&mount_point, LocalFs::new(local_root));
1039 }
1040 #[cfg(not(feature = "overlay"))]
1041 {
1042 if config.overlay {
1043 return Err(anyhow::anyhow!(
1044 "overlay=true requires the `overlay` feature, but this build \
1045 was compiled without it. Recompile with --features overlay \
1046 (or the default feature set) to enable overlay mode."
1047 ));
1048 }
1049 // Mount at the real path for transparent access
1050 vfs.mount(&mount_point, LocalFs::new(local_root));
1051 }
1052 }
1053 VfsMountMode::NoLocal => {
1054 if config.overlay {
1055 return Err(anyhow::anyhow!(
1056 "overlay=true is incompatible with VfsMountMode::NoLocal: \
1057 everything is already virtual, there is no real lower layer \
1058 to wrap. Use with_overlay(false) or switch to a Passthrough \
1059 or Sandboxed VFS mode."
1060 ));
1061 }
1062 // Pure memory mode — no local filesystem
1063 vfs.mount("/", mem(&budget));
1064 vfs.mount("/tmp", mem(&budget));
1065 vfs.mount("/v", mem(&budget));
1066 // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1067 vfs.mount("/dev", DevFs::new());
1068 }
1069 }
1070
1071 Ok(VfsSetupResult {
1072 vfs,
1073 budget,
1074 #[cfg(all(feature = "localfs", feature = "overlay"))]
1075 overlay_handle,
1076 })
1077 }
1078
1079 /// Create a transient kernel (no persistence).
1080 pub fn transient() -> Result<Self> {
1081 Self::new(KernelConfig::transient())
1082 }
1083
1084 /// Create a kernel with a custom backend and `/v/*` virtual path support.
1085 ///
1086 /// This is the constructor for embedding kaish in other systems that provide
1087 /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1088 ///
1089 /// A `VirtualOverlayBackend` routes paths automatically:
1090 /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1091 /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1092 /// `/dev/urandom`) — kernel-owned so it works even when your backend is
1093 /// read-only
1094 /// - Everything else → Your custom backend
1095 ///
1096 /// The optional `configure_vfs` closure lets you add additional virtual mounts
1097 /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1098 ///
1099 /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1100 /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1101 /// `skip_validation`, and `interactive`.
1102 ///
1103 /// # Example
1104 ///
1105 /// ```ignore
1106 /// // Simple: default /v/* mounts only
1107 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1108 ///
1109 /// // With custom mounts
1110 /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1111 /// vfs.mount_arc("/v/docs", docs_fs);
1112 /// vfs.mount_arc("/v/g", git_fs);
1113 /// }, |_| {})?;
1114 ///
1115 /// // With custom tools
1116 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1117 /// tools.register(MyCustomTool::new());
1118 /// })?;
1119 /// ```
1120 pub fn with_backend(
1121 backend: Arc<dyn KernelBackend>,
1122 config: KernelConfig,
1123 configure_vfs: impl FnOnce(&mut VfsRouter),
1124 configure_tools: impl FnOnce(&mut ToolRegistry),
1125 ) -> Result<Self> {
1126 use crate::backend::VirtualOverlayBackend;
1127
1128 // overlay=true is incompatible with with_backend: the embedder controls
1129 // the VFS and the kernel cannot wrap it without bypassing the embedder's
1130 // semantics. Fail loudly rather than silently ignoring the flag.
1131 if config.overlay {
1132 return Err(anyhow::anyhow!(
1133 "overlay=true is incompatible with Kernel::with_backend: the embedder \
1134 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1135 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1136 ));
1137 }
1138
1139 let mut vfs = VfsRouter::new();
1140 // See `Kernel::new` — the embedder's manager wins here too.
1141 let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1142 // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1143 // kill builtin bounds its wait-for-death on the same number (GH #244).
1144 jobs.set_kill_grace(config.kill_grace);
1145
1146 // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1147 // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1148 // kernel-owned memory mount here — embedders own the rest of the VFS.
1149 let vfs_budget: Option<Arc<ByteBudget>> = config
1150 .vfs_budget_bytes
1151 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1152
1153 vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1154 let blobs_fs = match &vfs_budget {
1155 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1156 None => MemoryFs::new(),
1157 };
1158 vfs.mount("/v/blobs", blobs_fs);
1159
1160 // /dev/null and friends are software-backed (see DevFs) and must not
1161 // depend on the embedder's backend — a read-only embedder backend
1162 // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1163 // to /dev/null as a filesystem error instead of discarding them.
1164 vfs.mount("/dev", DevFs::new());
1165
1166 // Let caller add custom mounts (e.g., /v/docs, /v/g)
1167 configure_vfs(&mut vfs);
1168
1169 // A custom-backend kernel owns no host mounts — the embedder supplies
1170 // the entire VFS — so any kernel write to a host filesystem via
1171 // `std::fs` (output spill, job output files) bypasses that VFS and its
1172 // read-only guarantees. Forbid host side channels unconditionally.
1173 Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1174 let overlay: Arc<dyn KernelBackend> =
1175 Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1176 ExecContext::with_backend(overlay)
1177 })
1178 }
1179
1180 /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1181 ///
1182 /// The `make_ctx` closure receives the VFS and tools so backends that need
1183 /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1184 /// that already have their own storage can ignore these parameters.
1185 fn assemble(
1186 config: KernelConfig,
1187 mut vfs: VfsRouter,
1188 jobs: Arc<JobManager>,
1189 no_host_filesystem: bool,
1190 vfs_budget: Option<Arc<ByteBudget>>,
1191 configure_tools: impl FnOnce(&mut ToolRegistry),
1192 make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1193 ) -> Result<Self> {
1194 // A kernel with no host filesystem of its own must never write to one
1195 // through a side channel. Two paths bypass the VFS by going straight to
1196 // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1197 // background-job output files (`Job::write_output_file` → host temp).
1198 // Both would punch through the isolation, so force them off:
1199 // in-memory truncation for spill, no host file for job output.
1200 //
1201 // This is true for a `NoLocal` kernel (mounts nothing) and for any
1202 // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1203 // VFS, so the kernel controls no host mounts and any host write is a
1204 // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1205 // when there is no kernel-owned host filesystem to spill to.
1206 let no_host_side_channel =
1207 no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1208
1209 let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1210
1211 if no_host_side_channel {
1212 output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1213 jobs.set_persist_output_files(false);
1214 }
1215
1216 let mut tools = ToolRegistry::new();
1217 register_builtins(&mut tools);
1218 configure_tools(&mut tools);
1219 let tools = Arc::new(tools);
1220
1221 // Mount BuiltinFs so `ls /v/bin` lists builtins
1222 vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1223
1224 let vfs = Arc::new(vfs);
1225
1226 let runner = PipelineRunner::new(tools.clone());
1227
1228 let (stderr_writer, stderr_receiver) = stderr_stream();
1229
1230 let mut exec_ctx = make_ctx(&vfs, &tools);
1231 exec_ctx.set_cwd(cwd);
1232 exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1233 exec_ctx.set_job_manager(jobs.clone());
1234 exec_ctx.set_tool_schemas(tools.schemas());
1235 exec_ctx.set_tools(tools.clone());
1236 #[cfg(feature = "os-integration")]
1237 exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1238 exec_ctx.stderr = Some(stderr_writer);
1239 exec_ctx.ignore_config = ignore_config;
1240 exec_ctx.output_limit = output_limit;
1241 exec_ctx.allow_external_commands = allow_external_commands;
1242 exec_ctx.vfs_budget = vfs_budget.clone();
1243
1244 Ok(Self {
1245 name,
1246 scope: RwLock::new({
1247 let mut scope = Scope::new();
1248 scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1249 // HOME is NOT read from the host env here — the kernel is
1250 // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1251 // below (from `std::env::vars()`); a hermetic embedder leaves
1252 // `initial_vars` empty and gets no HOME (tilde stays literal).
1253 // Apply caller-supplied initial variables, all marked exported.
1254 // Frontends (REPL, MCP) populate this from std::env::vars()
1255 // for shell-like UX; embedders that want hermetic behavior
1256 // simply leave it empty.
1257 for (name, value) in initial_vars.clone() {
1258 scope.set_exported(name, value);
1259 }
1260 scope.set_trash_enabled(trash_enabled);
1261 scope
1262 }),
1263 initial_vars,
1264 tools,
1265 user_tools: RwLock::new(HashMap::new()),
1266 vfs,
1267 jobs,
1268 runner,
1269 exec_ctx: RwLock::new(exec_ctx),
1270 skip_validation,
1271 interactive,
1272 allow_external_commands,
1273 vfs_budget,
1274 request_timeout,
1275 kill_grace,
1276 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1277 cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1278 interrupt: std::sync::Mutex::new(None),
1279 #[cfg(all(unix, feature = "subprocess"))]
1280 terminal_state: None,
1281 self_weak: std::sync::OnceLock::new(),
1282 execute_lock: tokio::sync::Mutex::new(()),
1283 recursion_depth: AtomicUsize::new(0),
1284 bg_job_id: None,
1285 // Overlay handle is set by Kernel::new after assemble returns;
1286 // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1287 // with_backend always has None (overlay=true is rejected above).
1288 #[cfg(all(feature = "localfs", feature = "overlay"))]
1289 overlay_handle: None,
1290 })
1291 }
1292
1293 /// Plan every statement of `source` without executing anything —
1294 /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1295 /// embedder holding a kernel can pair the plans with `get_var` lookups
1296 /// against this kernel's live state.
1297 ///
1298 /// # Errors
1299 ///
1300 /// Returns the parse errors when `source` does not parse.
1301 pub fn plan_program(
1302 &self,
1303 source: &str,
1304 ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1305 crate::ast::plan::plan_program(source)
1306 }
1307
1308 /// Get the kernel name.
1309 pub fn name(&self) -> &str {
1310 &self.name
1311 }
1312
1313 /// Wrap this Kernel in an Arc and initialize its self-reference.
1314 ///
1315 /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1316 /// to child contexts, allowing builtins like `timeout` to dispatch inner
1317 /// commands through the full resolution chain (user tools → builtins →
1318 /// .kai scripts → external commands).
1319 pub fn into_arc(self) -> Arc<Self> {
1320 let arc = Arc::new(self);
1321 let _ = arc.self_weak.set(Arc::downgrade(&arc));
1322 arc
1323 }
1324
1325 /// Fork a subsidiary kernel for concurrent execution.
1326 ///
1327 /// The fork is a fully-functional `Kernel` that:
1328 /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1329 /// user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1330 /// the fork do NOT propagate back to the parent — matching bash
1331 /// subshell / background-job semantics.
1332 /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1333 /// registry, the VFS router, and the job manager. A job registered by
1334 /// the fork is visible to the parent's `jobs` builtin, and the fork
1335 /// sees the same VFS mounts.
1336 /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1337 /// `execute_lock`. It is never the TTY owner, so `interactive` is
1338 /// `false` and `terminal_state` is `None`.
1339 ///
1340 /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1341 /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1342 /// routes through the fork itself, not the parent — which is essential
1343 /// for concurrency safety.
1344 ///
1345 /// Use this for **detached** background concurrency where the fork should
1346 /// survive parent cancellation: the `&` background-job operator and any
1347 /// other "fire and forget" worker. The fork gets a fresh, independent
1348 /// cancellation token.
1349 ///
1350 /// For foreground concurrency (scatter workers, concurrent pipeline
1351 /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1352 /// into the fork's external children, use [`Self::fork_attached`].
1353 pub async fn fork(&self) -> Arc<Self> {
1354 self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1355 .await
1356 }
1357
1358 /// Fork attached to the parent's cancellation.
1359 ///
1360 /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1361 /// the parent's. When the parent cancels (request timeout, embedder
1362 /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1363 /// turn kills any external children spawned in the fork via the
1364 /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1365 pub async fn fork_attached(&self) -> Arc<Self> {
1366 let child_token = {
1367 #[allow(clippy::expect_used)]
1368 let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1369 parent.child_token()
1370 };
1371 self.fork_inner(child_token, self.bg_job_id).await
1372 }
1373
1374 /// Fork for a background job, stamping the job id so external commands
1375 /// spawned anywhere beneath it record their process groups on that job
1376 /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1377 /// `JobManager::cancel`.
1378 pub async fn fork_for_background(
1379 &self,
1380 cancel: tokio_util::sync::CancellationToken,
1381 job_id: crate::scheduler::JobId,
1382 ) -> Arc<Self> {
1383 self.fork_inner(cancel, Some(job_id)).await
1384 }
1385
1386 /// Shared fork implementation. Caller decides the cancellation token and
1387 /// which background job (if any) this fork runs on behalf of.
1388 async fn fork_inner(
1389 &self,
1390 cancel: tokio_util::sync::CancellationToken,
1391 bg_job_id: Option<crate::scheduler::JobId>,
1392 ) -> Arc<Self> {
1393 let scope_snapshot = self.scope.read().await.clone();
1394 let user_tools_snapshot = self.user_tools.read().await.clone();
1395
1396 // Snapshot exec_ctx by cloning the cloneable fields, then override
1397 // the ones that should not carry over (stderr channel, dispatcher,
1398 // interactive flag, terminal state, cancel — set from `cancel` arg).
1399 let mut fork_ctx = {
1400 let parent_ctx = self.exec_ctx.read().await;
1401 parent_ctx.child_for_pipeline()
1402 };
1403 let (stderr_writer, stderr_receiver) = stderr_stream();
1404 fork_ctx.stderr = Some(stderr_writer);
1405 // Clear dispatcher; dispatch_command will repopulate it to point at
1406 // the fork on the first dispatch call.
1407 fork_ctx.dispatcher = None;
1408 fork_ctx.interactive = false;
1409 fork_ctx.cancel = cancel.clone();
1410 #[cfg(all(unix, feature = "subprocess"))]
1411 {
1412 fork_ctx.terminal_state = None;
1413 }
1414
1415 let fork = Self {
1416 name: format!("{}:fork", self.name),
1417 scope: RwLock::new(scope_snapshot),
1418 initial_vars: self.initial_vars.clone(),
1419 tools: Arc::clone(&self.tools),
1420 user_tools: RwLock::new(user_tools_snapshot),
1421 vfs: Arc::clone(&self.vfs),
1422 jobs: Arc::clone(&self.jobs),
1423 runner: self.runner.clone(),
1424 exec_ctx: RwLock::new(fork_ctx),
1425 skip_validation: self.skip_validation,
1426 // Forks are never the TTY owner — they run in the background.
1427 interactive: false,
1428 allow_external_commands: self.allow_external_commands,
1429 // Arc-clone the budget so the fork draws from the same pool as the
1430 // parent — background jobs and scatter workers count against the same
1431 // cap as foreground writes.
1432 vfs_budget: self.vfs_budget.clone(),
1433 request_timeout: self.request_timeout,
1434 kill_grace: self.kill_grace,
1435 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1436 cancel_token: std::sync::Mutex::new(cancel),
1437 interrupt: std::sync::Mutex::new(None),
1438 #[cfg(all(unix, feature = "subprocess"))]
1439 terminal_state: None,
1440 self_weak: std::sync::OnceLock::new(),
1441 execute_lock: tokio::sync::Mutex::new(()),
1442 // A fork runs on a fresh stack (spawned task) — its recursion
1443 // budget is independent of the parent's current depth (GH #46).
1444 recursion_depth: AtomicUsize::new(0),
1445 // A fork surfaces its own holds; the parent's slot stays put.
1446 bg_job_id,
1447 // Arc-clone the overlay handle so forks (background jobs, scatter
1448 // workers, pipeline stages) can reach the same overlay transaction
1449 // via `kaish-vfs status/diff/commit/reset`.
1450 #[cfg(all(feature = "localfs", feature = "overlay"))]
1451 overlay_handle: self.overlay_handle.clone(),
1452 };
1453
1454 fork.into_arc()
1455 }
1456
1457 /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1458 ///
1459 /// Returns `None` if the Kernel was not wrapped, or if all strong references
1460 /// have been dropped (the `Weak` can no longer upgrade).
1461 pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1462 self.self_weak
1463 .get()
1464 .and_then(|weak| weak.upgrade())
1465 .map(|arc| arc as Arc<dyn CommandDispatcher>)
1466 }
1467
1468 /// Initialize terminal state for interactive job control.
1469 ///
1470 /// Call this after kernel creation when running as an interactive REPL
1471 /// and stdin is a TTY. Sets up process groups and signal handling.
1472 #[cfg(all(unix, feature = "subprocess"))]
1473 pub fn init_terminal(&mut self) {
1474 if !self.interactive {
1475 return;
1476 }
1477 match crate::terminal::TerminalState::init() {
1478 Ok(state) => {
1479 let state = Arc::new(state);
1480 self.terminal_state = Some(state.clone());
1481 // Set on exec_ctx so builtins (fg, bg, kill) can access it
1482 self.exec_ctx.get_mut().terminal_state = Some(state);
1483 tracing::debug!("terminal job control initialized");
1484 }
1485 Err(e) => {
1486 tracing::warn!("failed to initialize terminal job control: {}", e);
1487 }
1488 }
1489 }
1490
1491 /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1492 ///
1493 /// The kernel installs the OS trash (`SystemTrash`) automatically when
1494 /// built with the `os-integration` feature. Embedders and tests can swap
1495 /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1496 /// it — with trash enabled but no backend present, `rm` fails loud
1497 /// rather than falling through to permanent delete.
1498 pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1499 self.exec_ctx.get_mut().trash_backend = backend;
1500 }
1501
1502 /// Cancel the current execution.
1503 ///
1504 /// This cancels the current cancellation token, causing any execution
1505 /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1506 /// A fresh token is installed for the next `execute()` call.
1507 pub fn cancel(&self) {
1508 #[allow(clippy::expect_used)]
1509 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1510 token.cancel();
1511 }
1512
1513 /// Check if the current execution has been cancelled.
1514 ///
1515 /// Also the polling point for `ExecuteOptions::interrupt`: when the
1516 /// embedder's check reports true, the internal token fires here, so every
1517 /// call site of this method is an interrupt checkpoint for free.
1518 pub fn is_cancelled(&self) -> bool {
1519 let interrupted = {
1520 #[allow(clippy::expect_used)]
1521 let check = self.interrupt.lock().expect("interrupt poisoned");
1522 check.as_ref().is_some_and(|f| f())
1523 };
1524 if interrupted {
1525 self.cancel();
1526 }
1527 #[allow(clippy::expect_used)]
1528 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1529 token.is_cancelled()
1530 }
1531
1532 /// Reset the cancellation token (called at the start of each execute).
1533 fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1534 #[allow(clippy::expect_used)]
1535 let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1536 if token.is_cancelled() {
1537 *token = tokio_util::sync::CancellationToken::new();
1538 }
1539 token.clone()
1540 }
1541
1542 /// Acquire the per-Kernel execute lock, warning on contention.
1543 ///
1544 /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1545 /// the lock is already held, emit a warning so the silent serialization
1546 /// is observable in logs — if you need real parallelism, fork the kernel.
1547 async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1548 match self.execute_lock.try_lock() {
1549 Ok(guard) => guard,
1550 Err(_) => {
1551 tracing::warn!(
1552 target: "kaish::kernel::concurrency",
1553 kernel = %self.name,
1554 "execute() contended — serializing concurrent caller; \
1555 use Kernel::fork() for parallelism instead of sharing"
1556 );
1557 self.execute_lock.lock().await
1558 }
1559 }
1560 }
1561
1562 /// Execute kaish source code with default options.
1563 ///
1564 /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1565 /// Returns the result of the last statement executed.
1566 pub async fn execute(&self, input: &str) -> Result<ExecResult> {
1567 self.run_inner(input, ExecuteOptions::default(), None, None).await
1568 }
1569
1570 /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1571 /// are **already tokenized**.
1572 ///
1573 /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1574 /// that already holds OS/structured argv (a busybox-style multicall binary, a
1575 /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1576 /// into a string just to have the lexer split it apart again — a round-trip
1577 /// that is lossy for typed values, since `to_argv()` stringifies
1578 /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1579 ///
1580 /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1581 /// command substitution, no word splitting — the "single-quoted word"
1582 /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1583 /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1584 /// does still apply, for consistency with the string door: a leading `~` is
1585 /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1586 /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1587 /// non-string `Value`
1588 /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1589 /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1590 /// clap arg model means a builtin that re-parses its own `to_argv()` still
1591 /// sees a stringified value; the typed-passthrough win fully lands only for
1592 /// builtins that read `args.positional` directly — the documented pattern.)
1593 ///
1594 /// This is a *peer*, not a subset: a command string can carry pipelines,
1595 /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1596 /// doors converge **late** (at the shared dispatch chain) rather than one
1597 /// wrapping the other. From argv classification onward `execute_argv` reuses
1598 /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1599 /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1600 /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1601 /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1602 /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1603 ///
1604 /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1605 /// and the kernel's configured `request_timeout` applies (a hung builtin or
1606 /// external is interrupted at the deadline with exit code 124, the same as the
1607 /// string door). There is no per-call options surface yet — a future
1608 /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1609 #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1610 pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1611 let _guard = self.acquire_execute_lock().await;
1612 self.execute_argv_locked(name, argv).await
1613 }
1614
1615 /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1616 async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1617 // Fresh cancel surface for this call: `execute_pipeline` reads
1618 // `self.cancel_token`, so a stale cancelled token from a prior call must be
1619 // replaced first. The returned clone is the token the watchdog cancels on
1620 // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1621 // cascading SIGTERM/SIGKILL to any external child.
1622 let cancel = self.reset_cancel();
1623
1624 // Honor the kernel-configured request timeout for parity with `execute`.
1625 let timeout = self.request_timeout;
1626 if timeout == Some(Duration::ZERO) {
1627 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1628 }
1629
1630 let command = crate::ast::Command {
1631 name: name.to_string(),
1632 args: argv_to_args(argv),
1633 redirects: Vec::new(),
1634 };
1635
1636 let pipeline = crate::ast::Pipeline {
1637 commands: vec![command],
1638 background: false,
1639 };
1640 let work = async {
1641 let result = self.execute_pipeline(&pipeline).await?;
1642 // A gate raised while evaluating inside the dispatched tool — a
1643 // user tool body's `$(…)` — surfaces as this call's own held
1644 // result, and must not strand in the slot for the next serialized
1645 // call to mis-take.
1646 Ok(result)
1647 };
1648 let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1649 self.update_last_result(&result).await;
1650 Ok(result)
1651 }
1652
1653 /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1654 /// string door ([`Self::execute_with_options`]) and the argv door
1655 /// ([`Self::execute_argv`]).
1656 ///
1657 /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1658 /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1659 /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1660 /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1661 /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1662 /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1663 /// stale handle would silently suspend nothing). Callers must short-circuit a
1664 /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1665 async fn run_under_watchdog<F>(
1666 &self,
1667 timeout: Option<Duration>,
1668 cancel: &tokio_util::sync::CancellationToken,
1669 work: F,
1670 ) -> Result<ExecResult>
1671 where
1672 F: std::future::Future<Output = Result<ExecResult>>,
1673 {
1674 // Assigned unconditionally (clearing any stale handle); None without a timeout.
1675 let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1676 {
1677 let mut ec = self.exec_ctx.write().await;
1678 ec.watchdog = watchdog.clone();
1679 }
1680
1681 let result = if let Some(d) = timeout {
1682 #[allow(clippy::expect_used)]
1683 let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1684 let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1685 let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1686 let r = work.await;
1687 timer.abort();
1688 match r {
1689 Ok(mut res) => {
1690 if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1691 res.code = 124;
1692 if res.err.is_empty() {
1693 res.err = format!("timeout: timed out after {:?}", d);
1694 }
1695 }
1696 Ok(res)
1697 }
1698 Err(e) => Err(e),
1699 }
1700 } else {
1701 work.await
1702 };
1703
1704 // The timer task is gone (fired or aborted); drop the stale handle.
1705 {
1706 let mut ec = self.exec_ctx.write().await;
1707 ec.watchdog = None;
1708 }
1709 result
1710 }
1711
1712 /// Execute with per-call options. The primary entry point for embedders
1713 /// that don't need per-statement output streaming.
1714 ///
1715 /// `opts` carries timeout, transient vars overlay, optional cwd override,
1716 /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1717 /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1718 ///
1719 /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1720 /// against the kernel's internal token. Either firing cancels and kills
1721 /// external children. The embedder's token is read-only — kernel
1722 /// timeouts do NOT propagate into it. Distinguish via the returned
1723 /// `code`: 124 = timeout, 130 = cancellation.
1724 ///
1725 /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1726 /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1727 ///
1728 /// Concurrent callers on the same Kernel serialize on the kernel-wide
1729 /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1730 /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1731 pub async fn execute_with_options(
1732 &self,
1733 input: &str,
1734 opts: ExecuteOptions,
1735 ) -> Result<ExecResult> {
1736 self.run_inner(input, opts, None, None).await
1737 }
1738
1739 /// Same as [`Self::execute_with_options`] but with a per-statement output
1740 /// callback. The callback fires after each top-level statement so the
1741 /// embedder (REPL, MCP streaming) can flush output incrementally.
1742 pub async fn execute_with_options_streaming(
1743 &self,
1744 input: &str,
1745 opts: ExecuteOptions,
1746 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1747 ) -> Result<ExecResult> {
1748 self.run_inner(input, opts, None, Some(on_output)).await
1749 }
1750
1751 /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1752 ///
1753 /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1754 /// forces the input to be drained before execution: the reader seeds the
1755 /// first top-level command's `pipe_stdin`, and a command that does not read
1756 /// stdin (`echo`) returns without touching it. This is the seam a
1757 /// non-interactive frontend uses to forward an *open* process stdin without
1758 /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1759 ///
1760 /// Embedders that already hold a complete buffer (text or binary) should
1761 /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1762 pub async fn execute_with_pipe_stdin(
1763 &self,
1764 input: &str,
1765 opts: ExecuteOptions,
1766 pipe_stdin: crate::scheduler::PipeReader,
1767 ) -> Result<ExecResult> {
1768 self.run_inner(input, opts, Some(pipe_stdin), None).await
1769 }
1770
1771 /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1772 /// `-c`/script frontend uses this to print output incrementally while
1773 /// feeding a lazy process-stdin pipe.
1774 pub async fn execute_with_pipe_stdin_streaming(
1775 &self,
1776 input: &str,
1777 opts: ExecuteOptions,
1778 pipe_stdin: crate::scheduler::PipeReader,
1779 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1780 ) -> Result<ExecResult> {
1781 self.run_inner(input, opts, Some(pipe_stdin), Some(on_output)).await
1782 }
1783
1784 /// Execute kaish source code with a transient overlay of exported variables.
1785 ///
1786 /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1787 /// should use that method directly:
1788 /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1789 #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1790 pub async fn execute_with_vars(
1791 &self,
1792 input: &str,
1793 vars: HashMap<String, Value>,
1794 ) -> Result<ExecResult> {
1795 self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None).await
1796 }
1797
1798 /// Execute kaish source code with a per-statement callback.
1799 ///
1800 /// Deprecated thin wrapper. New code should use
1801 /// [`Self::execute_with_options_streaming`].
1802 #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1803 pub async fn execute_streaming(
1804 &self,
1805 input: &str,
1806 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1807 ) -> Result<ExecResult> {
1808 self.run_inner(input, ExecuteOptions::default(), None, Some(on_output)).await
1809 }
1810
1811 /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1812 ///
1813 /// The `#[instrument]` execution span resolves its parent from the *current*
1814 /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1815 /// captured when the span is first entered — not when the future is
1816 /// constructed. So a thread-local `attach()` scoped to construction is too
1817 /// early to be seen (the integration test confirms this). `with_context`
1818 /// re-attaches the embedder's context on *every* poll of the inner future,
1819 /// so the context is current at first-enter and survives runtime thread
1820 /// hops. With no embedder trace context, the future runs unwrapped.
1821 async fn run_inner(
1822 &self,
1823 input: &str,
1824 opts: ExecuteOptions,
1825 pipe_stdin: Option<crate::scheduler::PipeReader>,
1826 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1827 ) -> Result<ExecResult> {
1828 use opentelemetry::context::FutureExt;
1829
1830 // Capture the embedder's baggage before `opts` is consumed so it can be
1831 // echoed back onto the result on egress (see `merge_egress_baggage`).
1832 let embedder_baggage = opts.baggage.clone();
1833
1834 let result = match crate::telemetry::extract_parent(&opts) {
1835 Some(parent) => self
1836 .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1837 .with_context(parent)
1838 .await,
1839 None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1840 };
1841
1842 result.map(|mut r| {
1843 crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1844 r
1845 })
1846 }
1847
1848 /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1849 /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1850 /// cwd override, and timeout race.
1851 #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1852 async fn execute_with_options_inner(
1853 &self,
1854 input: &str,
1855 opts: ExecuteOptions,
1856 pipe_stdin: Option<crate::scheduler::PipeReader>,
1857 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1858 ) -> Result<ExecResult> {
1859 let _guard = self.acquire_execute_lock().await;
1860
1861 // Always reset to a fresh internal token; this is the kernel's own
1862 // cancel surface for embedders calling `Kernel::cancel()`. The
1863 // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1864 // is NOT written into `self.cancel_token`, because doing so would
1865 // (a) leak the embedder's token past this call's lifetime,
1866 // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1867 // (c) extend the token's lifetime via the kernel's strong clone.
1868 let internal = self.reset_cancel();
1869
1870 // Install the per-call polled interrupt for `is_cancelled()` to
1871 // consult. The guard clears it on every exit path — a stale check
1872 // must not outlive its call and fire into a later one.
1873 struct ClearInterrupt<'a>(&'a Kernel);
1874 impl Drop for ClearInterrupt<'_> {
1875 fn drop(&mut self) {
1876 if let Ok(mut slot) = self.0.interrupt.lock() {
1877 *slot = None;
1878 }
1879 }
1880 }
1881 {
1882 #[allow(clippy::expect_used)]
1883 let mut slot = self.interrupt.lock().expect("interrupt poisoned");
1884 *slot = opts.interrupt.clone();
1885 }
1886 let _interrupt_guard = ClearInterrupt(self);
1887
1888 // Race the embedder token against the kernel's internal token via a
1889 // tracked watcher task. We hold the JoinHandle so we can abort the
1890 // task at function exit — otherwise it would wait forever for either
1891 // token to fire and leak per call.
1892 let (effective_cancel, watcher_handle): (
1893 tokio_util::sync::CancellationToken,
1894 Option<tokio::task::JoinHandle<()>>,
1895 ) = if let Some(ext) = opts.cancel_token {
1896 let combined = tokio_util::sync::CancellationToken::new();
1897 let combined_writer = combined.clone();
1898 let i = internal.clone();
1899 let handle = tokio::spawn(async move {
1900 tokio::select! {
1901 _ = i.cancelled() => combined_writer.cancel(),
1902 _ = ext.cancelled() => combined_writer.cancel(),
1903 }
1904 });
1905 (combined, Some(handle))
1906 } else {
1907 (internal, None)
1908 };
1909
1910 // Effective timeout: per-call wins over kernel-config default.
1911 let timeout = opts.timeout.or(self.request_timeout);
1912
1913 // ZERO timeout: return 124 immediately without spawning anything.
1914 if timeout == Some(Duration::ZERO) {
1915 if let Some(h) = watcher_handle {
1916 h.abort();
1917 }
1918 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1919 }
1920
1921 // Apply per-call vars overlay (push frame + set_exported), wrapped in
1922 // an RAII guard so a panic inside `execute_streaming_inner` still
1923 // pops the frame and unexports the temporarily-exported names.
1924 struct VarsFrameGuard<'a> {
1925 kernel: &'a Kernel,
1926 newly_exported: Vec<String>,
1927 }
1928 impl Drop for VarsFrameGuard<'_> {
1929 fn drop(&mut self) {
1930 // Best-effort cleanup using try_write. The execute_lock held
1931 // throughout execute_with_options means there is no concurrent
1932 // foreground caller; forks have their own scope and won't
1933 // block this. blocking_write would deadlock the runtime when
1934 // called from a tokio worker thread, so we explicitly do NOT
1935 // fall back to it — if try_write fails (which we've never
1936 // seen in practice), log loudly and accept the leak rather
1937 // than deadlock the entire kernel.
1938 let Ok(mut scope) = self.kernel.scope.try_write() else {
1939 tracing::error!(
1940 "vars frame guard: scope lock unexpectedly busy; \
1941 skipping pop_frame to avoid runtime deadlock — \
1942 transient vars may leak"
1943 );
1944 return;
1945 };
1946 scope.pop_frame();
1947 for name in self.newly_exported.drain(..) {
1948 scope.unexport(&name);
1949 }
1950 }
1951 }
1952
1953 // Per-call cwd override: save current cwd, set the new one, restore
1954 // on Drop so the kernel's persistent cwd doesn't leak between calls.
1955 // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
1956 struct CwdGuard<'a> {
1957 kernel: &'a Kernel,
1958 saved: PathBuf,
1959 }
1960 impl Drop for CwdGuard<'_> {
1961 fn drop(&mut self) {
1962 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1963 tracing::error!(
1964 "cwd guard: exec_ctx lock unexpectedly busy; \
1965 skipping cwd restore — kernel cwd may be wrong for next call"
1966 );
1967 return;
1968 };
1969 ec.cwd = std::mem::take(&mut self.saved);
1970 }
1971 }
1972 let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
1973 let mut ec = self.exec_ctx.write().await;
1974 let saved = std::mem::replace(&mut ec.cwd, new_cwd);
1975 drop(ec);
1976 Some(CwdGuard { kernel: self, saved })
1977 } else {
1978 None
1979 };
1980
1981 // Per-call stdin: seed the persistent exec_ctx so the first top-level
1982 // command that reads stdin consumes it (it's `take()`n at dispatch).
1983 // Restore the prior value on Drop — normally `None`, so this also drops
1984 // any residual seed an stdin-less program never consumed, keeping it
1985 // from bleeding into the next call. Same RAII pattern as CwdGuard.
1986 struct StdinGuard<'a> {
1987 kernel: &'a Kernel,
1988 saved: Option<Vec<u8>>,
1989 }
1990 impl Drop for StdinGuard<'_> {
1991 fn drop(&mut self) {
1992 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
1993 tracing::error!(
1994 "stdin guard: exec_ctx lock unexpectedly busy; \
1995 skipping stdin restore — stale stdin may leak to next call"
1996 );
1997 return;
1998 };
1999 ec.stdin = self.saved.take();
2000 }
2001 }
2002 let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2003 let mut ec = self.exec_ctx.write().await;
2004 let saved = ec.stdin.replace(stdin);
2005 drop(ec);
2006 Some(StdinGuard { kernel: self, saved })
2007 } else {
2008 None
2009 };
2010
2011 // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2012 // persistent exec_ctx so the first stdin-reading command drains it (it's
2013 // `take()`n at pipeline build). The RAII guard restores the prior value
2014 // on Drop (normally `None`), so an unread reader doesn't bleed into the
2015 // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2016 struct PipeStdinGuard<'a> {
2017 kernel: &'a Kernel,
2018 saved: Option<crate::scheduler::PipeReader>,
2019 }
2020 impl Drop for PipeStdinGuard<'_> {
2021 fn drop(&mut self) {
2022 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2023 tracing::error!(
2024 "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2025 skipping restore — stale pipe stdin may leak to next call"
2026 );
2027 return;
2028 };
2029 ec.pipe_stdin = self.saved.take();
2030 }
2031 }
2032 let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2033 let mut ec = self.exec_ctx.write().await;
2034 let saved = ec.pipe_stdin.replace(reader);
2035 drop(ec);
2036 Some(PipeStdinGuard { kernel: self, saved })
2037 } else {
2038 None
2039 };
2040
2041 let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2042 let mut scope = self.scope.write().await;
2043 scope.push_frame();
2044 let mut newly = Vec::with_capacity(opts.vars.len());
2045 for (name, value) in opts.vars {
2046 if !scope.is_exported(&name) {
2047 newly.push(name.clone());
2048 }
2049 scope.set_exported(name, value);
2050 }
2051 drop(scope);
2052 Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2053 } else {
2054 None
2055 };
2056
2057 // Sync the effective cancel into self.exec_ctx so try_execute_external
2058 // (which reads via self.cancel_token) sees cancellation. We also need
2059 // builtins to see it via ctx.cancel — handled in execute_command.
2060 // For simplicity here we mirror effective_cancel into self.cancel_token
2061 // for the duration of this call, then restore the internal token at
2062 // the end (so a later Kernel::cancel still hits our internal surface).
2063 {
2064 #[allow(clippy::expect_used)]
2065 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2066 *cur = effective_cancel.clone();
2067 }
2068
2069 // Run the script under the movable-deadline watchdog (shared with the
2070 // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2071 // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2072 // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2073 // already handled by the early return above.
2074 let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2075 let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2076 Some(cb) => cb,
2077 None => &mut *noop_cb,
2078 };
2079
2080 let result = self
2081 .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2082 .await;
2083
2084 // Restore self.cancel_token to a fresh, uncancelled token so the
2085 // embedder's view of `Kernel::cancel()` stays predictable on the
2086 // next call (it cancels the kernel's own token, not whatever was
2087 // left over from this call's combined token).
2088 {
2089 #[allow(clippy::expect_used)]
2090 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2091 *cur = tokio_util::sync::CancellationToken::new();
2092 }
2093
2094 // Tear down the embedder-token race watcher (if any). Leaving it
2095 // alive would idle forever waiting for tokens that may never fire.
2096 if let Some(h) = watcher_handle {
2097 h.abort();
2098 }
2099
2100 // VarsFrameGuard drops here on the success path and on early-return
2101 // paths above (error path included). Panic safety preserved.
2102 result
2103 }
2104
2105 /// The actual body of `execute_streaming`, run while holding the execute lock.
2106 ///
2107 /// Split out so internal kernel paths that are already under the lock can
2108 /// call this without deadlocking on re-entry. External callers must go
2109 /// through [`Self::execute_streaming`] so they acquire the lock.
2110 async fn execute_streaming_inner(
2111 &self,
2112 input: &str,
2113 on_output: &mut (dyn FnMut(&ExecResult) + Send),
2114 ) -> Result<ExecResult> {
2115 let program = parse(input).map_err(|errors| {
2116 let msg = errors
2117 .iter()
2118 .map(|e| e.format(input))
2119 .collect::<Vec<_>>()
2120 .join("\n");
2121 anyhow::anyhow!("parse error:\n{}", msg)
2122 })?;
2123
2124 // AST display mode: show AST instead of executing
2125 {
2126 let scope = self.scope.read().await;
2127 if scope.show_ast() {
2128 let output = format!("{:#?}\n", program);
2129 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2130 }
2131 }
2132
2133 // Pre-execution validation. Most warnings stay trace-only (every
2134 // external command fires an `UndefinedCommand` warning), but a warning
2135 // whose code opts into agent surfacing is collected here and prepended
2136 // to the result's stderr at each return point below.
2137 let mut surfaced_warnings = String::new();
2138 if !self.skip_validation {
2139 let user_tools = self.user_tools.read().await;
2140 let validator = Validator::new(&self.tools, &user_tools);
2141 let issues = validator.validate(&program);
2142
2143 // Collect errors (warnings are logged but don't prevent execution)
2144 let errors: Vec<_> = issues
2145 .iter()
2146 .filter(|i| i.severity == Severity::Error)
2147 .collect();
2148
2149 if !errors.is_empty() {
2150 let error_msg = errors
2151 .iter()
2152 .map(|e| e.format(input))
2153 .collect::<Vec<_>>()
2154 .join("\n");
2155 return Err(anyhow::anyhow!("validation failed:\n{}", error_msg));
2156 }
2157
2158 // Log warnings via tracing (trace level to avoid noise); surface the
2159 // opted-in ones to the agent so the guidance is actually seen.
2160 for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2161 tracing::trace!("validation: {}", warning.format(input));
2162 if warning.code.surfaces_to_agent() {
2163 surfaced_warnings.push_str(&warning.format(input));
2164 surfaced_warnings.push('\n');
2165 }
2166 }
2167 }
2168
2169 // Surface opted-in validation warnings to the streaming frontend once,
2170 // before any command output. The streaming consumer (`-c`, REPL) prints
2171 // per `on_output` and ignores the returned aggregate err; non-streaming
2172 // callers (`kernel.execute`) use a noop callback and read the aggregate
2173 // `result.err` (prepended at each return below). The two paths are
2174 // disjoint, so this prints the advisory exactly once on each.
2175 if !surfaced_warnings.is_empty() {
2176 let mut advisory = ExecResult::success("");
2177 advisory.err = surfaced_warnings.clone();
2178 on_output(&advisory);
2179 }
2180
2181 let mut result = ExecResult::success("");
2182
2183 // Reset cancellation token for this execution.
2184 let cancel = self.reset_cancel();
2185
2186 for stmt in program.statements.into_iter() {
2187 if matches!(stmt, Stmt::Empty) {
2188 continue;
2189 }
2190
2191 // Cancellation checkpoint
2192 if cancel.is_cancelled() {
2193 result.code = 130;
2194 return Ok(result);
2195 }
2196
2197 // The statement tap and gate (spec §C.6) — one of exactly two
2198 // sites. It runs before `execute_stmt_flow`, so a held statement
2199 // has run *nothing*: no substitution, no redirect opened, no
2200 let flow_result = self.execute_stmt_flow(&stmt).await;
2201 let flow = flow_result?;
2202
2203 // Drain any stderr written by pipeline stages during this statement.
2204 // This captures stderr from intermediate pipeline stages that would
2205 // otherwise be lost (only the last stage's result is returned).
2206 let drained_stderr = {
2207 let mut receiver = self.stderr_receiver.lock().await;
2208 receiver.drain_lossy()
2209 };
2210
2211 match flow {
2212 ControlFlow::Normal(mut r) => {
2213 if !drained_stderr.is_empty() {
2214 if !r.err.is_empty() && !r.err.ends_with('\n') {
2215 r.err.push('\n');
2216 }
2217 // Prepend pipeline stderr before the last stage's stderr
2218 let combined = format!("{}{}", drained_stderr, r.err);
2219 r.err = combined;
2220 }
2221 on_output(&r);
2222 // Carry the last statement's structured output for MCP TOON encoding.
2223 // Must be done here (not in accumulate_result) because accumulate_result
2224 // is also used in loops where per-iteration output would be wrong.
2225 let last_output = r.output().cloned();
2226 accumulate_result(&mut result, &r);
2227 result.set_output(last_output);
2228 }
2229 ControlFlow::Exit { code } => {
2230 if !drained_stderr.is_empty() {
2231 result.err.push_str(&drained_stderr);
2232 }
2233 result.code = code;
2234 if !surfaced_warnings.is_empty() {
2235 result.err = format!("{surfaced_warnings}{}", result.err);
2236 }
2237 return Ok(result);
2238 }
2239 ControlFlow::Return { mut value } => {
2240 if !drained_stderr.is_empty() {
2241 value.err = format!("{}{}", drained_stderr, value.err);
2242 }
2243 on_output(&value);
2244 // A top-level `return` stops the script, like `exit` —
2245 // it must not discard prior statements' accumulated
2246 // output nor let execution continue past it.
2247 accumulate_result(&mut result, &value);
2248 if !surfaced_warnings.is_empty() {
2249 result.err = format!("{surfaced_warnings}{}", result.err);
2250 }
2251 return Ok(result);
2252 }
2253 ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2254 if !drained_stderr.is_empty() {
2255 r.err = format!("{}{}", drained_stderr, r.err);
2256 }
2257 on_output(&r);
2258 accumulate_result(&mut result, &r);
2259 }
2260 }
2261 }
2262
2263 if !surfaced_warnings.is_empty() {
2264 result.err = format!("{surfaced_warnings}{}", result.err);
2265 }
2266 Ok(result)
2267 }
2268
2269 /// Execute a single statement, returning control flow information.
2270 fn execute_stmt_flow<'a>(
2271 &'a self,
2272 stmt: &'a Stmt,
2273 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2274 // No per-statement span here: `execute_stmt_flow` is the largest future
2275 // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2276 // the span's state through every `.await` at every level, costing native
2277 // stack per level (GH #48). Coarser spans on the outer execute entries
2278 // remain. See item 3 of the #48 burndown.
2279 Box::pin(async move {
2280 match stmt {
2281 Stmt::Assignment(assign) => {
2282 // Use async evaluator to support command substitution
2283 let value = self.eval_expr_async(&assign.value).await
2284 .context("failed to evaluate assignment")?;
2285 let mut scope = self.scope.write().await;
2286 if assign.path.segments.len() == 1 {
2287 // Plain `NAME=value` — no subscript, so `local` applies.
2288 if assign.local {
2289 // local: set in innermost (current function) frame
2290 scope.set(assign.name(), value.clone());
2291 } else {
2292 // non-local: update existing or create in root frame
2293 scope.set_global(assign.name(), value.clone());
2294 }
2295 } else {
2296 // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2297 // mutates the existing root wherever it lives, so `local`
2298 // has nothing to declare. See docs/LANGUAGE.md,
2299 // "Assignment — bracket-path lvalues".
2300 scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2301 PathError::UndefinedRoot(name) => anyhow::anyhow!(
2302 "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2303 ),
2304 PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2305 })?;
2306 }
2307 drop(scope);
2308
2309 // Assignments don't produce output (like sh)
2310 Ok(ControlFlow::ok(ExecResult::success("")))
2311 }
2312 Stmt::Command(cmd) => {
2313 // Route single commands through execute_pipeline for a unified path.
2314 // This ensures all commands go through the dispatcher chain.
2315 let pipeline = crate::ast::Pipeline {
2316 commands: vec![cmd.clone()],
2317 background: false,
2318 };
2319 let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2320 self.update_last_result(&result).await;
2321
2322 // Check for error exit mode (set -e)
2323 if !result.ok() {
2324 let scope = self.scope.read().await;
2325 if scope.error_exit_enabled() {
2326 return Ok(ControlFlow::exit_code(result.code));
2327 }
2328 }
2329
2330 Ok(ControlFlow::ok(result))
2331 }
2332 Stmt::Pipeline(pipeline) => {
2333 let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2334 self.update_last_result(&result).await;
2335
2336 // Check for error exit mode (set -e)
2337 if !result.ok() {
2338 let scope = self.scope.read().await;
2339 if scope.error_exit_enabled() {
2340 return Ok(ControlFlow::exit_code(result.code));
2341 }
2342 }
2343
2344 Ok(ControlFlow::ok(result))
2345 }
2346 Stmt::If(if_stmt) => {
2347 // Use async evaluator to support command substitution in conditions
2348 let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2349
2350 let branch = if is_truthy(&cond_value) {
2351 &if_stmt.then_branch
2352 } else {
2353 if_stmt.else_branch.as_deref().unwrap_or(&[])
2354 };
2355
2356 let mut result = ExecResult::success("");
2357 for stmt in branch {
2358 let flow = self.execute_stmt_flow(stmt).await?;
2359 match flow {
2360 ControlFlow::Normal(r) => {
2361 accumulate_result(&mut result, &r);
2362 self.drain_stderr_into(&mut result).await;
2363 }
2364 other => {
2365 self.drain_stderr_into(&mut result).await;
2366 return Ok(other);
2367 }
2368 }
2369 }
2370 Ok(ControlFlow::ok(result))
2371 }
2372 Stmt::For(for_loop) => {
2373 // Evaluate all items and collect values for iteration
2374 // Use async evaluator to support command substitution like $(seq 1 5)
2375 let mut items: Vec<Value> = Vec::new();
2376 for item_expr in &for_loop.items {
2377 // Glob expansion in for-loop items: `for f in *.txt`
2378 if let Expr::GlobPattern(pattern) = item_expr {
2379 let glob_enabled = {
2380 let scope = self.scope.read().await;
2381 scope.glob_enabled()
2382 };
2383 if glob_enabled {
2384 let (paths, cwd) = {
2385 let ctx = self.exec_ctx.read().await;
2386 let paths = ctx.expand_glob(pattern).await
2387 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2388 let cwd = ctx.resolve_path(".");
2389 (paths, cwd)
2390 };
2391 if paths.is_empty() {
2392 return Err(anyhow::anyhow!("no matches: {}", pattern));
2393 }
2394 for path in paths {
2395 let display = if !pattern.starts_with('/') {
2396 path.strip_prefix(&cwd)
2397 .unwrap_or(&path)
2398 .to_string_lossy().into_owned()
2399 } else {
2400 path.to_string_lossy().into_owned()
2401 };
2402 items.push(Value::String(display));
2403 }
2404 continue;
2405 }
2406 }
2407 // Track whether this item came from $(cmd); that's the
2408 // only position where multi-line stdout auto-splits per
2409 // line. Arrays still spread element-by-element; bare
2410 // $VAR is rejected upstream by validator E012. See
2411 // docs/LANGUAGE.md.
2412 let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2413 let item = self.eval_expr_async(item_expr).await?;
2414 match item {
2415 // JSON arrays iterate over elements (preferred path
2416 // when builtins emit .data — seq, jq, cut, find, …)
2417 Value::Json(serde_json::Value::Array(arr)) => {
2418 for elem in arr {
2419 // Envelope-free: an element that happens to be
2420 // envelope-shaped (e.g. from `fromjson`) is
2421 // external data, not an internal bytes round-trip,
2422 // so it must NOT be re-decoded to Value::Bytes.
2423 items.push(json_to_value_no_envelope(elem));
2424 }
2425 }
2426 // Strings from $(cmd): empty → 0 iterations,
2427 // multi-line → split per line (trimming trailing
2428 // newlines and per-line trailing \r), single-line
2429 // → one iteration. Whitespace within a line is
2430 // NOT split — the "$VAR with spaces just works"
2431 // promise is preserved because this only fires
2432 // in CommandSubst position.
2433 Value::String(s) if from_command_subst => {
2434 let trimmed = s.trim_end_matches(['\n', '\r']);
2435 if trimmed.is_empty() {
2436 continue;
2437 }
2438 if trimmed.contains('\n') {
2439 for line in trimmed.split('\n') {
2440 let line = line.trim_end_matches('\r');
2441 items.push(Value::String(line.to_string()));
2442 }
2443 } else {
2444 items.push(Value::String(trimmed.to_string()));
2445 }
2446 }
2447 // Binary isn't iterable — fail loud rather than loop
2448 // once over an opaque byte blob.
2449 Value::Bytes(_) => {
2450 anyhow::bail!(
2451 "for: cannot iterate over binary data — decode it \
2452 (base64/xxd) first"
2453 );
2454 }
2455 // Strings not from $(cmd) stay as one value.
2456 other => items.push(other),
2457 }
2458 }
2459
2460 let mut result = ExecResult::success("");
2461 {
2462 let mut scope = self.scope.write().await;
2463 scope.push_frame();
2464 }
2465
2466 'outer: for item in items {
2467 // Cancellation checkpoint per iteration
2468 if self.is_cancelled() {
2469 let mut scope = self.scope.write().await;
2470 scope.pop_frame();
2471 result.code = 130;
2472 return Ok(ControlFlow::ok(result));
2473 }
2474 {
2475 let mut scope = self.scope.write().await;
2476 scope.set(&for_loop.variable, item);
2477 }
2478 for stmt in &for_loop.body {
2479 let mut flow = match self.execute_stmt_flow(stmt).await {
2480 Ok(f) => f,
2481 Err(e) => {
2482 let mut scope = self.scope.write().await;
2483 scope.pop_frame();
2484 return Err(e);
2485 }
2486 };
2487 self.drain_stderr_into(&mut result).await;
2488 match &mut flow {
2489 ControlFlow::Normal(r) => {
2490 accumulate_result(&mut result, r);
2491 if !r.ok() {
2492 let scope = self.scope.read().await;
2493 if scope.error_exit_enabled() {
2494 drop(scope);
2495 let mut scope = self.scope.write().await;
2496 scope.pop_frame();
2497 return Ok(ControlFlow::exit_code(r.code));
2498 }
2499 }
2500 }
2501 ControlFlow::Break { .. } => {
2502 if flow.decrement_level() {
2503 accumulate_flow_output(&mut result, &flow);
2504 break 'outer;
2505 }
2506 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2507 let mut scope = self.scope.write().await;
2508 scope.pop_frame();
2509 return Ok(flow);
2510 }
2511 ControlFlow::Continue { .. } => {
2512 if flow.decrement_level() {
2513 accumulate_flow_output(&mut result, &flow);
2514 continue 'outer;
2515 }
2516 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2517 let mut scope = self.scope.write().await;
2518 scope.pop_frame();
2519 return Ok(flow);
2520 }
2521 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2522 let mut scope = self.scope.write().await;
2523 scope.pop_frame();
2524 return Ok(flow);
2525 }
2526 }
2527 }
2528 }
2529
2530 {
2531 let mut scope = self.scope.write().await;
2532 scope.pop_frame();
2533 }
2534 Ok(ControlFlow::ok(result))
2535 }
2536 Stmt::While(while_loop) => {
2537 let mut result = ExecResult::success("");
2538
2539 'outer: loop {
2540 // Evaluate condition - use async to support command substitution
2541 // Cancellation checkpoint per iteration
2542 if self.is_cancelled() {
2543 result.code = 130;
2544 return Ok(ControlFlow::ok(result));
2545 }
2546
2547 let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2548
2549 if !is_truthy(&cond_value) {
2550 break;
2551 }
2552
2553 // Execute body
2554 for stmt in &while_loop.body {
2555 let mut flow = self.execute_stmt_flow(stmt).await?;
2556 self.drain_stderr_into(&mut result).await;
2557 match &mut flow {
2558 ControlFlow::Normal(r) => {
2559 accumulate_result(&mut result, r);
2560 if !r.ok() {
2561 let scope = self.scope.read().await;
2562 if scope.error_exit_enabled() {
2563 return Ok(ControlFlow::exit_code(r.code));
2564 }
2565 }
2566 }
2567 ControlFlow::Break { .. } => {
2568 if flow.decrement_level() {
2569 accumulate_flow_output(&mut result, &flow);
2570 break 'outer;
2571 }
2572 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2573 return Ok(flow);
2574 }
2575 ControlFlow::Continue { .. } => {
2576 if flow.decrement_level() {
2577 accumulate_flow_output(&mut result, &flow);
2578 continue 'outer;
2579 }
2580 fold_loop_output_into_flow(std::mem::take(&mut result), &mut flow);
2581 return Ok(flow);
2582 }
2583 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2584 return Ok(flow);
2585 }
2586 }
2587 }
2588 }
2589
2590 Ok(ControlFlow::ok(result))
2591 }
2592 Stmt::Case(case_stmt) => {
2593 // Evaluate the expression to match against. Text sink: a
2594 // `case $bin in ...)` pattern match on binary goes loud
2595 // rather than glob-matching against the `[binary: N bytes]`
2596 // placeholder (Decision E — same class as `==`/`in`).
2597 let match_value = {
2598 let value = self.eval_expr_async(&case_stmt.expr).await?;
2599 value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2600 };
2601
2602 // Try each branch until we find a match
2603 for branch in &case_stmt.branches {
2604 let matched = branch.patterns.iter().any(|pattern| {
2605 glob_match(pattern, &match_value)
2606 });
2607
2608 if matched {
2609 // Execute the branch body
2610 let mut result = ExecResult::success("");
2611 for stmt in &branch.body {
2612 let flow = self.execute_stmt_flow(stmt).await?;
2613 match flow {
2614 ControlFlow::Normal(r) => {
2615 accumulate_result(&mut result, &r);
2616 self.drain_stderr_into(&mut result).await;
2617 }
2618 other => {
2619 self.drain_stderr_into(&mut result).await;
2620 return Ok(other);
2621 }
2622 }
2623 }
2624 return Ok(ControlFlow::ok(result));
2625 }
2626 }
2627
2628 // No match - return success with empty output (like sh)
2629 Ok(ControlFlow::ok(ExecResult::success("")))
2630 }
2631 Stmt::Break(levels) => {
2632 Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2633 }
2634 Stmt::Continue(levels) => {
2635 Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2636 }
2637 Stmt::Return(expr) => {
2638 // return [N] - N becomes the exit code, NOT stdout
2639 // Shell semantics: return sets exit code, doesn't produce output
2640 let result = if let Some(e) = expr {
2641 let val = self.eval_expr_async(e).await?;
2642 let code = crate::interpreter::value_to_exit_code(&val)
2643 .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2644 ExecResult::from_parts(code, String::new(), String::new(), None)
2645 } else {
2646 ExecResult::success("")
2647 };
2648 Ok(ControlFlow::return_value(result))
2649 }
2650 Stmt::Exit(expr) => {
2651 let code = if let Some(e) = expr {
2652 let val = self.eval_expr_async(e).await?;
2653 crate::interpreter::value_to_exit_code(&val)
2654 .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2655 } else {
2656 0
2657 };
2658 Ok(ControlFlow::exit_code(code))
2659 }
2660 Stmt::ToolDef(tool_def) => {
2661 let mut user_tools = self.user_tools.write().await;
2662 user_tools.insert(tool_def.name.clone(), tool_def.clone());
2663 Ok(ControlFlow::ok(ExecResult::success("")))
2664 }
2665 Stmt::AndChain { left, right } => {
2666 // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2667 // Suppress errexit for the left side — && handles failure itself.
2668 {
2669 let mut scope = self.scope.write().await;
2670 scope.suppress_errexit();
2671 }
2672 let left_flow = match self.execute_stmt_flow(left).await {
2673 Ok(f) => f,
2674 Err(e) => {
2675 let mut scope = self.scope.write().await;
2676 scope.unsuppress_errexit();
2677 return Err(e);
2678 }
2679 };
2680 {
2681 let mut scope = self.scope.write().await;
2682 scope.unsuppress_errexit();
2683 }
2684 match left_flow {
2685 ControlFlow::Normal(mut left_result) => {
2686 self.drain_stderr_into(&mut left_result).await;
2687 self.update_last_result(&left_result).await;
2688 // Pending is not failure (spec §I.5) — see the
2689 // `OrChain` twin. The stash check matters here for a
2690 // hold swallowed into an apparent success below.
2691 if left_result.ok() {
2692 let right_flow = self.execute_stmt_flow(right).await?;
2693 match right_flow {
2694 ControlFlow::Normal(mut right_result) => {
2695 self.drain_stderr_into(&mut right_result).await;
2696 self.update_last_result(&right_result).await;
2697 let mut combined = left_result;
2698 accumulate_result(&mut combined, &right_result);
2699 Ok(ControlFlow::ok(combined))
2700 }
2701 other => Ok(other),
2702 }
2703 } else {
2704 Ok(ControlFlow::ok(left_result))
2705 }
2706 }
2707 _ => Ok(left_flow),
2708 }
2709 }
2710 Stmt::OrChain { left, right } => {
2711 // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2712 // Suppress errexit for the left side — || handles failure itself.
2713 {
2714 let mut scope = self.scope.write().await;
2715 scope.suppress_errexit();
2716 }
2717 let left_flow = match self.execute_stmt_flow(left).await {
2718 Ok(f) => f,
2719 Err(e) => {
2720 let mut scope = self.scope.write().await;
2721 scope.unsuppress_errexit();
2722 return Err(e);
2723 }
2724 };
2725 {
2726 let mut scope = self.scope.write().await;
2727 scope.unsuppress_errexit();
2728 }
2729 match left_flow {
2730 ControlFlow::Normal(mut left_result) => {
2731 self.drain_stderr_into(&mut left_result).await;
2732 self.update_last_result(&left_result).await;
2733 // Pending is not failure (spec §I.5): a fallback
2734 // written for failure must not run on a decision
2735 // nobody has made yet — and running it would also
2736 // overwrite the request in the accumulated result.
2737 // The stash check covers a hold whose typed error a
2738 // layer below already stringified out of the result.
2739 // On a stash-based hold the returned `left_result` is
2740 // that stringified failure, not the held result — the
2741 // statement boundary discards it and surfaces the
2742 // slot's result instead. Do not "fix" this by taking
2743 // the slot here: only statement boundaries take it.
2744 if !left_result.ok() {
2745 let right_flow = self.execute_stmt_flow(right).await?;
2746 match right_flow {
2747 ControlFlow::Normal(mut right_result) => {
2748 self.drain_stderr_into(&mut right_result).await;
2749 self.update_last_result(&right_result).await;
2750 let mut combined = left_result;
2751 accumulate_result(&mut combined, &right_result);
2752 Ok(ControlFlow::ok(combined))
2753 }
2754 other => Ok(other),
2755 }
2756 } else {
2757 Ok(ControlFlow::ok(left_result))
2758 }
2759 }
2760 _ => Ok(left_flow), // Propagate non-normal flow
2761 }
2762 }
2763 Stmt::Test(test_expr) => {
2764 let is_true = self.eval_test_async(test_expr).await?;
2765 let result = if is_true {
2766 ExecResult::success("")
2767 } else {
2768 ExecResult::failure(1, "")
2769 };
2770 // A bare test writes `$?` and honors `set -e` like any command
2771 // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2772 // safe: the chain arms suppress errexit around their left side,
2773 // and `if`/`while` conditions evaluate as expressions, never
2774 // through this statement arm.
2775 self.update_last_result(&result).await;
2776 if !result.ok() {
2777 let scope = self.scope.read().await;
2778 if scope.error_exit_enabled() {
2779 return Ok(ControlFlow::exit_code(result.code));
2780 }
2781 }
2782 Ok(ControlFlow::ok(result))
2783 }
2784 Stmt::EnvScoped { assignments, body } => {
2785 // Inline env prefix (`NAME=value ... command`): apply the
2786 // assignments as EXPORTED vars in a fresh frame so the command
2787 // — and its subprocess environment — sees them, then unwind so
2788 // they do NOT persist (bash-style command-scoped env). Values
2789 // evaluate left-to-right with earlier ones already in scope, so
2790 // `A=1 B=$A cmd` works.
2791 {
2792 let mut scope = self.scope.write().await;
2793 scope.push_frame();
2794 }
2795 let mut prior_export: Vec<(String, bool)> =
2796 Vec::with_capacity(assignments.len());
2797 let mut setup_err: Option<anyhow::Error> = None;
2798 for assign in assignments {
2799 match self.eval_expr_async(&assign.value).await {
2800 Ok(value) => {
2801 let mut scope = self.scope.write().await;
2802 prior_export
2803 .push((assign.name().to_string(), scope.is_exported(assign.name())));
2804 scope.set_exported(assign.name(), value);
2805 }
2806 Err(e) => {
2807 setup_err = Some(e);
2808 break;
2809 }
2810 }
2811 }
2812
2813 let flow = if setup_err.is_none() {
2814 self.execute_stmt_flow(body).await
2815 } else {
2816 Ok(ControlFlow::ok(ExecResult::success("")))
2817 };
2818
2819 // Unwind the env frame and restore export marks unconditionally
2820 // (names that were not exported before must not stay exported).
2821 {
2822 let mut scope = self.scope.write().await;
2823 scope.pop_frame();
2824 for (name, was_exported) in &prior_export {
2825 if !*was_exported {
2826 scope.unexport(name);
2827 }
2828 }
2829 }
2830
2831 match setup_err {
2832 Some(e) => Err(e),
2833 None => flow,
2834 }
2835 }
2836 Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2837 }
2838 })
2839 }
2840
2841 /// Build a boxed per-command `ExecContext` snapshot from the persistent
2842 /// kernel state (`ec`/`scope`, both already locked by the caller).
2843 ///
2844 /// Sync on purpose: the ~30 field clones live in this transient frame rather
2845 /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2846 /// — not the 960-byte struct — rides the dispatch await at every recursion
2847 /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2848 /// per-site differences (the pipeline runner uses the kernel's own cancel
2849 /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2850 /// they're parameters; every other field is snapshotted identically.
2851 fn snapshot_exec_ctx(
2852 &self,
2853 ec: &ExecContext,
2854 scope: &Scope,
2855 pipeline_position: PipelinePosition,
2856 cancel: tokio_util::sync::CancellationToken,
2857 ) -> Box<ExecContext> {
2858 Box::new(ExecContext {
2859 backend: ec.backend.clone(),
2860 scope: scope.clone(),
2861 cwd: ec.cwd.clone(),
2862 prev_cwd: ec.prev_cwd.clone(),
2863 stdin: ec.stdin.clone(),
2864 stdin_data: ec.stdin_data.clone(),
2865 stdin_data_rx: None,
2866 pipe_stdin: None,
2867 pipe_stdout: None,
2868 stderr: ec.stderr.clone(),
2869 tool_schemas: ec.tool_schemas.clone(),
2870 tools: ec.tools.clone(),
2871 job_manager: ec.job_manager.clone(),
2872 pipeline_position,
2873 interactive: self.interactive,
2874 // The kernel-wide setting; a snapshot inherits it like `interactive`.
2875 kill_children_on_parent_death: ec.kill_children_on_parent_death,
2876 aliases: ec.aliases.clone(),
2877 ignore_config: ec.ignore_config.clone(),
2878 output_limit: ec.output_limit.clone(),
2879 allow_external_commands: self.allow_external_commands,
2880 trash_backend: ec.trash_backend.clone(),
2881 #[cfg(all(unix, feature = "subprocess"))]
2882 terminal_state: ec.terminal_state.clone(),
2883 dispatcher: self.dispatcher(),
2884 cancel,
2885 output_format: None,
2886 vfs_budget: self.vfs_budget.clone(),
2887 watchdog: ec.watchdog.clone(),
2888 #[cfg(all(feature = "localfs", feature = "overlay"))]
2889 overlay_handle: self.overlay_handle.clone(),
2890 // Correlate this command's requests with the background job it
2891 // runs for, if any — the ONE place `job_id` is stamped.
2892 // A replay correlation belongs to exactly one dispatch. Moved
2893 // (not cloned) out of the parent context at the dispatch seam —
2894 // see the stdin hand-off below, which takes it under the same
2895 // write lock — so the gate this snapshot reaches is the only one
2896 // that can adopt it.
2897 // A forked or backgrounded execution keeps its parenthood: a
2898 // gate reached from inside a gated statement is nested under it
2899 // (spec §A.7).
2900 })
2901 }
2902
2903 /// Execute a pipeline.
2904 async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2905 if pipeline.commands.is_empty() {
2906 return Ok(ExecResult::success(""));
2907 }
2908
2909 // Handle background execution (`&` operator)
2910 if pipeline.background {
2911 return self.execute_background(pipeline).await;
2912 }
2913
2914 // All commands go through the runner with the Kernel as dispatcher.
2915 // This is the single execution path — no fast path for single commands.
2916 //
2917 // IMPORTANT: We snapshot exec_ctx into a local context and release the
2918 // lock before running. This prevents deadlocks when dispatch_command
2919 // is called from within the pipeline and recursively triggers another
2920 // pipeline (e.g., via user-defined tools).
2921 let (mut ctx, has_pipe_stdin) = {
2922 let ec = self.exec_ctx.read().await;
2923 let scope = self.scope.read().await;
2924 // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2925 // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2926 // the consume-once block below, so note its presence here.
2927 let has_pipe_stdin = ec.pipe_stdin.is_some();
2928 // The pipeline runner drives stage 0 with the first stage's stdin
2929 // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2930 // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2931 // and uses the kernel's own cancel token so a `cancel()` reaches the
2932 // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2933 let cancel = {
2934 #[allow(clippy::expect_used)]
2935 let token = self.cancel_token.lock().expect("cancel_token poisoned");
2936 token.clone()
2937 };
2938 (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2939 }; // locks released
2940
2941 // Consume-once: move/clear the seeded stdin sources from the persistent
2942 // exec_ctx now that this pipeline's ctx owns them, so a later statement
2943 // in the same call (`cat ; cat`) does not re-receive them — matching
2944 // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2945 // (the ctx above was built with `pipe_stdin: None`).
2946 if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2947 let mut ec = self.exec_ctx.write().await;
2948 ctx.pipe_stdin = ec.pipe_stdin.take();
2949 ec.stdin = None;
2950 ec.stdin_data = None;
2951 }
2952
2953 let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2954
2955 // Post-hoc spill check + exit-3 remap (catches builtins and fast
2956 // external commands; also catches a ring overflow that already
2957 // flipped `did_spill` even when the limit itself is disabled, GH
2958 // #191). This is the shared contract every execution surface must
2959 // apply — see `apply_spill_contract`'s doc comment (GH #212).
2960 crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
2961
2962 // Sync changes back from context
2963 {
2964 let mut ec = self.exec_ctx.write().await;
2965 ec.cwd = ctx.cwd.clone();
2966 ec.prev_cwd = ctx.prev_cwd.clone();
2967 ec.aliases = ctx.aliases.clone();
2968 ec.ignore_config = ctx.ignore_config.clone();
2969 ec.output_limit = ctx.output_limit.clone();
2970 }
2971 {
2972 let mut scope = self.scope.write().await;
2973 *scope = ctx.scope.clone();
2974 }
2975
2976 Ok(result)
2977 }
2978
2979 /// Execute a pipeline in the background.
2980 ///
2981 /// The command is spawned as a tokio task and registered with the
2982 /// JobManager. The job is observable via `/v/jobs/{id}/status`,
2983 /// `/v/jobs/{id}/command`, and — while it is
2984 /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
2985 ///
2986 /// GH #240 removed those two nodes because they filled once, at
2987 /// completion, while the docs promised a live stream. They are back on
2988 /// the terms the docs always claimed: `try_execute_external` tees each
2989 /// 8 KiB chunk into the job's stream as the child emits it. See
2990 /// `Job::stdout_stream` for exactly which bytes reach them.
2991 ///
2992 /// Returns immediately with a job ID like "[1]".
2993 #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
2994 async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2995 use tokio::sync::oneshot;
2996
2997 // Format the command for display in /v/jobs/{id}/command
2998 let command_str = self.format_pipeline(pipeline);
2999
3000 // Create channel for result notification
3001 let (tx, rx) = oneshot::channel();
3002
3003 // Register with JobManager to get job ID and create VFS entries
3004 let job_id = self.jobs.register(command_str.clone(), rx).await;
3005
3006 // Fork the kernel for this background job. The fork snapshots the
3007 // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3008 // while sharing the job manager, VFS, and tool registry. The fork's
3009 // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3010 // is available here — something BackendDispatcher couldn't provide.
3011 //
3012 // The fork gets its own cancellation token (recorded on the job so
3013 // `kill %N` can stop the job — including a pure-builtin job with no OS
3014 // process group) and is stamped with the job id so any external
3015 // command it spawns records its process group for `kill -<sig> %N`.
3016 let cancel = tokio_util::sync::CancellationToken::new();
3017 self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3018 let jobs = self.jobs.clone();
3019 let fork = self.fork_for_background(cancel, job_id).await;
3020 let runner = self.runner.clone();
3021 let commands = pipeline.commands.clone();
3022
3023 // Snapshot the fork's exec_ctx for the spawned task. We have to do
3024 // this before tokio::spawn because the fork's exec_ctx is behind a
3025 // tokio RwLock and we want the spawned task to own its ctx.
3026 let mut bg_ctx = {
3027 let ec = fork.exec_ctx.read().await;
3028 ec.child_for_pipeline()
3029 };
3030 bg_ctx.scope = fork.scope.read().await.clone();
3031 // The fork's dispatcher points at the fork itself; set it here so
3032 // builtins inside the background task (e.g. timeout) re-dispatch
3033 // through the fork, not the parent.
3034 bg_ctx.dispatcher = fork.dispatcher();
3035
3036 // Spawn the background task. Propagate the embedder's trace context
3037 // across the spawn boundary so the job's spans stay in the same trace.
3038 tokio::spawn(crate::telemetry::bind_current_context(async move {
3039 // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3040 // gives us that (Kernel implements CommandDispatcher).
3041 let mut result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
3042
3043 // Apply the same spill/exit-3 contract the foreground path gets
3044 // (`execute_pipeline`'s `apply_spill_contract` call) — without
3045 // this, a background job whose output overflows the capture ring
3046 // or trips the output limit reports the child's ORIGINAL exit
3047 // code to JobManager, so `[N] done:0`/`Job::status()` silently
3048 // read success even though the output was capped (GH #212).
3049 crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3050
3051 // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3052 // drain tasks already fed live is left alone (re-writing the
3053 // aggregate would duplicate every byte), an untouched one takes
3054 // the captured result, and both close. Before `tx.send`, so a
3055 // reader that observes a terminal `status` also observes a
3056 // finished stream — never a `done:0` job whose output is still
3057 // arriving.
3058 jobs.finalize_streams(job_id, &result).await;
3059
3060 // Send result to JobManager (ignore error if receiver dropped)
3061 let _ = tx.send(result);
3062 }));
3063
3064 Ok(ExecResult::success(format!("[{}]", job_id)))
3065 }
3066
3067 /// Format a pipeline as a command string for display.
3068 fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3069 pipeline.commands
3070 .iter()
3071 .map(|cmd| {
3072 let mut parts = vec![cmd.name.clone()];
3073 for arg in &cmd.args {
3074 match arg {
3075 Arg::Positional(expr) => {
3076 parts.push(self.format_expr(expr));
3077 }
3078 Arg::Named { key, value } => {
3079 parts.push(format!("--{}={}", key, self.format_expr(value)));
3080 }
3081 Arg::WordAssign { key, value } => {
3082 parts.push(format!("{}={}", key, self.format_expr(value)));
3083 }
3084 Arg::ShortFlag(name) => {
3085 parts.push(format!("-{}", name));
3086 }
3087 Arg::LongFlag(name) => {
3088 parts.push(format!("--{}", name));
3089 }
3090 Arg::DoubleDash => {
3091 parts.push("--".to_string());
3092 }
3093 }
3094 }
3095 parts.join(" ")
3096 })
3097 .collect::<Vec<_>>()
3098 .join(" | ")
3099 }
3100
3101 /// Format an expression as a string for display.
3102 fn format_expr(&self, expr: &Expr) -> String {
3103 match expr {
3104 Expr::Literal(Value::String(s)) => {
3105 if s.contains(' ') || s.contains('"') {
3106 format!("'{}'", s.replace('\'', "\\'"))
3107 } else {
3108 s.clone()
3109 }
3110 }
3111 Expr::Literal(Value::Int(i)) => i.to_string(),
3112 Expr::Literal(Value::Float(f)) => f.to_string(),
3113 Expr::Literal(Value::Bool(b)) => b.to_string(),
3114 Expr::Literal(Value::Null) => "null".to_string(),
3115 Expr::VarRef(path) => {
3116 let mut name = String::new();
3117 for (i, seg) in path.segments.iter().enumerate() {
3118 match seg {
3119 crate::ast::VarSegment::Field(f) => {
3120 if i > 0 {
3121 name.push('.');
3122 }
3123 name.push_str(f);
3124 }
3125 crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3126 crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3127 crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3128 crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3129 "[{}:{}]",
3130 a.map(|n| n.to_string()).unwrap_or_default(),
3131 b.map(|n| n.to_string()).unwrap_or_default()
3132 )),
3133 }
3134 }
3135 format!("${{{}}}", name)
3136 }
3137 Expr::Interpolated(_) => "\"...\"".to_string(),
3138 Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3139 _ => "...".to_string(),
3140 }
3141 }
3142
3143 /// Execute a single command.
3144 async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3145 self.execute_command_depth(name, args, 0).await
3146 }
3147
3148 async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3149 // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3150 // most-recursed function on the ring, so wrapping its future in
3151 // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3152 // every level (GH #48, item 3). A `trace!` event records the command name
3153 // without living in the future.
3154 tracing::trace!(command = %name, alias_depth, "dispatch");
3155 // Special built-ins. `SpecialForm::from_name` is the single source of
3156 // truth (shared with `classify_command` via `is_runtime_special_form`),
3157 // and this match on the enum is *exhaustive* — adding a special-form is a
3158 // compile error until both the name mapping and the behavior here are
3159 // updated. A name that is not a special-form falls through to alias /
3160 // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3161 if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3162 return match form {
3163 crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3164 crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3165 crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3166 };
3167 }
3168
3169 // Alias expansion (with recursion limit)
3170 if alias_depth < 10 {
3171 let alias_value = {
3172 let ctx = self.exec_ctx.read().await;
3173 ctx.aliases.get(name).cloned()
3174 };
3175 if let Some(alias_val) = alias_value {
3176 // Split alias value into command + args
3177 let parts: Vec<&str> = alias_val.split_whitespace().collect();
3178 if let Some((alias_cmd, alias_args)) = parts.split_first() {
3179 let mut new_args: Vec<Arg> = alias_args
3180 .iter()
3181 .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3182 .collect();
3183 new_args.extend_from_slice(args);
3184 return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3185 }
3186 }
3187 }
3188
3189 // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3190 if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3191 return match self.tools.get(builtin_name) {
3192 Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3193 None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3194 };
3195 }
3196
3197 // Check user-defined tools first
3198 {
3199 let user_tools = self.user_tools.read().await;
3200 if let Some(tool_def) = user_tools.get(name) {
3201 let tool_def = tool_def.clone();
3202 drop(user_tools);
3203 return Box::pin(self.execute_user_tool(tool_def, args)).await;
3204 }
3205 }
3206
3207 // Look up builtin tool
3208 let tool = match self.tools.get(name) {
3209 Some(t) => t,
3210 None => {
3211 // Try executing as .kai script from PATH
3212 if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3213 return Ok(result);
3214 }
3215 // Try executing as external command from PATH — boxed because its
3216 // future is the heaviest branch here (holds a `tokio::process::Command`,
3217 // argv, the child's stdio streams, and kill/reap drop guards); leaving
3218 // it inline fattens every `execute_command_depth` frame on the recursion
3219 // ring even when the command is a builtin.
3220 if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3221 return Ok(result);
3222 }
3223
3224 // Try backend-registered tools (embedder engines, etc.)
3225 // Look up tool schema for positional→named mapping.
3226 // Clone backend and drop read lock before awaiting (may involve network I/O).
3227 // Backend tools expect named JSON params, so enable positional mapping.
3228 let backend = self.exec_ctx.read().await.backend.clone();
3229 let tool_schema = backend
3230 .get_tool(name)
3231 .await
3232 .unwrap_or_else(|e| {
3233 // Schema lookup failing just means positionals won't
3234 // get name-mapped below — `call_tool` is still
3235 // attempted. Trace it so the degradation is visible
3236 // rather than silently swallowed.
3237 tracing::debug!("backend get_tool error for {name}: {e}");
3238 None
3239 })
3240 .map(|t| {
3241 let mut s = t.schema;
3242 // Flat backend/MCP tools expect named JSON params, so map
3243 // bare positionals onto named params. Subcommand-aware tools
3244 // route positionals through the subcommand path and declare
3245 // map_positionals per leaf (kj keeps it false so it re-parses
3246 // the argv with its own clap) — don't blanket-override them.
3247 if s.subcommands.is_empty() {
3248 s.map_positionals = true;
3249 }
3250 s
3251 });
3252 let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3253 let mut ctx = self.exec_ctx.write().await;
3254 {
3255 let scope = self.scope.read().await;
3256 ctx.scope = scope.clone();
3257 }
3258 let backend = ctx.backend.clone();
3259 match backend.call_tool(name, tool_args, &mut *ctx).await {
3260 Ok(tool_result) => {
3261 let mut scope = self.scope.write().await;
3262 *scope = ctx.scope.clone();
3263 // Preserve every field (data/content_type/baggage,
3264 // not just stdout text) — this is the embedder seam:
3265 // `x=$(embedder_tool)` and structured iteration over
3266 // its result depend on `.data` surviving the crossing
3267 // back into the kernel.
3268 return Ok(ExecResult::from(tool_result));
3269 }
3270 Err(BackendError::ToolNotFound(_)) => {
3271 // The backend confirms no such tool exists — fall
3272 // through to "command not found" below.
3273 }
3274 Err(e) => {
3275 // The tool was found (dispatch reached real
3276 // execution) but running it failed — a genuine
3277 // execution error, not "command not found". Surface
3278 // it loudly instead of masking it as exit-127.
3279 return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3280 }
3281 }
3282
3283 return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3284 }
3285 };
3286
3287 // Build arguments (async to support command substitution, schema-aware
3288 // for flag values), then decide `--help` and `owns_output` — all three
3289 // read the tool's schema and nothing after this block does, so the whole
3290 // schema borrow is scoped here and cannot ride the `tool.execute` await
3291 // below (GH #48, item 7).
3292 let (tool_args, wants_help, owns_output) = {
3293 // Prefer the kernel's schema catalog over `tool.schema()`: for a
3294 // clap-derived builtin, `schema()` rebuilds the entire clap
3295 // `Command` and reflects it into a fresh `ToolSchema` — ~34
3296 // allocations per command, 18% of all allocations in the GH #48
3297 // many-small-commands profile — to produce exactly what the catalog
3298 // already holds. The catalog is seeded from this same registry in
3299 // `Kernel::assemble` and is name-sorted, so this is a binary search
3300 // with no allocation at all. `owned` covers a tool the catalog
3301 // doesn't list (registered after assembly, or whose schema name
3302 // differs from its dispatch name): the fallback calls the same
3303 // `schema()` and is equivalent, just not free.
3304 let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3305 let owned;
3306 let schema: &crate::tools::ToolSchema =
3307 match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3308 Ok(i) => &catalog[i],
3309 Err(_) => {
3310 owned = tool.schema();
3311 &owned
3312 }
3313 };
3314
3315 let tool_args = self.build_args_async(args, Some(schema)).await?;
3316
3317 // --help / -h: show the generic whole-tool help, unless either the tool's
3318 // root schema claims that flag OR the tool owns its output. Owned-output
3319 // tools re-parse their own argv and route their own `--help` — including
3320 // leaf/subcommand help — through their internal (clap) parser, so the root
3321 // schema can't express "this leaf claims help" and intercepting here would
3322 // render top-level help and return before `execute()` ever sees the
3323 // request (#51). Pass it through and let the tool render its own help.
3324 let schema_claims = |flag: &str| -> bool {
3325 let bare = flag.trim_start_matches('-');
3326 schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3327 };
3328 let wants_help = !schema.owns_output
3329 && ((tool_args.flags.contains("help") && !schema_claims("help"))
3330 || (tool_args.flags.contains("h") && !schema_claims("-h")));
3331
3332 (tool_args, wants_help, schema.owns_output)
3333 };
3334
3335 if wants_help {
3336 let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3337 let ctx = self.exec_ctx.read().await;
3338 let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3339 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3340 }
3341
3342 // Snapshot exec_ctx into a local context and release the write lock
3343 // before calling tool.execute. Holding the write across tool execution
3344 // would deadlock any builtin that re-dispatches through ctx.dispatcher
3345 // (timeout, scatter) — the inner dispatch_command needs its own
3346 // exec_ctx.write() and would block forever.
3347 let mut ctx = {
3348 let ec = self.exec_ctx.write().await;
3349 let scope = self.scope.read().await;
3350 // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3351 // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3352 // child token — e.g. timeout's — reaches the spawned external via
3353 // wait_or_kill; it falls back to the kernel's own token on a
3354 // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3355 self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3356 }; // both locks released — tool.execute can re-dispatch safely
3357
3358 // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3359 // semantics): take() so a later dispatch doesn't see stale stdin.
3360 // Done after the snapshot above so we hold the write briefly.
3361 {
3362 let mut ec = self.exec_ctx.write().await;
3363 ctx.stdin = ec.stdin.take();
3364 ctx.stdin_data = ec.stdin_data.take();
3365 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3366 ctx.pipe_stdin = ec.pipe_stdin.take();
3367 ctx.pipe_stdout = ec.pipe_stdout.take();
3368 // Same take-don't-clone discipline as stdin, and for the same
3369 // reason: these belong to exactly one dispatch, and a copy left
3370 // behind would let the next command adopt it.
3371 }
3372
3373 // Honor --json before the builtin runs so its setting survives a clap
3374 // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3375 // --json on the floor when `try_parse_from` returns Err early).
3376 // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3377 GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3378
3379 let result = tool.execute(tool_args, &mut *ctx).await;
3380
3381 // Sync mutations back. Tools may have changed scope (set/cd),
3382 // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3383 // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3384 // hands them back to the pipeline runner — the runner uses
3385 // stage_ctx.pipe_stdout to write the result to the next stage when
3386 // the tool itself didn't take and write to it.
3387 {
3388 let mut scope = self.scope.write().await;
3389 *scope = ctx.scope.clone();
3390 }
3391 {
3392 let mut ec = self.exec_ctx.write().await;
3393 ec.cwd = ctx.cwd;
3394 ec.prev_cwd = ctx.prev_cwd;
3395 ec.aliases = ctx.aliases;
3396 // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3397 // mutate the runtime output limit; without this sync the change is
3398 // dropped here and never reaches dispatch_command's read-back, so
3399 // it would not survive past the current statement.
3400 ec.output_limit = ctx.output_limit.clone();
3401 // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3402 // was missing from this sync, so every runtime ignore mutation
3403 // silently died at the end of its own statement — including the
3404 // documented `kaish-ignore add .gitignore` rc-file recipe.
3405 ec.ignore_config = ctx.ignore_config.clone();
3406 ec.pipe_stdin = ctx.pipe_stdin.take();
3407 ec.pipe_stdout = ctx.pipe_stdout.take();
3408 }
3409
3410 // Builtins parse --json via the GlobalFlags flatten in their clap
3411 // struct and write ctx.output_format. The kernel applies it — unless the
3412 // tool owns its own output (renders --json itself), in which case we
3413 // leave its bytes untouched.
3414 let result = finalize_output(result, ctx.output_format, owns_output);
3415
3416 Ok(result)
3417 }
3418
3419 /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3420 /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3421 /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3422 /// unexpanded rather than leaking the host home directory.
3423 async fn scope_home(&self) -> Option<String> {
3424 match self.scope.read().await.get("HOME") {
3425 Some(Value::String(s)) => Some(s.clone()),
3426 _ => None,
3427 }
3428 }
3429
3430 /// Build tool arguments from AST args.
3431 ///
3432 /// Uses async evaluation to support command substitution in arguments.
3433 /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3434 /// now only supplies the evaluator — `self` implements `ArgValueSource`
3435 /// against the kernel's own session state (full recursion through the
3436 /// async pipeline, real glob expansion, tilde expansion). Before this,
3437 /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3438 /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3439 /// scatter/gather's own option parsing and the `#[cfg(test)]`
3440 /// `BackendDispatcher`) that could — and did — drift from this method,
3441 /// the same drift-class GH #133 fixed for the external-command spawn
3442 /// sites. Now both paths call the one `bind_tool_args` core, differing
3443 /// only in which `ArgValueSource` they hand it.
3444 async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3445 bind_tool_args(args, schema, self).await
3446 }
3447
3448 /// Build arguments as flat string list for external commands.
3449 ///
3450 /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3451 /// this preserves the original flag format as strings for external commands:
3452 /// - `-l` stays as `-l`
3453 /// - `--verbose` stays as `--verbose`
3454 /// - `key=value` stays as `key=value`
3455 ///
3456 /// This is what external commands expect in their argv.
3457 #[cfg(feature = "subprocess")]
3458 async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3459 let mut argv = Vec::new();
3460 let home = self.scope_home().await;
3461 for arg in args {
3462 match arg {
3463 Arg::Positional(expr) => {
3464 // Glob expansion for external commands
3465 if let Expr::GlobPattern(pattern) = expr {
3466 let glob_enabled = {
3467 let scope = self.scope.read().await;
3468 scope.glob_enabled()
3469 };
3470 if glob_enabled {
3471 let (paths, cwd) = {
3472 let ctx = self.exec_ctx.read().await;
3473 let paths = ctx.expand_glob(pattern).await
3474 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3475 let cwd = ctx.resolve_path(".");
3476 (paths, cwd)
3477 };
3478 if paths.is_empty() {
3479 return Err(anyhow::anyhow!("no matches: {}", pattern));
3480 }
3481 for path in paths {
3482 let display = if !pattern.starts_with('/') {
3483 path.strip_prefix(&cwd)
3484 .unwrap_or(&path)
3485 .to_string_lossy().into_owned()
3486 } else {
3487 path.to_string_lossy().into_owned()
3488 };
3489 argv.push(display);
3490 }
3491 continue;
3492 }
3493 }
3494 let value = self.eval_expr_async(expr).await?;
3495 // Decision D: a bare collection can't cross the external
3496 // process boundary as an argv element — refuse rather than
3497 // silently JSON-serializing it. A quoted `"$x"` already
3498 // reduced to a `Value::String` above (via `Expr::Interpolated`),
3499 // so only a live, un-interpolated `$x` trips this.
3500 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3501 return Err(anyhow::anyhow!(msg));
3502 }
3503 let value = apply_tilde_expansion(value, home.as_deref());
3504 // External-command argv is a text sink: a bare `$BIN` binary
3505 // word goes loud, never the `[binary: N bytes]` placeholder.
3506 argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3507 }
3508 Arg::Named { key, value } => {
3509 let val = self.eval_expr_async(value).await?;
3510 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3511 return Err(anyhow::anyhow!(msg));
3512 }
3513 let val = apply_tilde_expansion(val, home.as_deref());
3514 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3515 argv.push(format!("--{key}={val_str}"));
3516 }
3517 Arg::WordAssign { key, value } => {
3518 let val = self.eval_expr_async(value).await?;
3519 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3520 return Err(anyhow::anyhow!(msg));
3521 }
3522 let val = apply_tilde_expansion(val, home.as_deref());
3523 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3524 argv.push(format!("{key}={val_str}"));
3525 }
3526 Arg::ShortFlag(name) => {
3527 // Preserve original format: -l, -la (combined flags)
3528 argv.push(format!("-{}", name));
3529 }
3530 Arg::LongFlag(name) => {
3531 // Preserve original format: --verbose
3532 argv.push(format!("--{}", name));
3533 }
3534 Arg::DoubleDash => {
3535 // Preserve the -- marker
3536 argv.push("--".to_string());
3537 }
3538 }
3539 }
3540 Ok(argv)
3541 }
3542
3543 /// Async expression evaluator that supports command substitution.
3544 ///
3545 /// This is used for contexts where expressions may contain `$(...)` command
3546 /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3547 fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3548 Box::pin(async move {
3549 match expr {
3550 Expr::Literal(value) => Ok(value.clone()),
3551 Expr::VarRef(path) => {
3552 let scope = self.scope.read().await;
3553 match scope.resolve_path(path) {
3554 Ok(v) => Ok(v),
3555 Err(PathError::UndefinedRoot(_)) => {
3556 Err(anyhow::anyhow!("undefined variable"))
3557 }
3558 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3559 Err(anyhow::anyhow!(msg))
3560 }
3561 }
3562 }
3563 Expr::Interpolated(parts) => {
3564 let mut result = String::new();
3565 for part in parts {
3566 result.push_str(&self.eval_string_part_async(part).await?);
3567 }
3568 Ok(Value::String(result))
3569 }
3570 Expr::HereDocBody { parts, strip_tabs } => {
3571 // Assemble part-by-part so `<<-` tab stripping applies to the
3572 // literal source, not to tabs from a `$var` value (bash strips
3573 // source-line tabs before parameter expansion).
3574 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3575 for sp in parts {
3576 match &sp.part {
3577 StringPart::Literal(s) => asm.push_literal(s),
3578 other => {
3579 asm.push_interpolated(&self.eval_string_part_async(other).await?)
3580 }
3581 }
3582 }
3583 Ok(Value::String(asm.into_string()))
3584 }
3585 Expr::BinaryOp { left, op, right } => match op {
3586 BinaryOp::And => {
3587 let left_val = self.eval_expr_async(left).await?;
3588 if !is_truthy(&left_val) {
3589 return Ok(left_val);
3590 }
3591 self.eval_expr_async(right).await
3592 }
3593 BinaryOp::Or => {
3594 let left_val = self.eval_expr_async(left).await?;
3595 if is_truthy(&left_val) {
3596 return Ok(left_val);
3597 }
3598 self.eval_expr_async(right).await
3599 }
3600 },
3601 Expr::CommandSubst(stmts) => {
3602 // Snapshot scope, cwd, and session config before running —
3603 // only output escapes, not side effects like `cd`, variable
3604 // assignments, or config mutations (`kaish-ignore`,
3605 // `kaish-output-limit`, `alias`/`unalias`) — matching how
3606 // every other execution context (background forks, scatter
3607 // workers) already isolates mutations (GH #139).
3608 // Boxed: this ~470 B scope snapshot is held across the nested
3609 // `$(…)` recursion await below, so inlining it grows every
3610 // command-substitution level's future (GH #48, item 4).
3611 let saved_scope = Box::new(self.scope.read().await.clone());
3612 let saved_ec = {
3613 let ec = self.exec_ctx.read().await;
3614 (
3615 ec.cwd.clone(),
3616 ec.prev_cwd.clone(),
3617 ec.aliases.clone(),
3618 ec.ignore_config.clone(),
3619 ec.output_limit.clone(),
3620 )
3621 };
3622
3623 // Capture result without `?` — restore state unconditionally
3624 let run_result = self.execute_block_capturing(stmts).await;
3625
3626 // Restore scope and cwd regardless of success/failure
3627 {
3628 let mut scope = self.scope.write().await;
3629 *scope = *saved_scope;
3630 if let Ok(ref r) = run_result {
3631 scope.set_last_result(r.clone());
3632 }
3633 }
3634 {
3635 let mut ec = self.exec_ctx.write().await;
3636 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3637 ec.cwd = cwd;
3638 ec.prev_cwd = prev_cwd;
3639 ec.aliases = aliases;
3640 ec.ignore_config = ignore_config;
3641 ec.output_limit = output_limit;
3642 }
3643
3644 // Now propagate the error
3645 let result = run_result?;
3646
3647 // A held body stops the enclosing statement before its
3648 // missing output is used (spec §I.5) — the request rides up
3649 // as a typed error the statement loop converts back into a
3650 // held result, and is stashed for the boundary in case an
3651 // intermediate catch stringifies the error.
3652
3653 // A binary result is preserved as bytes — never lossy-decoded to
3654 // a string. No trailing-newline trim (every byte is significant).
3655 if let Some(bytes) = result.out_bytes() {
3656 Ok(Value::Bytes(bytes.to_vec()))
3657 // Prefer structured data (enables `for i in $(cmd)` iteration)
3658 } else if let Some(data) = &result.data {
3659 Ok(data.clone())
3660 } else if let Some(output) = result.output() {
3661 // Flat non-text node lists (glob, ls, tree) → iterable array
3662 if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3663 let items: Vec<serde_json::Value> = output.root.iter()
3664 .map(|n| serde_json::Value::String(n.display_name().to_string()))
3665 .collect();
3666 Ok(Value::Json(serde_json::Value::Array(items)))
3667 } else {
3668 // Strip trailing newlines only (POSIX command-subst),
3669 // not all trailing whitespace — spaces/tabs are
3670 // significant. Use the exact same trim as the quoted
3671 // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3672 // `trim_end_matches('\n')`) so bare and quoted command
3673 // substitution agree.
3674 Ok(Value::String(
3675 result.text_out().trim_end_matches('\n').to_string(),
3676 ))
3677 }
3678 } else {
3679 // Otherwise return stdout as single string (NO implicit splitting)
3680 Ok(Value::String(
3681 result.text_out().trim_end_matches('\n').to_string(),
3682 ))
3683 }
3684 }
3685 Expr::Test(test_expr) => {
3686 Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3687 }
3688 Expr::Positional(n) => {
3689 let scope = self.scope.read().await;
3690 match scope.get_positional(*n) {
3691 Some(s) => Ok(Value::String(s.to_string())),
3692 None => Ok(Value::String(String::new())),
3693 }
3694 }
3695 Expr::AllArgs => {
3696 let scope = self.scope.read().await;
3697 Ok(Value::String(scope.all_args().join(" ")))
3698 }
3699 Expr::ArgCount => {
3700 let scope = self.scope.read().await;
3701 Ok(Value::Int(scope.arg_count() as i64))
3702 }
3703 Expr::VarLength(path) => {
3704 let scope = self.scope.read().await;
3705 crate::interpreter::resolve_length(&scope, path)
3706 .map(Value::Int)
3707 .map_err(|msg| anyhow::anyhow!(msg))
3708 }
3709 Expr::VarWithDefault { path, default } => {
3710 // Resolve inside a scoped guard so the lock is released before the
3711 // recursive default evaluation.
3712 let resolved = {
3713 let scope = self.scope.read().await;
3714 crate::interpreter::resolve_default(&scope, path)
3715 .map_err(|msg| anyhow::anyhow!(msg))?
3716 };
3717 match resolved {
3718 Some(value) => Ok(value),
3719 None => self.eval_string_parts_async(default).await.map(Value::String),
3720 }
3721 }
3722 Expr::Arithmetic(expr_str) => {
3723 let scope = self.scope.read().await;
3724 crate::arithmetic::eval_arithmetic(expr_str, &scope)
3725 .map(Value::Int)
3726 .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3727 }
3728 Expr::Command(cmd) => {
3729 // Execute command and return boolean based on exit code
3730 let result = self.execute_command(&cmd.name, &cmd.args).await?;
3731 Ok(Value::Bool(result.code == 0))
3732 }
3733 Expr::LastExitCode => {
3734 let scope = self.scope.read().await;
3735 Ok(Value::Int(scope.last_result().code))
3736 }
3737 Expr::CurrentPid => {
3738 let scope = self.scope.read().await;
3739 Ok(Value::Int(scope.pid() as i64))
3740 }
3741 Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3742 Expr::ListLiteral(elems) => {
3743 // Spread must itself be a list — a scalar/record spread is a
3744 // loud error, never silently coerced or dropped (mirrors the
3745 // sync `Evaluator::eval_list_literal`; wording shared via
3746 // `spread_non_list_message` so the two paths can't diverge).
3747 let mut out = Vec::with_capacity(elems.len());
3748 for elem in elems {
3749 match elem {
3750 ListElem::Item(e) => {
3751 let value = self.eval_expr_async(e).await?;
3752 out.push(crate::interpreter::value_to_json(&value));
3753 }
3754 ListElem::Spread(e) => {
3755 let value = self.eval_expr_async(e).await?;
3756 match value {
3757 Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3758 other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3759 }
3760 }
3761 }
3762 }
3763 Ok(Value::Json(serde_json::Value::Array(out)))
3764 }
3765 Expr::RecordLiteral(entries) => {
3766 // Insertion order preserved (workspace serde_json has
3767 // `preserve_order`); a duplicate key keeps the last value
3768 // written, matching plain map-insert semantics.
3769 let mut map = serde_json::Map::new();
3770 for entry in entries {
3771 let key = match &entry.key {
3772 RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3773 // `{"$k": v}` resolves like any double-quoted string
3774 // (used to silently create a literal "$k" key).
3775 RecordKey::Interpolated(parts) => {
3776 self.eval_string_parts_async(parts).await?
3777 }
3778 };
3779 let value = self.eval_expr_async(&entry.value).await?;
3780 map.insert(key, crate::interpreter::value_to_json(&value));
3781 }
3782 Ok(Value::Json(serde_json::Value::Object(map)))
3783 }
3784 }
3785 })
3786 }
3787
3788 /// Async helper to evaluate multiple StringParts into a single string.
3789 fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3790 Box::pin(async move {
3791 let mut result = String::new();
3792 for part in parts {
3793 result.push_str(&self.eval_string_part_async(part).await?);
3794 }
3795 Ok(result)
3796 })
3797 }
3798
3799 /// Async helper to evaluate a StringPart.
3800 /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3801 /// through the VFS backend instead of using raw `std::path`.
3802 fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3803 Box::pin(async move {
3804 match test_expr {
3805 TestExpr::FileTest { op, path } => {
3806 let path_value = self.eval_expr_async(path).await?;
3807 // Expand `~` against the session HOME before stat'ing, the
3808 // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3809 // stats the literal `~/x` and is always false.
3810 let home = self.scope_home().await;
3811 let path_value = apply_tilde_expansion(path_value, home.as_deref());
3812 // A binary `[[ -f $bin ]]` operand goes loud rather than
3813 // silently stat'ing a file literally named
3814 // `[binary: N bytes]` (the same path-positional guard
3815 // builtins like `stat`/`cp` use).
3816 let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
3817 .map_err(|e| anyhow::anyhow!("{e}"))?;
3818 // Resolve against the *session* cwd, not the process cwd, so a
3819 // relative `[[ -f rel ]]` honors `cd` and agrees with the
3820 // VFS-aware `test` builtin (GH #101). Backend stats a raw
3821 // relative path against the process cwd otherwise.
3822 let (resolved, backend) = {
3823 let ctx = self.exec_ctx.read().await;
3824 (ctx.resolve_path(&path_str), ctx.backend.clone())
3825 };
3826 let entry = backend.stat(&resolved).await.ok();
3827 Ok(match op {
3828 FileTestOp::Exists => entry.is_some(),
3829 FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3830 FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3831 FileTestOp::Readable => entry.is_some(),
3832 FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3833 e.permissions.is_none_or(|p| p & 0o222 != 0)
3834 }),
3835 FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3836 e.permissions.is_some_and(|p| p & 0o111 != 0)
3837 }),
3838 })
3839 }
3840 TestExpr::StringTest { op, value } => match op {
3841 crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
3842 let val = self.eval_expr_async(value).await?;
3843 // Decision E: a collection operand is a loud Shape error
3844 // here too — must not diverge from the sync path in
3845 // interpreter/eval.rs (shared `scalar_test_operand_error`).
3846 let symbol = match op {
3847 crate::ast::StringTestOp::IsEmpty => "-z",
3848 crate::ast::StringTestOp::IsNonEmpty => "-n",
3849 crate::ast::StringTestOp::IsList
3850 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3851 };
3852 if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
3853 anyhow::bail!(msg);
3854 }
3855 let s = value_to_string(&val);
3856 Ok(match op {
3857 crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3858 crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3859 crate::ast::StringTestOp::IsList
3860 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3861 })
3862 }
3863 // Shape guard: propagates eval errors like -z/-n (a bare
3864 // `$unset` is an undefined-variable error, not a silent
3865 // false). A defined-but-wrong-shaped value is false. Must
3866 // not diverge from the sync path in interpreter/eval.rs.
3867 crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
3868 let val = self.eval_expr_async(value).await?;
3869 Ok(op.matches_shape(&val))
3870 }
3871 },
3872 TestExpr::Comparison { left, op, right } => {
3873 // Evaluate operands async (handles $(cmd)), then compare sync
3874 let left_val = self.eval_expr_async(left).await?;
3875 let right_val = self.eval_expr_async(right).await?;
3876 let resolved = TestExpr::Comparison {
3877 left: Box::new(Expr::Literal(left_val)),
3878 op: *op,
3879 right: Box::new(Expr::Literal(right_val)),
3880 };
3881 let expr = Expr::Test(Box::new(resolved));
3882 let mut scope = self.scope.write().await;
3883 let value = eval_expr(&expr, &mut scope)
3884 .map_err(|e| anyhow::anyhow!("{}", e))?;
3885 Ok(value_to_bool(&value))
3886 }
3887 TestExpr::And { left, right } => {
3888 if !self.eval_test_async(left).await? {
3889 Ok(false)
3890 } else {
3891 self.eval_test_async(right).await
3892 }
3893 }
3894 TestExpr::Or { left, right } => {
3895 if self.eval_test_async(left).await? {
3896 Ok(true)
3897 } else {
3898 self.eval_test_async(right).await
3899 }
3900 }
3901 TestExpr::Not { expr } => {
3902 Ok(!self.eval_test_async(expr).await?)
3903 }
3904 TestExpr::In { left, right } => {
3905 let left_val = self.eval_expr_async(left).await?;
3906 let right_val = self.eval_expr_async(right).await?;
3907 let resolved = TestExpr::In {
3908 left: Box::new(Expr::Literal(left_val)),
3909 right: Box::new(Expr::Literal(right_val)),
3910 };
3911 let expr = Expr::Test(Box::new(resolved));
3912 let mut scope = self.scope.write().await;
3913 let value = eval_expr(&expr, &mut scope)
3914 .map_err(|e| anyhow::anyhow!("{}", e))?;
3915 Ok(value_to_bool(&value))
3916 }
3917 TestExpr::NotIn { left, right } => {
3918 let left_val = self.eval_expr_async(left).await?;
3919 let right_val = self.eval_expr_async(right).await?;
3920 let resolved = TestExpr::NotIn {
3921 left: Box::new(Expr::Literal(left_val)),
3922 right: Box::new(Expr::Literal(right_val)),
3923 };
3924 let expr = Expr::Test(Box::new(resolved));
3925 let mut scope = self.scope.write().await;
3926 let value = eval_expr(&expr, &mut scope)
3927 .map_err(|e| anyhow::anyhow!("{}", e))?;
3928 Ok(value_to_bool(&value))
3929 }
3930 }
3931 })
3932 }
3933
3934 fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3935 Box::pin(async move {
3936 match part {
3937 StringPart::Literal(s) => Ok(s.clone()),
3938 StringPart::Var(path) => {
3939 let scope = self.scope.read().await;
3940 match scope.resolve_path(path) {
3941 // Text sink: binary goes loud, never the placeholder —
3942 // a `b=$(cat blob)` capture holds real bytes; splicing
3943 // `[binary: N bytes]` into "$b" would be silent loss.
3944 Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3945 // Unset vars expand to empty; loud path errors surface.
3946 Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
3947 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3948 Err(anyhow::anyhow!(msg))
3949 }
3950 }
3951 }
3952 StringPart::VarWithDefault { path, default } => {
3953 let resolved = {
3954 let scope = self.scope.read().await;
3955 crate::interpreter::resolve_default(&scope, path)
3956 .map_err(|msg| anyhow::anyhow!(msg))?
3957 };
3958 match resolved {
3959 Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3960 None => self.eval_string_parts_async(default).await,
3961 }
3962 }
3963 StringPart::VarLength(path) => {
3964 let scope = self.scope.read().await;
3965 crate::interpreter::resolve_length(&scope, path)
3966 .map(|n| n.to_string())
3967 .map_err(|msg| anyhow::anyhow!(msg))
3968 }
3969 StringPart::Positional(n) => {
3970 let scope = self.scope.read().await;
3971 match scope.get_positional(*n) {
3972 Some(s) => Ok(s.to_string()),
3973 None => Ok(String::new()),
3974 }
3975 }
3976 StringPart::AllArgs => {
3977 let scope = self.scope.read().await;
3978 Ok(scope.all_args().join(" "))
3979 }
3980 StringPart::ArgCount => {
3981 let scope = self.scope.read().await;
3982 Ok(scope.arg_count().to_string())
3983 }
3984 StringPart::Arithmetic(expr) => {
3985 // Loud on purpose (GH #183): this used to be `Err(_) =>
3986 // Ok(String::new())`, silently splicing in "" for e.g.
3987 // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
3988 // at exit 0 instead of failing. Matches the bare (non-string)
3989 // `Expr::Arithmetic` arm above, which already propagates.
3990 let scope = self.scope.read().await;
3991 crate::arithmetic::eval_arithmetic(expr, &scope)
3992 .map(|value| value.to_string())
3993 .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
3994 }
3995 StringPart::CommandSubst(stmts) => {
3996 // Snapshot scope, cwd, and session config — command
3997 // substitution in strings must not leak side effects (e.g.,
3998 // `"dir: $(cd /; pwd)"` must not change cwd, and
3999 // `"$(kaish-ignore clear)"` must not change the session's
4000 // ignore config) — matching how every other execution
4001 // context (background forks, scatter workers) already
4002 // isolates mutations (GH #139).
4003 // Boxed: this ~470 B scope snapshot is held across the nested
4004 // `$(…)` recursion await below, so inlining it grows every
4005 // command-substitution level's future (GH #48, item 4).
4006 let saved_scope = Box::new(self.scope.read().await.clone());
4007 let saved_ec = {
4008 let ec = self.exec_ctx.read().await;
4009 (
4010 ec.cwd.clone(),
4011 ec.prev_cwd.clone(),
4012 ec.aliases.clone(),
4013 ec.ignore_config.clone(),
4014 ec.output_limit.clone(),
4015 )
4016 };
4017
4018 // Capture result without `?` — restore state unconditionally
4019 let run_result = self.execute_block_capturing(stmts).await;
4020
4021 // Restore scope and cwd regardless of success/failure
4022 {
4023 let mut scope = self.scope.write().await;
4024 *scope = *saved_scope;
4025 if let Ok(ref r) = run_result {
4026 scope.set_last_result(r.clone());
4027 }
4028 }
4029 {
4030 let mut ec = self.exec_ctx.write().await;
4031 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4032 ec.cwd = cwd;
4033 ec.prev_cwd = prev_cwd;
4034 ec.aliases = aliases;
4035 ec.ignore_config = ignore_config;
4036 ec.output_limit = output_limit;
4037 }
4038
4039 // Now propagate the error
4040 let result = run_result?;
4041
4042 // A held body stops the enclosing statement before its
4043 // missing output is spliced in (spec §I.5) — same conversion
4044 // and stash as the bare `$(…)` arm.
4045
4046 // Embedding binary into a string is a text context: fail loud
4047 // rather than splice in U+FFFD garbage.
4048 match result.try_text_out() {
4049 // Text wins when present — unchanged behavior.
4050 Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4051 // `.out` is empty: a builtin/tool that set only structured
4052 // `.data` must not silently evaporate to "" (SILENT DATA
4053 // LOSS). Render it the same way a bare `"$x"`
4054 // collection-valued variable renders — compact JSON for
4055 // lists/records, plain form for scalars — by reusing
4056 // `value_to_string` (the exact `StringPart::Var` helper
4057 // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4058 // identically. No trailing-newline trim here: that's a
4059 // text-path artifact, not applicable to a freshly
4060 // rendered JSON/scalar string.
4061 Ok(_) => match &result.data {
4062 Some(data) => Ok(value_to_string(data)),
4063 None => Ok(String::new()),
4064 },
4065 Err(e) => anyhow::bail!(
4066 "command substitution in a string produced binary data ({e}) — \
4067 pipe through base64/xxd"
4068 ),
4069 }
4070 }
4071 StringPart::LastExitCode => {
4072 let scope = self.scope.read().await;
4073 Ok(scope.last_result().code.to_string())
4074 }
4075 StringPart::CurrentPid => {
4076 let scope = self.scope.read().await;
4077 Ok(scope.pid().to_string())
4078 }
4079 }
4080 })
4081 }
4082
4083 /// Update the last result in scope.
4084 async fn update_last_result(&self, result: &ExecResult) {
4085 let mut scope = self.scope.write().await;
4086 scope.set_last_result(result.clone());
4087 }
4088
4089 /// Drain accumulated pipeline stderr into a result.
4090 ///
4091 /// Called after each sub-statement inside control structures (`if`, `for`,
4092 /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4093 /// than batching until the entire structure finishes.
4094 async fn drain_stderr_into(&self, result: &mut ExecResult) {
4095 let drained = {
4096 let mut receiver = self.stderr_receiver.lock().await;
4097 receiver.drain_lossy()
4098 };
4099 if !drained.is_empty() {
4100 if !result.err.is_empty() && !result.err.ends_with('\n') {
4101 result.err.push('\n');
4102 }
4103 result.err.push_str(&drained);
4104 }
4105 }
4106
4107 /// Execute a user-defined function with local variable scoping.
4108 ///
4109 /// Functions push a new scope frame for local variables. Variables declared
4110 /// with `local` are scoped to the function; other assignments modify outer
4111 /// scopes (or create in root if new).
4112 async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4113 let _depth = self.enter_recursion("a shell function")?;
4114
4115 // 1. Build function args from AST args (async to support command substitution)
4116 let tool_args = self.build_args_async(args, None).await?;
4117
4118 // 2. Push a new scope frame for local variables
4119 {
4120 let mut scope = self.scope.write().await;
4121 scope.push_frame();
4122 }
4123
4124 // 3. Save current positional parameters and set new ones for this function
4125 let saved_positional = {
4126 let mut scope = self.scope.write().await;
4127 let saved = scope.save_positional();
4128
4129 // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4130 let positional_args: Vec<String> = tool_args.positional
4131 .iter()
4132 .map(value_to_string)
4133 .collect();
4134 scope.set_positional(&def.name, positional_args);
4135
4136 saved
4137 };
4138
4139 // 3. Execute body statements with control flow handling
4140 // Accumulate output across statements (like sh)
4141 // Accumulate stdout as raw bytes so a binary-producing statement in a
4142 // function body survives instead of being lossy-decoded here.
4143 let mut accumulated_out: Vec<u8> = Vec::new();
4144 let mut accumulated_err = String::new();
4145 let mut last_code = 0i64;
4146 let mut last_data: Option<Value> = None;
4147
4148 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4149 match r.out_bytes() {
4150 Some(b) => buf.extend_from_slice(b),
4151 None => buf.extend_from_slice(r.text_out().as_bytes()),
4152 }
4153 }
4154
4155 // Track execution error for propagation after cleanup
4156 let mut exec_error: Option<anyhow::Error> = None;
4157 let mut exit_code: Option<i64> = None;
4158
4159 for stmt in &def.body {
4160 match self.execute_stmt_flow(stmt).await {
4161 Ok(flow) => {
4162 // Drain pipeline stderr after each sub-statement.
4163 let drained = {
4164 let mut receiver = self.stderr_receiver.lock().await;
4165 receiver.drain_lossy()
4166 };
4167 if !drained.is_empty() {
4168 accumulated_err.push_str(&drained);
4169 }
4170
4171 match flow {
4172 ControlFlow::Normal(r) => {
4173 push_out(&mut accumulated_out, &r);
4174 accumulated_err.push_str(&r.err);
4175 last_code = r.code;
4176 last_data = r.data;
4177 }
4178 ControlFlow::Return { value } => {
4179 push_out(&mut accumulated_out, &value);
4180 accumulated_err.push_str(&value.err);
4181 last_code = value.code;
4182 last_data = value.data;
4183 break;
4184 }
4185 ControlFlow::Exit { code } => {
4186 exit_code = Some(code);
4187 break;
4188 }
4189 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4190 push_out(&mut accumulated_out, &r);
4191 accumulated_err.push_str(&r.err);
4192 last_code = r.code;
4193 last_data = r.data;
4194 }
4195 }
4196 }
4197 Err(e) => {
4198 exec_error = Some(e);
4199 break;
4200 }
4201 }
4202 }
4203
4204 // 4. Pop scope frame and restore original positional parameters (unconditionally)
4205 {
4206 let mut scope = self.scope.write().await;
4207 scope.pop_frame();
4208 scope.set_positional(saved_positional.0, saved_positional.1);
4209 }
4210
4211 // 5. Propagate error or exit after cleanup
4212 if let Some(e) = exec_error {
4213 return Err(e);
4214 }
4215 let code = exit_code.unwrap_or(last_code);
4216 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4217 result.err = accumulated_err;
4218 result.data = last_data;
4219 Ok(result)
4220 }
4221
4222 fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4223 let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4224 let guard = RecursionGuard { counter: &self.recursion_depth };
4225 if depth > MAX_RECURSION_DEPTH {
4226 return Err(anyhow::anyhow!(
4227 "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4228 a runaway or mutually recursive script (deeply nested $(…), or \
4229 functions/scripts that call each other without a base case) was \
4230 stopped before it could overflow the stack"
4231 ));
4232 }
4233 Ok(guard)
4234 }
4235
4236 async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4237 let _depth = self.enter_recursion("command substitution")?;
4238 // Accumulate stdout as raw bytes so a binary-producing statement
4239 // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4240 // caller can preserve it. The final result is text iff valid UTF-8.
4241 let mut accumulated_out: Vec<u8> = Vec::new();
4242 let mut accumulated_err = String::new();
4243 let mut last_code = 0i64;
4244 let mut last_data: Option<Value> = None;
4245
4246 // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4247 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4248 match r.out_bytes() {
4249 Some(b) => buf.extend_from_slice(b),
4250 None => buf.extend_from_slice(r.text_out().as_bytes()),
4251 }
4252 }
4253
4254 for stmt in stmts {
4255 let flow = self.execute_stmt_flow(stmt).await?;
4256
4257 // Drain pipeline stderr after each sub-statement (incremental, like
4258 // the control-structure and function-body executors).
4259 let drained = {
4260 let mut receiver = self.stderr_receiver.lock().await;
4261 receiver.drain_lossy()
4262 };
4263 if !drained.is_empty() {
4264 accumulated_err.push_str(&drained);
4265 }
4266
4267 match flow {
4268 ControlFlow::Normal(r)
4269 | ControlFlow::Break { result: r, .. }
4270 | ControlFlow::Continue { result: r, .. } => {
4271 push_out(&mut accumulated_out, &r);
4272 accumulated_err.push_str(&r.err);
4273 last_code = r.code;
4274 last_data = r.data;
4275 }
4276 ControlFlow::Return { value } => {
4277 push_out(&mut accumulated_out, &value);
4278 accumulated_err.push_str(&value.err);
4279 last_code = value.code;
4280 last_data = value.data;
4281 break;
4282 }
4283 ControlFlow::Exit { code } => {
4284 last_code = code;
4285 break;
4286 }
4287 }
4288 }
4289
4290 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4291 result.err = accumulated_err;
4292 result.data = last_data;
4293 Ok(result)
4294 }
4295
4296 /// Execute the `source` / `.` command to include and run a script.
4297 ///
4298 /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4299 /// allowing the sourced script to set variables and modify shell state.
4300 async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4301 // `source`/`.` is the fourth dynamic re-entry point: it runs the
4302 // sourced file's statements inline via `execute_stmt_flow`, so a file
4303 // that sources itself recurses unbounded just like a runaway function
4304 // (GH #46). It's intercepted as a special form *before* the other
4305 // guarded paths, so it needs its own guard.
4306 let _depth = self.enter_recursion("source")?;
4307
4308 // Get the file path from the first positional argument
4309 let tool_args = self.build_args_async(args, None).await?;
4310 let path = match tool_args.positional.first() {
4311 Some(Value::String(s)) => s.clone(),
4312 Some(v) => value_to_string(v),
4313 None => {
4314 return Ok(ExecResult::failure(1, "source: missing filename"));
4315 }
4316 };
4317
4318 // Resolve path relative to cwd
4319 let full_path = {
4320 let ctx = self.exec_ctx.read().await;
4321 if path.starts_with('/') {
4322 std::path::PathBuf::from(&path)
4323 } else {
4324 ctx.cwd.join(&path)
4325 }
4326 };
4327
4328 // Read file content via backend
4329 let content = {
4330 let ctx = self.exec_ctx.read().await;
4331 match ctx.backend.read(&full_path, None).await {
4332 Ok(bytes) => {
4333 String::from_utf8(bytes).map_err(|e| {
4334 anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4335 })?
4336 }
4337 Err(e) => {
4338 return Ok(ExecResult::failure(
4339 1,
4340 format!("source: {}: {}", path, e),
4341 ));
4342 }
4343 }
4344 };
4345
4346 // Parse the content
4347 let program = match crate::parser::parse(&content) {
4348 Ok(p) => p,
4349 Err(errors) => {
4350 let msg = errors
4351 .iter()
4352 .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4353 .collect::<Vec<_>>()
4354 .join("\n");
4355 return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4356 }
4357 };
4358
4359 // Execute each statement in the CURRENT scope (not isolated), accumulating
4360 // stdout/stderr across statements like `execute_user_tool` — a sourced
4361 // script's earlier statements must not be silently dropped in favor of
4362 // just the last one.
4363 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4364 match r.out_bytes() {
4365 Some(b) => buf.extend_from_slice(b),
4366 None => buf.extend_from_slice(r.text_out().as_bytes()),
4367 }
4368 }
4369
4370 let mut accumulated_out: Vec<u8> = Vec::new();
4371 let mut accumulated_err = String::new();
4372 let mut last_code = 0i64;
4373 let mut last_data: Option<Value> = None;
4374
4375 for stmt in program.statements {
4376 if matches!(stmt, crate::ast::Stmt::Empty) {
4377 continue;
4378 }
4379
4380 match self.execute_stmt_flow(&stmt).await {
4381 Ok(flow) => {
4382 let drained = {
4383 let mut receiver = self.stderr_receiver.lock().await;
4384 receiver.drain_lossy()
4385 };
4386 if !drained.is_empty() {
4387 accumulated_err.push_str(&drained);
4388 }
4389 match flow {
4390 ControlFlow::Normal(r) => {
4391 push_out(&mut accumulated_out, &r);
4392 accumulated_err.push_str(&r.err);
4393 last_code = r.code;
4394 last_data = r.data.clone();
4395 self.update_last_result(&r).await;
4396 }
4397 ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4398 return Err(anyhow::anyhow!(
4399 "source: {}: unexpected break/continue outside loop",
4400 path
4401 ));
4402 }
4403 ControlFlow::Return { value } => {
4404 push_out(&mut accumulated_out, &value);
4405 accumulated_err.push_str(&value.err);
4406 let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4407 .with_code(value.code);
4408 result.err = accumulated_err;
4409 result.data = value.data;
4410 return Ok(result);
4411 }
4412 ControlFlow::Exit { code } => {
4413 let mut result =
4414 ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4415 result.err = accumulated_err;
4416 result.data = last_data;
4417 return Ok(result);
4418 }
4419 }
4420 }
4421 Err(e) => {
4422 return Err(e.context(format!("source: {}", path)));
4423 }
4424 }
4425 }
4426
4427 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4428 result.err = accumulated_err;
4429 result.data = last_data;
4430 Ok(result)
4431 }
4432
4433 /// Try to execute a script from PATH directories.
4434 ///
4435 /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4436 /// (like user-defined tools). Returns None if no script is found.
4437 async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4438 // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4439 // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4440 // A non-script command pays only a transient, balanced increment during
4441 // the probe before falling through to the external path.
4442 let _depth = self.enter_recursion("a .kai script")?;
4443
4444 // Get PATH from scope (default to "/bin")
4445 let path_value = {
4446 let scope = self.scope.read().await;
4447 scope
4448 .get("PATH")
4449 .map(value_to_string)
4450 .unwrap_or_else(|| "/bin".to_string())
4451 };
4452
4453 // Search PATH directories for script
4454 for dir in path_value.split(':') {
4455 if dir.is_empty() {
4456 continue;
4457 }
4458
4459 // Build script path: {dir}/{name}.kai
4460 let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4461
4462 // Check if script exists
4463 let exists = {
4464 let ctx = self.exec_ctx.read().await;
4465 ctx.backend.exists(&script_path).await
4466 };
4467
4468 if !exists {
4469 continue;
4470 }
4471
4472 // Read script content
4473 let content = {
4474 let ctx = self.exec_ctx.read().await;
4475 match ctx.backend.read(&script_path, None).await {
4476 Ok(bytes) => match String::from_utf8(bytes) {
4477 Ok(s) => s,
4478 Err(e) => {
4479 return Ok(Some(ExecResult::failure(
4480 1,
4481 format!("{}: invalid UTF-8: {}", script_path.display(), e),
4482 )));
4483 }
4484 },
4485 Err(e) => {
4486 return Ok(Some(ExecResult::failure(
4487 1,
4488 format!("{}: {}", script_path.display(), e),
4489 )));
4490 }
4491 }
4492 };
4493
4494 // Parse the script
4495 let program = match crate::parser::parse(&content) {
4496 Ok(p) => p,
4497 Err(errors) => {
4498 let msg = errors
4499 .iter()
4500 .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4501 .collect::<Vec<_>>()
4502 .join("\n");
4503 return Ok(Some(ExecResult::failure(1, msg)));
4504 }
4505 };
4506
4507 // Build tool_args from args (async for command substitution support)
4508 let tool_args = self.build_args_async(args, None).await?;
4509
4510 // Create isolated scope (like user tools). The trash rail is NOT
4511 // session state a script may shed: a `.kai` script starting from
4512 // a blank scope would otherwise overwrite and delete without the
4513 // recovery net `set -o trash` promised. Carry it.
4514 let mut isolated_scope = Scope::new();
4515 {
4516 let scope = self.scope.read().await;
4517 isolated_scope.set_pid(scope.pid());
4518 isolated_scope.set_trash_enabled(scope.trash_enabled());
4519 isolated_scope.set_trash_max_size(scope.trash_max_size());
4520 }
4521
4522 // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4523 let positional_args: Vec<String> = tool_args.positional
4524 .iter()
4525 .map(value_to_string)
4526 .collect();
4527 isolated_scope.set_positional(name, positional_args);
4528
4529 // Save current scope and swap with isolated scope
4530 let original_scope = {
4531 let mut scope = self.scope.write().await;
4532 std::mem::replace(&mut *scope, isolated_scope)
4533 };
4534
4535 // Execute script statements — accumulate stdout/stderr across
4536 // statements like `execute_user_tool`, rather than keeping only the
4537 // last one's result.
4538 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4539 match r.out_bytes() {
4540 Some(b) => buf.extend_from_slice(b),
4541 None => buf.extend_from_slice(r.text_out().as_bytes()),
4542 }
4543 }
4544
4545 let mut accumulated_out: Vec<u8> = Vec::new();
4546 let mut accumulated_err = String::new();
4547 let mut last_code = 0i64;
4548 let mut last_data: Option<Value> = None;
4549 let mut exec_error: Option<anyhow::Error> = None;
4550 let mut exit_code: Option<i64> = None;
4551
4552 for stmt in program.statements {
4553 if matches!(stmt, crate::ast::Stmt::Empty) {
4554 continue;
4555 }
4556
4557 match self.execute_stmt_flow(&stmt).await {
4558 Ok(flow) => {
4559 let drained = {
4560 let mut receiver = self.stderr_receiver.lock().await;
4561 receiver.drain_lossy()
4562 };
4563 if !drained.is_empty() {
4564 accumulated_err.push_str(&drained);
4565 }
4566 match flow {
4567 ControlFlow::Normal(r) => {
4568 push_out(&mut accumulated_out, &r);
4569 accumulated_err.push_str(&r.err);
4570 last_code = r.code;
4571 last_data = r.data;
4572 }
4573 ControlFlow::Return { value } => {
4574 push_out(&mut accumulated_out, &value);
4575 accumulated_err.push_str(&value.err);
4576 last_code = value.code;
4577 last_data = value.data;
4578 break;
4579 }
4580 ControlFlow::Exit { code } => {
4581 exit_code = Some(code);
4582 break;
4583 }
4584 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4585 push_out(&mut accumulated_out, &r);
4586 accumulated_err.push_str(&r.err);
4587 last_code = r.code;
4588 last_data = r.data;
4589 }
4590 }
4591 }
4592 Err(e) => {
4593 exec_error = Some(e);
4594 break;
4595 }
4596 }
4597 }
4598
4599 // Restore original scope unconditionally
4600 {
4601 let mut scope = self.scope.write().await;
4602 *scope = original_scope;
4603 }
4604
4605 // Propagate error or exit after cleanup
4606 if let Some(e) = exec_error {
4607 return Err(e.context(format!("script: {}", script_path.display())));
4608 }
4609 let code = exit_code.unwrap_or(last_code);
4610 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4611 result.err = accumulated_err;
4612 result.data = last_data;
4613 return Ok(Some(result));
4614 }
4615
4616 // No script found
4617 Ok(None)
4618 }
4619
4620 /// Try to execute an external command from PATH.
4621 ///
4622 /// This is the fallback when no builtin or user-defined tool matches.
4623 /// External commands receive a clean argv (flags preserved in their original format).
4624 ///
4625 /// # Requirements
4626 /// - Command must be found in PATH
4627 /// - Current working directory must be on a real filesystem (not virtual like /v)
4628 ///
4629 /// # Returns
4630 /// - `Ok(Some(result))` if command was found and executed
4631 /// - `Ok(None)` if command was not found in PATH
4632 /// - `Err` on execution errors
4633 #[cfg(not(feature = "subprocess"))]
4634 async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4635 Ok(None)
4636 }
4637
4638 /// Try to execute an external command from PATH.
4639 #[cfg(feature = "subprocess")]
4640 #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4641 async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4642 // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4643 // populates from the inbound ctx.cancel on every dispatch. This is
4644 // what makes the `timeout` builtin's swapped child token reach the
4645 // wait_or_kill discipline below — reading `self.cancel_token` would
4646 // give the kernel-wide token and miss the timeout's child cascade.
4647 let cancel = {
4648 let ec = self.exec_ctx.read().await;
4649 ec.cancel.clone()
4650 };
4651 let kill_grace = self.kill_grace;
4652 if !self.allow_external_commands {
4653 return Ok(None);
4654 }
4655
4656 // Get the shell's cwd and its real filesystem location, if any. A
4657 // `None` real path means the cwd is virtual (a CoW overlay, an
4658 // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4659 // process to run. Don't bail out here: a bare command name that isn't
4660 // in PATH at all is a genuine "not found" regardless of cwd, and the
4661 // virtual-cwd error would blame the wrong thing for that case. Once
4662 // the command actually resolves, `real_cwd` is checked again below
4663 // and the honest reason is given then (issue #181).
4664 let (cwd, real_cwd) = {
4665 let ctx = self.exec_ctx.read().await;
4666 (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4667 };
4668
4669 let executable = if name.contains('/') {
4670 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4671 let resolved = if std::path::Path::new(name).is_absolute() {
4672 std::path::PathBuf::from(name)
4673 } else {
4674 match &real_cwd {
4675 Some(real_cwd) => real_cwd.join(name),
4676 // A relative path can't be resolved without a real cwd to
4677 // join against, so we can't even tell whether it would
4678 // exist — name the actual blocker instead of a
4679 // misleading "No such file or directory".
4680 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4681 }
4682 };
4683 if !resolved.exists() {
4684 return Ok(Some(ExecResult::failure(
4685 127,
4686 format!("{}: No such file or directory", name),
4687 )));
4688 }
4689 if !resolved.is_file() {
4690 return Ok(Some(ExecResult::failure(
4691 126,
4692 format!("{}: Is a directory", name),
4693 )));
4694 }
4695 #[cfg(unix)]
4696 {
4697 use std::os::unix::fs::PermissionsExt;
4698 let mode = std::fs::metadata(&resolved)
4699 .map(|m| m.permissions().mode())
4700 .unwrap_or(0);
4701 if mode & 0o111 == 0 {
4702 return Ok(Some(ExecResult::failure(
4703 126,
4704 format!("{}: Permission denied", name),
4705 )));
4706 }
4707 }
4708 resolved.to_string_lossy().into_owned()
4709 } else {
4710 // Get PATH from scope only. The kernel never reads OS env: a
4711 // frontend that wants host PATH seeds it via initial_vars (the REPL
4712 // does, with os_env_vars()). No PATH in scope → nothing resolves.
4713 let path_var = {
4714 let scope = self.scope.read().await;
4715 scope.get("PATH").map(value_to_string).unwrap_or_default()
4716 };
4717
4718 // Resolve command in PATH
4719 match resolve_in_path(name, &path_var) {
4720 Some(path) => path,
4721 None => return Ok(None), // Not found - let caller handle error
4722 }
4723 };
4724
4725 // The executable resolved — found in PATH, or a path that exists and
4726 // is executable — but there's still nowhere to run it without a real
4727 // cwd to spawn the child process in.
4728 let real_cwd = match real_cwd {
4729 Some(p) => p,
4730 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4731 };
4732
4733 tracing::debug!(executable = %executable, "resolved external command");
4734
4735 // Build flat argv (preserves flag format)
4736 let argv = self.build_args_flat(args).await?;
4737
4738 // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4739 // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4740 // byte vector. Take both out under the lock but do NOT drain here — a
4741 // pipe read can block on its producer (a still-running upstream stage),
4742 // so draining before spawn would serialize the pipeline (deadlocking
4743 // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4744 // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
4745 // are mutually exclusive in practice; prefer the pipe.
4746 let (pipe_stdin, stdin_bytes) = {
4747 let mut ctx = self.exec_ctx.write().await;
4748 (ctx.pipe_stdin.take(), ctx.take_stdin())
4749 };
4750 let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
4751
4752 // Build and spawn the command
4753 use tokio::process::Command;
4754
4755 let mut cmd = Command::new(&executable);
4756 cmd.args(&argv);
4757 cmd.current_dir(&real_cwd);
4758
4759 // Hermetic env: child sees only kaish's exported vars, not the kaish
4760 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4761 // populate it via KernelConfig::initial_vars at construction.
4762 cmd.env_clear();
4763 {
4764 let scope = self.scope.read().await;
4765 let exported = scope.exported_vars();
4766 // A structured value can't cross the process boundary; refuse rather
4767 // than silently JSON-serialize it into the child's environment.
4768 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4769 return Err(anyhow::anyhow!(msg));
4770 }
4771 for (var_name, value) in exported {
4772 // Binary can't cross the process boundary as an env var value
4773 // either — loud, not the `[binary: N bytes]` placeholder
4774 // silently exported in its place (kept in sync with
4775 // dispatch.rs::try_external and env.rs::execute_with_env).
4776 let value_str = crate::interpreter::value_to_text_sink_named(
4777 &value,
4778 "an exported environment variable value",
4779 )
4780 .map_err(|e| anyhow::anyhow!("{e}"))?;
4781 cmd.env(var_name, value_str);
4782 }
4783 }
4784
4785 // Handle stdin
4786 cmd.stdin(if has_stdin {
4787 std::process::Stdio::piped()
4788 } else if self.interactive {
4789 std::process::Stdio::inherit()
4790 } else {
4791 std::process::Stdio::null()
4792 });
4793
4794 // In interactive mode, standalone or last-in-pipeline commands inherit
4795 // the terminal's stdout/stderr so output streams in real-time.
4796 // First/middle commands must capture stdout for the pipe — same as bash.
4797 let pipeline_position = {
4798 let ctx = self.exec_ctx.read().await;
4799 ctx.pipeline_position
4800 };
4801 let inherit_output = self.interactive
4802 && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4803
4804 if inherit_output {
4805 cmd.stdout(std::process::Stdio::inherit());
4806 cmd.stderr(std::process::Stdio::inherit());
4807 } else {
4808 cmd.stdout(std::process::Stdio::piped());
4809 cmd.stderr(std::process::Stdio::piped());
4810 }
4811
4812 // On Unix, always put the child in its own process group so cancellation
4813 // can `killpg` the whole tree (the child plus any grandchildren).
4814 // Restoring default tty-related signal handlers stays gated on
4815 // job-control mode — those only matter when the child has a controlling
4816 // terminal.
4817 #[cfg(unix)]
4818 {
4819 let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4820 // Read before the fork: the child compares `getppid()` against it to
4821 // catch a parent that died inside the fork/prctl window.
4822 let kill_on_parent_death = {
4823 let ec = self.exec_ctx.read().await;
4824 ec.kill_children_on_parent_death
4825 };
4826 let parent_pid = std::process::id();
4827 // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
4828 // async-signal-safe per POSIX; safe to call between fork and exec.
4829 #[allow(unsafe_code)]
4830 unsafe {
4831 cmd.pre_exec(move || {
4832 // Own process group — for kill scope.
4833 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4834 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4835 if kill_on_parent_death {
4836 crate::dispatch::arm_parent_death_signal(parent_pid)?;
4837 }
4838 if restore_jc_signals {
4839 use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4840 let mut sa: nix::libc::sigaction = std::mem::zeroed();
4841 sa.sa_sigaction = SIG_DFL;
4842 if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4843 return Err(std::io::Error::last_os_error());
4844 }
4845 if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4846 return Err(std::io::Error::last_os_error());
4847 }
4848 if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4849 return Err(std::io::Error::last_os_error());
4850 }
4851 if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4852 return Err(std::io::Error::last_os_error());
4853 }
4854 }
4855 Ok(())
4856 });
4857 }
4858 }
4859
4860 // Backstop for kill on drop in case our explicit kill path is bypassed
4861 // (panic, early return, etc) on the **capture** wait path. We do NOT
4862 // set this on the JC inherit path: that uses sync `waitpid` outside
4863 // tokio's view of the child, so on drop tokio would try to kill an
4864 // already-reaped (possibly-reused) PID. The JC path has its own
4865 // cancel handling via the side-task watcher.
4866 let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4867 if !in_jc_inherit_path {
4868 cmd.kill_on_drop(true);
4869 }
4870
4871 // Spawn the process. Capture a `KillTarget` immediately so cancel/
4872 // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4873 // to this process's generation, immune to PID reuse if the OS reaps
4874 // the child before our kill syscalls fire.
4875 let mut child = match cmd.spawn() {
4876 Ok(child) => child,
4877 Err(e) => {
4878 return Ok(Some(ExecResult::failure(
4879 127,
4880 format!("{}: {}", name, e),
4881 )));
4882 }
4883 };
4884 let kill_target = crate::pidfd::KillTarget::from_child(&child);
4885
4886 // If this external runs on behalf of a background job, record its
4887 // process group on the job so `kill -<sig> %N` can signal the real
4888 // process directly (STOP/CONT/USR1/…, not just terminate). The child
4889 // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4890 if let Some(job_id) = self.bg_job_id
4891 && let Some(pid) = child.id()
4892 {
4893 self.jobs.add_pgid(job_id, pid).await;
4894 }
4895
4896 // Same seam, for output: a background job's streams outlive this one
4897 // command, so the drain tasks below tee into them and the job closes
4898 // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
4899 // `cargo build &` is still building (GH #240 removed the node rather
4900 // than wire this tee; the tee is the half that was missing).
4901 let job_streams = match self.bg_job_id {
4902 Some(job_id) => self.jobs.streams(job_id).await,
4903 None => None,
4904 };
4905
4906 // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4907 // detached task (bounded memory, no pre-drain) so an upstream stage and
4908 // this child run concurrently — and a child that never reads stdin (or
4909 // is killed) just breaks the copy, which stops. A buffered byte vector
4910 // is written verbatim (no text detour), so binary stdin survives.
4911 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4912 child.stdin.take().map(|mut child_stdin| {
4913 tokio::spawn(async move {
4914 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4915 let mut buf = [0u8; 8192];
4916 loop {
4917 match pipe_in.read(&mut buf).await {
4918 Ok(0) => break, // EOF
4919 Ok(n) => {
4920 if child_stdin.write_all(&buf[..n]).await.is_err() {
4921 break; // child closed stdin
4922 }
4923 }
4924 Err(_) => break,
4925 }
4926 }
4927 // Dropping child_stdin signals EOF to the child.
4928 })
4929 })
4930 } else if let Some(data) = stdin_bytes {
4931 // Write the buffered bytes from a detached task too — NOT inline.
4932 // An inline write blocks once the stdin pipe fills, and the output
4933 // drain hasn't spawned yet, so a child that emits a lot before
4934 // consuming all its input (every pipe buffer full) deadlocks. A
4935 // write error here is normal, not a failure: a child that closes
4936 // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
4937 // signals EOF.
4938 child.stdin.take().map(|mut child_stdin| {
4939 tokio::spawn(async move {
4940 use tokio::io::AsyncWriteExt;
4941 let _ = child_stdin.write_all(&data).await;
4942 })
4943 })
4944 } else {
4945 None
4946 };
4947
4948 // Abort the stdin-copy task on EVERY exit path (the capture path, both
4949 // interactive `inherit_output` returns, and any early error return).
4950 // Once the child is reaped the copy has nothing left to deliver; if it
4951 // were left parked on `pipe_in.read()` it would leak and hold the
4952 // upstream pipe reader open. A drop guard is the single place that
4953 // covers all returns — explicit per-return aborts were error-prone (an
4954 // earlier version missed the two inherit_output returns).
4955 struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
4956 impl Drop for AbortStdinCopyOnDrop {
4957 fn drop(&mut self) {
4958 if let Some(t) = self.0.take() {
4959 t.abort();
4960 }
4961 }
4962 }
4963 let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
4964
4965 if inherit_output {
4966 // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
4967 #[cfg(unix)]
4968 if let Some(ref term) = self.terminal_state {
4969 let child_id = child.id().unwrap_or(0);
4970 let pid = nix::unistd::Pid::from_raw(child_id as i32);
4971 let pgid = pid; // child is its own pgid leader
4972
4973 // Give the terminal to the child's process group
4974 if let Err(e) = term.give_terminal_to(pgid) {
4975 tracing::warn!("failed to give terminal to child: {}", e);
4976 }
4977
4978 let term_clone = term.clone();
4979 let cmd_name = name.to_string();
4980 let cmd_display = format!("{} {}", name, argv.join(" "));
4981 let jobs = self.jobs.clone();
4982
4983 // Side task that watches for cancellation while the blocking
4984 // waitpid runs. On cancel, it SIGTERMs the process group, waits
4985 // the grace period, then SIGKILLs. The blocking waitpid returns
4986 // when the child dies. AbortOnDrop guard cancels the watcher
4987 // on the success path so it doesn't keep running after wait
4988 // returns naturally.
4989 //
4990 // `wait_complete` shrinks the PID-reuse race: the watcher
4991 // checks it before each kill syscall and bails out if
4992 // wait_for_foreground has already reaped the child. This
4993 // doesn't fully eliminate the race (atomic load + kill is
4994 // not atomic with the OS reap+reuse), but narrows the window
4995 // to nanoseconds — enough to be ignorable in practice.
4996 let wait_complete = std::sync::Arc::new(
4997 std::sync::atomic::AtomicBool::new(false)
4998 );
4999 let cancel_watcher = {
5000 let cancel = cancel.clone();
5001 let wc = wait_complete.clone();
5002 // Ownership transfer: the JC path's sync wait inside
5003 // block_in_place owns the child's reaping, so the
5004 // cancel_watcher drives the kill side via KillTarget
5005 // (pidfd-bound on Linux). When kill_target is None
5006 // (older kernel + open failure, or non-Linux), falls
5007 // through to the older PID-based path the closure
5008 // captures from `pid`.
5009 let target = kill_target.as_ref().map(|t| {
5010 // Re-borrow the components we need into Owned-ish form
5011 // so the spawned task is 'static. We can't move
5012 // KillTarget directly because try_execute_external
5013 // still uses it after the spawn — but on the JC path
5014 // there is no further use after the watcher spawn,
5015 // so a clone-of-pid + owned None pidfd is safe.
5016 // Simpler: signal via the existing target by cloning
5017 // a fresh pidfd; the original keeps its handle.
5018 // Pidfd is just an OwnedFd — not Clone — so do it
5019 // by re-opening from the pid. Fall back if reopen
5020 // fails (race already reaped → best-effort kill).
5021 crate::pidfd::KillTarget::from_pid(t.pid())
5022 });
5023 tokio::spawn(async move {
5024 cancel.cancelled().await;
5025 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5026 use nix::sys::signal::Signal;
5027 if let Some(t) = &target {
5028 t.signal(Signal::SIGTERM);
5029 t.signal_pg(Signal::SIGTERM);
5030 } else {
5031 let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5032 let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5033 }
5034 if kill_grace > Duration::ZERO {
5035 tokio::time::sleep(kill_grace).await;
5036 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5037 }
5038 if let Some(t) = &target {
5039 t.signal(Signal::SIGKILL);
5040 t.signal_pg(Signal::SIGKILL);
5041 } else {
5042 let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5043 let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5044 }
5045 })
5046 };
5047 struct AbortOnDrop(tokio::task::JoinHandle<()>);
5048 impl Drop for AbortOnDrop {
5049 fn drop(&mut self) {
5050 self.0.abort();
5051 }
5052 }
5053 let _watcher_guard = AbortOnDrop(cancel_watcher);
5054
5055 let wait_complete_setter = wait_complete.clone();
5056 let code = tokio::task::block_in_place(move || {
5057 let result = term_clone.wait_for_foreground(pid);
5058 // Mark wait done before the watcher might fire.
5059 wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5060
5061 // Always reclaim the terminal
5062 if let Err(e) = term_clone.reclaim_terminal() {
5063 tracing::warn!("failed to reclaim terminal: {}", e);
5064 }
5065
5066 match result {
5067 crate::terminal::WaitResult::Exited(code) => code as i64,
5068 crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5069 crate::terminal::WaitResult::Stopped(_sig) => {
5070 // Register as a stopped job
5071 let rt = tokio::runtime::Handle::current();
5072 let job_id = rt.block_on(jobs.register_stopped(
5073 cmd_display,
5074 child_id,
5075 child_id, // pgid = pid for group leader
5076 ));
5077 eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5078 148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5079 }
5080 }
5081 });
5082
5083 return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5084 }
5085
5086 // Non-job-control path with inherited stdio.
5087 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5088 Ok(s) => s,
5089 Err(e) => {
5090 return Ok(Some(ExecResult::failure(
5091 1,
5092 format!("{}: failed to wait: {}", name, e),
5093 )));
5094 }
5095 };
5096
5097 let code = exit_code_from_status(&status);
5098
5099 // stdout/stderr already went to the terminal
5100 Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5101 } else {
5102 // Capture output via bounded streams
5103 let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5104 let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5105
5106 let stdout_pipe = child.stdout.take();
5107 let stderr_pipe = child.stderr.take();
5108
5109 let stdout_clone = stdout_stream.clone();
5110 let stderr_clone = stderr_stream.clone();
5111
5112 // Only the stage whose stdout *is* the job's stdout tees: in
5113 // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5114 // the pipeline's intermediate data into the node alongside its
5115 // real output. stderr has no such routing — every stage's stderr
5116 // is the job's stderr — so it tees from any position.
5117 let stdout_tee = job_streams.as_ref().and_then(|s| {
5118 matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5119 .then(|| s.stdout.clone())
5120 });
5121 let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5122
5123 let stdout_task = stdout_pipe.map(|pipe| {
5124 tokio::spawn(async move {
5125 drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5126 })
5127 });
5128
5129 let stderr_task = stderr_pipe.map(|pipe| {
5130 tokio::spawn(async move {
5131 drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5132 })
5133 });
5134
5135 let cancelled_before_wait = cancel.is_cancelled();
5136 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5137 Ok(s) => s,
5138 Err(e) => {
5139 // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5140 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5141 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5142 return Ok(Some(ExecResult::failure(
5143 1,
5144 format!("{}: failed to wait: {}", name, e),
5145 )));
5146 }
5147 };
5148
5149 // On cancel, abort the drain tasks (the child's pipes are gone;
5150 // late output is lost but predictable death beats partial capture).
5151 // On normal exit, await drains so we don't lose buffered output.
5152 if cancelled_before_wait || cancel.is_cancelled() {
5153 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5154 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5155 } else {
5156 if let Some(task) = stdout_task {
5157 // Ignore join error — the drain task logs its own errors
5158 let _ = task.await;
5159 }
5160 if let Some(task) = stderr_task {
5161 let _ = task.await;
5162 }
5163 }
5164
5165 let code = exit_code_from_status(&status);
5166
5167 // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5168 // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5169 // intact. stderr stays text. See docs/binary-data.md.
5170 let stdout = stdout_stream.read().await;
5171 let mut stderr = stderr_stream.read_string().await;
5172 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5173
5174 // Both streams are fixed-size rings regardless of `ctx.output_limit`
5175 // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5176 // when enabled). With the limit disabled — the repl/embedded/test
5177 // default — an overflow here used to be silent: `write` evicted the
5178 // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5179 // counter, so a >10MB stdout reported clean success with its head
5180 // quietly gone (GH #191). Surface it loudly instead.
5181 if stderr_stream.has_overflowed().await {
5182 let stats = stderr_stream.stats().await;
5183 stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5184 }
5185 if stdout_stream.has_overflowed().await {
5186 // The marker goes in stderr, never prepended into `result`'s
5187 // stdout payload: stdout may be binary
5188 // (`success_text_or_bytes` yields a `Bytes` result for
5189 // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5190 // string-formatting a marker into it would lossily reinterpret
5191 // bytes as text, introducing a SECOND, different kind of
5192 // corruption on top of the eviction itself.
5193 //
5194 // Only stdout overflow flips `did_spill` — exit-code integrity
5195 // tracks stdout, matching the enabled-limit path's contract
5196 // (stderr overflow alone doesn't remap the exit code).
5197 let stats = stdout_stream.stats().await;
5198 stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5199 result.did_spill = true;
5200 }
5201 result.err = stderr;
5202 Ok(Some(result))
5203 }
5204 }
5205
5206 // --- Variable Access ---
5207
5208 /// Get a variable value.
5209 pub async fn get_var(&self, name: &str) -> Option<Value> {
5210 let scope = self.scope.read().await;
5211 scope.get(name).cloned()
5212 }
5213
5214 /// Check if error-exit mode is enabled (for testing).
5215 #[cfg(test)]
5216 pub async fn error_exit_enabled(&self) -> bool {
5217 let scope = self.scope.read().await;
5218 scope.error_exit_enabled()
5219 }
5220
5221 /// Set a variable value.
5222 pub async fn set_var(&self, name: &str, value: Value) {
5223 let mut scope = self.scope.write().await;
5224 scope.set(name.to_string(), value);
5225 }
5226
5227 /// Set positional parameters ($0 script name and $1-$9 args).
5228 pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5229 let mut scope = self.scope.write().await;
5230 scope.set_positional(script_name, args);
5231 }
5232
5233 /// List all variables.
5234 pub async fn list_vars(&self) -> Vec<(String, Value)> {
5235 let scope = self.scope.read().await;
5236 scope.all()
5237 }
5238
5239 /// List exported variables (name, value), sorted by name. These are the
5240 /// vars a child process would see (see `dispatch`'s hermetic env build).
5241 pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5242 let scope = self.scope.read().await;
5243 scope.exported_vars()
5244 }
5245
5246 // --- CWD ---
5247
5248 /// Get current working directory.
5249 pub async fn cwd(&self) -> PathBuf {
5250 self.exec_ctx.read().await.cwd.clone()
5251 }
5252
5253 /// Set current working directory.
5254 pub async fn set_cwd(&self, path: PathBuf) {
5255 let mut ctx = self.exec_ctx.write().await;
5256 ctx.set_cwd(path);
5257 }
5258
5259 /// Set the working directory only if `path` resolves to a directory in the
5260 /// kernel's backend — the same namespace `cd` validates against. Unlike a
5261 /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5262 /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5263 /// disappeared. Returns whether the cwd was changed.
5264 pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5265 // Clone the backend Arc out before the stat so we never hold the
5266 // exec_ctx lock across the await.
5267 let backend = self.exec_ctx.read().await.backend.clone();
5268 let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5269 if is_dir {
5270 self.exec_ctx.write().await.set_cwd(path);
5271 }
5272 is_dir
5273 }
5274
5275 // --- Last Result ---
5276
5277 /// Get the last result ($?).
5278 pub async fn last_result(&self) -> ExecResult {
5279 let scope = self.scope.read().await;
5280 scope.last_result().clone()
5281 }
5282
5283 // --- Tools ---
5284
5285 /// Check if a user-defined function exists.
5286 pub async fn has_function(&self, name: &str) -> bool {
5287 self.user_tools.read().await.contains_key(name)
5288 }
5289
5290 /// Get available tool schemas.
5291 pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5292 self.tools.schemas()
5293 }
5294
5295 /// Classify how the kernel will resolve a command name.
5296 ///
5297 /// This is the supported, single source of truth for command resolution that
5298 /// embedders should call instead of re-deriving the rules. Walk a parsed
5299 /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5300 /// this per command name to bucket each into builtin / user-function /
5301 /// special-form / dynamic / external — for example a consent gate that blocks
5302 /// a script until external commands are approved.
5303 ///
5304 /// The classification mirrors the interpreter's real resolution order
5305 /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5306 /// short-circuit first, then **aliases are expanded** (bounded recursion,
5307 /// re-checking special-forms each step, exactly as execution does), then user
5308 /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5309 /// name that is a variable or command-substitution expansion (`$cmd`,
5310 /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5311 /// be resolved statically.
5312 ///
5313 /// Aliases are resolved against the kernel's current alias table, so an
5314 /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5315 /// thing it would actually run. The safe direction of any residual imprecision
5316 /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5317 /// `.kai`/backend-tool resolution are reported `External` even though some of
5318 /// those resolve in-process, so a consent gate over-gates rather than letting
5319 /// a `PATH` escape slip through.
5320 pub async fn classify_command(&self, name: &str) -> CommandKind {
5321 // Resolve the command head the way `execute_command_depth` does: a
5322 // special-form short-circuits before any alias lookup, otherwise expand
5323 // aliases (bounded, recursive) and re-check from the top. A dynamic name
5324 // can't be resolved at all.
5325 let mut name = name.to_string();
5326 let mut alias_depth = 0u8;
5327 loop {
5328 if !crate::validator::is_static_command_name(&name) {
5329 return CommandKind::Dynamic;
5330 }
5331 if crate::validator::is_runtime_special_form(&name) {
5332 return CommandKind::Special;
5333 }
5334 if alias_depth >= 10 {
5335 break;
5336 }
5337 let alias_value = {
5338 let ctx = self.exec_ctx.read().await;
5339 ctx.aliases.get(&name).cloned()
5340 };
5341 // Expand to the alias's head command. An empty alias value (no head)
5342 // is ignored by execution, so resolution continues with this name.
5343 match alias_value
5344 .as_deref()
5345 .and_then(|v| v.split_whitespace().next())
5346 {
5347 Some(head) => {
5348 name = head.to_string();
5349 alias_depth += 1;
5350 }
5351 None => break,
5352 }
5353 }
5354
5355 let is_user_tool = self.user_tools.read().await.contains_key(&name);
5356 let is_builtin = self.tools.contains(&name);
5357 crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5358 }
5359
5360 // --- Jobs ---
5361
5362 /// Get job manager.
5363 pub fn jobs(&self) -> Arc<JobManager> {
5364 self.jobs.clone()
5365 }
5366
5367 // --- VFS ---
5368
5369 /// Get VFS router.
5370 pub fn vfs(&self) -> Arc<VfsRouter> {
5371 self.vfs.clone()
5372 }
5373
5374 // --- State ---
5375
5376 /// Reset kernel to initial state.
5377 ///
5378 /// Clears in-memory variables and resets cwd to root. History is not
5379 /// cleared (it persists across resets). The kernel's `$$` identity, the
5380 /// trash-on-delete configuration, and any frontend-seeded `initial_vars`
5381 /// (HOME/PATH/etc, from `KernelConfig`) are re-applied to the fresh
5382 /// scope rather than silently reverting to defaults — an embedder that
5383 /// opted into trash must not find it quietly disabled after a `reset()`
5384 /// between requests.
5385 ///
5386 /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
5387 /// reset, not a session boundary for `&`. A job started before `reset()`
5388 /// keeps running, stays in `jobs`, and the job ID counter keeps counting
5389 /// up. An embedder treating `reset()` as "new session" (a fresh MCP
5390 /// conversation reusing one kernel, say) inherits every job the previous
5391 /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
5392 /// that inheritance is not wanted.
5393 pub async fn reset(&self) -> Result<()> {
5394 {
5395 let mut scope = self.scope.write().await;
5396 let pid = scope.pid();
5397 let trash_enabled = scope.trash_enabled();
5398 let mut fresh = Scope::new();
5399 fresh.set_pid(pid);
5400 for (name, value) in self.initial_vars.clone() {
5401 fresh.set_exported(name, value);
5402 }
5403 // The pin travels with the policy it pins — a `reset()` between
5404 // requests that dropped it would hand the next request an
5405 // unpinned session (spec §F.3 item 3).
5406 fresh.set_trash_enabled(trash_enabled);
5407 *scope = fresh;
5408 }
5409 {
5410 let mut ctx = self.exec_ctx.write().await;
5411 ctx.cwd = PathBuf::from("/");
5412 }
5413 Ok(())
5414 }
5415
5416 /// Trip the cancellation token of every tracked background job (`&`) —
5417 /// whether or not `shutdown` follows.
5418 ///
5419 /// This is the same lever `kill %N` uses: a *running* job's in-process
5420 /// future exits at its next checkpoint, and any external children it
5421 /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
5422 /// status `Killed` once it unwinds. For an already-finished job the
5423 /// token trip is a no-op — its future has already resolved and the job
5424 /// keeps reporting its terminal status. This only
5425 /// *starts* cancellation, it does not wait (pair with
5426 /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
5427 /// unwind, bounded as [`Self::shutdown`] does).
5428 ///
5429 /// A job registered by an embedder via [`JobManager::register`] with no
5430 /// cancel token attached has no lever to cancel — silently skipped here,
5431 /// same as `kill %N`'s own "no cancellation token" case.
5432 ///
5433 /// Returns how many jobs a token was actually tripped for.
5434 pub async fn cancel_all_jobs(&self) -> usize {
5435 let ids = self.jobs.list_ids().await;
5436 let mut cancelled = 0;
5437 for id in ids {
5438 if self.jobs.mark_killed_and_cancel(id, false).await {
5439 cancelled += 1;
5440 }
5441 }
5442 cancelled
5443 }
5444
5445 /// Shut down the kernel.
5446 ///
5447 /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
5448 /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
5449 /// gives a single target (GH #244) — for it to actually unwind. The
5450 /// waits are sequential, so the worst case is additive: N jobs that all
5451 /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
5452 /// that unwind promptly (the normal case) cost only their own unwind
5453 /// time. Before this fix `shutdown` called `wait_all()` with no timeout
5454 /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
5455 ///
5456 /// A job that has not unwound by its deadline is abandoned: logged via
5457 /// `tracing::warn!` and left running detached until the tokio runtime
5458 /// itself goes away. There is no further lever once `shutdown()` has
5459 /// returned — this method does not hang, but it also does not guarantee
5460 /// every job actually stopped.
5461 ///
5462 /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
5463 /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
5464 /// `Arc::try_unwrap`, since the work here only touches the shared
5465 /// `Arc<JobManager>`, never kernel state that would need exclusive
5466 /// ownership.
5467 pub async fn shutdown(&self) -> Result<()> {
5468 let ids = self.jobs.list_ids().await;
5469 self.cancel_all_jobs().await;
5470
5471 let bound = self.jobs.kill_grace() + Duration::from_secs(3);
5472 for id in ids {
5473 if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
5474 tracing::warn!(
5475 job_id = %id,
5476 bound_secs = bound.as_secs_f64(),
5477 "kernel shutdown: job did not exit within the grace period after \
5478 cancellation — abandoning it"
5479 );
5480 }
5481 }
5482 Ok(())
5483 }
5484
5485 /// Dispatch a single command using the full resolution chain.
5486 ///
5487 /// This is the core of `CommandDispatcher` — it syncs state between the
5488 /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5489 /// then delegates to `execute_command` for the actual dispatch.
5490 ///
5491 /// State flow:
5492 /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5493 /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5494 /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5495 async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5496 // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5497 // its inner command via ctx.dispatcher) routes through THIS kernel,
5498 // not a stale parent. Critical for forks: the fork's builtins must
5499 // use the fork's dispatcher, not the parent's.
5500 if let Some(d) = self.dispatcher() {
5501 ctx.dispatcher = Some(d);
5502 }
5503
5504 // 1. Sync ctx → self internals
5505 {
5506 let mut scope = self.scope.write().await;
5507 *scope = ctx.scope.clone();
5508 }
5509 {
5510 let mut ec = self.exec_ctx.write().await;
5511 ec.cwd = ctx.cwd.clone();
5512 ec.prev_cwd = ctx.prev_cwd.clone();
5513 ec.stdin = ctx.stdin.take();
5514 ec.stdin_data = ctx.stdin_data.take();
5515 // The structured-data sideband receiver (set by the concurrent
5516 // pipeline runner on the stage ctx) must reach the tool's snapshot
5517 // too — same reason as the pipe endpoints below. Without this a
5518 // pipeline consumer never sees the producer's `.data`.
5519 ec.stdin_data_rx = ctx.stdin_data_rx.take();
5520 // Streaming pipe endpoints and kernel stderr must flow to the
5521 // tool via self.exec_ctx — execute_command reads that, not the
5522 // passed-in ctx. Without moving these, concurrent pipeline
5523 // stages dispatched via a fork get pipe_stdin = None and
5524 // silently read nothing.
5525 ec.pipe_stdin = ctx.pipe_stdin.take();
5526 ec.pipe_stdout = ctx.pipe_stdout.take();
5527 if let Some(stderr) = ctx.stderr.clone() {
5528 ec.stderr = Some(stderr);
5529 }
5530 ec.aliases = ctx.aliases.clone();
5531 ec.ignore_config = ctx.ignore_config.clone();
5532 ec.output_limit = ctx.output_limit.clone();
5533 ec.pipeline_position = ctx.pipeline_position;
5534 // Sync the cancel token from ctx → ec. Builtins like `timeout`
5535 // swap ctx.cancel to a derived child token before re-dispatching;
5536 // execute_command's snapshot reads ec.cancel (kept aligned by
5537 // this sync), so try_execute_external sees the right token.
5538 ec.cancel = ctx.cancel.clone();
5539 // Same alignment for the watchdog: a fork dispatching through its
5540 // own kernel must hand the shared script clock to the snapshot so
5541 // patient holds in forked stages suspend the right timer.
5542 ec.watchdog = ctx.watchdog.clone();
5543 }
5544
5545 // 2. Execute via the full dispatch chain
5546 let result = self.execute_command(&cmd.name, &cmd.args).await?;
5547
5548 // 3. Sync self → ctx
5549 {
5550 let scope = self.scope.read().await;
5551 ctx.scope = scope.clone();
5552 }
5553 {
5554 let mut ec = self.exec_ctx.write().await;
5555 ctx.cwd = ec.cwd.clone();
5556 ctx.prev_cwd = ec.prev_cwd.clone();
5557 ctx.aliases = ec.aliases.clone();
5558 ctx.ignore_config = ec.ignore_config.clone();
5559 ctx.output_limit = ec.output_limit.clone();
5560 // Return any pipe endpoints that the tool didn't consume.
5561 // `take()` here keeps the fork's exec_ctx in a clean state for
5562 // the next dispatch — these are per-command and shouldn't leak
5563 // between calls.
5564 ctx.pipe_stdin = ec.pipe_stdin.take();
5565 ctx.pipe_stdout = ec.pipe_stdout.take();
5566 // Same take-don't-clone discipline as stdin, and for the same
5567 // reason: these belong to exactly one dispatch, and a copy left
5568 // behind would let the next command adopt it.
5569 }
5570
5571 Ok(result)
5572 }
5573}
5574
5575/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5576/// shared arg-binding core behind both `Kernel::build_args_async`
5577/// (production: full recursion through the async pipeline, command
5578/// substitution, real glob expansion) and the reduced sync evaluator behind
5579/// scatter/gather's own option parsing and the `#[cfg(test)]`
5580/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5581/// `SyncEvalSource`). GH #188 closes the drift class between those two
5582/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5583/// is now the ONLY implementation; only expression evaluation, which is
5584/// capability-bound (recursing into command substitution needs a live async
5585/// pipeline the reduced context doesn't have), still has two providers.
5586#[async_trait]
5587pub(crate) trait ArgValueSource: Send + Sync {
5588 /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5589 /// this evaluator" — the reduced sync evaluator's bash-compatible
5590 /// "coalesce" convention for an unset bare variable, or an expression
5591 /// form it doesn't support (a binary op) — and the caller drops the
5592 /// argument the same way an unset bare variable always has. The real
5593 /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5594 /// evaluate.
5595 async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5596
5597 /// Expand a bare glob-pattern positional to display strings, or `None`
5598 /// if this evaluator doesn't expand globs here (disabled, or the reduced
5599 /// sync context, which never has — matching its documented "no
5600 /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5601 /// back to `eval` (which hands back the pattern text as a literal
5602 /// string) when this returns `None`. An enabled expansion that matches
5603 /// nothing is a genuine error, not `Ok(None)`.
5604 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5605
5606 /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5607 /// — the reduced sync evaluator's existing behavior (it never expanded
5608 /// `~`).
5609 async fn home(&self) -> Option<String>;
5610}
5611
5612#[async_trait]
5613impl ArgValueSource for Kernel {
5614 async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5615 Ok(Some(self.eval_expr_async(expr).await?))
5616 }
5617
5618 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5619 let glob_enabled = self.scope.read().await.glob_enabled();
5620 if !glob_enabled {
5621 return Ok(None);
5622 }
5623 let (paths, cwd) = {
5624 let ctx = self.exec_ctx.read().await;
5625 let paths = ctx
5626 .expand_glob(pattern)
5627 .await
5628 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
5629 let cwd = ctx.resolve_path(".");
5630 (paths, cwd)
5631 };
5632 if paths.is_empty() {
5633 anyhow::bail!("no matches: {}", pattern);
5634 }
5635 let display = paths
5636 .into_iter()
5637 .map(|path| {
5638 if !pattern.starts_with('/') {
5639 path.strip_prefix(&cwd)
5640 .unwrap_or(&path)
5641 .to_string_lossy()
5642 .into_owned()
5643 } else {
5644 path.to_string_lossy().into_owned()
5645 }
5646 })
5647 .collect();
5648 Ok(Some(display))
5649 }
5650
5651 async fn home(&self) -> Option<String> {
5652 self.scope_home().await
5653 }
5654}
5655
5656/// Pull `consumes` positional args after a non-bool flag and stash them on
5657/// `tool_args.named` under the canonical param name. Shared core behind
5658/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
5659/// function's doc comment for the unification story (GH #188).
5660///
5661/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
5662/// single scalar value (last write wins on the rare duplicate).
5663/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
5664/// inside `named[canonical] = Value::Json(Array(...))`, preserving
5665/// invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
5666/// a repeated single-value flag must keep every value, not silently drop
5667/// all but the last (a "no silent corruption" violation).
5668/// - `consumes > 1` accumulates each occurrence as an inner
5669/// `serde_json::Value::Array` inside `named[canonical] =
5670/// Value::Json(Array(...))`, preserving invocation order. This is the
5671/// shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
5672///
5673/// Errors loudly if the flag is missing required positionals — matches
5674/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
5675/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
5676/// can't represent — Kernel's evaluator never returns this) falls back to a
5677/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
5678/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
5679/// error rather than a silently-partial array.
5680#[allow(clippy::too_many_arguments)]
5681async fn consume_flag_positionals(
5682 source: &dyn ArgValueSource,
5683 home: Option<&str>,
5684 args: &[Arg],
5685 flag_name: &str,
5686 canonical: &str,
5687 consumes: usize,
5688 repeatable: bool,
5689 positional_indices: &[usize],
5690 consumed: &mut std::collections::HashSet<usize>,
5691 current_idx: usize,
5692 tool_args: &mut ToolArgs,
5693) -> Result<()> {
5694 let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
5695 for _ in 0..consumes.max(1) {
5696 // A `key=value` (WordAssign) token is consumable only by a
5697 // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
5698 // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
5699 // filter` would reassemble `x=1` into the first slot and steal the
5700 // filter into the second. Multi-value flags take plain positionals.
5701 let allow_word_assign = consumes <= 1;
5702 let next_pos = positional_indices
5703 .iter()
5704 .find(|idx| {
5705 **idx > current_idx
5706 && !consumed.contains(idx)
5707 && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
5708 })
5709 .copied();
5710 match next_pos {
5711 Some(pos_idx) => match &args[pos_idx] {
5712 Arg::Positional(expr) => match source.eval(expr).await? {
5713 Some(value) => {
5714 let value = apply_tilde_expansion(value, home);
5715 collected.push(value);
5716 consumed.insert(pos_idx);
5717 }
5718 None if collected.is_empty() => {
5719 tool_args.flags.insert(flag_name.to_string());
5720 return Ok(());
5721 }
5722 None => anyhow::bail!(
5723 "--{flag_name}: could not evaluate argument {} in this context",
5724 collected.len() + 1
5725 ),
5726 },
5727 // `-v a=1`: reassemble the `key=value` token as the flag's
5728 // scalar value (see `positional_indices` construction).
5729 Arg::WordAssign { key, value } => match source.eval(value).await? {
5730 Some(val) => {
5731 let val = apply_tilde_expansion(val, home);
5732 // Loud on binary (GH #116): `-v a=$BIN` must not silently
5733 // reassemble the `[binary: N bytes]` placeholder into the
5734 // flag's value — same text-sink boundary as the primary
5735 // sinks fixed in #93 item 1.
5736 let val_str = crate::interpreter::value_to_text_sink_named(
5737 &val,
5738 "a key=value argument",
5739 )
5740 .map_err(|e| anyhow::anyhow!("{e}"))?;
5741 collected.push(Value::String(format!("{key}={val_str}")));
5742 consumed.insert(pos_idx);
5743 }
5744 None if collected.is_empty() => {
5745 tool_args.flags.insert(flag_name.to_string());
5746 return Ok(());
5747 }
5748 None => anyhow::bail!(
5749 "--{flag_name}: could not evaluate argument {} in this context",
5750 collected.len() + 1
5751 ),
5752 },
5753 _ => {}
5754 },
5755 None => {
5756 if consumes <= 1 && collected.is_empty() {
5757 // Back-compat: a flag with no follow-up positional
5758 // becomes a bare flag. `--path` with nothing after
5759 // lands in `flags`, same as before this refactor.
5760 tool_args.flags.insert(flag_name.to_string());
5761 return Ok(());
5762 }
5763 anyhow::bail!(
5764 "--{flag_name} requires {consumes} argument{}, got {}",
5765 if consumes == 1 { "" } else { "s" },
5766 collected.len()
5767 );
5768 }
5769 }
5770 }
5771
5772 if consumes <= 1 {
5773 if let Some(v) = collected.pop() {
5774 if repeatable {
5775 push_repeatable_value(tool_args, flag_name, canonical, v)?;
5776 } else {
5777 tool_args.named.insert(canonical.to_string(), v);
5778 }
5779 }
5780 return Ok(());
5781 }
5782
5783 // Multi-consume: accumulate under named[canonical] as array-of-arrays.
5784 let occ: Vec<serde_json::Value> = collected
5785 .into_iter()
5786 .map(|v| crate::interpreter::value_to_json(&v))
5787 .collect();
5788 let entry = tool_args
5789 .named
5790 .entry(canonical.to_string())
5791 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5792 if let Value::Json(serde_json::Value::Array(outer)) = entry {
5793 outer.push(serde_json::Value::Array(occ));
5794 } else {
5795 anyhow::bail!(
5796 "--{flag_name}: named[{canonical}] already holds a non-array value"
5797 );
5798 }
5799 Ok(())
5800}
5801
5802/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
5803/// (GH #188) shared by `Kernel::build_args_async` (production) and the
5804/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
5805/// scatter/gather's own option parsing and the `#[cfg(test)]`
5806/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
5807/// pass: Kernel's evaluates full expressions (including `$(...)` command
5808/// substitution) and expands real globs/tilde; the reduced one can't recurse
5809/// into the async pipeline this early (scatter/gather's own flags bind
5810/// before any worker forks) so it evaluates a smaller expression subset and
5811/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
5812///
5813/// If a schema is provided, uses it to determine argument types:
5814/// - For `--flag` where schema says type is non-bool: consume next
5815/// positional(s) as value(s) (`consumes`/`repeatable`-aware).
5816/// - For `--flag` where schema says type is bool (or unknown): treat as a
5817/// boolean flag.
5818///
5819/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
5820pub(crate) async fn bind_tool_args(
5821 args: &[Arg],
5822 schema: Option<&crate::tools::ToolSchema>,
5823 source: &dyn ArgValueSource,
5824) -> Result<ToolArgs> {
5825 let mut tool_args = ToolArgs::new();
5826 let home = source.home().await;
5827
5828 // A glob-passthrough tool (`glob`) consumes patterns as data: skip
5829 // argv glob expansion so the pattern reaches the tool as written —
5830 // otherwise `glob **/*.rs` binds the first *matching path* as its
5831 // pattern. The eval fallback turns `Expr::GlobPattern` into its
5832 // literal string.
5833 let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
5834
5835 // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
5836 // in source order with types preserved — operators (`-f`, `=`, `!`) as
5837 // strings, operands keeping their `Value` — leaving `flags`/`named`
5838 // empty. A position-sensitive command needs the *true* argv: an operand
5839 // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
5840 // hoisted into the unordered flag set the normal binder splits into.
5841 // Globs still expand and `~` still resolves, matching normal positional
5842 // binding — so `test -f *.rs` errors on too many args, not a literal
5843 // pattern stat.
5844 if schema.is_some_and(|s| s.raw_argv) {
5845 for arg in args {
5846 match arg {
5847 Arg::Positional(expr) => {
5848 let glob = if let Expr::GlobPattern(p) = expr {
5849 (!glob_passthrough).then(|| p.clone())
5850 } else {
5851 None
5852 };
5853 if let Some(pattern) = glob {
5854 match source.expand_glob(&pattern).await? {
5855 Some(paths) => {
5856 for path in paths {
5857 tool_args.positional.push(Value::String(path));
5858 }
5859 }
5860 None => {
5861 let value = source.eval(expr).await?.ok_or_else(|| {
5862 anyhow::anyhow!(
5863 "raw-argv positional could not be evaluated in this context"
5864 )
5865 })?;
5866 let value = apply_tilde_expansion(value, home.as_deref());
5867 tool_args.positional.push(value);
5868 }
5869 }
5870 } else {
5871 let value = source.eval(expr).await?.ok_or_else(|| {
5872 anyhow::anyhow!(
5873 "raw-argv positional could not be evaluated in this context"
5874 )
5875 })?;
5876 let value = apply_tilde_expansion(value, home.as_deref());
5877 tool_args.positional.push(value);
5878 }
5879 }
5880 Arg::ShortFlag(name) => {
5881 tool_args.positional.push(Value::String(format!("-{name}")));
5882 }
5883 Arg::LongFlag(name) => {
5884 tool_args.positional.push(Value::String(format!("--{name}")));
5885 }
5886 Arg::Named { key, value } => {
5887 let val = source.eval(value).await?.ok_or_else(|| {
5888 anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
5889 })?;
5890 let val = apply_tilde_expansion(val, home.as_deref());
5891 // Loud on binary (GH #116): `test --k=$BIN` must not
5892 // silently reassemble the placeholder into the raw-argv
5893 // positional stream `test` binds against.
5894 let val_str = crate::interpreter::value_to_text_sink_named(
5895 &val,
5896 "a --key=value argument",
5897 )
5898 .map_err(|e| anyhow::anyhow!("{e}"))?;
5899 tool_args
5900 .positional
5901 .push(Value::String(format!("--{key}={val_str}")));
5902 }
5903 Arg::WordAssign { key, value } => {
5904 let val = source.eval(value).await?.ok_or_else(|| {
5905 anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
5906 })?;
5907 let val = apply_tilde_expansion(val, home.as_deref());
5908 // Loud on binary (GH #116): same reasoning as the Named
5909 // arm above, for the bare `key=value` raw-argv form.
5910 let val_str = crate::interpreter::value_to_text_sink_named(
5911 &val,
5912 "a key=value argument",
5913 )
5914 .map_err(|e| anyhow::anyhow!("{e}"))?;
5915 tool_args
5916 .positional
5917 .push(Value::String(format!("{key}={val_str}")));
5918 }
5919 Arg::DoubleDash => {
5920 tool_args.positional.push(Value::String("--".to_string()));
5921 }
5922 }
5923 }
5924 return Ok(tool_args);
5925 }
5926
5927 // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
5928 // schemas; pick the leaf the leading positionals route to and bind
5929 // flags against *its* params. Flat tools return the root. select_leaf
5930 // errors (fail loud) if a computed positional sits where a subcommand
5931 // selector is required.
5932 let leaf = match schema {
5933 Some(s) => Some(select_leaf(s, args)?),
5934 None => None,
5935 };
5936 // Bind against the leaf's params, but MERGE the root schema's params on
5937 // top as "global" flags: a value-flag declared at the tool's top level
5938 // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
5939 // it trails the subcommand path (`kj context retag a b --confirm <n>`).
5940 // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
5941 // merge is a harmless no-op.
5942 let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
5943 if let Some(l) = leaf {
5944 param_lookup.extend(schema_param_lookup(l));
5945 }
5946 // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
5947 // not the leaf — it's a property of the command, not the subcommand.
5948 let accepts_word_assign = schema
5949 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
5950 .unwrap_or(false);
5951
5952 // Track which positional indices have been consumed as flag values
5953 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
5954 let mut past_double_dash = false;
5955
5956 // Indices a value-flag may consume as its value. Positionals always
5957 // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
5958 // itself treat `key=value` as an assignment (everything but
5959 // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
5960 // `-v`, rather than skipping it and grabbing the next positional (the
5961 // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
5962 // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
5963 let positional_indices: Vec<usize> = args
5964 .iter()
5965 .enumerate()
5966 .filter_map(|(i, a)| {
5967 let consumable = matches!(a, Arg::Positional(_))
5968 || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
5969 consumable.then_some(i)
5970 })
5971 .collect();
5972
5973 let mut i = 0;
5974 while i < args.len() {
5975 match &args[i] {
5976 Arg::DoubleDash => {
5977 past_double_dash = true;
5978 }
5979 Arg::Positional(expr) => {
5980 if !consumed.contains(&i) {
5981 // Glob expansion: bare glob patterns expand to matching files
5982 if let Expr::GlobPattern(pattern) = expr {
5983 if !glob_passthrough {
5984 if let Some(paths) = source.expand_glob(pattern).await? {
5985 for path in paths {
5986 tool_args.positional.push(Value::String(path));
5987 }
5988 i += 1;
5989 continue;
5990 }
5991 }
5992 }
5993 if let Some(value) = source.eval(expr).await? {
5994 let value = apply_tilde_expansion(value, home.as_deref());
5995 tool_args.positional.push(value);
5996 }
5997 }
5998 }
5999 Arg::Named { key, value } => {
6000 if let Some(val) = source.eval(value).await? {
6001 let val = apply_tilde_expansion(val, home.as_deref());
6002 // A repeatable flag in `--flag=value` form must accumulate too,
6003 // not overwrite — otherwise `--expression=A --expression=B`
6004 // would silently keep only B, and mixing with the `-e` space
6005 // form would clobber the array. Route it through the same
6006 // accumulator the space form uses.
6007 let is_declared_value_flag = param_lookup
6008 .get(key.as_str())
6009 .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6010 if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6011 push_repeatable_value(&mut tool_args, key, canonical, val)?;
6012 } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6013 // Flagify at bind time (GH #189): `--flag=true`/
6014 // `--flag=false` binds the same way the bare
6015 // `--flag`/its absence already do (true → flag
6016 // presence, false → dropped) instead of landing in
6017 // `named` as a literal `Value::Bool` that a clap
6018 // `bool` field's `SetTrue` action rejects
6019 // (`seq --json=true` used to exit 2 with a clap
6020 // parse error). Covers both a schema-declared bool
6021 // param AND an undeclared flag — `--json` itself is
6022 // deliberately excluded from every builtin's schema
6023 // (`clap_schema::is_skipped`), so this is what makes
6024 // `--json=true` work universally instead of only for
6025 // the builtins that happen to call
6026 // `ToolArgs::flagify_bool_named` themselves. A
6027 // declared VALUE-taking flag's own `=true` literal
6028 // (`spawn --command=true`) is excluded by
6029 // `is_declared_value_flag` and still falls to
6030 // `named` below.
6031 if let Value::Bool(true) = val {
6032 tool_args.flags.insert(key.clone());
6033 }
6034 // Value::Bool(false): absent == false, nothing to insert.
6035 } else {
6036 tool_args.named.insert(key.clone(), val);
6037 }
6038 }
6039 }
6040 Arg::WordAssign { key, value } => {
6041 // Already pulled in as a preceding value-flag's argument
6042 // (`awk -v a=1`); don't also emit it as a positional.
6043 if consumed.contains(&i) {
6044 i += 1;
6045 continue;
6046 }
6047 if let Some(val) = source.eval(value).await? {
6048 let val = apply_tilde_expansion(val, home.as_deref());
6049 // Past `--`, EVERY token is raw data — including for
6050 // export/alias, whose `key=value` is normally a shell
6051 // assignment (GH #189). `export -- A=1` must bind `A=1`
6052 // as a literal positional, not silently re-enter the
6053 // named-assignment path `past_double_dash` exists to
6054 // suppress for flags right above this arm.
6055 if accepts_word_assign && !past_double_dash {
6056 tool_args.named.insert(key.clone(), val);
6057 } else {
6058 // Stringify "key=value" and pass as a positional.
6059 // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6060 // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6061 // must not silently become a path/operand literally named
6062 // `foo=[binary: N bytes]`.
6063 let val_str = crate::interpreter::value_to_text_sink_named(
6064 &val,
6065 "a key=value argument",
6066 )
6067 .map_err(|e| anyhow::anyhow!("{e}"))?;
6068 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6069 }
6070 }
6071 }
6072 Arg::ShortFlag(name) => {
6073 if past_double_dash {
6074 tool_args.positional.push(Value::String(format!("-{name}")));
6075 } else if name.len() == 1 {
6076 let flag_name = name.as_str();
6077 let lookup = param_lookup.get(flag_name);
6078
6079 // Same ambiguity guard as the `LongFlag` arm below (GH
6080 // #189 item 4): an undeclared short flag immediately
6081 // followed by an unconsumed positional under a
6082 // map_positionals (backend/MCP) schema is exactly as
6083 // ambiguous as the long-flag case — kaish can't tell a
6084 // space-form value (`-t explorer`) from a bool flag
6085 // sitting before a real positional (`-f file.txt`).
6086 // Unlike `--flag`, there is no `-f=value` escape hatch to
6087 // suggest: a glued `-f=val` is two tokens with a dangling
6088 // `=` that the parser's no-token-pasting guard already
6089 // rejects — the only fix is declaring the flag.
6090 let ambiguous_value = (lookup.is_none()
6091 && leaf.is_some_and(|s| s.map_positionals)
6092 && !consumed.contains(&(i + 1)))
6093 .then(|| match args.get(i + 1) {
6094 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6095 Some(s.clone())
6096 }
6097 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6098 _ => None,
6099 })
6100 .flatten();
6101 if let Some(val) = ambiguous_value {
6102 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6103 anyhow::bail!(
6104 "{tool}: -{name} is not a declared flag, so the \
6105 space-separated value ({val:?}) would be silently \
6106 dropped. Have {tool} declare -{name} in its schema \
6107 (short flags have no -{name}=value form to fall \
6108 back on)."
6109 );
6110 }
6111
6112 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6113
6114 if is_bool {
6115 tool_args.flags.insert(flag_name.to_string());
6116 } else {
6117 // Non-bool: consume `consumes` positionals as value(s)
6118 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6119 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6120 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6121 consume_flag_positionals(
6122 source,
6123 home.as_deref(),
6124 args,
6125 name,
6126 canonical,
6127 consumes,
6128 repeatable,
6129 &positional_indices,
6130 &mut consumed,
6131 i,
6132 &mut tool_args,
6133 )
6134 .await?;
6135 }
6136 } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
6137 // Multi-char short flag matches a schema param (POSIX style: -name value)
6138 if is_bool_type(typ) {
6139 tool_args.flags.insert(canonical.to_string());
6140 } else {
6141 consume_flag_positionals(
6142 source,
6143 home.as_deref(),
6144 args,
6145 name,
6146 canonical,
6147 consumes,
6148 repeatable,
6149 &positional_indices,
6150 &mut consumed,
6151 i,
6152 &mut tool_args,
6153 )
6154 .await?;
6155 }
6156 } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
6157 .get(&name[..1])
6158 .filter(|(_, typ, ..)| !is_bool_type(typ))
6159 {
6160 // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
6161 // `grep -A1`, `sed -e1d`. The first char is a declared
6162 // value-taking short flag, so the rest of the token is its
6163 // value — the coreutils idiom. The lexer's flag char class is
6164 // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6165 // (safe to slice) and the tail is a plain literal.
6166 bind_glued_short_value(
6167 &mut tool_args,
6168 &name[..1],
6169 canonical,
6170 consumes,
6171 repeatable,
6172 name[1..].to_string(),
6173 )?;
6174 } else {
6175 // Multi-char combined short flags. Bool flags stack
6176 // (`-la`), but the FIRST value-taking flag reached
6177 // consumes the rest of the token as its glued value
6178 // (`-ivC3` → C=3) or, if it is the last char, the next
6179 // positional (`grep -ivC 3` → C=3). Before this, a
6180 // trailing value-flag was silently treated as a bool,
6181 // stranding its argument as a stray positional (arity
6182 // error). Undeclared/bool chars stay bare flags, so a
6183 // schemaless tool keeps the old all-boolean behavior.
6184 // The first char being value-taking is handled by the
6185 // glued arm above, so it never reaches here. The flag
6186 // char class is ASCII, so byte indexing is char indexing
6187 // (no `Vec<char>` allocation needed).
6188 let bytes = name.as_bytes();
6189 let mut p = 0;
6190 while p < bytes.len() {
6191 let key = &name[p..p + 1];
6192 match param_lookup.get(key) {
6193 Some(&(canonical, typ, consumes, repeatable))
6194 if !is_bool_type(typ) =>
6195 {
6196 let glued = name[p + 1..].to_string();
6197 if glued.is_empty() {
6198 // Value flag is the last char: take the
6199 // next positional. `consume_flag_positionals`
6200 // respects `consumes`.
6201 consume_flag_positionals(
6202 source,
6203 home.as_deref(),
6204 args,
6205 key,
6206 canonical,
6207 consumes,
6208 repeatable,
6209 &positional_indices,
6210 &mut consumed,
6211 i,
6212 &mut tool_args,
6213 )
6214 .await?;
6215 } else {
6216 bind_glued_short_value(
6217 &mut tool_args,
6218 key,
6219 canonical,
6220 consumes,
6221 repeatable,
6222 glued,
6223 )?;
6224 }
6225 break;
6226 }
6227 _ => {
6228 tool_args.flags.insert(key.to_string());
6229 p += 1;
6230 }
6231 }
6232 }
6233 }
6234 }
6235 Arg::LongFlag(name) => {
6236 if past_double_dash {
6237 tool_args.positional.push(Value::String(format!("--{name}")));
6238 } else {
6239 let lookup = param_lookup.get(name.as_str());
6240 // An *undeclared* long flag under a `map_positionals`
6241 // (backend/MCP) schema that is immediately followed by an
6242 // unconsumed positional is ambiguous: kaish can't tell the
6243 // space-form value (`--type explorer`) from a bool flag
6244 // before a real positional (`--force file.txt`). Defaulting
6245 // to bool here silently divorces the value and misroutes it
6246 // — a privilege-escalation-by-typo against deny-by-default
6247 // embedders. Fail loud instead of guessing.
6248 let ambiguous_value = (lookup.is_none()
6249 && leaf.is_some_and(|s| s.map_positionals)
6250 && !consumed.contains(&(i + 1)))
6251 .then(|| match args.get(i + 1) {
6252 // Echo a concrete value for a copy-pasteable fix
6253 // when it's a plain literal; fall back to VALUE.
6254 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6255 Some(s.clone())
6256 }
6257 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6258 _ => None,
6259 })
6260 .flatten();
6261 if let Some(val) = ambiguous_value {
6262 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6263 anyhow::bail!(
6264 "{tool}: --{name} is not a declared flag, so the \
6265 space-separated value would be silently dropped. \
6266 Use --{name}={val}, or have {tool} declare --{name} \
6267 in its schema."
6268 );
6269 }
6270 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6271
6272 if is_bool {
6273 tool_args.flags.insert(name.clone());
6274 } else {
6275 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6276 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6277 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6278 consume_flag_positionals(
6279 source,
6280 home.as_deref(),
6281 args,
6282 name,
6283 canonical,
6284 consumes,
6285 repeatable,
6286 &positional_indices,
6287 &mut consumed,
6288 i,
6289 &mut tool_args,
6290 )
6291 .await?;
6292 }
6293 }
6294 }
6295 }
6296 i += 1;
6297 }
6298
6299 // Map remaining positionals to unfilled non-bool schema params (in order).
6300 // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6301 // Positionals that appeared after `--` are never mapped (they're raw data).
6302 // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6303 // Keyed off the routed leaf so a subcommand tool maps against the active
6304 // leaf's params (kj leaves keep map_positionals=false → block skipped).
6305 if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6306 let pre_dash_count = if past_double_dash {
6307 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6308 positional_indices.iter()
6309 .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6310 .count()
6311 } else {
6312 tool_args.positional.len()
6313 };
6314
6315 let mut remaining = Vec::new();
6316 let mut positional_iter = tool_args.positional.drain(..).enumerate();
6317
6318 for param in &schema.params {
6319 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
6320 continue;
6321 }
6322 if is_bool_type(¶m.param_type) {
6323 continue;
6324 }
6325 loop {
6326 match positional_iter.next() {
6327 Some((idx, val)) if idx < pre_dash_count => {
6328 tool_args.named.insert(param.name.clone(), val);
6329 break;
6330 }
6331 Some((_, val)) => {
6332 remaining.push(val);
6333 }
6334 None => break,
6335 }
6336 }
6337 }
6338
6339 remaining.extend(positional_iter.map(|(_, v)| v));
6340 tool_args.positional = remaining;
6341 }
6342
6343 Ok(tool_args)
6344}
6345
6346#[async_trait]
6347impl CommandDispatcher for Kernel {
6348 /// Dispatch a command through the Kernel's full resolution chain.
6349 ///
6350 /// This is the single path for all command execution when called from
6351 /// the pipeline runner. It provides the full dispatch chain:
6352 /// user tools → builtins → .kai scripts → external commands → backend tools.
6353 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6354 self.dispatch_command(cmd, ctx).await
6355 }
6356
6357 /// Evaluate an expression through the kernel's async chain, including
6358 /// command substitution. Delegates to `eval_expr_async`, which snapshots
6359 /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6360 /// only command output escapes. The `ctx` is unused here because the
6361 /// kernel evaluates against its own session state (a fork carries the
6362 /// pipeline stage's snapshot); var refs resolve against that scope.
6363 async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6364 self.eval_expr_async(expr).await
6365 }
6366
6367 /// Produce a forked dispatcher with independent mutable state (detached).
6368 ///
6369 /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6370 /// recursing into the trait method we're defining) and coerces the
6371 /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6372 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6373 let fork: Arc<Kernel> = Kernel::fork(self).await;
6374 fork
6375 }
6376
6377 /// Produce a forked dispatcher with cancellation cascading from this kernel.
6378 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6379 let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6380 fork
6381 }
6382}
6383
6384/// Apply the requested output format to a builtin's result, unless the tool
6385/// owns its own output — and even then, only on success.
6386///
6387/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6388/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6389/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6390/// gather never render a structured error themselves, so a failure
6391/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6392/// never "already rendered" by the tool. Skipping `apply_output_format` on
6393/// that path just leaked the raw diagnostic under `--json` instead of the
6394/// uniform `{"error","code"}` envelope every other builtin's failure gets
6395/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6396/// whole owns_output error-path class). Gating the skip on `result.ok()`
6397/// keeps the intentional success-path opt-out while closing that gap.
6398fn finalize_output(
6399 result: ExecResult,
6400 format: Option<crate::interpreter::OutputFormat>,
6401 owns_output: bool,
6402) -> ExecResult {
6403 match format {
6404 Some(_) if owns_output && result.ok() => result,
6405 Some(format) => apply_output_format(result, format),
6406 None => result,
6407 }
6408}
6409
6410/// Accumulate output from one result into another.
6411///
6412/// Appends stdout and stderr verbatim and updates the exit code to match the
6413/// new result. Used to preserve output from multiple statements, loop
6414/// iterations, and command chains. No separator is inserted between outputs —
6415/// each command's output concatenates raw, matching bash (`printf a; printf b`
6416/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6417/// when a command emits its own, as `echo` does).
6418fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6419 // Materialize lazy OutputData into .out before accumulating.
6420 // Without this, the first command's output stays in .output while
6421 // the second's text gets appended to .out, losing the first.
6422 accumulated.materialize();
6423 match new.out_bytes() {
6424 // A binary result must not be lossy-decoded by text_out(): concatenate
6425 // raw bytes so the combined output stays binary (this is the path every
6426 // top-level statement's result flows through). See docs/binary-data.md.
6427 Some(new_bytes) => {
6428 let mut combined: Vec<u8> = match accumulated.out_bytes() {
6429 Some(b) => b.to_vec(),
6430 None => accumulated.text_out().into_owned().into_bytes(),
6431 };
6432 combined.extend_from_slice(new_bytes);
6433 accumulated.set_out_bytes(combined);
6434 }
6435 None => accumulated.push_out(&new.text_out()),
6436 }
6437 accumulated.err.push_str(&new.err);
6438 accumulated.code = new.code;
6439 accumulated.data = new.data.clone();
6440 accumulated.did_spill = new.did_spill;
6441 accumulated.original_code = new.original_code;
6442 accumulated.content_type = new.content_type.clone();
6443 accumulated.baggage.clone_from(&new.baggage);
6444}
6445
6446/// Fold a loop's accumulated output into a break/continue signal that is
6447/// propagating to an *outer* loop. Output printed before `break N`/`continue N`
6448/// (with `N > 1`) would otherwise be discarded when the signal replaces the
6449/// loop's result on its way up. The loop's output comes first (it ran before
6450/// the signal was raised), then the signal's already-carried output.
6451fn fold_loop_output_into_flow(loop_output: ExecResult, flow: &mut ControlFlow) {
6452 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6453 let mut merged = loop_output;
6454 accumulate_result(&mut merged, result);
6455 *result = merged;
6456 }
6457}
6458
6459/// Accumulate the output a break/continue signal carried (from inner loops it
6460/// propagated through) into the loop that finally handles it, so it survives
6461/// into that loop's result.
6462fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6463 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6464 accumulate_result(accumulated, result);
6465 }
6466}
6467
6468/// Check if a value is truthy.
6469fn is_truthy(value: &Value) -> bool {
6470 match value {
6471 Value::Null => false,
6472 Value::Bool(b) => *b,
6473 Value::Int(i) => *i != 0,
6474 Value::Float(f) => *f != 0.0,
6475 Value::String(s) => !s.is_empty(),
6476 Value::Json(json) => match json {
6477 serde_json::Value::Null => false,
6478 serde_json::Value::Array(arr) => !arr.is_empty(),
6479 serde_json::Value::Object(obj) => !obj.is_empty(),
6480 serde_json::Value::Bool(b) => *b,
6481 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6482 serde_json::Value::String(s) => !s.is_empty(),
6483 },
6484 Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6485 }
6486}
6487
6488/// Apply tilde expansion to a value.
6489///
6490/// Only string values starting with `~` are expanded. `home` is the session
6491/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6492/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6493fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6494 match value {
6495 Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6496 _ => value,
6497 }
6498}
6499
6500/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6501/// how the lexer tokenizes the equivalent minimally-quoted command string —
6502/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6503/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6504/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6505/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6506///
6507/// Classification matches the lexer's word classes:
6508/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6509/// the binder's `past_double_dash` arms, exactly as for the string door).
6510/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6511/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6512/// (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6513/// they fall through to a positional, not a flag).
6514/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6515/// binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6516/// `key=value` positional, per the command's word-assign allowlist).
6517/// - everything else → a literal [`Arg::Positional`].
6518///
6519/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6520/// positional — it can never be a flag — and rides through as-is. That is the
6521/// typed passthrough the string-native door cannot offer.
6522pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6523 argv.iter().map(classify_argv_token).collect()
6524}
6525
6526fn classify_argv_token(token: &Value) -> Arg {
6527 let Value::String(s) = token else {
6528 return Arg::Positional(Expr::Literal(token.clone()));
6529 };
6530
6531 if s == "--" {
6532 return Arg::DoubleDash;
6533 }
6534
6535 // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6536 // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6537 // literal word (GH #137), matching this classifier's own literal
6538 // fallback — so they fall through to a literal positional rather than a
6539 // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6540 if let Some(rest) = s.strip_prefix("--") {
6541 if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6542 return match rest.split_once('=') {
6543 Some((key, val)) => Arg::Named {
6544 key: key.to_string(),
6545 value: Expr::Literal(Value::String(val.to_string())),
6546 },
6547 None => Arg::LongFlag(rest.to_string()),
6548 };
6549 }
6550 } else if let Some(rest) = s.strip_prefix('-') {
6551 // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6552 // token carrying any other char — notably `=` (`-k=v` is a parse error in
6553 // the string door) — or a leading digit (`-1` lexes as a number) is not a
6554 // short-flag word, so it falls through to a literal positional instead of
6555 // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6556 if is_short_flag_body(rest) {
6557 return Arg::ShortFlag(rest.to_string());
6558 }
6559 }
6560
6561 if let Some((key, val)) = s.split_once('=') {
6562 if is_shell_identifier(key) {
6563 return Arg::WordAssign {
6564 key: key.to_string(),
6565 value: Expr::Literal(Value::String(val.to_string())),
6566 };
6567 }
6568 }
6569
6570 Arg::Positional(Expr::Literal(Value::String(s.clone())))
6571}
6572
6573/// A short-flag word: a leading ASCII letter, then only ASCII
6574/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6575/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6576/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6577/// (`=` is the assignment operator — a parse error in the string door), and
6578/// any non-ASCII tail (never produced by the lexer, and not safe for the
6579/// combined-short-flag binder's byte-index slicing) do not, so they fall
6580/// through to a literal positional instead of a malformed `ShortFlag`.
6581fn is_short_flag_body(s: &str) -> bool {
6582 s.starts_with(|c: char| c.is_ascii_alphabetic())
6583 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6584}
6585
6586/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6587fn is_shell_identifier(s: &str) -> bool {
6588 let mut chars = s.chars();
6589 match chars.next() {
6590 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6591 _ => return false,
6592 }
6593 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6594}
6595
6596/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6597/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6598/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6599/// must keep every value, not silently drop all but the last. Used by every flag
6600/// surface that can carry the same flag twice — the space form
6601/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6602/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6603/// ordered array.
6604pub(crate) fn push_repeatable_value(
6605 tool_args: &mut ToolArgs,
6606 flag_name: &str,
6607 canonical: &str,
6608 v: Value,
6609) -> anyhow::Result<()> {
6610 let occ = crate::interpreter::value_to_json(&v);
6611 let entry = tool_args
6612 .named
6613 .entry(canonical.to_string())
6614 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6615 if let Value::Json(serde_json::Value::Array(items)) = entry {
6616 items.push(occ);
6617 Ok(())
6618 } else {
6619 anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6620 }
6621}
6622
6623/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6624/// is one token, so it carries a single value: a repeatable flag accumulates
6625/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6626/// first-char glued arm and the combined-bundle arm so the two can't drift on
6627/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6628/// is a loud error, not a silent single-value bind.
6629pub(crate) fn bind_glued_short_value(
6630 tool_args: &mut ToolArgs,
6631 flag_name: &str,
6632 canonical: &str,
6633 consumes: usize,
6634 repeatable: bool,
6635 value: String,
6636) -> anyhow::Result<()> {
6637 if consumes > 1 {
6638 anyhow::bail!(
6639 "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6640 );
6641 }
6642 if repeatable {
6643 push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6644 } else {
6645 tool_args
6646 .named
6647 .insert(canonical.to_string(), Value::String(value));
6648 Ok(())
6649 }
6650}
6651
6652/// Map a child's exit status to a shell-style exit code.
6653///
6654/// `ExitStatus::code()` is `None` when the process died from a signal rather
6655/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6656/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6657/// number. Shared by both external-command spawn sites — production
6658/// (`try_execute_external`, below) and the test-only twin
6659/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6660/// this mapping again (GH #133 item 1).
6661#[cfg(feature = "subprocess")]
6662pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6663 status.code().unwrap_or_else(|| {
6664 #[cfg(unix)]
6665 {
6666 use std::os::unix::process::ExitStatusExt;
6667 128 + status.signal().unwrap_or(0)
6668 }
6669 #[cfg(not(unix))]
6670 {
6671 -1
6672 }
6673 }) as i64
6674}
6675
6676/// Wait for a child to exit, killing it if `cancel` fires first.
6677///
6678/// `target` carries a Linux pidfd (when available) for race-free direct-child
6679/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6680/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6681#[cfg(all(unix, feature = "subprocess"))]
6682pub(crate) async fn wait_or_kill(
6683 child: &mut tokio::process::Child,
6684 target: Option<&crate::pidfd::KillTarget>,
6685 cancel: &tokio_util::sync::CancellationToken,
6686 grace: Duration,
6687) -> std::io::Result<std::process::ExitStatus> {
6688 tokio::select! {
6689 biased;
6690 status = child.wait() => status,
6691 _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6692 }
6693}
6694
6695#[cfg(all(not(unix), feature = "subprocess"))]
6696pub(crate) async fn wait_or_kill(
6697 child: &mut tokio::process::Child,
6698 _target: Option<&()>,
6699 cancel: &tokio_util::sync::CancellationToken,
6700 _grace: Duration,
6701) -> std::io::Result<std::process::ExitStatus> {
6702 tokio::select! {
6703 biased;
6704 status = child.wait() => status,
6705 _ = cancel.cancelled() => {
6706 let _ = child.start_kill();
6707 child.wait().await
6708 }
6709 }
6710}
6711
6712/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6713///
6714/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6715/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6716/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6717#[cfg(all(unix, feature = "subprocess"))]
6718pub(crate) async fn kill_with_grace(
6719 child: &mut tokio::process::Child,
6720 target: Option<&crate::pidfd::KillTarget>,
6721 grace: Duration,
6722) -> std::io::Result<std::process::ExitStatus> {
6723 use nix::sys::signal::Signal;
6724
6725 if let Some(t) = target {
6726 t.signal(Signal::SIGTERM);
6727 t.signal_pg(Signal::SIGTERM);
6728 if grace > Duration::ZERO
6729 && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6730 {
6731 return status;
6732 }
6733 t.signal(Signal::SIGKILL);
6734 t.signal_pg(Signal::SIGKILL);
6735 }
6736 child.wait().await
6737}
6738
6739#[cfg(test)]
6740#[allow(clippy::unwrap_used, clippy::expect_used)]
6741mod argv_classify_tests {
6742 use super::*;
6743
6744 /// A normalized, comparable view of one `Arg` representing its *logical
6745 /// argument* (what the command observably receives), not its exact AST shape:
6746 ///
6747 /// - Value-bearing arms compare by *stringified* value, so the parser's
6748 /// number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6749 /// (`String("-1")`) count as the same argument.
6750 /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6751 /// For every command except the `export`/`alias` allowlist, a bareword
6752 /// `key=value` is stringified straight back to a `"key=value"` positional
6753 /// (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6754 /// converge observably even when they disagree on the AST tag — e.g. the
6755 /// lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6756 /// `WordAssign`, where the classifier (bash-correctly) makes a positional.
6757 /// The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6758 /// separately by `classifies_each_word_class`.
6759 ///
6760 /// Returns `None` for shapes we deliberately don't compare:
6761 /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6762 /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6763 /// lex to `Int`, dropping the literal text, where the classifier keeps the
6764 /// string. That divergence is *intentional* — `execute_argv` preserves a
6765 /// literal numeric string (pass `Value::Int` for a number), the string door
6766 /// can only guess — so the property skips it rather than demanding the
6767 /// classifier replicate a lossy coercion. Numeric edges are pinned exactly
6768 /// by `classifies_each_word_class`.
6769 fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6770 // Only a *string*-valued literal is comparable; a coerced number is not.
6771 let lit = |e: &Expr| match e {
6772 Expr::Literal(Value::String(s)) => Some(s.clone()),
6773 _ => None,
6774 };
6775 Some(match arg {
6776 Arg::DoubleDash => ("dash", String::new(), String::new()),
6777 Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6778 Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6779 Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6780 Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6781 Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6782 })
6783 }
6784
6785 /// Classify a single string token the way `execute_argv` would.
6786 fn classify(token: &str) -> Arg {
6787 classify_argv_token(&Value::String(token.to_string()))
6788 }
6789
6790 #[test]
6791 fn classifies_each_word_class() {
6792 assert_eq!(classify("--"), Arg::DoubleDash);
6793 assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6794 assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6795 assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6796 assert_eq!(
6797 classify("--key=value"),
6798 Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6799 );
6800 assert_eq!(
6801 classify("NAME=val"),
6802 Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6803 );
6804 // Digits after the first flag char are ordinary (kept verbatim).
6805 assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6806 assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6807 // Leading-digit dash is a number to the lexer, not a flag → positional.
6808 assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6809 // Numeric strings keep their literal text — `execute_argv` does NOT
6810 // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6811 // who wants a number passes `Value::Int`; a string stays the string.
6812 assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6813 assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6814 // A lone dash (stdin convention) is a positional, not a flag.
6815 assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6816 // Non-identifier LHS is not an assignment.
6817 assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6818 assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6819 }
6820
6821 #[test]
6822 fn typed_values_pass_through_as_literal_positionals() {
6823 // The whole point of the `&[Value]` signature: a non-string value is a
6824 // literal positional carrying the *exact* value, never stringified and
6825 // never flag-interpreted.
6826 let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6827 assert_eq!(
6828 classify_argv_token(&bytes),
6829 Arg::Positional(Expr::Literal(bytes.clone()))
6830 );
6831 let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6832 assert_eq!(
6833 classify_argv_token(&json),
6834 Arg::Positional(Expr::Literal(json.clone()))
6835 );
6836 // An integer token that *looks* like a flag is still a positional value
6837 // (only strings are inspected for a leading dash).
6838 assert_eq!(
6839 classify_argv_token(&Value::Int(-9)),
6840 Arg::Positional(Expr::Literal(Value::Int(-9)))
6841 );
6842 }
6843
6844 #[test]
6845 fn double_dash_only_matches_exactly() {
6846 // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6847 // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6848 // as a single argv token here it's likewise literal.
6849 assert_eq!(classify("--"), Arg::DoubleDash);
6850 assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6851 assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6852 }
6853
6854 #[test]
6855 fn malformed_flag_words_fall_back_to_literal_positionals() {
6856 // A token that isn't a well-formed flag word must NOT be silently misbound
6857 // into the arg binder (house rule: loud/visible over silent-wrong). Each
6858 // of these is a parse error or different tokenization in the string door,
6859 // so the argv door keeps them as literal positionals.
6860 let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6861 // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6862 // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6863 assert_eq!(classify("-k=v"), pos("-k=v"));
6864 assert_eq!(classify("-="), pos("-="));
6865 // Empty long-flag key.
6866 assert_eq!(classify("--=v"), pos("--=v"));
6867 // `--` followed by a non-letter is not a long flag.
6868 assert_eq!(classify("--1"), pos("--1"));
6869 // A bare dash and a number-dash are positionals (covered above too).
6870 assert_eq!(classify("-"), pos("-"));
6871 assert_eq!(classify("-9"), pos("-9"));
6872 // A non-ASCII tail is not part of the lexer's short-flag char class
6873 // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6874 // absorbs) — classifying it as `ShortFlag` would hand the combined
6875 // short-flag binder a byte string it (correctly, for real ASCII flag
6876 // words) slices by *byte* index, panicking on a multi-byte char
6877 // boundary. Fall back to a literal positional instead.
6878 assert_eq!(classify("-lé"), pos("-lé"));
6879 assert_eq!(classify("-é"), pos("-é"));
6880 }
6881
6882 #[tokio::test]
6883 async fn non_ascii_short_flag_bundle_does_not_panic() {
6884 // Regression: `execute_argv`'s combined-short-flag loop assumed the
6885 // flag body was ASCII (safe to byte-slice) because the lexer's
6886 // grammar guarantees that on the *string* door. The argv door's
6887 // classifier let a non-ASCII tail through as `ShortFlag`, so
6888 // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6889 let kernel = Kernel::transient().expect("failed to create kernel");
6890 let result = kernel
6891 .execute_argv("ls", &[Value::String("-lé".into())])
6892 .await
6893 .expect("execute_argv must not panic on a non-ASCII short-flag token");
6894 // Not a well-formed flag word, so it's a literal positional — `ls`
6895 // then reports it as a missing path rather than mangling flags.
6896 assert_ne!(result.code, 0);
6897 }
6898
6899 proptest::proptest! {
6900 /// The core correctness claim: the classifier mirrors the lexer/parser
6901 /// on metacharacter-free tokens. For any such single token, the `Arg`
6902 /// the classifier produces matches the one the real parser produces for
6903 /// the equivalent one-word command — so `execute_argv` reusing the
6904 /// string door's binder is sound. (First proptest in the workspace.)
6905 #[test]
6906 fn classifier_matches_parser_on_clean_tokens(
6907 // No digits: this property tests the *classification* boundary
6908 // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6909 // positional), not numeric coercion. The lexer coerces digit runs to
6910 // `Int`/`Float` and drops the literal text (even inside a colon-merged
6911 // word: `00:` → `0:`); the classifier intentionally preserves the raw
6912 // string. Those numeric edges are pinned exactly by the unit tests.
6913 token in "[a-zA-Z_=./@:+-]{1,8}"
6914 ) {
6915 let parsed = match parse(&format!("cmd {token}")) {
6916 Ok(p) => p,
6917 Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
6918 };
6919 let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
6920 return Ok(());
6921 };
6922 // Only compare when the token lexed as exactly one argument.
6923 let [arg] = cmd.args.as_slice() else { return Ok(()); };
6924
6925 let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
6926 return Ok(()); // a non-literal parsed Expr we don't model — skip
6927 };
6928 proptest::prop_assert_eq!(
6929 ours, theirs,
6930 "classifier diverged from parser on token {:?}", token
6931 );
6932 }
6933 }
6934}
6935
6936#[cfg(all(test, feature = "subprocess"))]
6937#[allow(clippy::expect_used)]
6938mod tests {
6939 use super::*;
6940
6941 #[tokio::test]
6942 async fn test_kernel_transient() {
6943 let kernel = Kernel::transient().expect("failed to create kernel");
6944 assert_eq!(kernel.name(), "transient");
6945 }
6946
6947 #[tokio::test]
6948 async fn test_kernel_execute_echo() {
6949 let kernel = Kernel::transient().expect("failed to create kernel");
6950 let result = kernel.execute("echo hello").await.expect("execution failed");
6951 assert!(result.ok());
6952 assert_eq!(result.text_out().trim(), "hello");
6953 }
6954
6955 #[tokio::test]
6956 async fn test_multiple_statements_accumulate_output() {
6957 let kernel = Kernel::transient().expect("failed to create kernel");
6958 let result = kernel
6959 .execute("echo one\necho two\necho three")
6960 .await
6961 .expect("execution failed");
6962 assert!(result.ok());
6963 // Should have all three outputs separated by newlines
6964 assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
6965 assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
6966 assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
6967 }
6968
6969 #[tokio::test]
6970 async fn test_and_chain_accumulates_output() {
6971 let kernel = Kernel::transient().expect("failed to create kernel");
6972 let result = kernel
6973 .execute("echo first && echo second")
6974 .await
6975 .expect("execution failed");
6976 assert!(result.ok());
6977 assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
6978 assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
6979 }
6980
6981 #[tokio::test]
6982 async fn test_for_loop_accumulates_output() {
6983 let kernel = Kernel::transient().expect("failed to create kernel");
6984 let result = kernel
6985 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
6986 .await
6987 .expect("execution failed");
6988 assert!(result.ok());
6989 assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
6990 assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
6991 assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
6992 }
6993
6994 #[tokio::test]
6995 async fn test_while_loop_accumulates_output() {
6996 let kernel = Kernel::transient().expect("failed to create kernel");
6997 let result = kernel
6998 .execute(r#"
6999 N=3
7000 while [[ ${N} -gt 0 ]]; do
7001 echo "N=${N}"
7002 N=$((N - 1))
7003 done
7004 "#)
7005 .await
7006 .expect("execution failed");
7007 assert!(result.ok());
7008 assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7009 assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7010 assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7011 }
7012
7013 #[tokio::test]
7014 async fn test_kernel_set_var() {
7015 let kernel = Kernel::transient().expect("failed to create kernel");
7016
7017 kernel.execute("X=42").await.expect("set failed");
7018
7019 let value = kernel.get_var("X").await;
7020 assert_eq!(value, Some(Value::Int(42)));
7021 }
7022
7023 #[tokio::test]
7024 async fn test_kernel_var_expansion() {
7025 let kernel = Kernel::transient().expect("failed to create kernel");
7026
7027 kernel.execute("NAME=\"world\"").await.expect("set failed");
7028 let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7029
7030 assert!(result.ok());
7031 assert_eq!(result.text_out().trim(), "hello world");
7032 }
7033
7034 #[tokio::test]
7035 async fn test_kernel_last_result() {
7036 let kernel = Kernel::transient().expect("failed to create kernel");
7037
7038 kernel.execute("echo test").await.expect("echo failed");
7039
7040 let last = kernel.last_result().await;
7041 assert!(last.ok());
7042 assert_eq!(last.text_out().trim(), "test");
7043 }
7044
7045 #[tokio::test]
7046 async fn test_kernel_tool_not_found() {
7047 let kernel = Kernel::transient().expect("failed to create kernel");
7048
7049 let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
7050 assert!(!result.ok());
7051 assert_eq!(result.code, 127);
7052 assert!(result.err.contains("command not found"));
7053 }
7054
7055 #[tokio::test]
7056 async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
7057 // The embedder seam: a backend-registered tool (kaijutsu, an MCP
7058 // engine, …) returns a `ToolResult` with structured `data` — this
7059 // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
7060 // and `for r in $(embedder_tool)` see the typed value, not just
7061 // stdout text.
7062 use crate::backend::testing::MockBackend;
7063 use crate::backend::ToolResult;
7064 let (mock, _calls) = MockBackend::new();
7065 let backend = mock.with_tool_result(|_name| {
7066 let mut baggage = std::collections::BTreeMap::new();
7067 baggage.insert("trace_id".to_string(), "abc123".to_string());
7068 // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
7069 // construct via with_data + the with_* setters, not a struct literal.
7070 Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
7071 .with_content_type("application/json")
7072 .with_baggage(baggage))
7073 });
7074 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7075 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7076 .expect("with_backend kernel");
7077
7078 let result = kernel
7079 .execute("embedder_tool")
7080 .await
7081 .expect("execution failed");
7082 assert!(result.ok(), "backend tool call should succeed: {result:?}");
7083 assert_eq!(
7084 result.data,
7085 Some(Value::Json(serde_json::json!({"key": "value"}))),
7086 "backend tool's structured data must survive into ExecResult, not be dropped"
7087 );
7088 assert_eq!(
7089 result.content_type.as_deref(),
7090 Some("application/json"),
7091 "backend tool's content_type must survive into ExecResult"
7092 );
7093 assert_eq!(
7094 result.baggage.get("trace_id").map(String::as_str),
7095 Some("abc123"),
7096 "backend tool's baggage must survive into ExecResult"
7097 );
7098 }
7099
7100 #[tokio::test]
7101 async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
7102 // A backend tool that IS found but fails during execution (`Io`,
7103 // `PermissionDenied`, …) must surface its real error, not get
7104 // misreported as exit-127 "command not found" — that masks a genuine
7105 // failure as a lookup miss.
7106 use crate::backend::testing::MockBackend;
7107 let (mock, _calls) = MockBackend::new();
7108 let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
7109 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7110 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7111 .expect("with_backend kernel");
7112
7113 let result = kernel
7114 .execute("embedder_tool")
7115 .await
7116 .expect("execution failed");
7117 assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
7118 assert!(!result.ok());
7119 assert!(
7120 result.err.contains("disk exploded"),
7121 "the real backend error must be visible, not masked: {result:?}"
7122 );
7123 }
7124
7125 #[tokio::test]
7126 async fn test_external_command_true() {
7127 // Use REPL config for passthrough filesystem access
7128 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7129
7130 // /bin/true should be available on any Unix system
7131 let result = kernel.execute("true").await.expect("execution failed");
7132 // This should use the builtin true, which returns 0
7133 assert!(result.ok(), "true should succeed: {:?}", result);
7134 }
7135
7136 #[tokio::test]
7137 async fn test_external_command_basic() {
7138 // Use REPL config for passthrough filesystem access
7139 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7140
7141 // Test with /bin/echo which is external
7142 // Note: kaish has a builtin echo, so this will use the builtin
7143 // Let's test with a command that's not a builtin
7144 // Actually, let's just test that PATH resolution works by checking the PATH var
7145 let path_var = std::env::var("PATH").unwrap_or_default();
7146 eprintln!("System PATH: {}", path_var);
7147
7148 // Set PATH in kernel to ensure it's available
7149 kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
7150
7151 // Now try an external command like /usr/bin/env
7152 // But env is also a builtin... let's try uname
7153 let result = kernel.execute("uname").await.expect("execution failed");
7154 eprintln!("uname result: {:?}", result);
7155 // uname should succeed if external commands work
7156 assert!(result.ok() || result.code == 127, "uname: {:?}", result);
7157 }
7158
7159 #[tokio::test]
7160 async fn test_kernel_reset() {
7161 let kernel = Kernel::transient().expect("failed to create kernel");
7162
7163 kernel.execute("X=1").await.expect("set failed");
7164 assert!(kernel.get_var("X").await.is_some());
7165
7166 kernel.reset().await.expect("reset failed");
7167 assert!(kernel.get_var("X").await.is_none());
7168 }
7169
7170 #[tokio::test]
7171 async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7172 let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7173 .expect("failed to create kernel");
7174
7175 let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7176 assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7177
7178 kernel.reset().await.expect("reset failed");
7179
7180 let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7181 assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7182 assert_eq!(
7183 kernel.get_var("HOME").await,
7184 Some(Value::String("/home/probe".into())),
7185 "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7186 );
7187 }
7188
7189 #[tokio::test]
7190 async fn test_kernel_cwd() {
7191 let kernel = Kernel::transient().expect("failed to create kernel");
7192
7193 // Transient kernel uses sandboxed mode with cwd=$HOME
7194 let cwd = kernel.cwd().await;
7195 let home = std::env::var("HOME")
7196 .map(PathBuf::from)
7197 .unwrap_or_else(|_| PathBuf::from("/"));
7198 assert_eq!(cwd, home);
7199
7200 kernel.set_cwd(PathBuf::from("/tmp")).await;
7201 assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7202 }
7203
7204 #[tokio::test]
7205 async fn test_kernel_list_vars() {
7206 let kernel = Kernel::transient().expect("failed to create kernel");
7207
7208 kernel.execute("A=1").await.ok();
7209 kernel.execute("B=2").await.ok();
7210
7211 let vars = kernel.list_vars().await;
7212 assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7213 assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7214 }
7215
7216 #[tokio::test]
7217 async fn test_is_truthy() {
7218 assert!(!is_truthy(&Value::Null));
7219 assert!(!is_truthy(&Value::Bool(false)));
7220 assert!(is_truthy(&Value::Bool(true)));
7221 assert!(!is_truthy(&Value::Int(0)));
7222 assert!(is_truthy(&Value::Int(1)));
7223 assert!(!is_truthy(&Value::String("".into())));
7224 assert!(is_truthy(&Value::String("x".into())));
7225 }
7226
7227 #[tokio::test]
7228 async fn test_jq_in_pipeline() {
7229 let kernel = Kernel::transient().expect("failed to create kernel");
7230 // kaish uses double quotes only; escape inner quotes
7231 let result = kernel
7232 .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7233 .await
7234 .expect("execution failed");
7235 assert!(result.ok(), "jq pipeline failed: {}", result.err);
7236 assert_eq!(result.text_out().trim(), "Alice");
7237 }
7238
7239 #[tokio::test]
7240 async fn test_user_defined_tool() {
7241 let kernel = Kernel::transient().expect("failed to create kernel");
7242
7243 // Define a function
7244 kernel
7245 .execute(r#"greet() { echo "Hello, $1!" }"#)
7246 .await
7247 .expect("function definition failed");
7248
7249 // Call the function
7250 let result = kernel
7251 .execute(r#"greet "World""#)
7252 .await
7253 .expect("function call failed");
7254
7255 assert!(result.ok(), "greet failed: {}", result.err);
7256 assert_eq!(result.text_out().trim(), "Hello, World!");
7257 }
7258
7259 #[tokio::test]
7260 async fn test_user_tool_positional_args() {
7261 let kernel = Kernel::transient().expect("failed to create kernel");
7262
7263 // Define a function with positional param
7264 kernel
7265 .execute(r#"greet() { echo "Hi $1" }"#)
7266 .await
7267 .expect("function definition failed");
7268
7269 // Call with positional argument
7270 let result = kernel
7271 .execute(r#"greet "Amy""#)
7272 .await
7273 .expect("function call failed");
7274
7275 assert!(result.ok(), "greet failed: {}", result.err);
7276 assert_eq!(result.text_out().trim(), "Hi Amy");
7277 }
7278
7279 #[tokio::test]
7280 async fn test_function_shared_scope() {
7281 let kernel = Kernel::transient().expect("failed to create kernel");
7282
7283 // Set a variable in parent scope
7284 kernel
7285 .execute(r#"SECRET="hidden""#)
7286 .await
7287 .expect("set failed");
7288
7289 // Define a function that accesses and modifies parent variable
7290 kernel
7291 .execute(r#"access_parent() {
7292 echo "${SECRET}"
7293 SECRET="modified"
7294 }"#)
7295 .await
7296 .expect("function definition failed");
7297
7298 // Call the function - it SHOULD see SECRET (shared scope like sh)
7299 let result = kernel.execute("access_parent").await.expect("function call failed");
7300
7301 // Function should have access to parent scope
7302 assert!(
7303 result.text_out().contains("hidden"),
7304 "Function should access parent scope, got: {}",
7305 result.text_out()
7306 );
7307
7308 // Function should have modified the parent variable
7309 let secret = kernel.get_var("SECRET").await;
7310 assert_eq!(
7311 secret,
7312 Some(Value::String("modified".into())),
7313 "Function should modify parent scope"
7314 );
7315 }
7316
7317 #[tokio::test]
7318 #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7319 async fn test_exec_builtin() {
7320 let kernel = Kernel::transient().expect("failed to create kernel");
7321 // argv is now a space-separated string or JSON array string
7322 let result = kernel
7323 .execute(r#"exec command="/bin/echo" argv="hello world""#)
7324 .await
7325 .expect("exec failed");
7326
7327 assert!(result.ok(), "exec failed: {}", result.err);
7328 assert_eq!(result.text_out().trim(), "hello world");
7329 }
7330
7331 #[tokio::test]
7332 async fn test_while_false_never_runs() {
7333 let kernel = Kernel::transient().expect("failed to create kernel");
7334
7335 // A while loop with false condition should never run
7336 let result = kernel
7337 .execute(r#"
7338 while false; do
7339 echo "should not run"
7340 done
7341 "#)
7342 .await
7343 .expect("while false failed");
7344
7345 assert!(result.ok());
7346 assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7347 }
7348
7349 #[tokio::test]
7350 async fn test_while_string_comparison() {
7351 let kernel = Kernel::transient().expect("failed to create kernel");
7352
7353 // Set a flag
7354 kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7355
7356 // Use string comparison as condition (shell-compatible [[ ]] syntax)
7357 // Note: Put echo last so we can check the output
7358 let result = kernel
7359 .execute(r#"
7360 while [[ ${FLAG} == "go" ]]; do
7361 FLAG="stop"
7362 echo "running"
7363 done
7364 "#)
7365 .await
7366 .expect("while with string cmp failed");
7367
7368 assert!(result.ok());
7369 assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7370
7371 // Verify flag was changed
7372 let flag = kernel.get_var("FLAG").await;
7373 assert_eq!(flag, Some(Value::String("stop".into())));
7374 }
7375
7376 #[tokio::test]
7377 async fn test_while_numeric_comparison() {
7378 let kernel = Kernel::transient().expect("failed to create kernel");
7379
7380 // Test > comparison (shell-compatible [[ ]] with -gt)
7381 kernel.execute("N=5").await.expect("set failed");
7382
7383 // Note: Put echo last so we can check the output
7384 let result = kernel
7385 .execute(r#"
7386 while [[ ${N} -gt 3 ]]; do
7387 N=3
7388 echo "N was greater"
7389 done
7390 "#)
7391 .await
7392 .expect("while with > failed");
7393
7394 assert!(result.ok());
7395 assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7396 }
7397
7398 #[tokio::test]
7399 async fn test_break_in_while_loop() {
7400 let kernel = Kernel::transient().expect("failed to create kernel");
7401
7402 let result = kernel
7403 .execute(r#"
7404 I=0
7405 while true; do
7406 I=1
7407 echo "before break"
7408 break
7409 echo "after break"
7410 done
7411 "#)
7412 .await
7413 .expect("while with break failed");
7414
7415 assert!(result.ok());
7416 assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7417 assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7418
7419 // Verify we exited the loop
7420 let i = kernel.get_var("I").await;
7421 assert_eq!(i, Some(Value::Int(1)));
7422 }
7423
7424 #[tokio::test]
7425 async fn test_continue_in_while_loop() {
7426 let kernel = Kernel::transient().expect("failed to create kernel");
7427
7428 // Test continue in a while loop where variables persist
7429 // We use string state transition: "start" -> "middle" -> "end"
7430 // continue on "middle" should skip to next iteration
7431 // Shell-compatible: use [[ ]] for comparisons
7432 let result = kernel
7433 .execute(r#"
7434 STATE="start"
7435 AFTER_CONTINUE="no"
7436 while [[ ${STATE} != "done" ]]; do
7437 if [[ ${STATE} == "start" ]]; then
7438 STATE="middle"
7439 continue
7440 AFTER_CONTINUE="yes"
7441 fi
7442 if [[ ${STATE} == "middle" ]]; then
7443 STATE="done"
7444 fi
7445 done
7446 "#)
7447 .await
7448 .expect("while with continue failed");
7449
7450 assert!(result.ok());
7451
7452 // STATE should be "done" (we completed the loop)
7453 let state = kernel.get_var("STATE").await;
7454 assert_eq!(state, Some(Value::String("done".into())));
7455
7456 // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7457 let after = kernel.get_var("AFTER_CONTINUE").await;
7458 assert_eq!(after, Some(Value::String("no".into())));
7459 }
7460
7461 #[tokio::test]
7462 async fn test_break_with_level() {
7463 let kernel = Kernel::transient().expect("failed to create kernel");
7464
7465 // Nested loop with break 2 to exit both loops
7466 // We verify by checking OUTER value:
7467 // - If break 2 works, OUTER stays at 1 (set before for loop)
7468 // - If break 2 fails, OUTER becomes 2 (set after for loop)
7469 let result = kernel
7470 .execute(r#"
7471 OUTER=0
7472 while true; do
7473 OUTER=1
7474 for X in "1 2"; do
7475 break 2
7476 done
7477 OUTER=2
7478 done
7479 "#)
7480 .await
7481 .expect("nested break failed");
7482
7483 assert!(result.ok());
7484
7485 // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7486 let outer = kernel.get_var("OUTER").await;
7487 assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7488 }
7489
7490 #[tokio::test]
7491 async fn test_return_from_tool() {
7492 let kernel = Kernel::transient().expect("failed to create kernel");
7493
7494 // Define a function that returns early
7495 kernel
7496 .execute(r#"early_return() {
7497 if [[ $1 == 1 ]]; then
7498 return 42
7499 fi
7500 echo "not returned"
7501 }"#)
7502 .await
7503 .expect("function definition failed");
7504
7505 // Call with arg=1 should return with exit code 42
7506 // (POSIX shell behavior: return N sets exit code, doesn't output N)
7507 let result = kernel
7508 .execute("early_return 1")
7509 .await
7510 .expect("function call failed");
7511
7512 // Exit code should be 42 (non-zero, so not ok())
7513 assert_eq!(result.code, 42);
7514 // Output should be empty (we returned before echo)
7515 assert!(result.text_out().is_empty());
7516 }
7517
7518 #[tokio::test]
7519 async fn test_return_without_value() {
7520 let kernel = Kernel::transient().expect("failed to create kernel");
7521
7522 // Define a function that returns without a value
7523 kernel
7524 .execute(r#"early_exit() {
7525 if [[ $1 == "stop" ]]; then
7526 return
7527 fi
7528 echo "continued"
7529 }"#)
7530 .await
7531 .expect("function definition failed");
7532
7533 // Call with arg="stop" should return early
7534 let result = kernel
7535 .execute(r#"early_exit "stop""#)
7536 .await
7537 .expect("function call failed");
7538
7539 assert!(result.ok());
7540 assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7541 }
7542
7543 #[tokio::test]
7544 async fn test_exit_stops_execution() {
7545 let kernel = Kernel::transient().expect("failed to create kernel");
7546
7547 // exit should stop further execution
7548 kernel
7549 .execute(r#"
7550 BEFORE="yes"
7551 exit 0
7552 AFTER="yes"
7553 "#)
7554 .await
7555 .expect("execution failed");
7556
7557 // BEFORE should be set, AFTER should not
7558 let before = kernel.get_var("BEFORE").await;
7559 assert_eq!(before, Some(Value::String("yes".into())));
7560
7561 let after = kernel.get_var("AFTER").await;
7562 assert!(after.is_none(), "AFTER should not be set after exit");
7563 }
7564
7565 #[tokio::test]
7566 async fn test_exit_with_code() {
7567 let kernel = Kernel::transient().expect("failed to create kernel");
7568
7569 // exit with code should propagate the exit code
7570 let result = kernel
7571 .execute("exit 42")
7572 .await
7573 .expect("exit failed");
7574
7575 assert_eq!(result.code, 42);
7576 assert!(result.text_out().is_empty(), "exit should not produce stdout");
7577 }
7578
7579 #[tokio::test]
7580 async fn test_set_e_stops_on_failure() {
7581 let kernel = Kernel::transient().expect("failed to create kernel");
7582
7583 // Enable error-exit mode
7584 kernel.execute("set -e").await.expect("set -e failed");
7585
7586 // Run a sequence where the middle command fails
7587 kernel
7588 .execute(r#"
7589 STEP1="done"
7590 false
7591 STEP2="done"
7592 "#)
7593 .await
7594 .expect("execution failed");
7595
7596 // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7597 let step1 = kernel.get_var("STEP1").await;
7598 assert_eq!(step1, Some(Value::String("done".into())));
7599
7600 let step2 = kernel.get_var("STEP2").await;
7601 assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7602 }
7603
7604 #[tokio::test]
7605 async fn test_set_plus_e_disables_error_exit() {
7606 let kernel = Kernel::transient().expect("failed to create kernel");
7607
7608 // Enable then disable error-exit mode
7609 kernel.execute("set -e").await.expect("set -e failed");
7610 kernel.execute("set +e").await.expect("set +e failed");
7611
7612 // Now failure should NOT stop execution
7613 kernel
7614 .execute(r#"
7615 STEP1="done"
7616 false
7617 STEP2="done"
7618 "#)
7619 .await
7620 .expect("execution failed");
7621
7622 // Both should be set since +e disables error exit
7623 let step1 = kernel.get_var("STEP1").await;
7624 assert_eq!(step1, Some(Value::String("done".into())));
7625
7626 let step2 = kernel.get_var("STEP2").await;
7627 assert_eq!(step2, Some(Value::String("done".into())));
7628 }
7629
7630 #[tokio::test]
7631 async fn test_set_ignores_unknown_options() {
7632 let kernel = Kernel::transient().expect("failed to create kernel");
7633
7634 // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7635 let result = kernel
7636 .execute("set -e -u -o pipefail")
7637 .await
7638 .expect("set with unknown options failed");
7639
7640 assert!(result.ok(), "set should succeed with unknown options");
7641
7642 // -e should still be enabled
7643 kernel
7644 .execute(r#"
7645 BEFORE="yes"
7646 false
7647 AFTER="yes"
7648 "#)
7649 .await
7650 .ok();
7651
7652 let after = kernel.get_var("AFTER").await;
7653 assert!(after.is_none(), "-e should be enabled despite unknown options");
7654 }
7655
7656 #[tokio::test]
7657 async fn test_set_no_args_shows_settings() {
7658 let kernel = Kernel::transient().expect("failed to create kernel");
7659
7660 // Enable -e
7661 kernel.execute("set -e").await.expect("set -e failed");
7662
7663 // Call set with no args to see settings
7664 let result = kernel.execute("set").await.expect("set failed");
7665
7666 assert!(result.ok());
7667 assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7668 }
7669
7670 #[tokio::test]
7671 async fn test_set_e_in_pipeline() {
7672 let kernel = Kernel::transient().expect("failed to create kernel");
7673
7674 kernel.execute("set -e").await.expect("set -e failed");
7675
7676 // Pipeline failure should trigger exit
7677 kernel
7678 .execute(r#"
7679 BEFORE="yes"
7680 false | cat
7681 AFTER="yes"
7682 "#)
7683 .await
7684 .ok();
7685
7686 let before = kernel.get_var("BEFORE").await;
7687 assert_eq!(before, Some(Value::String("yes".into())));
7688
7689 // AFTER should not be set if pipeline failure triggers exit
7690 // Note: The exit code of a pipeline is the exit code of the last command
7691 // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7692 // To test pipeline failure, we need the last command to fail.
7693 }
7694
7695 #[tokio::test]
7696 async fn test_set_e_with_and_chain() {
7697 let kernel = Kernel::transient().expect("failed to create kernel");
7698
7699 kernel.execute("set -e").await.expect("set -e failed");
7700
7701 // Commands in && chain should not trigger -e on the first failure
7702 // because && explicitly handles the error
7703 kernel
7704 .execute(r#"
7705 RESULT="initial"
7706 false && RESULT="chained"
7707 RESULT="continued"
7708 "#)
7709 .await
7710 .ok();
7711
7712 // In bash, commands in && don't trigger -e. The chain handles the failure.
7713 // Our implementation may differ - let's verify current behavior.
7714 let result = kernel.get_var("RESULT").await;
7715 // If we follow bash semantics, RESULT should be "continued"
7716 // If we trigger -e on the false, RESULT stays "initial"
7717 assert!(result.is_some(), "RESULT should be set");
7718 }
7719
7720 #[tokio::test]
7721 async fn test_set_e_exits_in_for_loop() {
7722 let kernel = Kernel::transient().expect("failed to create kernel");
7723
7724 kernel.execute("set -e").await.expect("set -e failed");
7725
7726 kernel
7727 .execute(r#"
7728 REACHED="no"
7729 for x in 1 2 3; do
7730 false
7731 REACHED="yes"
7732 done
7733 "#)
7734 .await
7735 .ok();
7736
7737 // With set -e, false should trigger exit; REACHED should remain "no"
7738 let reached = kernel.get_var("REACHED").await;
7739 assert_eq!(reached, Some(Value::String("no".into())),
7740 "set -e should exit on failure in for loop body");
7741 }
7742
7743 #[tokio::test]
7744 async fn test_for_loop_continues_without_set_e() {
7745 let kernel = Kernel::transient().expect("failed to create kernel");
7746
7747 // Without set -e, for loop should continue normally
7748 kernel
7749 .execute(r#"
7750 COUNT=0
7751 for x in 1 2 3; do
7752 false
7753 COUNT=$((COUNT + 1))
7754 done
7755 "#)
7756 .await
7757 .ok();
7758
7759 let count = kernel.get_var("COUNT").await;
7760 // Arithmetic produces Int values; accept either Int or String representation
7761 let count_val = match &count {
7762 Some(Value::Int(n)) => *n,
7763 Some(Value::String(s)) => s.parse().unwrap_or(-1),
7764 _ => -1,
7765 };
7766 assert_eq!(count_val, 3,
7767 "without set -e, loop should complete all iterations (got {:?})", count);
7768 }
7769
7770 // ═══════════════════════════════════════════════════════════════════════════
7771 // Source Tests
7772 // ═══════════════════════════════════════════════════════════════════════════
7773
7774 #[tokio::test]
7775 async fn test_source_sets_variables() {
7776 let kernel = Kernel::transient().expect("failed to create kernel");
7777
7778 // Write a script to the VFS
7779 kernel
7780 .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7781 .await
7782 .expect("write failed");
7783
7784 // Source the script
7785 let result = kernel
7786 .execute(r#"source "/test.kai""#)
7787 .await
7788 .expect("source failed");
7789
7790 assert!(result.ok(), "source should succeed");
7791
7792 // Variable should be set in current scope
7793 let foo = kernel.get_var("FOO").await;
7794 assert_eq!(foo, Some(Value::String("bar".into())));
7795 }
7796
7797 #[tokio::test]
7798 async fn test_source_with_dot_alias() {
7799 let kernel = Kernel::transient().expect("failed to create kernel");
7800
7801 // Write a script to the VFS
7802 kernel
7803 .execute(r#"write "/vars.kai" 'X=42'"#)
7804 .await
7805 .expect("write failed");
7806
7807 // Source using . alias
7808 let result = kernel
7809 .execute(r#". "/vars.kai""#)
7810 .await
7811 .expect(". failed");
7812
7813 assert!(result.ok(), ". should succeed");
7814
7815 // Variable should be set in current scope
7816 let x = kernel.get_var("X").await;
7817 assert_eq!(x, Some(Value::Int(42)));
7818 }
7819
7820 #[tokio::test]
7821 async fn test_source_not_found() {
7822 let kernel = Kernel::transient().expect("failed to create kernel");
7823
7824 // Try to source a non-existent file
7825 let result = kernel
7826 .execute(r#"source "/nonexistent.kai""#)
7827 .await
7828 .expect("source should not fail with error");
7829
7830 assert!(!result.ok(), "source of non-existent file should fail");
7831 assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7832 }
7833
7834 #[tokio::test]
7835 async fn test_source_missing_filename() {
7836 let kernel = Kernel::transient().expect("failed to create kernel");
7837
7838 // Call source with no arguments
7839 let result = kernel
7840 .execute("source")
7841 .await
7842 .expect("source should not fail with error");
7843
7844 assert!(!result.ok(), "source without filename should fail");
7845 assert!(result.err.contains("missing filename"), "error should mention missing filename");
7846 }
7847
7848 #[tokio::test]
7849 async fn test_source_executes_multiple_statements() {
7850 let kernel = Kernel::transient().expect("failed to create kernel");
7851
7852 // Write a script with multiple statements
7853 kernel
7854 .execute(r#"write "/multi.kai" 'A=1
7855B=2
7856C=3'"#)
7857 .await
7858 .expect("write failed");
7859
7860 // Source it
7861 kernel
7862 .execute(r#"source "/multi.kai""#)
7863 .await
7864 .expect("source failed");
7865
7866 // All variables should be set
7867 assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7868 assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7869 assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7870 }
7871
7872 #[tokio::test]
7873 async fn test_source_can_define_functions() {
7874 let kernel = Kernel::transient().expect("failed to create kernel");
7875
7876 // Write a script that defines a function
7877 kernel
7878 .execute(r#"write "/functions.kai" 'greet() {
7879 echo "Hello, $1!"
7880}'"#)
7881 .await
7882 .expect("write failed");
7883
7884 // Source it
7885 kernel
7886 .execute(r#"source "/functions.kai""#)
7887 .await
7888 .expect("source failed");
7889
7890 // Use the defined function
7891 let result = kernel
7892 .execute(r#"greet "World""#)
7893 .await
7894 .expect("greet failed");
7895
7896 assert!(result.ok());
7897 assert!(result.text_out().contains("Hello, World!"));
7898 }
7899
7900 #[tokio::test]
7901 async fn test_source_inherits_error_exit() {
7902 let kernel = Kernel::transient().expect("failed to create kernel");
7903
7904 // Enable error exit
7905 kernel.execute("set -e").await.expect("set -e failed");
7906
7907 // Write a script that has a failure
7908 kernel
7909 .execute(r#"write "/fail.kai" 'BEFORE="yes"
7910false
7911AFTER="yes"'"#)
7912 .await
7913 .expect("write failed");
7914
7915 // Source it (should exit on false due to set -e)
7916 kernel
7917 .execute(r#"source "/fail.kai""#)
7918 .await
7919 .ok();
7920
7921 // BEFORE should be set, AFTER should NOT be set due to error exit
7922 let before = kernel.get_var("BEFORE").await;
7923 assert_eq!(before, Some(Value::String("yes".into())));
7924
7925 // Note: This test depends on whether error exit is checked within source
7926 // Currently our implementation checks per-statement in the main kernel
7927 }
7928
7929 // ═══════════════════════════════════════════════════════════════════════════
7930 // set -e with && / || chains
7931 // ═══════════════════════════════════════════════════════════════════════════
7932
7933 #[tokio::test]
7934 async fn test_set_e_and_chain_left_fails() {
7935 // set -e; false && echo hi; REACHED=1 → REACHED should be set
7936 let kernel = Kernel::transient().expect("failed to create kernel");
7937 kernel.execute("set -e").await.expect("set -e failed");
7938
7939 kernel
7940 .execute("false && echo hi; REACHED=1")
7941 .await
7942 .expect("execution failed");
7943
7944 let reached = kernel.get_var("REACHED").await;
7945 assert_eq!(
7946 reached,
7947 Some(Value::Int(1)),
7948 "set -e should not trigger on left side of &&"
7949 );
7950 }
7951
7952 #[tokio::test]
7953 async fn test_set_e_and_chain_right_fails() {
7954 // set -e; true && false; REACHED=1 → REACHED should NOT be set
7955 let kernel = Kernel::transient().expect("failed to create kernel");
7956 kernel.execute("set -e").await.expect("set -e failed");
7957
7958 kernel
7959 .execute("true && false; REACHED=1")
7960 .await
7961 .expect("execution failed");
7962
7963 let reached = kernel.get_var("REACHED").await;
7964 assert!(
7965 reached.is_none(),
7966 "set -e should trigger when right side of && fails"
7967 );
7968 }
7969
7970 #[tokio::test]
7971 async fn test_set_e_or_chain_recovers() {
7972 // set -e; false || echo recovered; REACHED=1 → REACHED should be set
7973 let kernel = Kernel::transient().expect("failed to create kernel");
7974 kernel.execute("set -e").await.expect("set -e failed");
7975
7976 kernel
7977 .execute("false || echo recovered; REACHED=1")
7978 .await
7979 .expect("execution failed");
7980
7981 let reached = kernel.get_var("REACHED").await;
7982 assert_eq!(
7983 reached,
7984 Some(Value::Int(1)),
7985 "set -e should not trigger when || recovers the failure"
7986 );
7987 }
7988
7989 #[tokio::test]
7990 async fn test_set_e_or_chain_both_fail() {
7991 // set -e; false || false; REACHED=1 → REACHED should NOT be set
7992 let kernel = Kernel::transient().expect("failed to create kernel");
7993 kernel.execute("set -e").await.expect("set -e failed");
7994
7995 kernel
7996 .execute("false || false; REACHED=1")
7997 .await
7998 .expect("execution failed");
7999
8000 let reached = kernel.get_var("REACHED").await;
8001 assert!(
8002 reached.is_none(),
8003 "set -e should trigger when || chain ultimately fails"
8004 );
8005 }
8006
8007 // ═══════════════════════════════════════════════════════════════════════════
8008 // Cancellation Tests
8009 // ═══════════════════════════════════════════════════════════════════════════
8010
8011 /// Helper: schedule a cancel after a delay from a background thread.
8012 /// Uses std::thread because cancel() is sync and Kernel is not Send.
8013 fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
8014 let k = Arc::clone(kernel);
8015 std::thread::spawn(move || {
8016 std::thread::sleep(delay);
8017 k.cancel();
8018 });
8019 }
8020
8021 #[tokio::test]
8022 async fn test_cancel_interrupts_for_loop() {
8023 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8024
8025 // Schedule cancel after a short delay from a background OS thread
8026 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8027
8028 // #149: a bare `X=$i` body has no await point, so the for-loop's
8029 // cancellation checkpoint (checked once per iteration, see the
8030 // `Stmt::For` arm above) never gets a chance to run mid-body — under
8031 // host load, 100_000 trivial iterations could complete and return
8032 // before the background thread's 10ms sleep ever elapsed, racing a
8033 // natural exit-0 completion against the scheduled cancel. Rather than
8034 // widen the margin (there's no bound on how slow "under load" can be),
8035 // make completion deterministically impossible inside the test
8036 // window: `sleep` is a real interruptible await point (it races
8037 // `tokio::time::sleep` against the same cancellation token — see
8038 // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
8039 // cancellation somewhere to land almost immediately AND, at enough
8040 // iterations, makes natural completion take far longer than the
8041 // bounded wait below. The outer timeout is the "must not hang CI if
8042 // cancellation is broken" backstop: it fails loudly well before the
8043 // loop could ever finish on its own.
8044 const ITERATIONS: u32 = 2000;
8045 const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
8046 let bound = std::time::Duration::from_secs(10);
8047 let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
8048
8049 let result = tokio::time::timeout(bound, kernel.execute(&script))
8050 .await
8051 .unwrap_or_else(|_| {
8052 panic!(
8053 "for-loop did not return within {bound:?} — cancellation support looks \
8054 broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
8055 longer than this bound)",
8056 ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
8057 )
8058 })
8059 .expect("execute failed");
8060
8061 assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
8062
8063 // The loop variable should be set to something well short of the full
8064 // iteration count — i.e. cancellation landed long before the loop
8065 // could complete on its own.
8066 let x = kernel.get_var("X").await;
8067 if let Some(Value::Int(n)) = x {
8068 assert!(
8069 n < i64::from(ITERATIONS),
8070 "loop should have been interrupted before finishing, got X={n}"
8071 );
8072 }
8073 }
8074
8075 #[tokio::test]
8076 async fn test_cancel_interrupts_while_loop() {
8077 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8078 kernel.execute("COUNT=0").await.expect("init failed");
8079
8080 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8081
8082 let result = kernel
8083 .execute("while true; do COUNT=$((COUNT + 1)); done")
8084 .await
8085 .expect("execute failed");
8086
8087 assert_eq!(result.code, 130);
8088
8089 let count = kernel.get_var("COUNT").await;
8090 if let Some(Value::Int(n)) = count {
8091 assert!(n > 0, "loop should have run at least once");
8092 }
8093 }
8094
8095 #[tokio::test]
8096 async fn test_reset_after_cancel() {
8097 // After cancellation, the next execute() should work normally
8098 let kernel = Kernel::transient().expect("failed to create kernel");
8099 kernel.cancel(); // cancel with nothing running
8100
8101 let result = kernel.execute("echo hello").await.expect("execute failed");
8102 assert!(result.ok(), "execute after cancel should succeed");
8103 assert_eq!(result.text_out().trim(), "hello");
8104 }
8105
8106 #[tokio::test]
8107 async fn test_cancel_interrupts_statement_sequence() {
8108 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8109
8110 // Schedule cancel after the first statement runs but before sleep finishes
8111 schedule_cancel(&kernel, std::time::Duration::from_millis(50));
8112
8113 let result = kernel
8114 .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
8115 .await
8116 .expect("execute failed");
8117
8118 assert_eq!(result.code, 130);
8119
8120 // STEP should be 1 (set before sleep), not 2 or 3
8121 let step = kernel.get_var("STEP").await;
8122 assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
8123 }
8124
8125 // ═══════════════════════════════════════════════════════════════════════════
8126 // Case Statement Tests
8127 // ═══════════════════════════════════════════════════════════════════════════
8128
8129 #[tokio::test]
8130 async fn test_case_simple_match() {
8131 let kernel = Kernel::transient().expect("failed to create kernel");
8132
8133 let result = kernel
8134 .execute(r#"
8135 case "hello" in
8136 hello) echo "matched hello" ;;
8137 world) echo "matched world" ;;
8138 esac
8139 "#)
8140 .await
8141 .expect("case failed");
8142
8143 assert!(result.ok());
8144 assert_eq!(result.text_out().trim(), "matched hello");
8145 }
8146
8147 #[tokio::test]
8148 async fn test_case_wildcard_match() {
8149 let kernel = Kernel::transient().expect("failed to create kernel");
8150
8151 let result = kernel
8152 .execute(r#"
8153 case "main.rs" in
8154 *.py) echo "Python" ;;
8155 *.rs) echo "Rust" ;;
8156 *) echo "Unknown" ;;
8157 esac
8158 "#)
8159 .await
8160 .expect("case failed");
8161
8162 assert!(result.ok());
8163 assert_eq!(result.text_out().trim(), "Rust");
8164 }
8165
8166 #[tokio::test]
8167 async fn test_case_default_match() {
8168 let kernel = Kernel::transient().expect("failed to create kernel");
8169
8170 let result = kernel
8171 .execute(r#"
8172 case "unknown.xyz" in
8173 *.py) echo "Python" ;;
8174 *.rs) echo "Rust" ;;
8175 *) echo "Default" ;;
8176 esac
8177 "#)
8178 .await
8179 .expect("case failed");
8180
8181 assert!(result.ok());
8182 assert_eq!(result.text_out().trim(), "Default");
8183 }
8184
8185 #[tokio::test]
8186 async fn test_case_no_match() {
8187 let kernel = Kernel::transient().expect("failed to create kernel");
8188
8189 // Case with no default branch and no match
8190 let result = kernel
8191 .execute(r#"
8192 case "nope" in
8193 "yes") echo "yes" ;;
8194 "no") echo "no" ;;
8195 esac
8196 "#)
8197 .await
8198 .expect("case failed");
8199
8200 assert!(result.ok());
8201 assert!(result.text_out().is_empty(), "no match should produce empty output");
8202 }
8203
8204 #[tokio::test]
8205 async fn test_case_with_variable() {
8206 let kernel = Kernel::transient().expect("failed to create kernel");
8207
8208 kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8209
8210 let result = kernel
8211 .execute(r#"
8212 case ${LANG} in
8213 python) echo "snake" ;;
8214 rust) echo "crab" ;;
8215 go) echo "gopher" ;;
8216 esac
8217 "#)
8218 .await
8219 .expect("case failed");
8220
8221 assert!(result.ok());
8222 assert_eq!(result.text_out().trim(), "crab");
8223 }
8224
8225 #[tokio::test]
8226 async fn test_case_multiple_patterns() {
8227 let kernel = Kernel::transient().expect("failed to create kernel");
8228
8229 let result = kernel
8230 .execute(r#"
8231 case "yes" in
8232 "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8233 "n"|"no"|"N"|"NO") echo "negative" ;;
8234 esac
8235 "#)
8236 .await
8237 .expect("case failed");
8238
8239 assert!(result.ok());
8240 assert_eq!(result.text_out().trim(), "affirmative");
8241 }
8242
8243 #[tokio::test]
8244 async fn test_case_glob_question_mark() {
8245 let kernel = Kernel::transient().expect("failed to create kernel");
8246
8247 let result = kernel
8248 .execute(r#"
8249 case "test1" in
8250 test?) echo "matched test?" ;;
8251 *) echo "default" ;;
8252 esac
8253 "#)
8254 .await
8255 .expect("case failed");
8256
8257 assert!(result.ok());
8258 assert_eq!(result.text_out().trim(), "matched test?");
8259 }
8260
8261 #[tokio::test]
8262 async fn test_case_char_class() {
8263 let kernel = Kernel::transient().expect("failed to create kernel");
8264
8265 let result = kernel
8266 .execute(r#"
8267 case "Yes" in
8268 [Yy]*) echo "yes-like" ;;
8269 [Nn]*) echo "no-like" ;;
8270 esac
8271 "#)
8272 .await
8273 .expect("case failed");
8274
8275 assert!(result.ok());
8276 assert_eq!(result.text_out().trim(), "yes-like");
8277 }
8278
8279 // ═══════════════════════════════════════════════════════════════════════════
8280 // Cat Stdin Tests
8281 // ═══════════════════════════════════════════════════════════════════════════
8282
8283 #[tokio::test]
8284 async fn test_cat_from_pipeline() {
8285 let kernel = Kernel::transient().expect("failed to create kernel");
8286
8287 let result = kernel
8288 .execute(r#"echo "piped text" | cat"#)
8289 .await
8290 .expect("cat pipeline failed");
8291
8292 assert!(result.ok(), "cat failed: {}", result.err);
8293 assert_eq!(result.text_out().trim(), "piped text");
8294 }
8295
8296 #[tokio::test]
8297 async fn test_cat_from_pipeline_multiline() {
8298 let kernel = Kernel::transient().expect("failed to create kernel");
8299
8300 let result = kernel
8301 .execute(r#"echo "line1\nline2" | cat -n"#)
8302 .await
8303 .expect("cat pipeline failed");
8304
8305 assert!(result.ok(), "cat failed: {}", result.err);
8306 assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8307 }
8308
8309 // ═══════════════════════════════════════════════════════════════════════════
8310 // Heredoc Tests
8311 // ═══════════════════════════════════════════════════════════════════════════
8312
8313 #[tokio::test]
8314 async fn test_heredoc_basic() {
8315 let kernel = Kernel::transient().expect("failed to create kernel");
8316
8317 let result = kernel
8318 .execute("cat <<EOF\nhello\nEOF")
8319 .await
8320 .expect("heredoc failed");
8321
8322 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8323 assert_eq!(result.text_out().trim(), "hello");
8324 }
8325
8326 #[tokio::test]
8327 async fn test_arithmetic_in_string() {
8328 let kernel = Kernel::transient().expect("failed to create kernel");
8329
8330 let result = kernel
8331 .execute(r#"echo "result: $((1 + 2))""#)
8332 .await
8333 .expect("arithmetic in string failed");
8334
8335 assert!(result.ok(), "echo failed: {}", result.err);
8336 assert_eq!(result.text_out().trim(), "result: 3");
8337 }
8338
8339 #[tokio::test]
8340 async fn test_heredoc_multiline() {
8341 let kernel = Kernel::transient().expect("failed to create kernel");
8342
8343 let result = kernel
8344 .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8345 .await
8346 .expect("heredoc failed");
8347
8348 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8349 assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8350 assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8351 assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8352 }
8353
8354 #[tokio::test]
8355 async fn test_heredoc_variable_expansion() {
8356 // Bug N: unquoted heredoc should expand variables
8357 let kernel = Kernel::transient().expect("failed to create kernel");
8358
8359 kernel.execute("GREETING=hello").await.expect("set var");
8360
8361 let result = kernel
8362 .execute("cat <<EOF\n$GREETING world\nEOF")
8363 .await
8364 .expect("heredoc expansion failed");
8365
8366 assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8367 assert_eq!(result.text_out().trim(), "hello world");
8368 }
8369
8370 #[tokio::test]
8371 async fn test_heredoc_quoted_no_expansion() {
8372 // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8373 let kernel = Kernel::transient().expect("failed to create kernel");
8374
8375 kernel.execute("GREETING=hello").await.expect("set var");
8376
8377 let result = kernel
8378 .execute("cat <<'EOF'\n$GREETING world\nEOF")
8379 .await
8380 .expect("quoted heredoc failed");
8381
8382 assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8383 assert_eq!(result.text_out().trim(), "$GREETING world");
8384 }
8385
8386 #[tokio::test]
8387 async fn test_heredoc_default_value_expansion() {
8388 // Bug N: ${VAR:-default} should expand in unquoted heredocs
8389 let kernel = Kernel::transient().expect("failed to create kernel");
8390
8391 let result = kernel
8392 .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8393 .await
8394 .expect("heredoc default expansion failed");
8395
8396 assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8397 assert_eq!(result.text_out().trim(), "fallback");
8398 }
8399
8400 // ═══════════════════════════════════════════════════════════════════════════
8401 // Read Builtin Tests
8402 // ═══════════════════════════════════════════════════════════════════════════
8403
8404 #[tokio::test]
8405 async fn test_read_from_pipeline() {
8406 let kernel = Kernel::transient().expect("failed to create kernel");
8407
8408 // Pipe input to read
8409 let result = kernel
8410 .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8411 .await
8412 .expect("read pipeline failed");
8413
8414 assert!(result.ok(), "read failed: {}", result.err);
8415 assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8416 }
8417
8418 #[tokio::test]
8419 async fn test_read_multiple_vars_from_pipeline() {
8420 let kernel = Kernel::transient().expect("failed to create kernel");
8421
8422 let result = kernel
8423 .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8424 .await
8425 .expect("read pipeline failed");
8426
8427 assert!(result.ok(), "read failed: {}", result.err);
8428 assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8429 }
8430
8431 // ═══════════════════════════════════════════════════════════════════════════
8432 // Shell-Style Function Tests
8433 // ═══════════════════════════════════════════════════════════════════════════
8434
8435 #[tokio::test]
8436 async fn test_posix_function_with_positional_params() {
8437 let kernel = Kernel::transient().expect("failed to create kernel");
8438
8439 // Define POSIX-style function
8440 kernel
8441 .execute(r#"greet() { echo "Hello, $1!" }"#)
8442 .await
8443 .expect("function definition failed");
8444
8445 // Call the function
8446 let result = kernel
8447 .execute(r#"greet "Amy""#)
8448 .await
8449 .expect("function call failed");
8450
8451 assert!(result.ok(), "greet failed: {}", result.err);
8452 assert_eq!(result.text_out().trim(), "Hello, Amy!");
8453 }
8454
8455 #[tokio::test]
8456 async fn test_posix_function_multiple_args() {
8457 let kernel = Kernel::transient().expect("failed to create kernel");
8458
8459 // Define function using $1 and $2
8460 kernel
8461 .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8462 .await
8463 .expect("function definition failed");
8464
8465 // Call the function
8466 let result = kernel
8467 .execute(r#"add_greeting "Hello" "World""#)
8468 .await
8469 .expect("function call failed");
8470
8471 assert!(result.ok(), "function failed: {}", result.err);
8472 assert_eq!(result.text_out().trim(), "Hello World!");
8473 }
8474
8475 #[tokio::test]
8476 async fn test_bash_function_with_positional_params() {
8477 let kernel = Kernel::transient().expect("failed to create kernel");
8478
8479 // Define bash-style function (function keyword, no parens)
8480 kernel
8481 .execute(r#"function greet { echo "Hi $1" }"#)
8482 .await
8483 .expect("function definition failed");
8484
8485 // Call the function
8486 let result = kernel
8487 .execute(r#"greet "Bob""#)
8488 .await
8489 .expect("function call failed");
8490
8491 assert!(result.ok(), "greet failed: {}", result.err);
8492 assert_eq!(result.text_out().trim(), "Hi Bob");
8493 }
8494
8495 #[tokio::test]
8496 async fn test_shell_function_with_all_args() {
8497 let kernel = Kernel::transient().expect("failed to create kernel");
8498
8499 // Define function using $@ (all args)
8500 kernel
8501 .execute(r#"echo_all() { echo "args: $@" }"#)
8502 .await
8503 .expect("function definition failed");
8504
8505 // Call with multiple args
8506 let result = kernel
8507 .execute(r#"echo_all "a" "b" "c""#)
8508 .await
8509 .expect("function call failed");
8510
8511 assert!(result.ok(), "function failed: {}", result.err);
8512 assert_eq!(result.text_out().trim(), "args: a b c");
8513 }
8514
8515 #[tokio::test]
8516 async fn test_shell_function_with_arg_count() {
8517 let kernel = Kernel::transient().expect("failed to create kernel");
8518
8519 // Define function using $# (arg count)
8520 kernel
8521 .execute(r#"count_args() { echo "count: $#" }"#)
8522 .await
8523 .expect("function definition failed");
8524
8525 // Call with three args
8526 let result = kernel
8527 .execute(r#"count_args "x" "y" "z""#)
8528 .await
8529 .expect("function call failed");
8530
8531 assert!(result.ok(), "function failed: {}", result.err);
8532 assert_eq!(result.text_out().trim(), "count: 3");
8533 }
8534
8535 #[tokio::test]
8536 async fn test_shell_function_shared_scope() {
8537 let kernel = Kernel::transient().expect("failed to create kernel");
8538
8539 // Set a variable in parent scope
8540 kernel
8541 .execute(r#"PARENT_VAR="visible""#)
8542 .await
8543 .expect("set failed");
8544
8545 // Define shell function that reads and writes parent variable
8546 kernel
8547 .execute(r#"modify_parent() {
8548 echo "saw: ${PARENT_VAR}"
8549 PARENT_VAR="changed by function"
8550 }"#)
8551 .await
8552 .expect("function definition failed");
8553
8554 // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8555 let result = kernel.execute("modify_parent").await.expect("function failed");
8556
8557 assert!(
8558 result.text_out().contains("visible"),
8559 "Shell function should access parent scope, got: {}",
8560 result.text_out()
8561 );
8562
8563 // Parent variable should be modified
8564 let var = kernel.get_var("PARENT_VAR").await;
8565 assert_eq!(
8566 var,
8567 Some(Value::String("changed by function".into())),
8568 "Shell function should modify parent scope"
8569 );
8570 }
8571
8572 // ═══════════════════════════════════════════════════════════════════════════
8573 // Script Execution via PATH Tests
8574 // ═══════════════════════════════════════════════════════════════════════════
8575
8576 #[tokio::test]
8577 async fn test_script_execution_from_path() {
8578 let kernel = Kernel::transient().expect("failed to create kernel");
8579
8580 // Create /bin directory and script
8581 kernel.execute(r#"mkdir "/bin""#).await.ok();
8582 kernel
8583 .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8584 .await
8585 .expect("write script failed");
8586
8587 // Set PATH to /bin
8588 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8589
8590 // Call script by name (without .kai extension)
8591 let result = kernel
8592 .execute("hello")
8593 .await
8594 .expect("script execution failed");
8595
8596 assert!(result.ok(), "script failed: {}", result.err);
8597 assert_eq!(result.text_out().trim(), "Hello from script!");
8598 }
8599
8600 #[tokio::test]
8601 async fn test_script_with_args() {
8602 let kernel = Kernel::transient().expect("failed to create kernel");
8603
8604 // Create script that uses positional params
8605 kernel.execute(r#"mkdir "/bin""#).await.ok();
8606 kernel
8607 .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8608 .await
8609 .expect("write script failed");
8610
8611 // Set PATH
8612 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8613
8614 // Call script with arg
8615 let result = kernel
8616 .execute(r#"greet "World""#)
8617 .await
8618 .expect("script execution failed");
8619
8620 assert!(result.ok(), "script failed: {}", result.err);
8621 assert_eq!(result.text_out().trim(), "Hello, World!");
8622 }
8623
8624 #[tokio::test]
8625 async fn test_script_not_found() {
8626 let kernel = Kernel::transient().expect("failed to create kernel");
8627
8628 // Set empty PATH
8629 kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8630
8631 // Call non-existent script
8632 let result = kernel
8633 .execute("noscript")
8634 .await
8635 .expect("execution failed");
8636
8637 assert!(!result.ok(), "should fail with command not found");
8638 assert_eq!(result.code, 127);
8639 assert!(result.err.contains("command not found"));
8640 }
8641
8642 #[tokio::test]
8643 async fn test_script_path_search_order() {
8644 let kernel = Kernel::transient().expect("failed to create kernel");
8645
8646 // Create two directories with same-named script
8647 // Note: using "myscript" not "test" to avoid conflict with test builtin
8648 kernel.execute(r#"mkdir "/first""#).await.ok();
8649 kernel.execute(r#"mkdir "/second""#).await.ok();
8650 kernel
8651 .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8652 .await
8653 .expect("write failed");
8654 kernel
8655 .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8656 .await
8657 .expect("write failed");
8658
8659 // Set PATH with first before second
8660 kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8661
8662 // Should find first one
8663 let result = kernel
8664 .execute("myscript")
8665 .await
8666 .expect("script execution failed");
8667
8668 assert!(result.ok(), "script failed: {}", result.err);
8669 assert_eq!(result.text_out().trim(), "from first");
8670 }
8671
8672 // ═══════════════════════════════════════════════════════════════════════════
8673 // Special Variable Tests ($?, $$, unset vars)
8674 // ═══════════════════════════════════════════════════════════════════════════
8675
8676 #[tokio::test]
8677 async fn test_last_exit_code_success() {
8678 let kernel = Kernel::transient().expect("failed to create kernel");
8679
8680 // true exits with 0
8681 let result = kernel.execute("true; echo $?").await.expect("execution failed");
8682 assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8683 }
8684
8685 #[tokio::test]
8686 async fn test_last_exit_code_failure() {
8687 let kernel = Kernel::transient().expect("failed to create kernel");
8688
8689 // false exits with 1
8690 let result = kernel.execute("false; echo $?").await.expect("execution failed");
8691 assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8692 }
8693
8694 #[tokio::test]
8695 async fn test_current_pid() {
8696 let kernel = Kernel::transient().expect("failed to create kernel");
8697
8698 let result = kernel.execute("echo $$").await.expect("execution failed");
8699 // PID should be a positive number
8700 let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8701 assert!(pid > 0, "PID should be positive");
8702 }
8703
8704 #[tokio::test]
8705 async fn test_unset_variable_expands_to_empty() {
8706 let kernel = Kernel::transient().expect("failed to create kernel");
8707
8708 // Unset variable in interpolation should be empty
8709 let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8710 assert_eq!(result.text_out().trim(), "prefix::suffix");
8711 }
8712
8713 #[tokio::test]
8714 async fn test_eq_ne_operators() {
8715 let kernel = Kernel::transient().expect("failed to create kernel");
8716
8717 // Test -eq operator
8718 let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8719 assert_eq!(result.text_out().trim(), "eq works");
8720
8721 // Test -ne operator
8722 let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8723 assert_eq!(result.text_out().trim(), "ne works");
8724
8725 // Test -eq with different values
8726 let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8727 assert_eq!(result.text_out().trim(), "correct");
8728 }
8729
8730 #[tokio::test]
8731 async fn test_escaped_dollar_in_string() {
8732 let kernel = Kernel::transient().expect("failed to create kernel");
8733
8734 // \$ should produce literal $
8735 let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8736 assert_eq!(result.text_out().trim(), "$100");
8737 }
8738
8739 #[tokio::test]
8740 async fn test_special_vars_in_interpolation() {
8741 let kernel = Kernel::transient().expect("failed to create kernel");
8742
8743 // Test $? in string interpolation
8744 let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8745 assert_eq!(result.text_out().trim(), "exit: 0");
8746
8747 // Test $$ in string interpolation
8748 let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8749 assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8750 let text = result.text_out();
8751 let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8752 let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8753 }
8754
8755 // ═══════════════════════════════════════════════════════════════════════════
8756 // Command Substitution Tests
8757 // ═══════════════════════════════════════════════════════════════════════════
8758
8759 #[tokio::test]
8760 async fn test_command_subst_assignment() {
8761 let kernel = Kernel::transient().expect("failed to create kernel");
8762
8763 // Command substitution in assignment
8764 let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8765 assert_eq!(result.text_out().trim(), "hello");
8766 }
8767
8768 #[tokio::test]
8769 async fn test_command_subst_with_args() {
8770 let kernel = Kernel::transient().expect("failed to create kernel");
8771
8772 // Command substitution with string argument
8773 let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8774 assert_eq!(result.text_out().trim(), "a b c");
8775 }
8776
8777 #[tokio::test]
8778 async fn test_command_subst_nested_vars() {
8779 let kernel = Kernel::transient().expect("failed to create kernel");
8780
8781 // Variables inside command substitution
8782 let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8783 assert_eq!(result.text_out().trim(), "hello world");
8784 }
8785
8786 #[tokio::test]
8787 async fn test_background_job_basic() {
8788 use std::time::Duration;
8789
8790 let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8791
8792 // Run a simple background command, redirecting its output to a
8793 // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
8794 // live); the redirect is what this test asserts on.
8795 let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
8796 assert!(result.ok(), "background command should succeed: {}", result.err);
8797 assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8798
8799 // Give the job time to complete
8800 tokio::time::sleep(Duration::from_millis(100)).await;
8801
8802 // Check job status
8803 let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8804 assert!(status.ok(), "status should succeed: {}", status.err);
8805 assert!(
8806 status.text_out().contains("done:") || status.text_out().contains("running"),
8807 "should have valid status: {}",
8808 status.text_out()
8809 );
8810
8811 // Check the redirected output
8812 let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
8813 assert!(stdout.ok());
8814 assert!(stdout.text_out().contains("hello"));
8815 }
8816
8817 #[tokio::test]
8818 async fn test_heredoc_piped_to_command() {
8819 // Bug 4: heredoc content should pipe through to next command
8820 let kernel = Kernel::transient().expect("kernel");
8821 let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8822 assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8823 assert_eq!(result.text_out().trim(), "hello world");
8824 }
8825
8826 /// A transient kernel paired with a real, auto-cleaning tempdir. The
8827 /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8828 /// tests need actual files on disk. Hold the returned `TempDir` for the
8829 /// test's lifetime: it removes the directory tree on drop — including on
8830 /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8831 /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8832 /// as a string for interpolation into scripts.
8833 fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8834 let kernel = Kernel::transient().expect("kernel");
8835 let tmp = tempfile::tempdir().expect("tempdir");
8836 let dir = tmp.path().display().to_string();
8837 (kernel, tmp, dir)
8838 }
8839
8840 #[tokio::test]
8841 async fn test_for_loop_glob_iterates() {
8842 // Bug 1: for F in $(glob ...) should iterate per file, not once
8843 let (kernel, _tmp, dir) = transient_with_tempdir();
8844 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8845 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8846 let result = kernel.execute(&format!(r#"
8847 N=0
8848 for F in $(glob "{dir}/*.txt"); do
8849 N=$((N + 1))
8850 done
8851 echo $N
8852 "#)).await.unwrap();
8853 assert!(result.ok(), "for glob failed: {}", result.err);
8854 assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8855 }
8856
8857 #[tokio::test]
8858 async fn test_bare_glob_expansion_echo() {
8859 let (kernel, _tmp, dir) = transient_with_tempdir();
8860 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8861 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8862 kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8863 kernel.execute(&format!("cd {dir}")).await.unwrap();
8864 let result = kernel.execute("echo *.txt").await.unwrap();
8865 assert!(result.ok(), "echo *.txt failed: {}", result.err);
8866 let out = result.text_out();
8867 let out = out.trim();
8868 // Should contain both .txt files (order may vary)
8869 assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8870 assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8871 assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8872 }
8873
8874 #[tokio::test]
8875 async fn test_bare_glob_no_matches_errors() {
8876 let (kernel, _tmp, dir) = transient_with_tempdir();
8877 kernel.execute(&format!("cd {dir}")).await.unwrap();
8878 let result = kernel.execute("echo *.nonexistent").await;
8879 match &result {
8880 Ok(exec) => {
8881 // No-match glob should produce a non-zero exit code
8882 assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8883 assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8884 }
8885 Err(e) => {
8886 assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8887 }
8888 }
8889 }
8890
8891 #[tokio::test]
8892 async fn test_bare_glob_disabled_with_set() {
8893 let (kernel, _tmp, dir) = transient_with_tempdir();
8894 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8895 kernel.execute(&format!("cd {dir}")).await.unwrap();
8896 // Disable glob expansion
8897 kernel.execute("set +o glob").await.unwrap();
8898 let result = kernel.execute("echo *.txt").await.unwrap();
8899 // With glob disabled, *.txt should be passed as literal string
8900 assert!(result.ok(), "echo should succeed: {}", result.err);
8901 assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8902 }
8903
8904 #[tokio::test]
8905 async fn test_bare_glob_quoted_not_expanded() {
8906 let (kernel, _tmp, dir) = transient_with_tempdir();
8907 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8908 kernel.execute(&format!("cd {dir}")).await.unwrap();
8909 // Quoted globs should NOT expand
8910 let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8911 assert!(result.ok(), "echo should succeed: {}", result.err);
8912 assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8913 }
8914
8915 #[tokio::test]
8916 async fn test_bare_glob_for_loop() {
8917 let (kernel, _tmp, dir) = transient_with_tempdir();
8918 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8919 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8920 kernel.execute(&format!("cd {dir}")).await.unwrap();
8921 let result = kernel.execute(r#"
8922 N=0
8923 for f in *.txt; do
8924 N=$((N + 1))
8925 done
8926 echo $N
8927 "#).await.unwrap();
8928 assert!(result.ok(), "for loop failed: {}", result.err);
8929 assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
8930 }
8931
8932 #[tokio::test]
8933 async fn test_glob_in_assignment_is_literal() {
8934 let kernel = Kernel::transient().expect("kernel");
8935 let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
8936 assert!(result.ok());
8937 assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
8938 }
8939
8940 #[tokio::test]
8941 async fn test_glob_in_test_expr_is_literal() {
8942 let kernel = Kernel::transient().expect("kernel");
8943 let result = kernel.execute(r#"
8944 if [[ *.txt == "*.txt" ]]; then
8945 echo "match"
8946 else
8947 echo "no"
8948 fi
8949 "#).await.unwrap();
8950 assert!(result.ok());
8951 assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
8952 }
8953
8954 #[tokio::test]
8955 async fn test_command_subst_echo_not_iterable() {
8956 // Regression guard: $(echo "a b c") must remain a single string
8957 let kernel = Kernel::transient().expect("kernel");
8958 let result = kernel.execute(r#"
8959 N=0
8960 for X in $(echo "a b c"); do N=$((N + 1)); done
8961 echo $N
8962 "#).await.unwrap();
8963 assert!(result.ok());
8964 assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
8965 }
8966
8967 // -- accumulate_result / newline tests --
8968
8969 #[test]
8970 fn test_accumulate_preserves_own_newlines() {
8971 // Outputs concatenate verbatim — a command's own trailing newline is
8972 // kept, none is invented.
8973 let mut acc = ExecResult::success("line1\n");
8974 let new = ExecResult::success("line2\n");
8975 accumulate_result(&mut acc, &new);
8976 assert_eq!(&*acc.text_out(), "line1\nline2\n");
8977 assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
8978 }
8979
8980 #[test]
8981 fn test_accumulate_inserts_no_separator() {
8982 // No artificial separator: `printf a; printf b` style concatenates to
8983 // `ab`, matching bash (regression for the 2026-06-09 finding).
8984 let mut acc = ExecResult::success("line1");
8985 let new = ExecResult::success("line2");
8986 accumulate_result(&mut acc, &new);
8987 assert_eq!(&*acc.text_out(), "line1line2");
8988 }
8989
8990 #[test]
8991 fn test_accumulate_empty_into_nonempty() {
8992 let mut acc = ExecResult::success("");
8993 let new = ExecResult::success("hello\n");
8994 accumulate_result(&mut acc, &new);
8995 assert_eq!(&*acc.text_out(), "hello\n");
8996 }
8997
8998 #[test]
8999 fn test_accumulate_nonempty_into_empty() {
9000 let mut acc = ExecResult::success("hello\n");
9001 let new = ExecResult::success("");
9002 accumulate_result(&mut acc, &new);
9003 assert_eq!(&*acc.text_out(), "hello\n");
9004 }
9005
9006 #[test]
9007 fn test_accumulate_stderr_no_double_newlines() {
9008 let mut acc = ExecResult::failure(1, "err1\n");
9009 let new = ExecResult::failure(1, "err2\n");
9010 accumulate_result(&mut acc, &new);
9011 assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
9012 }
9013
9014 #[tokio::test]
9015 async fn test_multiple_echo_no_blank_lines() {
9016 let kernel = Kernel::transient().expect("kernel");
9017 let result = kernel
9018 .execute("echo one\necho two\necho three")
9019 .await
9020 .expect("execution failed");
9021 assert!(result.ok());
9022 assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
9023 }
9024
9025 #[tokio::test]
9026 async fn test_for_loop_no_blank_lines() {
9027 let kernel = Kernel::transient().expect("kernel");
9028 let result = kernel
9029 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
9030 .await
9031 .expect("execution failed");
9032 assert!(result.ok());
9033 assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
9034 }
9035
9036 #[tokio::test]
9037 async fn test_for_command_subst_no_blank_lines() {
9038 let kernel = Kernel::transient().expect("kernel");
9039 let result = kernel
9040 .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
9041 .await
9042 .expect("execution failed");
9043 assert!(result.ok());
9044 assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
9045 }
9046
9047 // ------------------------------------------------------------------
9048 // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
9049 // ------------------------------------------------------------------
9050
9051 /// Helper: a throwaway schema with one `--pair` param declared as
9052 /// consuming two positionals per occurrence. Modelled after what
9053 /// jq_native will declare for `--arg` / `--argjson`.
9054 fn multi_consume_schema() -> crate::tools::ToolSchema {
9055 use crate::tools::{ParamSchema, ToolSchema};
9056 ToolSchema::new("test", "multi-consume smoke")
9057 .param(
9058 ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
9059 .consumes(2),
9060 )
9061 }
9062
9063 fn pos(s: &str) -> Arg {
9064 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
9065 }
9066
9067 #[tokio::test]
9068 async fn build_args_multi_consume_single_occurrence() {
9069 let kernel = Kernel::transient().expect("kernel");
9070 let schema = multi_consume_schema();
9071 // Simulates: test --pair NAME VALUE filter
9072 let args = vec![
9073 Arg::LongFlag("pair".into()),
9074 pos("NAME"),
9075 pos("VALUE"),
9076 pos("filter"),
9077 ];
9078 let built = kernel
9079 .build_args_async(&args, Some(&schema))
9080 .await
9081 .expect("build_args should succeed");
9082
9083 // `--pair` + its two positionals are consumed into named["pair"],
9084 // which becomes an outer array of one inner 2-element array.
9085 let pair = built.named.get("pair").expect("named[pair] missing");
9086 match pair {
9087 Value::Json(serde_json::Value::Array(occurrences)) => {
9088 assert_eq!(occurrences.len(), 1, "expected one occurrence");
9089 match &occurrences[0] {
9090 serde_json::Value::Array(values) => {
9091 assert_eq!(values.len(), 2, "pair must have 2 values");
9092 assert_eq!(values[0], serde_json::Value::String("NAME".into()));
9093 assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
9094 }
9095 other => panic!("expected inner array, got {other:?}"),
9096 }
9097 }
9098 other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
9099 }
9100
9101 // The un-consumed positional ("filter") remains in `positional`.
9102 assert_eq!(built.positional.len(), 1);
9103 assert_eq!(built.positional[0], Value::String("filter".into()));
9104 }
9105 #[tokio::test]
9106 async fn build_args_multi_consume_two_occurrences_accumulate() {
9107 let kernel = Kernel::transient().expect("kernel");
9108 let schema = multi_consume_schema();
9109 // Simulates: test --pair A 1 --pair B 2 filter
9110 let args = vec![
9111 Arg::LongFlag("pair".into()),
9112 pos("A"),
9113 pos("1"),
9114 Arg::LongFlag("pair".into()),
9115 pos("B"),
9116 pos("2"),
9117 pos("filter"),
9118 ];
9119 let built = kernel
9120 .build_args_async(&args, Some(&schema))
9121 .await
9122 .expect("build_args should succeed");
9123
9124 let pair = built.named.get("pair").expect("named[pair] missing");
9125 match pair {
9126 Value::Json(serde_json::Value::Array(occurrences)) => {
9127 assert_eq!(occurrences.len(), 2, "expected two occurrences");
9128 // Preserved in invocation order.
9129 match &occurrences[0] {
9130 serde_json::Value::Array(values) => {
9131 assert_eq!(values[0], serde_json::Value::String("A".into()));
9132 assert_eq!(values[1], serde_json::Value::String("1".into()));
9133 }
9134 other => panic!("expected inner array, got {other:?}"),
9135 }
9136 match &occurrences[1] {
9137 serde_json::Value::Array(values) => {
9138 assert_eq!(values[0], serde_json::Value::String("B".into()));
9139 assert_eq!(values[1], serde_json::Value::String("2".into()));
9140 }
9141 other => panic!("expected inner array, got {other:?}"),
9142 }
9143 }
9144 other => panic!("expected Json(Array(...)), got {other:?}"),
9145 }
9146 }
9147
9148 // ── undeclared space-form flag under map_positionals (kj --type val) ──
9149 //
9150 // A backend/MCP tool whose schema does NOT declare a flag must not let
9151 // `--flag value` (space form) silently divorce the value: that was a
9152 // privilege-escalation-by-typo against kaijutsu.
9153 // kaish fails loud rather than guessing.
9154
9155 use crate::tools::{ParamSchema, ToolSchema};
9156
9157 /// Backend-style schema (map_positionals) declaring only a `name`
9158 /// positional — `--type` is intentionally undeclared.
9159 fn kj_like_schema() -> ToolSchema {
9160 ToolSchema::new("kj", "incomplete backend schema")
9161 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9162 .with_positional_mapping()
9163 }
9164
9165 #[tokio::test]
9166 async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9167 let kernel = Kernel::transient().expect("kernel");
9168 let schema = kj_like_schema();
9169 // kj context create exp --type explorer
9170 let args = vec![
9171 pos("context"),
9172 pos("create"),
9173 pos("exp"),
9174 Arg::LongFlag("type".into()),
9175 pos("explorer"),
9176 ];
9177 let err = kernel
9178 .build_args_async(&args, Some(&schema))
9179 .await
9180 .expect_err("undeclared --type with a space value must fail loud");
9181 let msg = err.to_string();
9182 assert!(msg.contains("--type"), "message should name the flag: {msg}");
9183 assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9184 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9185 }
9186
9187 #[tokio::test]
9188 async fn build_args_declared_space_flag_still_binds() {
9189 let kernel = Kernel::transient().expect("kernel");
9190 // Same tool, but now the schema DECLARES --type as a string param.
9191 let schema = ToolSchema::new("kj", "complete schema")
9192 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9193 .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9194 .with_positional_mapping();
9195 let args = vec![
9196 pos("exp"),
9197 Arg::LongFlag("type".into()),
9198 pos("explorer"),
9199 ];
9200 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9201 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9202 }
9203
9204 #[tokio::test]
9205 async fn build_args_equals_form_binds_for_undeclared_flag() {
9206 let kernel = Kernel::transient().expect("kernel");
9207 let schema = kj_like_schema();
9208 // The unambiguous `=` form must keep working even when undeclared.
9209 let args = vec![
9210 pos("exp"),
9211 Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9212 ];
9213 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9214 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9215 }
9216
9217 #[tokio::test]
9218 async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9219 let kernel = Kernel::transient().expect("kernel");
9220 let schema = kj_like_schema();
9221 // No positional follows --force → unambiguously a bare flag.
9222 let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9223 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9224 assert!(built.flags.contains("force"));
9225 }
9226
9227 #[tokio::test]
9228 async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9229 let kernel = Kernel::transient().expect("kernel");
9230 let schema = kj_like_schema();
9231 // --verbose is followed by a flag, not a positional → not ambiguous.
9232 let args = vec![
9233 Arg::LongFlag("verbose".into()),
9234 Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9235 ];
9236 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9237 assert!(built.flags.contains("verbose"));
9238 }
9239
9240 #[tokio::test]
9241 async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9242 let kernel = Kernel::transient().expect("kernel");
9243 // Builtins set map_positionals=false; the ambiguity guard must not
9244 // fire there (clap validates their flags separately).
9245 let schema = ToolSchema::new("frobnicate", "builtin-style")
9246 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9247 let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9248 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9249 assert!(built.flags.contains("frob"));
9250 }
9251
9252 // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9253 //
9254 // The long-flag guard above was closed by GH #188; an undeclared SHORT
9255 // flag under a map_positionals schema was still silently defaulting to
9256 // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9257 // same way the long-flag case used to.
9258
9259 #[tokio::test]
9260 async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9261 let kernel = Kernel::transient().expect("kernel");
9262 let schema = kj_like_schema();
9263 // kj exp -t explorer
9264 let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9265 let err = kernel
9266 .build_args_async(&args, Some(&schema))
9267 .await
9268 .expect_err("undeclared -t with a space value must fail loud");
9269 let msg = err.to_string();
9270 assert!(msg.contains("-t"), "message should name the flag: {msg}");
9271 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9272 }
9273
9274 #[tokio::test]
9275 async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9276 let kernel = Kernel::transient().expect("kernel");
9277 // Builtins set map_positionals=false; the ambiguity guard must not
9278 // fire there.
9279 let schema = ToolSchema::new("frobnicate", "builtin-style")
9280 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9281 let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9282 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9283 assert!(built.flags.contains("t"));
9284 }
9285
9286 // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9287 //
9288 // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9289 // params, not the root's. The subcommand-path positionals stay positional
9290 // (kj re-parses them with its own clap), and a value flag declared only on
9291 // a deep leaf still binds in space form.
9292
9293 /// kj → context (alias ctx) → create{--type value, --force bool}.
9294 /// map_positionals defaults false on every node (builtin/kj style).
9295 fn kj_tree_schema() -> ToolSchema {
9296 ToolSchema::new("kj", "subcommand tool").subcommand(
9297 ToolSchema::new("context", "context ops")
9298 .with_command_aliases(["ctx"])
9299 .subcommand(
9300 ToolSchema::new("create", "create context")
9301 .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9302 .param(ParamSchema::new("force", "bool")),
9303 ),
9304 )
9305 }
9306
9307 #[tokio::test]
9308 async fn build_args_binds_deep_leaf_value_flag_space_form() {
9309 let kernel = Kernel::transient().expect("kernel");
9310 let schema = kj_tree_schema();
9311 // kj context create --type explorer
9312 let args = vec![
9313 pos("context"),
9314 pos("create"),
9315 Arg::LongFlag("type".into()),
9316 pos("explorer"),
9317 ];
9318 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9319 // --type (declared only on the create leaf) binds in space form.
9320 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9321 // The subcommand path survives as positionals for kj to re-parse.
9322 let positionals: Vec<&str> = built
9323 .positional
9324 .iter()
9325 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9326 .collect();
9327 assert_eq!(positionals, vec!["context", "create"]);
9328 }
9329
9330 #[tokio::test]
9331 async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9332 let kernel = Kernel::transient().expect("kernel");
9333 let schema = kj_tree_schema();
9334 // kj context create --force somearg → --force is a leaf bool flag,
9335 // it must NOT consume `somearg`.
9336 let args = vec![
9337 pos("context"),
9338 pos("create"),
9339 Arg::LongFlag("force".into()),
9340 pos("somearg"),
9341 ];
9342 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9343 assert!(built.flags.contains("force"), "force should be a bare flag");
9344 let positionals: Vec<&str> = built
9345 .positional
9346 .iter()
9347 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9348 .collect();
9349 assert_eq!(positionals, vec!["context", "create", "somearg"]);
9350 }
9351
9352 #[tokio::test]
9353 async fn build_args_alias_routed_leaf_binds_value_flag() {
9354 let kernel = Kernel::transient().expect("kernel");
9355 let schema = kj_tree_schema();
9356 // kj ctx create -t explorer → command alias + short flag alias.
9357 let args = vec![
9358 pos("ctx"),
9359 pos("create"),
9360 Arg::ShortFlag("t".into()),
9361 pos("explorer"),
9362 ];
9363 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9364 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9365 }
9366
9367 #[tokio::test]
9368 async fn build_args_computed_subcommand_selector_fails_loud() {
9369 let kernel = Kernel::transient().expect("kernel");
9370 let schema = kj_tree_schema();
9371 // kj $(echo context) — routing can't see the value; fail loud.
9372 let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9373 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9374 )]))];
9375 let err = kernel
9376 .build_args_async(&args, Some(&schema))
9377 .await
9378 .expect_err("computed subcommand selector must error");
9379 assert!(
9380 err.to_string().contains("subcommand name is required"),
9381 "got: {err}"
9382 );
9383 }
9384
9385 // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9386
9387 #[test]
9388 fn finalize_output_renders_when_kernel_owns_it() {
9389 use crate::interpreter::{OutputData, OutputFormat};
9390 let r = ExecResult::with_output(OutputData::text("RAW"));
9391 let out = finalize_output(r, Some(OutputFormat::Json), false);
9392 // Kernel renders the typed OutputData → JSON; text is no longer bare.
9393 assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9394 }
9395
9396 #[test]
9397 fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9398 use crate::interpreter::{OutputData, OutputFormat};
9399 let r = ExecResult::with_output(OutputData::text("RAW"));
9400 let out = finalize_output(r, Some(OutputFormat::Json), true);
9401 // owns_output + success: the tool already rendered; kernel leaves bytes
9402 // untouched.
9403 assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9404 }
9405
9406 #[test]
9407 fn finalize_output_renders_owns_output_failure() {
9408 // scatter/gather (the only owns_output tools) never render their own
9409 // JSONL/array on a FAILURE path — their error returns are plain-text
9410 // `ExecResult::failure(code, msg)`, identical in shape to any other
9411 // builtin's. owns_output means "the tool already rendered its own
9412 // SUCCESS output", not "never touch this tool's bytes" — a failure
9413 // must still get the uniform --json error envelope like every other
9414 // builtin (kaibo review finding on merged PR #215; confirmed
9415 // pre-existing for scatter/gather's whole error-path class, including
9416 // the clap-parse-failure path).
9417 use crate::interpreter::OutputFormat;
9418 let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9419 let out = finalize_output(r, Some(OutputFormat::Json), true);
9420 let parsed: serde_json::Value =
9421 serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9422 assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9423 assert_eq!(parsed["code"], 2);
9424 }
9425
9426 #[test]
9427 fn finalize_output_no_format_is_noop() {
9428 use crate::interpreter::OutputData;
9429 let r = ExecResult::with_output(OutputData::text("RAW"));
9430 let out = finalize_output(r, None, false);
9431 assert_eq!(out.text_out(), "RAW");
9432 }
9433
9434 // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9435
9436 #[tokio::test]
9437 async fn test_initial_vars_set_and_exported() {
9438 let config = KernelConfig::transient()
9439 .with_var("INIT_FOO", Value::String("bar".into()));
9440 let kernel = Kernel::new(config).expect("failed to create kernel");
9441
9442 assert_eq!(
9443 kernel.get_var("INIT_FOO").await,
9444 Some(Value::String("bar".into()))
9445 );
9446 assert!(
9447 kernel.scope.read().await.is_exported("INIT_FOO"),
9448 "initial_vars entries must be marked exported"
9449 );
9450 }
9451
9452 #[tokio::test]
9453 async fn test_execute_with_vars_overlay_visible() {
9454 let kernel = Kernel::transient().expect("failed to create kernel");
9455 let mut overlay = HashMap::new();
9456 overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9457
9458 let result = kernel
9459 .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9460 .await
9461 .expect("execute failed");
9462
9463 assert!(result.ok());
9464 assert_eq!(result.text_out().trim(), "yes");
9465 }
9466
9467 #[tokio::test]
9468 async fn test_execute_with_vars_overlay_cleanup() {
9469 let kernel = Kernel::transient().expect("failed to create kernel");
9470 let mut overlay = HashMap::new();
9471 overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9472
9473 kernel
9474 .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9475 .await
9476 .expect("execute failed");
9477
9478 assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9479 assert!(
9480 !kernel.scope.read().await.is_exported("EPHEMERAL"),
9481 "overlay-only export must be cleared on return"
9482 );
9483 }
9484
9485 #[tokio::test]
9486 async fn test_execute_with_vars_does_not_clobber_existing_export() {
9487 let kernel = Kernel::transient().expect("failed to create kernel");
9488 kernel
9489 .execute("export OUTER=outer")
9490 .await
9491 .expect("export failed");
9492
9493 let mut overlay = HashMap::new();
9494 overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9495 let result = kernel
9496 .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9497 .await
9498 .expect("execute failed");
9499 assert_eq!(result.text_out().trim(), "inner");
9500
9501 assert_eq!(
9502 kernel.get_var("OUTER").await,
9503 Some(Value::String("outer".into())),
9504 "outer value must reappear after pop"
9505 );
9506 assert!(
9507 kernel.scope.read().await.is_exported("OUTER"),
9508 "outer export must survive overlay"
9509 );
9510 }
9511
9512 #[tokio::test]
9513 async fn test_execute_with_vars_inner_assignment_is_local() {
9514 let kernel = Kernel::transient().expect("failed to create kernel");
9515 let mut overlay = HashMap::new();
9516 overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9517
9518 // Variable assignment inside a single statement uses set() (innermost
9519 // frame), not set_global() — this matches bash function-local semantics.
9520 // We explicitly use `local FOO=...` style by relying on the pushed
9521 // frame; the assignment in the script body modifies the same frame.
9522 let result = kernel
9523 .execute_with_options(
9524 r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9525 ExecuteOptions::new().with_vars(overlay),
9526 )
9527 .await
9528 .expect("execute failed");
9529 assert!(result.ok());
9530
9531 // After the call the frame is popped, so LOCAL_FOO is gone regardless
9532 // of how the script reassigned it.
9533 assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9534 }
9535
9536 #[tokio::test]
9537 async fn test_external_command_sees_exported_var() {
9538 let kernel = Kernel::transient().expect("failed to create kernel");
9539 // PATH must be in scope to resolve the external `printenv` — the kernel
9540 // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9541 // what a frontend does through initial_vars.
9542 let path = std::env::var("PATH").unwrap_or_default();
9543 let result = kernel
9544 .execute(&format!(
9545 "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9546 ))
9547 .await
9548 .expect("execute failed");
9549
9550 assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9551 assert_eq!(result.text_out().trim(), "bar");
9552 }
9553
9554 #[tokio::test]
9555 async fn test_external_command_does_not_see_unexported_var() {
9556 let kernel = Kernel::transient().expect("failed to create kernel");
9557
9558 // Set without exporting; printenv must not see it (exit code != 0,
9559 // empty stdout per printenv semantics).
9560 let result = kernel
9561 .execute("EXT_BAR=hidden; printenv EXT_BAR")
9562 .await
9563 .expect("execute failed");
9564
9565 assert!(!result.ok(), "printenv should fail when var is unexported");
9566 assert!(
9567 result.text_out().trim().is_empty(),
9568 "no stdout when var is missing, got: {}",
9569 result.text_out()
9570 );
9571 }
9572
9573 #[tokio::test]
9574 async fn test_external_command_does_not_see_os_env() {
9575 // The kernel is hermetic: it never reads std::env::vars() and only
9576 // exports what it has been told to export. Cargo always sets PATH for
9577 // tests, so PATH is reliably present in the OS env — but a transient
9578 // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9579 // inside the kernel must fail.
9580 assert!(
9581 std::env::var_os("PATH").is_some(),
9582 "test precondition: cargo should set PATH"
9583 );
9584
9585 let kernel = Kernel::transient().expect("failed to create kernel");
9586 let result = kernel
9587 .execute("printenv PATH")
9588 .await
9589 .expect("execute failed");
9590
9591 assert!(
9592 !result.ok(),
9593 "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9594 result.text_out()
9595 );
9596 assert!(
9597 result.text_out().trim().is_empty(),
9598 "no PATH in subprocess env, got stdout={:?}",
9599 result.text_out()
9600 );
9601 }
9602
9603 #[tokio::test]
9604 async fn test_execute_with_vars_overlay_reaches_subprocess() {
9605 let kernel = Kernel::transient().expect("failed to create kernel");
9606 let mut overlay = HashMap::new();
9607 overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9608 // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9609 overlay.insert(
9610 "PATH".to_string(),
9611 Value::String(std::env::var("PATH").unwrap_or_default()),
9612 );
9613
9614 let result = kernel
9615 .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9616 .await
9617 .expect("execute failed");
9618
9619 assert!(
9620 result.ok(),
9621 "printenv should succeed: code={} stdout={:?} stderr={:?}",
9622 result.code,
9623 result.text_out(),
9624 result.err
9625 );
9626 assert_eq!(result.text_out().trim(), "subproc");
9627 }
9628
9629 #[tokio::test]
9630 async fn test_classify_command_builtin() {
9631 let kernel = Kernel::transient().expect("failed to create kernel");
9632 assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9633 assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9634 }
9635
9636 #[tokio::test]
9637 async fn test_classify_command_special_forms() {
9638 let kernel = Kernel::transient().expect("failed to create kernel");
9639 for name in ["true", "false", "source", "."] {
9640 assert_eq!(
9641 kernel.classify_command(name).await,
9642 CommandKind::Special,
9643 "{name} should be a special-form",
9644 );
9645 }
9646 }
9647
9648 #[tokio::test]
9649 async fn test_classify_command_dynamic() {
9650 let kernel = Kernel::transient().expect("failed to create kernel");
9651 assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9652 assert_eq!(
9653 kernel.classify_command("$(pick)").await,
9654 CommandKind::Dynamic
9655 );
9656 }
9657
9658 #[tokio::test]
9659 async fn test_classify_command_external() {
9660 let kernel = Kernel::transient().expect("failed to create kernel");
9661 // Not a builtin, user function, or special-form → escapes to PATH.
9662 assert_eq!(
9663 kernel.classify_command("definitely_not_a_kaish_builtin").await,
9664 CommandKind::External
9665 );
9666 // `readonly` is *not* a kaish special-form despite the validator's
9667 // warning heuristic — at runtime it resolves to an external command, so
9668 // a consent gate must see it as External (regression guard against the
9669 // validator/runtime divergence).
9670 assert_eq!(
9671 kernel.classify_command("readonly").await,
9672 CommandKind::External
9673 );
9674 assert!(kernel.classify_command("readonly").await.escapes_kernel());
9675 }
9676
9677 #[tokio::test]
9678 async fn test_classify_command_user_tool_shadows_builtin() {
9679 let kernel = Kernel::transient().expect("failed to create kernel");
9680 kernel
9681 .execute(r#"greet() { echo "hi" }"#)
9682 .await
9683 .expect("function definition failed");
9684 assert_eq!(
9685 kernel.classify_command("greet").await,
9686 CommandKind::UserTool
9687 );
9688
9689 // A user function named after a builtin classifies as UserTool, matching
9690 // the interpreter's user-tools-first resolution.
9691 kernel
9692 .execute(r#"cat() { echo "shadowed" }"#)
9693 .await
9694 .expect("function definition failed");
9695 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9696 }
9697
9698 #[tokio::test]
9699 async fn test_classify_command_alias_to_external_is_external() {
9700 let kernel = Kernel::transient().expect("failed to create kernel");
9701 // An alias whose head is an external binary must NOT report as the
9702 // builtin it shadows — execution expands the alias, so a consent gate
9703 // would otherwise be told an external command is internal.
9704 kernel
9705 .execute("alias cat='/usr/bin/whatever'")
9706 .await
9707 .expect("alias failed");
9708 assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9709 assert!(kernel.classify_command("cat").await.escapes_kernel());
9710 }
9711
9712 #[tokio::test]
9713 async fn test_classify_command_alias_to_builtin() {
9714 let kernel = Kernel::transient().expect("failed to create kernel");
9715 kernel.execute("alias g=grep").await.expect("alias failed");
9716 assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9717 }
9718
9719 #[tokio::test]
9720 async fn test_classify_command_alias_to_special_form() {
9721 let kernel = Kernel::transient().expect("failed to create kernel");
9722 kernel.execute("alias t=true").await.expect("alias failed");
9723 assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9724 }
9725
9726 #[tokio::test]
9727 async fn test_classify_command_braced_var_is_dynamic() {
9728 let kernel = Kernel::transient().expect("failed to create kernel");
9729 // The string API can be handed a `${VAR}` head; it must not be mistaken
9730 // for an external named literally "${VAR}".
9731 assert_eq!(
9732 kernel.classify_command("${CMD}").await,
9733 CommandKind::Dynamic
9734 );
9735 }
9736
9737 /// Drift guard: `classify_command` must agree with what the executor
9738 /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9739 /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9740 /// precedence, alias expansion); without this test those copies could diverge
9741 /// silently — the exact failure class `classify_command` exists to prevent,
9742 /// just moved inside the kernel. Each case asserts the classification AND
9743 /// observes the real resolution, so a future change to one side without the
9744 /// other fails here.
9745 #[tokio::test]
9746 async fn classify_command_matches_executor() {
9747 let kernel = Kernel::transient().expect("failed to create kernel");
9748
9749 // (1) Special-forms. `SpecialForm::from_name` is the single source of
9750 // truth: classify reports Special via it, and the executor matches the
9751 // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9752 // form won't build until both sides handle it). This test pins the other
9753 // half — that each form classifies Special AND actually short-circuits at
9754 // runtime rather than escaping to `PATH`. Every form is executed (not just
9755 // `true`/`false`): an external miss in this PATH-less kernel would be exit
9756 // 127, so a non-127 result that matches the form's own behavior proves the
9757 // short-circuit fired.
9758 for name in ["true", "false", "source", "."] {
9759 assert_eq!(
9760 kernel.classify_command(name).await,
9761 CommandKind::Special,
9762 "{name} should classify Special",
9763 );
9764 }
9765 assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9766 assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9767 // `source`/`.` short-circuit to execute_source, which (no filename) fails
9768 // with its own message — exit 1, never the 127 of an unresolved external.
9769 for name in ["source", "."] {
9770 let r = kernel.execute(name).await.expect("run source form");
9771 assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9772 assert!(
9773 r.err.contains("source: missing filename"),
9774 "{name} did not route to execute_source: {:?}",
9775 r.err,
9776 );
9777 }
9778
9779 // (2) Builtin: classify Builtin AND the executor runs the builtin.
9780 assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9781 let r = kernel.execute("echo hi").await.expect("run echo");
9782 assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9783
9784 // (3) User function shadows a builtin: classify UserTool AND the executor
9785 // runs the function body, not the `cat` builtin.
9786 kernel
9787 .execute(r#"cat() { echo SHADOWED }"#)
9788 .await
9789 .expect("define cat()");
9790 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9791 let r = kernel.execute("cat").await.expect("run shadowed cat");
9792 assert_eq!(
9793 r.text_out().trim(),
9794 "SHADOWED",
9795 "executor ran the builtin instead of the shadowing function",
9796 );
9797
9798 // (4) Alias whose head is external: classify External AND the executor
9799 // resolves through the alias to a missing external (not a builtin).
9800 kernel
9801 .execute("alias x='/nonexistent/binary'")
9802 .await
9803 .expect("define alias x");
9804 assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9805 let r = kernel.execute("x").await.expect("run alias x");
9806 assert!(
9807 !r.ok(),
9808 "alias to a missing external should fail, not resolve internally",
9809 );
9810 }
9811}