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, result: carried } => {
2230 if !drained_stderr.is_empty() {
2231 result.err.push_str(&drained_stderr);
2232 }
2233 // Output produced before the exit — e.g. by the loop the
2234 // `exit` ran inside — arrives on the signal. Emit it like
2235 // any other statement's, then let `code` decide the status.
2236 on_output(&carried);
2237 accumulate_result(&mut result, &carried);
2238 result.code = code;
2239 if !surfaced_warnings.is_empty() {
2240 result.err = format!("{surfaced_warnings}{}", result.err);
2241 }
2242 return Ok(result);
2243 }
2244 ControlFlow::Return { mut value } => {
2245 if !drained_stderr.is_empty() {
2246 value.err = format!("{}{}", drained_stderr, value.err);
2247 }
2248 on_output(&value);
2249 // A top-level `return` stops the script, like `exit` —
2250 // it must not discard prior statements' accumulated
2251 // output nor let execution continue past it.
2252 accumulate_result(&mut result, &value);
2253 if !surfaced_warnings.is_empty() {
2254 result.err = format!("{surfaced_warnings}{}", result.err);
2255 }
2256 return Ok(result);
2257 }
2258 ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2259 if !drained_stderr.is_empty() {
2260 r.err = format!("{}{}", drained_stderr, r.err);
2261 }
2262 on_output(&r);
2263 accumulate_result(&mut result, &r);
2264 }
2265 }
2266 }
2267
2268 if !surfaced_warnings.is_empty() {
2269 result.err = format!("{surfaced_warnings}{}", result.err);
2270 }
2271 Ok(result)
2272 }
2273
2274 /// Execute a single statement, returning control flow information.
2275 fn execute_stmt_flow<'a>(
2276 &'a self,
2277 stmt: &'a Stmt,
2278 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2279 // No per-statement span here: `execute_stmt_flow` is the largest future
2280 // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2281 // the span's state through every `.await` at every level, costing native
2282 // stack per level (GH #48). Coarser spans on the outer execute entries
2283 // remain. See item 3 of the #48 burndown.
2284 Box::pin(async move {
2285 match stmt {
2286 Stmt::Assignment(assign) => {
2287 // Use async evaluator to support command substitution
2288 let value = self.eval_expr_async(&assign.value).await
2289 .context("failed to evaluate assignment")?;
2290 let mut scope = self.scope.write().await;
2291 if assign.path.segments.len() == 1 {
2292 // Plain `NAME=value` — no subscript, so `local` applies.
2293 if assign.local {
2294 // local: set in innermost (current function) frame
2295 scope.set(assign.name(), value.clone());
2296 } else {
2297 // non-local: update existing or create in root frame
2298 scope.set_global(assign.name(), value.clone());
2299 }
2300 } else {
2301 // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2302 // mutates the existing root wherever it lives, so `local`
2303 // has nothing to declare. See docs/LANGUAGE.md,
2304 // "Assignment — bracket-path lvalues".
2305 scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2306 PathError::UndefinedRoot(name) => anyhow::anyhow!(
2307 "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2308 ),
2309 PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2310 })?;
2311 }
2312 drop(scope);
2313
2314 // Assignments don't produce output (like sh)
2315 Ok(ControlFlow::ok(ExecResult::success("")))
2316 }
2317 Stmt::Command(cmd) => {
2318 // Route single commands through execute_pipeline for a unified path.
2319 // This ensures all commands go through the dispatcher chain.
2320 let pipeline = crate::ast::Pipeline {
2321 commands: vec![cmd.clone()],
2322 background: false,
2323 };
2324 let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2325 self.update_last_result(&result).await;
2326
2327 // Check for error exit mode (set -e)
2328 if !result.ok() {
2329 let scope = self.scope.read().await;
2330 if scope.error_exit_enabled() {
2331 return Ok(ControlFlow::exit_code(result.code));
2332 }
2333 }
2334
2335 Ok(ControlFlow::ok(result))
2336 }
2337 Stmt::Pipeline(pipeline) => {
2338 let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2339 self.update_last_result(&result).await;
2340
2341 // Check for error exit mode (set -e)
2342 if !result.ok() {
2343 let scope = self.scope.read().await;
2344 if scope.error_exit_enabled() {
2345 return Ok(ControlFlow::exit_code(result.code));
2346 }
2347 }
2348
2349 Ok(ControlFlow::ok(result))
2350 }
2351 Stmt::If(if_stmt) => {
2352 // Use async evaluator to support command substitution in conditions
2353 let cond_value = self.eval_expr_async(&if_stmt.condition).await?;
2354
2355 let branch = if is_truthy(&cond_value) {
2356 &if_stmt.then_branch
2357 } else {
2358 if_stmt.else_branch.as_deref().unwrap_or(&[])
2359 };
2360
2361 let mut result = ExecResult::success("");
2362 for stmt in branch {
2363 let flow = self.execute_stmt_flow(stmt).await?;
2364 match flow {
2365 ControlFlow::Normal(r) => {
2366 accumulate_result(&mut result, &r);
2367 self.drain_stderr_into(&mut result).await;
2368 }
2369 mut other => {
2370 self.drain_stderr_into(&mut result).await;
2371 fold_block_output_into_flow(std::mem::take(&mut result), &mut other);
2372 return Ok(other);
2373 }
2374 }
2375 }
2376 Ok(ControlFlow::ok(result))
2377 }
2378 Stmt::For(for_loop) => {
2379 // Evaluate all items and collect values for iteration
2380 // Use async evaluator to support command substitution like $(seq 1 5)
2381 let mut items: Vec<Value> = Vec::new();
2382 for item_expr in &for_loop.items {
2383 // Glob expansion in for-loop items: `for f in *.txt`
2384 if let Expr::GlobPattern(pattern) = item_expr {
2385 let glob_enabled = {
2386 let scope = self.scope.read().await;
2387 scope.glob_enabled()
2388 };
2389 if glob_enabled {
2390 let (paths, cwd) = {
2391 let ctx = self.exec_ctx.read().await;
2392 let paths = ctx.expand_glob(pattern).await
2393 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2394 let cwd = ctx.resolve_path(".");
2395 (paths, cwd)
2396 };
2397 if paths.is_empty() {
2398 return Err(anyhow::anyhow!("no matches: {}", pattern));
2399 }
2400 for path in paths {
2401 let display = if !pattern.starts_with('/') {
2402 path.strip_prefix(&cwd)
2403 .unwrap_or(&path)
2404 .to_string_lossy().into_owned()
2405 } else {
2406 path.to_string_lossy().into_owned()
2407 };
2408 items.push(Value::String(display));
2409 }
2410 continue;
2411 }
2412 }
2413 // Track whether this item came from $(cmd); that's the
2414 // only position where multi-line stdout auto-splits per
2415 // line. Arrays still spread element-by-element; bare
2416 // $VAR is rejected upstream by validator E012. See
2417 // docs/LANGUAGE.md.
2418 let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2419 let item = self.eval_expr_async(item_expr).await?;
2420 match item {
2421 // JSON arrays iterate over elements (preferred path
2422 // when builtins emit .data — seq, jq, cut, find, …)
2423 Value::Json(serde_json::Value::Array(arr)) => {
2424 for elem in arr {
2425 // Envelope-free: an element that happens to be
2426 // envelope-shaped (e.g. from `fromjson`) is
2427 // external data, not an internal bytes round-trip,
2428 // so it must NOT be re-decoded to Value::Bytes.
2429 items.push(json_to_value_no_envelope(elem));
2430 }
2431 }
2432 // Strings from $(cmd): empty → 0 iterations,
2433 // multi-line → split per line (trimming trailing
2434 // newlines and per-line trailing \r), single-line
2435 // → one iteration. Whitespace within a line is
2436 // NOT split — the "$VAR with spaces just works"
2437 // promise is preserved because this only fires
2438 // in CommandSubst position.
2439 Value::String(s) if from_command_subst => {
2440 let trimmed = s.trim_end_matches(['\n', '\r']);
2441 if trimmed.is_empty() {
2442 continue;
2443 }
2444 if trimmed.contains('\n') {
2445 for line in trimmed.split('\n') {
2446 let line = line.trim_end_matches('\r');
2447 items.push(Value::String(line.to_string()));
2448 }
2449 } else {
2450 items.push(Value::String(trimmed.to_string()));
2451 }
2452 }
2453 // Binary isn't iterable — fail loud rather than loop
2454 // once over an opaque byte blob.
2455 Value::Bytes(_) => {
2456 anyhow::bail!(
2457 "for: cannot iterate over binary data — decode it \
2458 (base64/xxd) first"
2459 );
2460 }
2461 // Strings not from $(cmd) stay as one value.
2462 other => items.push(other),
2463 }
2464 }
2465
2466 let mut result = ExecResult::success("");
2467 {
2468 let mut scope = self.scope.write().await;
2469 scope.push_frame();
2470 }
2471
2472 'outer: for item in items {
2473 // Cancellation checkpoint per iteration
2474 if self.is_cancelled() {
2475 let mut scope = self.scope.write().await;
2476 scope.pop_frame();
2477 result.code = 130;
2478 return Ok(ControlFlow::ok(result));
2479 }
2480 {
2481 let mut scope = self.scope.write().await;
2482 scope.set(&for_loop.variable, item);
2483 }
2484 for stmt in &for_loop.body {
2485 let mut flow = match self.execute_stmt_flow(stmt).await {
2486 Ok(f) => f,
2487 Err(e) => {
2488 let mut scope = self.scope.write().await;
2489 scope.pop_frame();
2490 return Err(e);
2491 }
2492 };
2493 self.drain_stderr_into(&mut result).await;
2494 match &mut flow {
2495 ControlFlow::Normal(r) => {
2496 accumulate_result(&mut result, r);
2497 if !r.ok() {
2498 let scope = self.scope.read().await;
2499 if scope.error_exit_enabled() {
2500 drop(scope);
2501 let mut scope = self.scope.write().await;
2502 scope.pop_frame();
2503 return Ok(ControlFlow::exit_code(r.code));
2504 }
2505 }
2506 }
2507 ControlFlow::Break { .. } => {
2508 if flow.decrement_level() {
2509 accumulate_flow_output(&mut result, &flow);
2510 break 'outer;
2511 }
2512 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2513 let mut scope = self.scope.write().await;
2514 scope.pop_frame();
2515 return Ok(flow);
2516 }
2517 ControlFlow::Continue { .. } => {
2518 if flow.decrement_level() {
2519 accumulate_flow_output(&mut result, &flow);
2520 continue 'outer;
2521 }
2522 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2523 let mut scope = self.scope.write().await;
2524 scope.pop_frame();
2525 return Ok(flow);
2526 }
2527 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2528 fold_block_output_into_flow(
2529 std::mem::take(&mut result),
2530 &mut flow,
2531 );
2532 let mut scope = self.scope.write().await;
2533 scope.pop_frame();
2534 return Ok(flow);
2535 }
2536 }
2537 }
2538 }
2539
2540 {
2541 let mut scope = self.scope.write().await;
2542 scope.pop_frame();
2543 }
2544 Ok(ControlFlow::ok(result))
2545 }
2546 Stmt::While(while_loop) => {
2547 let mut result = ExecResult::success("");
2548
2549 'outer: loop {
2550 // Evaluate condition - use async to support command substitution
2551 // Cancellation checkpoint per iteration
2552 if self.is_cancelled() {
2553 result.code = 130;
2554 return Ok(ControlFlow::ok(result));
2555 }
2556
2557 let cond_value = self.eval_expr_async(&while_loop.condition).await?;
2558
2559 if !is_truthy(&cond_value) {
2560 break;
2561 }
2562
2563 // Execute body
2564 for stmt in &while_loop.body {
2565 let mut flow = self.execute_stmt_flow(stmt).await?;
2566 self.drain_stderr_into(&mut result).await;
2567 match &mut flow {
2568 ControlFlow::Normal(r) => {
2569 accumulate_result(&mut result, r);
2570 if !r.ok() {
2571 let scope = self.scope.read().await;
2572 if scope.error_exit_enabled() {
2573 return Ok(ControlFlow::exit_code(r.code));
2574 }
2575 }
2576 }
2577 ControlFlow::Break { .. } => {
2578 if flow.decrement_level() {
2579 accumulate_flow_output(&mut result, &flow);
2580 break 'outer;
2581 }
2582 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2583 return Ok(flow);
2584 }
2585 ControlFlow::Continue { .. } => {
2586 if flow.decrement_level() {
2587 accumulate_flow_output(&mut result, &flow);
2588 continue 'outer;
2589 }
2590 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2591 return Ok(flow);
2592 }
2593 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2594 fold_block_output_into_flow(
2595 std::mem::take(&mut result),
2596 &mut flow,
2597 );
2598 return Ok(flow);
2599 }
2600 }
2601 }
2602 }
2603
2604 Ok(ControlFlow::ok(result))
2605 }
2606 Stmt::Case(case_stmt) => {
2607 // Evaluate the expression to match against. Text sink: a
2608 // `case $bin in ...)` pattern match on binary goes loud
2609 // rather than glob-matching against the `[binary: N bytes]`
2610 // placeholder (Decision E — same class as `==`/`in`).
2611 let match_value = {
2612 let value = self.eval_expr_async(&case_stmt.expr).await?;
2613 value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2614 };
2615
2616 // Try each branch until we find a match
2617 for branch in &case_stmt.branches {
2618 let matched = branch.patterns.iter().any(|pattern| {
2619 glob_match(pattern, &match_value)
2620 });
2621
2622 if matched {
2623 // Execute the branch body
2624 let mut result = ExecResult::success("");
2625 for stmt in &branch.body {
2626 let flow = self.execute_stmt_flow(stmt).await?;
2627 match flow {
2628 ControlFlow::Normal(r) => {
2629 accumulate_result(&mut result, &r);
2630 self.drain_stderr_into(&mut result).await;
2631 }
2632 mut other => {
2633 self.drain_stderr_into(&mut result).await;
2634 fold_block_output_into_flow(
2635 std::mem::take(&mut result),
2636 &mut other,
2637 );
2638 return Ok(other);
2639 }
2640 }
2641 }
2642 return Ok(ControlFlow::ok(result));
2643 }
2644 }
2645
2646 // No match - return success with empty output (like sh)
2647 Ok(ControlFlow::ok(ExecResult::success("")))
2648 }
2649 Stmt::Break(levels) => {
2650 Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2651 }
2652 Stmt::Continue(levels) => {
2653 Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2654 }
2655 Stmt::Return(expr) => {
2656 // return [N] - N becomes the exit code, NOT stdout
2657 // Shell semantics: return sets exit code, doesn't produce output
2658 let result = if let Some(e) = expr {
2659 let val = self.eval_expr_async(e).await?;
2660 let code = crate::interpreter::value_to_exit_code(&val)
2661 .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2662 ExecResult::from_parts(code, String::new(), String::new(), None)
2663 } else {
2664 ExecResult::success("")
2665 };
2666 Ok(ControlFlow::return_value(result))
2667 }
2668 Stmt::Exit(expr) => {
2669 let code = if let Some(e) = expr {
2670 let val = self.eval_expr_async(e).await?;
2671 crate::interpreter::value_to_exit_code(&val)
2672 .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2673 } else {
2674 0
2675 };
2676 Ok(ControlFlow::exit_code(code))
2677 }
2678 Stmt::ToolDef(tool_def) => {
2679 let mut user_tools = self.user_tools.write().await;
2680 user_tools.insert(tool_def.name.clone(), tool_def.clone());
2681 Ok(ControlFlow::ok(ExecResult::success("")))
2682 }
2683 Stmt::AndChain { left, right } => {
2684 // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2685 // Suppress errexit for the left side — && handles failure itself.
2686 {
2687 let mut scope = self.scope.write().await;
2688 scope.suppress_errexit();
2689 }
2690 let left_flow = match self.execute_stmt_flow(left).await {
2691 Ok(f) => f,
2692 Err(e) => {
2693 let mut scope = self.scope.write().await;
2694 scope.unsuppress_errexit();
2695 return Err(e);
2696 }
2697 };
2698 {
2699 let mut scope = self.scope.write().await;
2700 scope.unsuppress_errexit();
2701 }
2702 match left_flow {
2703 ControlFlow::Normal(mut left_result) => {
2704 self.drain_stderr_into(&mut left_result).await;
2705 self.update_last_result(&left_result).await;
2706 // Pending is not failure (spec §I.5) — see the
2707 // `OrChain` twin. The stash check matters here for a
2708 // hold swallowed into an apparent success below.
2709 if left_result.ok() {
2710 let right_flow = self.execute_stmt_flow(right).await?;
2711 match right_flow {
2712 ControlFlow::Normal(mut right_result) => {
2713 self.drain_stderr_into(&mut right_result).await;
2714 self.update_last_result(&right_result).await;
2715 let mut combined = left_result;
2716 accumulate_result(&mut combined, &right_result);
2717 Ok(ControlFlow::ok(combined))
2718 }
2719 mut other => {
2720 // The left side already ran and printed;
2721 // a signal out of the right side must not
2722 // unprint it.
2723 fold_block_output_into_flow(left_result, &mut other);
2724 Ok(other)
2725 }
2726 }
2727 } else {
2728 Ok(ControlFlow::ok(left_result))
2729 }
2730 }
2731 _ => Ok(left_flow),
2732 }
2733 }
2734 Stmt::OrChain { left, right } => {
2735 // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2736 // Suppress errexit for the left side — || handles failure itself.
2737 {
2738 let mut scope = self.scope.write().await;
2739 scope.suppress_errexit();
2740 }
2741 let left_flow = match self.execute_stmt_flow(left).await {
2742 Ok(f) => f,
2743 Err(e) => {
2744 let mut scope = self.scope.write().await;
2745 scope.unsuppress_errexit();
2746 return Err(e);
2747 }
2748 };
2749 {
2750 let mut scope = self.scope.write().await;
2751 scope.unsuppress_errexit();
2752 }
2753 match left_flow {
2754 ControlFlow::Normal(mut left_result) => {
2755 self.drain_stderr_into(&mut left_result).await;
2756 self.update_last_result(&left_result).await;
2757 // Pending is not failure (spec §I.5): a fallback
2758 // written for failure must not run on a decision
2759 // nobody has made yet — and running it would also
2760 // overwrite the request in the accumulated result.
2761 // The stash check covers a hold whose typed error a
2762 // layer below already stringified out of the result.
2763 // On a stash-based hold the returned `left_result` is
2764 // that stringified failure, not the held result — the
2765 // statement boundary discards it and surfaces the
2766 // slot's result instead. Do not "fix" this by taking
2767 // the slot here: only statement boundaries take it.
2768 if !left_result.ok() {
2769 let right_flow = self.execute_stmt_flow(right).await?;
2770 match right_flow {
2771 ControlFlow::Normal(mut right_result) => {
2772 self.drain_stderr_into(&mut right_result).await;
2773 self.update_last_result(&right_result).await;
2774 let mut combined = left_result;
2775 accumulate_result(&mut combined, &right_result);
2776 Ok(ControlFlow::ok(combined))
2777 }
2778 mut other => {
2779 // The left side already ran and printed;
2780 // a signal out of the right side must not
2781 // unprint it.
2782 fold_block_output_into_flow(left_result, &mut other);
2783 Ok(other)
2784 }
2785 }
2786 } else {
2787 Ok(ControlFlow::ok(left_result))
2788 }
2789 }
2790 _ => Ok(left_flow), // Propagate non-normal flow
2791 }
2792 }
2793 Stmt::Test(test_expr) => {
2794 let is_true = self.eval_test_async(test_expr).await?;
2795 let result = if is_true {
2796 ExecResult::success("")
2797 } else {
2798 ExecResult::failure(1, "")
2799 };
2800 // A bare test writes `$?` and honors `set -e` like any command
2801 // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
2802 // safe: the chain arms suppress errexit around their left side,
2803 // and `if`/`while` conditions evaluate as expressions, never
2804 // through this statement arm.
2805 self.update_last_result(&result).await;
2806 if !result.ok() {
2807 let scope = self.scope.read().await;
2808 if scope.error_exit_enabled() {
2809 return Ok(ControlFlow::exit_code(result.code));
2810 }
2811 }
2812 Ok(ControlFlow::ok(result))
2813 }
2814 Stmt::EnvScoped { assignments, body } => {
2815 // Inline env prefix (`NAME=value ... command`): apply the
2816 // assignments as EXPORTED vars in a fresh frame so the command
2817 // — and its subprocess environment — sees them, then unwind so
2818 // they do NOT persist (bash-style command-scoped env). Values
2819 // evaluate left-to-right with earlier ones already in scope, so
2820 // `A=1 B=$A cmd` works.
2821 {
2822 let mut scope = self.scope.write().await;
2823 scope.push_frame();
2824 }
2825 let mut prior_export: Vec<(String, bool)> =
2826 Vec::with_capacity(assignments.len());
2827 let mut setup_err: Option<anyhow::Error> = None;
2828 for assign in assignments {
2829 match self.eval_expr_async(&assign.value).await {
2830 Ok(value) => {
2831 let mut scope = self.scope.write().await;
2832 prior_export
2833 .push((assign.name().to_string(), scope.is_exported(assign.name())));
2834 scope.set_exported(assign.name(), value);
2835 }
2836 Err(e) => {
2837 setup_err = Some(e);
2838 break;
2839 }
2840 }
2841 }
2842
2843 let flow = if setup_err.is_none() {
2844 self.execute_stmt_flow(body).await
2845 } else {
2846 Ok(ControlFlow::ok(ExecResult::success("")))
2847 };
2848
2849 // Unwind the env frame and restore export marks unconditionally
2850 // (names that were not exported before must not stay exported).
2851 {
2852 let mut scope = self.scope.write().await;
2853 scope.pop_frame();
2854 for (name, was_exported) in &prior_export {
2855 if !*was_exported {
2856 scope.unexport(name);
2857 }
2858 }
2859 }
2860
2861 match setup_err {
2862 Some(e) => Err(e),
2863 None => flow,
2864 }
2865 }
2866 Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
2867 }
2868 })
2869 }
2870
2871 /// Build a boxed per-command `ExecContext` snapshot from the persistent
2872 /// kernel state (`ec`/`scope`, both already locked by the caller).
2873 ///
2874 /// Sync on purpose: the ~30 field clones live in this transient frame rather
2875 /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
2876 /// — not the 960-byte struct — rides the dispatch await at every recursion
2877 /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
2878 /// per-site differences (the pipeline runner uses the kernel's own cancel
2879 /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
2880 /// they're parameters; every other field is snapshotted identically.
2881 fn snapshot_exec_ctx(
2882 &self,
2883 ec: &ExecContext,
2884 scope: &Scope,
2885 pipeline_position: PipelinePosition,
2886 cancel: tokio_util::sync::CancellationToken,
2887 ) -> Box<ExecContext> {
2888 Box::new(ExecContext {
2889 backend: ec.backend.clone(),
2890 scope: scope.clone(),
2891 cwd: ec.cwd.clone(),
2892 prev_cwd: ec.prev_cwd.clone(),
2893 stdin: ec.stdin.clone(),
2894 stdin_data: ec.stdin_data.clone(),
2895 stdin_data_rx: None,
2896 pipe_stdin: None,
2897 pipe_stdout: None,
2898 stderr: ec.stderr.clone(),
2899 tool_schemas: ec.tool_schemas.clone(),
2900 tools: ec.tools.clone(),
2901 job_manager: ec.job_manager.clone(),
2902 pipeline_position,
2903 interactive: self.interactive,
2904 // The kernel-wide setting; a snapshot inherits it like `interactive`.
2905 kill_children_on_parent_death: ec.kill_children_on_parent_death,
2906 aliases: ec.aliases.clone(),
2907 ignore_config: ec.ignore_config.clone(),
2908 output_limit: ec.output_limit.clone(),
2909 allow_external_commands: self.allow_external_commands,
2910 trash_backend: ec.trash_backend.clone(),
2911 #[cfg(all(unix, feature = "subprocess"))]
2912 terminal_state: ec.terminal_state.clone(),
2913 dispatcher: self.dispatcher(),
2914 cancel,
2915 output_format: None,
2916 vfs_budget: self.vfs_budget.clone(),
2917 watchdog: ec.watchdog.clone(),
2918 #[cfg(all(feature = "localfs", feature = "overlay"))]
2919 overlay_handle: self.overlay_handle.clone(),
2920 // Correlate this command's requests with the background job it
2921 // runs for, if any — the ONE place `job_id` is stamped.
2922 // A replay correlation belongs to exactly one dispatch. Moved
2923 // (not cloned) out of the parent context at the dispatch seam —
2924 // see the stdin hand-off below, which takes it under the same
2925 // write lock — so the gate this snapshot reaches is the only one
2926 // that can adopt it.
2927 // A forked or backgrounded execution keeps its parenthood: a
2928 // gate reached from inside a gated statement is nested under it
2929 // (spec §A.7).
2930 })
2931 }
2932
2933 /// Execute a pipeline.
2934 async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
2935 if pipeline.commands.is_empty() {
2936 return Ok(ExecResult::success(""));
2937 }
2938
2939 // Handle background execution (`&` operator)
2940 if pipeline.background {
2941 return self.execute_background(pipeline).await;
2942 }
2943
2944 // All commands go through the runner with the Kernel as dispatcher.
2945 // This is the single execution path — no fast path for single commands.
2946 //
2947 // IMPORTANT: We snapshot exec_ctx into a local context and release the
2948 // lock before running. This prevents deadlocks when dispatch_command
2949 // is called from within the pipeline and recursively triggers another
2950 // pipeline (e.g., via user-defined tools).
2951 let (mut ctx, has_pipe_stdin) = {
2952 let ec = self.exec_ctx.read().await;
2953 let scope = self.scope.read().await;
2954 // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
2955 // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
2956 // the consume-once block below, so note its presence here.
2957 let has_pipe_stdin = ec.pipe_stdin.is_some();
2958 // The pipeline runner drives stage 0 with the first stage's stdin
2959 // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
2960 // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
2961 // and uses the kernel's own cancel token so a `cancel()` reaches the
2962 // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
2963 let cancel = {
2964 #[allow(clippy::expect_used)]
2965 let token = self.cancel_token.lock().expect("cancel_token poisoned");
2966 token.clone()
2967 };
2968 (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
2969 }; // locks released
2970
2971 // Consume-once: move/clear the seeded stdin sources from the persistent
2972 // exec_ctx now that this pipeline's ctx owns them, so a later statement
2973 // in the same call (`cat ; cat`) does not re-receive them — matching
2974 // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
2975 // (the ctx above was built with `pipe_stdin: None`).
2976 if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
2977 let mut ec = self.exec_ctx.write().await;
2978 ctx.pipe_stdin = ec.pipe_stdin.take();
2979 ec.stdin = None;
2980 ec.stdin_data = None;
2981 }
2982
2983 let mut result = self.runner.run(&pipeline.commands, &mut ctx, self).await;
2984
2985 // Post-hoc spill check + exit-3 remap (catches builtins and fast
2986 // external commands; also catches a ring overflow that already
2987 // flipped `did_spill` even when the limit itself is disabled, GH
2988 // #191). This is the shared contract every execution surface must
2989 // apply — see `apply_spill_contract`'s doc comment (GH #212).
2990 crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
2991
2992 // Sync changes back from context
2993 {
2994 let mut ec = self.exec_ctx.write().await;
2995 ec.cwd = ctx.cwd.clone();
2996 ec.prev_cwd = ctx.prev_cwd.clone();
2997 ec.aliases = ctx.aliases.clone();
2998 ec.ignore_config = ctx.ignore_config.clone();
2999 ec.output_limit = ctx.output_limit.clone();
3000 // Unconsumed stdin goes back to the session, or it dies here with
3001 // `ctx`. A partial read (`read` takes one line) leaves the rest
3002 // split across two places: the bytes it over-read sit in `stdin`,
3003 // and the pipe still holds everything past them. Dropping the
3004 // reader discards that tail with no error — `read x; wc -c` over
3005 // 100 KiB counted 8187 bytes and said nothing.
3006 //
3007 // A multi-stage pipeline reaches here with the remainder already
3008 // returned by `run_pipeline`'s join, so this carries the
3009 // single-command and the pipeline case alike.
3010 ec.stdin = ctx.stdin.take();
3011 ec.pipe_stdin = ctx.pipe_stdin.take();
3012 }
3013 {
3014 let mut scope = self.scope.write().await;
3015 *scope = ctx.scope.clone();
3016 }
3017
3018 Ok(result)
3019 }
3020
3021 /// Execute a pipeline in the background.
3022 ///
3023 /// The command is spawned as a tokio task and registered with the
3024 /// JobManager. The job is observable via `/v/jobs/{id}/status`,
3025 /// `/v/jobs/{id}/command`, and — while it is
3026 /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
3027 ///
3028 /// GH #240 removed those two nodes because they filled once, at
3029 /// completion, while the docs promised a live stream. They are back on
3030 /// the terms the docs always claimed: `try_execute_external` tees each
3031 /// 8 KiB chunk into the job's stream as the child emits it. See
3032 /// `Job::stdout_stream` for exactly which bytes reach them.
3033 ///
3034 /// Returns immediately with a job ID like "[1]".
3035 #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.commands.len()))]
3036 async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3037 use tokio::sync::oneshot;
3038
3039 // Format the command for display in /v/jobs/{id}/command
3040 let command_str = self.format_pipeline(pipeline);
3041
3042 // Create channel for result notification
3043 let (tx, rx) = oneshot::channel();
3044
3045 // Register with JobManager to get job ID and create VFS entries
3046 let job_id = self.jobs.register(command_str.clone(), rx).await;
3047
3048 // Fork the kernel for this background job. The fork snapshots the
3049 // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3050 // while sharing the job manager, VFS, and tool registry. The fork's
3051 // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3052 // is available here — something BackendDispatcher couldn't provide.
3053 //
3054 // The fork gets its own cancellation token (recorded on the job so
3055 // `kill %N` can stop the job — including a pure-builtin job with no OS
3056 // process group) and is stamped with the job id so any external
3057 // command it spawns records its process group for `kill -<sig> %N`.
3058 let cancel = tokio_util::sync::CancellationToken::new();
3059 self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3060 let jobs = self.jobs.clone();
3061 let fork = self.fork_for_background(cancel, job_id).await;
3062 let runner = self.runner.clone();
3063 let commands = pipeline.commands.clone();
3064
3065 // Snapshot the fork's exec_ctx for the spawned task. We have to do
3066 // this before tokio::spawn because the fork's exec_ctx is behind a
3067 // tokio RwLock and we want the spawned task to own its ctx.
3068 let mut bg_ctx = {
3069 let ec = fork.exec_ctx.read().await;
3070 ec.child_for_pipeline()
3071 };
3072 bg_ctx.scope = fork.scope.read().await.clone();
3073 // The fork's dispatcher points at the fork itself; set it here so
3074 // builtins inside the background task (e.g. timeout) re-dispatch
3075 // through the fork, not the parent.
3076 bg_ctx.dispatcher = fork.dispatcher();
3077
3078 // Spawn the background task. Propagate the embedder's trace context
3079 // across the spawn boundary so the job's spans stay in the same trace.
3080 tokio::spawn(crate::telemetry::bind_current_context(async move {
3081 // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3082 // gives us that (Kernel implements CommandDispatcher).
3083 let mut result = runner.run(&commands, &mut bg_ctx, fork.as_ref()).await;
3084
3085 // Apply the same spill/exit-3 contract the foreground path gets
3086 // (`execute_pipeline`'s `apply_spill_contract` call) — without
3087 // this, a background job whose output overflows the capture ring
3088 // or trips the output limit reports the child's ORIGINAL exit
3089 // code to JobManager, so `[N] done:0`/`Job::status()` silently
3090 // read success even though the output was capped (GH #212).
3091 crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3092
3093 // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3094 // drain tasks already fed live is left alone (re-writing the
3095 // aggregate would duplicate every byte), an untouched one takes
3096 // the captured result, and both close. Before `tx.send`, so a
3097 // reader that observes a terminal `status` also observes a
3098 // finished stream — never a `done:0` job whose output is still
3099 // arriving.
3100 jobs.finalize_streams(job_id, &result).await;
3101
3102 // Send result to JobManager (ignore error if receiver dropped)
3103 let _ = tx.send(result);
3104 }));
3105
3106 Ok(ExecResult::success(format!("[{}]", job_id)))
3107 }
3108
3109 /// Format a pipeline as a command string for display.
3110 fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3111 pipeline.commands
3112 .iter()
3113 .map(|cmd| {
3114 let mut parts = vec![cmd.name.clone()];
3115 for arg in &cmd.args {
3116 match arg {
3117 Arg::Positional(expr) => {
3118 parts.push(self.format_expr(expr));
3119 }
3120 Arg::Named { key, value } => {
3121 parts.push(format!("--{}={}", key, self.format_expr(value)));
3122 }
3123 Arg::WordAssign { key, value } => {
3124 parts.push(format!("{}={}", key, self.format_expr(value)));
3125 }
3126 Arg::ShortFlag(name) => {
3127 parts.push(format!("-{}", name));
3128 }
3129 Arg::LongFlag(name) => {
3130 parts.push(format!("--{}", name));
3131 }
3132 Arg::DoubleDash => {
3133 parts.push("--".to_string());
3134 }
3135 }
3136 }
3137 parts.join(" ")
3138 })
3139 .collect::<Vec<_>>()
3140 .join(" | ")
3141 }
3142
3143 /// Format an expression as a string for display.
3144 fn format_expr(&self, expr: &Expr) -> String {
3145 match expr {
3146 Expr::Literal(Value::String(s)) => {
3147 if s.contains(' ') || s.contains('"') {
3148 format!("'{}'", s.replace('\'', "\\'"))
3149 } else {
3150 s.clone()
3151 }
3152 }
3153 Expr::Literal(Value::Int(i)) => i.to_string(),
3154 Expr::Literal(Value::Float(f)) => f.to_string(),
3155 Expr::Literal(Value::Bool(b)) => b.to_string(),
3156 Expr::Literal(Value::Null) => "null".to_string(),
3157 Expr::VarRef(path) => {
3158 let mut name = String::new();
3159 for (i, seg) in path.segments.iter().enumerate() {
3160 match seg {
3161 crate::ast::VarSegment::Field(f) => {
3162 if i > 0 {
3163 name.push('.');
3164 }
3165 name.push_str(f);
3166 }
3167 crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3168 crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3169 crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3170 crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3171 "[{}:{}]",
3172 a.map(|n| n.to_string()).unwrap_or_default(),
3173 b.map(|n| n.to_string()).unwrap_or_default()
3174 )),
3175 }
3176 }
3177 format!("${{{}}}", name)
3178 }
3179 Expr::Interpolated(_) => "\"...\"".to_string(),
3180 Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3181 _ => "...".to_string(),
3182 }
3183 }
3184
3185 /// Execute a single command.
3186 async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3187 self.execute_command_depth(name, args, 0).await
3188 }
3189
3190 async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3191 // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3192 // most-recursed function on the ring, so wrapping its future in
3193 // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3194 // every level (GH #48, item 3). A `trace!` event records the command name
3195 // without living in the future.
3196 tracing::trace!(command = %name, alias_depth, "dispatch");
3197 // Special built-ins. `SpecialForm::from_name` is the single source of
3198 // truth (shared with `classify_command` via `is_runtime_special_form`),
3199 // and this match on the enum is *exhaustive* — adding a special-form is a
3200 // compile error until both the name mapping and the behavior here are
3201 // updated. A name that is not a special-form falls through to alias /
3202 // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3203 if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3204 return match form {
3205 crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3206 crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3207 crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3208 };
3209 }
3210
3211 // Alias expansion (with recursion limit)
3212 if alias_depth < 10 {
3213 let alias_value = {
3214 let ctx = self.exec_ctx.read().await;
3215 ctx.aliases.get(name).cloned()
3216 };
3217 if let Some(alias_val) = alias_value {
3218 // Split alias value into command + args
3219 let parts: Vec<&str> = alias_val.split_whitespace().collect();
3220 if let Some((alias_cmd, alias_args)) = parts.split_first() {
3221 let mut new_args: Vec<Arg> = alias_args
3222 .iter()
3223 .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3224 .collect();
3225 new_args.extend_from_slice(args);
3226 return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3227 }
3228 }
3229 }
3230
3231 // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3232 if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3233 return match self.tools.get(builtin_name) {
3234 Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3235 None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3236 };
3237 }
3238
3239 // Check user-defined tools first
3240 {
3241 let user_tools = self.user_tools.read().await;
3242 if let Some(tool_def) = user_tools.get(name) {
3243 let tool_def = tool_def.clone();
3244 drop(user_tools);
3245 return Box::pin(self.execute_user_tool(tool_def, args)).await;
3246 }
3247 }
3248
3249 // Look up builtin tool
3250 let tool = match self.tools.get(name) {
3251 Some(t) => t,
3252 None => {
3253 // Try executing as .kai script from PATH
3254 if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3255 return Ok(result);
3256 }
3257 // Try executing as external command from PATH — boxed because its
3258 // future is the heaviest branch here (holds a `tokio::process::Command`,
3259 // argv, the child's stdio streams, and kill/reap drop guards); leaving
3260 // it inline fattens every `execute_command_depth` frame on the recursion
3261 // ring even when the command is a builtin.
3262 if let Some(result) = Box::pin(self.try_execute_external(name, args)).await? {
3263 return Ok(result);
3264 }
3265
3266 // Try backend-registered tools (embedder engines, etc.)
3267 // Look up tool schema for positional→named mapping.
3268 // Clone backend and drop read lock before awaiting (may involve network I/O).
3269 // Backend tools expect named JSON params, so enable positional mapping.
3270 let backend = self.exec_ctx.read().await.backend.clone();
3271 let tool_schema = backend
3272 .get_tool(name)
3273 .await
3274 .unwrap_or_else(|e| {
3275 // Schema lookup failing just means positionals won't
3276 // get name-mapped below — `call_tool` is still
3277 // attempted. Trace it so the degradation is visible
3278 // rather than silently swallowed.
3279 tracing::debug!("backend get_tool error for {name}: {e}");
3280 None
3281 })
3282 .map(|t| {
3283 let mut s = t.schema;
3284 // Flat backend/MCP tools expect named JSON params, so map
3285 // bare positionals onto named params. Subcommand-aware tools
3286 // route positionals through the subcommand path and declare
3287 // map_positionals per leaf (kj keeps it false so it re-parses
3288 // the argv with its own clap) — don't blanket-override them.
3289 if s.subcommands.is_empty() {
3290 s.map_positionals = true;
3291 }
3292 s
3293 });
3294 let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3295 let mut ctx = self.exec_ctx.write().await;
3296 {
3297 let scope = self.scope.read().await;
3298 ctx.scope = scope.clone();
3299 }
3300 let backend = ctx.backend.clone();
3301 match backend.call_tool(name, tool_args, &mut *ctx).await {
3302 Ok(tool_result) => {
3303 let mut scope = self.scope.write().await;
3304 *scope = ctx.scope.clone();
3305 // Preserve every field (data/content_type/baggage,
3306 // not just stdout text) — this is the embedder seam:
3307 // `x=$(embedder_tool)` and structured iteration over
3308 // its result depend on `.data` surviving the crossing
3309 // back into the kernel.
3310 return Ok(ExecResult::from(tool_result));
3311 }
3312 Err(BackendError::ToolNotFound(_)) => {
3313 // The backend confirms no such tool exists — fall
3314 // through to "command not found" below.
3315 }
3316 Err(e) => {
3317 // The tool was found (dispatch reached real
3318 // execution) but running it failed — a genuine
3319 // execution error, not "command not found". Surface
3320 // it loudly instead of masking it as exit-127.
3321 return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3322 }
3323 }
3324
3325 return Ok(ExecResult::failure(127, format!("command not found: {}", name)));
3326 }
3327 };
3328
3329 // Build arguments (async to support command substitution, schema-aware
3330 // for flag values), then decide `--help` and `owns_output` — all three
3331 // read the tool's schema and nothing after this block does, so the whole
3332 // schema borrow is scoped here and cannot ride the `tool.execute` await
3333 // below (GH #48, item 7).
3334 let (tool_args, wants_help, owns_output) = {
3335 // Prefer the kernel's schema catalog over `tool.schema()`: for a
3336 // clap-derived builtin, `schema()` rebuilds the entire clap
3337 // `Command` and reflects it into a fresh `ToolSchema` — ~34
3338 // allocations per command, 18% of all allocations in the GH #48
3339 // many-small-commands profile — to produce exactly what the catalog
3340 // already holds. The catalog is seeded from this same registry in
3341 // `Kernel::assemble` and is name-sorted, so this is a binary search
3342 // with no allocation at all. `owned` covers a tool the catalog
3343 // doesn't list (registered after assembly, or whose schema name
3344 // differs from its dispatch name): the fallback calls the same
3345 // `schema()` and is equivalent, just not free.
3346 let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3347 let owned;
3348 let schema: &crate::tools::ToolSchema =
3349 match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3350 Ok(i) => &catalog[i],
3351 Err(_) => {
3352 owned = tool.schema();
3353 &owned
3354 }
3355 };
3356
3357 let tool_args = self.build_args_async(args, Some(schema)).await?;
3358
3359 // --help / -h: show the generic whole-tool help, unless either the tool's
3360 // root schema claims that flag OR the tool owns its output. Owned-output
3361 // tools re-parse their own argv and route their own `--help` — including
3362 // leaf/subcommand help — through their internal (clap) parser, so the root
3363 // schema can't express "this leaf claims help" and intercepting here would
3364 // render top-level help and return before `execute()` ever sees the
3365 // request (#51). Pass it through and let the tool render its own help.
3366 let schema_claims = |flag: &str| -> bool {
3367 let bare = flag.trim_start_matches('-');
3368 schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3369 };
3370 let wants_help = !schema.owns_output
3371 && ((tool_args.flags.contains("help") && !schema_claims("help"))
3372 || (tool_args.flags.contains("h") && !schema_claims("-h")));
3373
3374 (tool_args, wants_help, schema.owns_output)
3375 };
3376
3377 if wants_help {
3378 let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3379 let ctx = self.exec_ctx.read().await;
3380 let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3381 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3382 }
3383
3384 // Snapshot exec_ctx into a local context and release the write lock
3385 // before calling tool.execute. Holding the write across tool execution
3386 // would deadlock any builtin that re-dispatches through ctx.dispatcher
3387 // (timeout, scatter) — the inner dispatch_command needs its own
3388 // exec_ctx.write() and would block forever.
3389 let mut ctx = {
3390 let ec = self.exec_ctx.write().await;
3391 let scope = self.scope.read().await;
3392 // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3393 // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3394 // child token — e.g. timeout's — reaches the spawned external via
3395 // wait_or_kill; it falls back to the kernel's own token on a
3396 // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3397 self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3398 }; // both locks released — tool.execute can re-dispatch safely
3399
3400 // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3401 // semantics): take() so a later dispatch doesn't see stale stdin.
3402 // Done after the snapshot above so we hold the write briefly.
3403 {
3404 let mut ec = self.exec_ctx.write().await;
3405 ctx.stdin = ec.stdin.take();
3406 ctx.stdin_data = ec.stdin_data.take();
3407 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3408 ctx.pipe_stdin = ec.pipe_stdin.take();
3409 ctx.pipe_stdout = ec.pipe_stdout.take();
3410 // Same take-don't-clone discipline as stdin, and for the same
3411 // reason: these belong to exactly one dispatch, and a copy left
3412 // behind would let the next command adopt it.
3413 }
3414
3415 // Honor --json before the builtin runs so its setting survives a clap
3416 // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3417 // --json on the floor when `try_parse_from` returns Err early).
3418 // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3419 GlobalFlags::apply_from_args(&tool_args, &mut *ctx);
3420
3421 let result = tool.execute(tool_args, &mut *ctx).await;
3422
3423 // Sync mutations back. Tools may have changed scope (set/cd),
3424 // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3425 // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3426 // hands them back to the pipeline runner — the runner uses
3427 // stage_ctx.pipe_stdout to write the result to the next stage when
3428 // the tool itself didn't take and write to it.
3429 {
3430 let mut scope = self.scope.write().await;
3431 *scope = ctx.scope.clone();
3432 }
3433 {
3434 let mut ec = self.exec_ctx.write().await;
3435 ec.cwd = ctx.cwd;
3436 ec.prev_cwd = ctx.prev_cwd;
3437 ec.aliases = ctx.aliases;
3438 // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3439 // mutate the runtime output limit; without this sync the change is
3440 // dropped here and never reaches dispatch_command's read-back, so
3441 // it would not survive past the current statement.
3442 ec.output_limit = ctx.output_limit.clone();
3443 // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3444 // was missing from this sync, so every runtime ignore mutation
3445 // silently died at the end of its own statement — including the
3446 // documented `kaish-ignore add .gitignore` rc-file recipe.
3447 ec.ignore_config = ctx.ignore_config.clone();
3448 ec.pipe_stdin = ctx.pipe_stdin.take();
3449 ec.pipe_stdout = ctx.pipe_stdout.take();
3450 // What a partial read left behind goes back too: `read` takes one
3451 // line and keeps the rest, and that remainder belongs to the next
3452 // reader. Without this it dies with the tool's context and
3453 // `read x; read y` loses the second line.
3454 ec.stdin = ctx.stdin.take();
3455 }
3456
3457 // Builtins parse --json via the GlobalFlags flatten in their clap
3458 // struct and write ctx.output_format. The kernel applies it — unless the
3459 // tool owns its own output (renders --json itself), in which case we
3460 // leave its bytes untouched.
3461 let result = finalize_output(result, ctx.output_format, owns_output);
3462
3463 Ok(result)
3464 }
3465
3466 /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3467 /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3468 /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3469 /// unexpanded rather than leaking the host home directory.
3470 async fn scope_home(&self) -> Option<String> {
3471 match self.scope.read().await.get("HOME") {
3472 Some(Value::String(s)) => Some(s.clone()),
3473 _ => None,
3474 }
3475 }
3476
3477 /// Build tool arguments from AST args.
3478 ///
3479 /// Uses async evaluation to support command substitution in arguments.
3480 /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3481 /// now only supplies the evaluator — `self` implements `ArgValueSource`
3482 /// against the kernel's own session state (full recursion through the
3483 /// async pipeline, real glob expansion, tilde expansion). Before this,
3484 /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3485 /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3486 /// scatter/gather's own option parsing and the `#[cfg(test)]`
3487 /// `BackendDispatcher`) that could — and did — drift from this method,
3488 /// the same drift-class GH #133 fixed for the external-command spawn
3489 /// sites. Now both paths call the one `bind_tool_args` core, differing
3490 /// only in which `ArgValueSource` they hand it.
3491 async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3492 bind_tool_args(args, schema, self).await
3493 }
3494
3495 /// Build arguments as flat string list for external commands.
3496 ///
3497 /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3498 /// this preserves the original flag format as strings for external commands:
3499 /// - `-l` stays as `-l`
3500 /// - `--verbose` stays as `--verbose`
3501 /// - `key=value` stays as `key=value`
3502 ///
3503 /// This is what external commands expect in their argv.
3504 #[cfg(feature = "subprocess")]
3505 async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3506 let mut argv = Vec::new();
3507 let home = self.scope_home().await;
3508 for arg in args {
3509 match arg {
3510 Arg::Positional(expr) => {
3511 // Glob expansion for external commands
3512 if let Expr::GlobPattern(pattern) = expr {
3513 let glob_enabled = {
3514 let scope = self.scope.read().await;
3515 scope.glob_enabled()
3516 };
3517 if glob_enabled {
3518 let (paths, cwd) = {
3519 let ctx = self.exec_ctx.read().await;
3520 let paths = ctx.expand_glob(pattern).await
3521 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3522 let cwd = ctx.resolve_path(".");
3523 (paths, cwd)
3524 };
3525 if paths.is_empty() {
3526 return Err(anyhow::anyhow!("no matches: {}", pattern));
3527 }
3528 for path in paths {
3529 let display = if !pattern.starts_with('/') {
3530 path.strip_prefix(&cwd)
3531 .unwrap_or(&path)
3532 .to_string_lossy().into_owned()
3533 } else {
3534 path.to_string_lossy().into_owned()
3535 };
3536 argv.push(display);
3537 }
3538 continue;
3539 }
3540 }
3541 let value = self.eval_expr_async(expr).await?;
3542 // Decision D: a bare collection can't cross the external
3543 // process boundary as an argv element — refuse rather than
3544 // silently JSON-serializing it. A quoted `"$x"` already
3545 // reduced to a `Value::String` above (via `Expr::Interpolated`),
3546 // so only a live, un-interpolated `$x` trips this.
3547 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3548 return Err(anyhow::anyhow!(msg));
3549 }
3550 let value = apply_tilde_expansion(value, home.as_deref());
3551 // External-command argv is a text sink: a bare `$BIN` binary
3552 // word goes loud, never the `[binary: N bytes]` placeholder.
3553 argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3554 }
3555 Arg::Named { key, value } => {
3556 let val = self.eval_expr_async(value).await?;
3557 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3558 return Err(anyhow::anyhow!(msg));
3559 }
3560 let val = apply_tilde_expansion(val, home.as_deref());
3561 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3562 argv.push(format!("--{key}={val_str}"));
3563 }
3564 Arg::WordAssign { key, value } => {
3565 let val = self.eval_expr_async(value).await?;
3566 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3567 return Err(anyhow::anyhow!(msg));
3568 }
3569 let val = apply_tilde_expansion(val, home.as_deref());
3570 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3571 argv.push(format!("{key}={val_str}"));
3572 }
3573 Arg::ShortFlag(name) => {
3574 // Preserve original format: -l, -la (combined flags)
3575 argv.push(format!("-{}", name));
3576 }
3577 Arg::LongFlag(name) => {
3578 // Preserve original format: --verbose
3579 argv.push(format!("--{}", name));
3580 }
3581 Arg::DoubleDash => {
3582 // Preserve the -- marker
3583 argv.push("--".to_string());
3584 }
3585 }
3586 }
3587 Ok(argv)
3588 }
3589
3590 /// Async expression evaluator that supports command substitution.
3591 ///
3592 /// This is used for contexts where expressions may contain `$(...)` command
3593 /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3594 fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3595 Box::pin(async move {
3596 match expr {
3597 Expr::Literal(value) => Ok(value.clone()),
3598 Expr::VarRef(path) => {
3599 let scope = self.scope.read().await;
3600 match scope.resolve_path(path) {
3601 Ok(v) => Ok(v),
3602 Err(PathError::UndefinedRoot(_)) => {
3603 Err(anyhow::anyhow!("undefined variable"))
3604 }
3605 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3606 Err(anyhow::anyhow!(msg))
3607 }
3608 }
3609 }
3610 Expr::Interpolated(parts) => {
3611 let mut result = String::new();
3612 for part in parts {
3613 result.push_str(&self.eval_string_part_async(part).await?);
3614 }
3615 Ok(Value::String(result))
3616 }
3617 Expr::HereDocBody { parts, strip_tabs } => {
3618 // Assemble part-by-part so `<<-` tab stripping applies to the
3619 // literal source, not to tabs from a `$var` value (bash strips
3620 // source-line tabs before parameter expansion).
3621 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
3622 for sp in parts {
3623 match &sp.part {
3624 StringPart::Literal(s) => asm.push_literal(s),
3625 other => {
3626 asm.push_interpolated(&self.eval_string_part_async(other).await?)
3627 }
3628 }
3629 }
3630 Ok(Value::String(asm.into_string()))
3631 }
3632 Expr::BinaryOp { left, op, right } => match op {
3633 BinaryOp::And => {
3634 let left_val = self.eval_expr_async(left).await?;
3635 if !is_truthy(&left_val) {
3636 return Ok(left_val);
3637 }
3638 self.eval_expr_async(right).await
3639 }
3640 BinaryOp::Or => {
3641 let left_val = self.eval_expr_async(left).await?;
3642 if is_truthy(&left_val) {
3643 return Ok(left_val);
3644 }
3645 self.eval_expr_async(right).await
3646 }
3647 },
3648 Expr::CommandSubst(stmts) => {
3649 // Snapshot scope, cwd, and session config before running —
3650 // only output escapes, not side effects like `cd`, variable
3651 // assignments, or config mutations (`kaish-ignore`,
3652 // `kaish-output-limit`, `alias`/`unalias`) — matching how
3653 // every other execution context (background forks, scatter
3654 // workers) already isolates mutations (GH #139).
3655 // Boxed: this ~470 B scope snapshot is held across the nested
3656 // `$(…)` recursion await below, so inlining it grows every
3657 // command-substitution level's future (GH #48, item 4).
3658 let saved_scope = Box::new(self.scope.read().await.clone());
3659 let saved_ec = {
3660 let ec = self.exec_ctx.read().await;
3661 (
3662 ec.cwd.clone(),
3663 ec.prev_cwd.clone(),
3664 ec.aliases.clone(),
3665 ec.ignore_config.clone(),
3666 ec.output_limit.clone(),
3667 )
3668 };
3669
3670 // Capture result without `?` — restore state unconditionally
3671 let run_result = self.execute_block_capturing(stmts).await;
3672
3673 // Restore scope and cwd regardless of success/failure
3674 {
3675 let mut scope = self.scope.write().await;
3676 *scope = *saved_scope;
3677 if let Ok(ref r) = run_result {
3678 scope.set_last_result(r.clone());
3679 }
3680 }
3681 {
3682 let mut ec = self.exec_ctx.write().await;
3683 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
3684 ec.cwd = cwd;
3685 ec.prev_cwd = prev_cwd;
3686 ec.aliases = aliases;
3687 ec.ignore_config = ignore_config;
3688 ec.output_limit = output_limit;
3689 }
3690
3691 // Now propagate the error
3692 let result = run_result?;
3693
3694 // A held body stops the enclosing statement before its
3695 // missing output is used (spec §I.5) — the request rides up
3696 // as a typed error the statement loop converts back into a
3697 // held result, and is stashed for the boundary in case an
3698 // intermediate catch stringifies the error.
3699
3700 // A binary result is preserved as bytes — never lossy-decoded to
3701 // a string. No trailing-newline trim (every byte is significant).
3702 if let Some(bytes) = result.out_bytes() {
3703 Ok(Value::Bytes(bytes.to_vec()))
3704 // Prefer structured data (enables `for i in $(cmd)` iteration)
3705 } else if let Some(data) = &result.data {
3706 Ok(data.clone())
3707 } else if let Some(output) = result.output() {
3708 // Flat non-text node lists (glob, ls, tree) → iterable array
3709 if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
3710 let items: Vec<serde_json::Value> = output.root.iter()
3711 .map(|n| serde_json::Value::String(n.display_name().to_string()))
3712 .collect();
3713 Ok(Value::Json(serde_json::Value::Array(items)))
3714 } else {
3715 // Strip trailing newlines only (POSIX command-subst),
3716 // not all trailing whitespace — spaces/tabs are
3717 // significant. Use the exact same trim as the quoted
3718 // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
3719 // `trim_end_matches('\n')`) so bare and quoted command
3720 // substitution agree.
3721 Ok(Value::String(
3722 result.text_out().trim_end_matches('\n').to_string(),
3723 ))
3724 }
3725 } else {
3726 // Otherwise return stdout as single string (NO implicit splitting)
3727 Ok(Value::String(
3728 result.text_out().trim_end_matches('\n').to_string(),
3729 ))
3730 }
3731 }
3732 Expr::Test(test_expr) => {
3733 Ok(Value::Bool(self.eval_test_async(test_expr).await?))
3734 }
3735 Expr::Positional(n) => {
3736 let scope = self.scope.read().await;
3737 match scope.get_positional(*n) {
3738 Some(s) => Ok(Value::String(s.to_string())),
3739 None => Ok(Value::String(String::new())),
3740 }
3741 }
3742 Expr::AllArgs => {
3743 let scope = self.scope.read().await;
3744 Ok(Value::String(scope.all_args().join(" ")))
3745 }
3746 Expr::ArgCount => {
3747 let scope = self.scope.read().await;
3748 Ok(Value::Int(scope.arg_count() as i64))
3749 }
3750 Expr::VarLength(path) => {
3751 let scope = self.scope.read().await;
3752 crate::interpreter::resolve_length(&scope, path)
3753 .map(Value::Int)
3754 .map_err(|msg| anyhow::anyhow!(msg))
3755 }
3756 Expr::VarWithDefault { path, default } => {
3757 // Resolve inside a scoped guard so the lock is released before the
3758 // recursive default evaluation.
3759 let resolved = {
3760 let scope = self.scope.read().await;
3761 crate::interpreter::resolve_default(&scope, path)
3762 .map_err(|msg| anyhow::anyhow!(msg))?
3763 };
3764 match resolved {
3765 Some(value) => Ok(value),
3766 None => self.eval_string_parts_async(default).await.map(Value::String),
3767 }
3768 }
3769 Expr::Arithmetic(expr_str) => {
3770 let scope = self.scope.read().await;
3771 crate::arithmetic::eval_arithmetic(expr_str, &scope)
3772 .map(Value::Int)
3773 .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
3774 }
3775 Expr::Command(cmd) => {
3776 // Execute command and return boolean based on exit code
3777 let result = self.execute_command(&cmd.name, &cmd.args).await?;
3778 Ok(Value::Bool(result.code == 0))
3779 }
3780 Expr::LastExitCode => {
3781 let scope = self.scope.read().await;
3782 Ok(Value::Int(scope.last_result().code))
3783 }
3784 Expr::CurrentPid => {
3785 let scope = self.scope.read().await;
3786 Ok(Value::Int(scope.pid() as i64))
3787 }
3788 Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
3789 Expr::ListLiteral(elems) => {
3790 // Spread must itself be a list — a scalar/record spread is a
3791 // loud error, never silently coerced or dropped (mirrors the
3792 // sync `Evaluator::eval_list_literal`; wording shared via
3793 // `spread_non_list_message` so the two paths can't diverge).
3794 let mut out = Vec::with_capacity(elems.len());
3795 for elem in elems {
3796 match elem {
3797 ListElem::Item(e) => {
3798 let value = self.eval_expr_async(e).await?;
3799 out.push(crate::interpreter::value_to_json(&value));
3800 }
3801 ListElem::Spread(e) => {
3802 let value = self.eval_expr_async(e).await?;
3803 match value {
3804 Value::Json(serde_json::Value::Array(items)) => out.extend(items),
3805 other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
3806 }
3807 }
3808 }
3809 }
3810 Ok(Value::Json(serde_json::Value::Array(out)))
3811 }
3812 Expr::RecordLiteral(entries) => {
3813 // Insertion order preserved (workspace serde_json has
3814 // `preserve_order`); a duplicate key keeps the last value
3815 // written, matching plain map-insert semantics.
3816 let mut map = serde_json::Map::new();
3817 for entry in entries {
3818 let key = match &entry.key {
3819 RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
3820 // `{"$k": v}` resolves like any double-quoted string
3821 // (used to silently create a literal "$k" key).
3822 RecordKey::Interpolated(parts) => {
3823 self.eval_string_parts_async(parts).await?
3824 }
3825 };
3826 let value = self.eval_expr_async(&entry.value).await?;
3827 map.insert(key, crate::interpreter::value_to_json(&value));
3828 }
3829 Ok(Value::Json(serde_json::Value::Object(map)))
3830 }
3831 }
3832 })
3833 }
3834
3835 /// Async helper to evaluate multiple StringParts into a single string.
3836 fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3837 Box::pin(async move {
3838 let mut result = String::new();
3839 for part in parts {
3840 result.push_str(&self.eval_string_part_async(part).await?);
3841 }
3842 Ok(result)
3843 })
3844 }
3845
3846 /// Async helper to evaluate a StringPart.
3847 /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
3848 /// through the VFS backend instead of using raw `std::path`.
3849 fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
3850 Box::pin(async move {
3851 match test_expr {
3852 TestExpr::FileTest { op, path } => {
3853 let path_value = self.eval_expr_async(path).await?;
3854 // Expand `~` against the session HOME before stat'ing, the
3855 // same way argv positionals do — otherwise `[[ -f ~/x ]]`
3856 // stats the literal `~/x` and is always false.
3857 let home = self.scope_home().await;
3858 let path_value = apply_tilde_expansion(path_value, home.as_deref());
3859 // A binary `[[ -f $bin ]]` operand goes loud rather than
3860 // silently stat'ing a file literally named
3861 // `[binary: N bytes]` (the same path-positional guard
3862 // builtins like `stat`/`cp` use).
3863 let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
3864 .map_err(|e| anyhow::anyhow!("{e}"))?;
3865 // Resolve against the *session* cwd, not the process cwd, so a
3866 // relative `[[ -f rel ]]` honors `cd` and agrees with the
3867 // VFS-aware `test` builtin (GH #101). Backend stats a raw
3868 // relative path against the process cwd otherwise.
3869 let (resolved, backend) = {
3870 let ctx = self.exec_ctx.read().await;
3871 (ctx.resolve_path(&path_str), ctx.backend.clone())
3872 };
3873 let entry = backend.stat(&resolved).await.ok();
3874 Ok(match op {
3875 FileTestOp::Exists => entry.is_some(),
3876 FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
3877 FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
3878 FileTestOp::Readable => entry.is_some(),
3879 FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
3880 e.permissions.is_none_or(|p| p & 0o222 != 0)
3881 }),
3882 FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
3883 e.permissions.is_some_and(|p| p & 0o111 != 0)
3884 }),
3885 })
3886 }
3887 TestExpr::StringTest { op, value } => match op {
3888 crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
3889 let val = self.eval_expr_async(value).await?;
3890 // Decision E: a collection operand is a loud Shape error
3891 // here too — must not diverge from the sync path in
3892 // interpreter/eval.rs (shared `scalar_test_operand_error`).
3893 let symbol = match op {
3894 crate::ast::StringTestOp::IsEmpty => "-z",
3895 crate::ast::StringTestOp::IsNonEmpty => "-n",
3896 crate::ast::StringTestOp::IsList
3897 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3898 };
3899 if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
3900 anyhow::bail!(msg);
3901 }
3902 let s = value_to_string(&val);
3903 Ok(match op {
3904 crate::ast::StringTestOp::IsEmpty => s.is_empty(),
3905 crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
3906 crate::ast::StringTestOp::IsList
3907 | crate::ast::StringTestOp::IsRecord => unreachable!(),
3908 })
3909 }
3910 // Shape guard: propagates eval errors like -z/-n (a bare
3911 // `$unset` is an undefined-variable error, not a silent
3912 // false). A defined-but-wrong-shaped value is false. Must
3913 // not diverge from the sync path in interpreter/eval.rs.
3914 crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
3915 let val = self.eval_expr_async(value).await?;
3916 Ok(op.matches_shape(&val))
3917 }
3918 },
3919 TestExpr::Comparison { left, op, right } => {
3920 // Evaluate operands async (handles $(cmd)), then compare sync
3921 let left_val = self.eval_expr_async(left).await?;
3922 let right_val = self.eval_expr_async(right).await?;
3923 let resolved = TestExpr::Comparison {
3924 left: Box::new(Expr::Literal(left_val)),
3925 op: *op,
3926 right: Box::new(Expr::Literal(right_val)),
3927 };
3928 let expr = Expr::Test(Box::new(resolved));
3929 let mut scope = self.scope.write().await;
3930 let value = eval_expr(&expr, &mut scope)
3931 .map_err(|e| anyhow::anyhow!("{}", e))?;
3932 Ok(value_to_bool(&value))
3933 }
3934 TestExpr::And { left, right } => {
3935 if !self.eval_test_async(left).await? {
3936 Ok(false)
3937 } else {
3938 self.eval_test_async(right).await
3939 }
3940 }
3941 TestExpr::Or { left, right } => {
3942 if self.eval_test_async(left).await? {
3943 Ok(true)
3944 } else {
3945 self.eval_test_async(right).await
3946 }
3947 }
3948 TestExpr::Not { expr } => {
3949 Ok(!self.eval_test_async(expr).await?)
3950 }
3951 TestExpr::In { left, right } => {
3952 let left_val = self.eval_expr_async(left).await?;
3953 let right_val = self.eval_expr_async(right).await?;
3954 let resolved = TestExpr::In {
3955 left: Box::new(Expr::Literal(left_val)),
3956 right: Box::new(Expr::Literal(right_val)),
3957 };
3958 let expr = Expr::Test(Box::new(resolved));
3959 let mut scope = self.scope.write().await;
3960 let value = eval_expr(&expr, &mut scope)
3961 .map_err(|e| anyhow::anyhow!("{}", e))?;
3962 Ok(value_to_bool(&value))
3963 }
3964 TestExpr::NotIn { left, right } => {
3965 let left_val = self.eval_expr_async(left).await?;
3966 let right_val = self.eval_expr_async(right).await?;
3967 let resolved = TestExpr::NotIn {
3968 left: Box::new(Expr::Literal(left_val)),
3969 right: Box::new(Expr::Literal(right_val)),
3970 };
3971 let expr = Expr::Test(Box::new(resolved));
3972 let mut scope = self.scope.write().await;
3973 let value = eval_expr(&expr, &mut scope)
3974 .map_err(|e| anyhow::anyhow!("{}", e))?;
3975 Ok(value_to_bool(&value))
3976 }
3977 }
3978 })
3979 }
3980
3981 fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
3982 Box::pin(async move {
3983 match part {
3984 StringPart::Literal(s) => Ok(s.clone()),
3985 StringPart::Var(path) => {
3986 let scope = self.scope.read().await;
3987 match scope.resolve_path(path) {
3988 // Text sink: binary goes loud, never the placeholder —
3989 // a `b=$(cat blob)` capture holds real bytes; splicing
3990 // `[binary: N bytes]` into "$b" would be silent loss.
3991 Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
3992 // Unset vars expand to empty; loud path errors surface.
3993 Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
3994 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
3995 Err(anyhow::anyhow!(msg))
3996 }
3997 }
3998 }
3999 StringPart::VarWithDefault { path, default } => {
4000 let resolved = {
4001 let scope = self.scope.read().await;
4002 crate::interpreter::resolve_default(&scope, path)
4003 .map_err(|msg| anyhow::anyhow!(msg))?
4004 };
4005 match resolved {
4006 Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4007 None => self.eval_string_parts_async(default).await,
4008 }
4009 }
4010 StringPart::VarLength(path) => {
4011 let scope = self.scope.read().await;
4012 crate::interpreter::resolve_length(&scope, path)
4013 .map(|n| n.to_string())
4014 .map_err(|msg| anyhow::anyhow!(msg))
4015 }
4016 StringPart::Positional(n) => {
4017 let scope = self.scope.read().await;
4018 match scope.get_positional(*n) {
4019 Some(s) => Ok(s.to_string()),
4020 None => Ok(String::new()),
4021 }
4022 }
4023 StringPart::AllArgs => {
4024 let scope = self.scope.read().await;
4025 Ok(scope.all_args().join(" "))
4026 }
4027 StringPart::ArgCount => {
4028 let scope = self.scope.read().await;
4029 Ok(scope.arg_count().to_string())
4030 }
4031 StringPart::Arithmetic(expr) => {
4032 // Loud on purpose (GH #183): this used to be `Err(_) =>
4033 // Ok(String::new())`, silently splicing in "" for e.g.
4034 // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
4035 // at exit 0 instead of failing. Matches the bare (non-string)
4036 // `Expr::Arithmetic` arm above, which already propagates.
4037 let scope = self.scope.read().await;
4038 crate::arithmetic::eval_arithmetic(expr, &scope)
4039 .map(|value| value.to_string())
4040 .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
4041 }
4042 StringPart::CommandSubst(stmts) => {
4043 // Snapshot scope, cwd, and session config — command
4044 // substitution in strings must not leak side effects (e.g.,
4045 // `"dir: $(cd /; pwd)"` must not change cwd, and
4046 // `"$(kaish-ignore clear)"` must not change the session's
4047 // ignore config) — matching how every other execution
4048 // context (background forks, scatter workers) already
4049 // isolates mutations (GH #139).
4050 // Boxed: this ~470 B scope snapshot is held across the nested
4051 // `$(…)` recursion await below, so inlining it grows every
4052 // command-substitution level's future (GH #48, item 4).
4053 let saved_scope = Box::new(self.scope.read().await.clone());
4054 let saved_ec = {
4055 let ec = self.exec_ctx.read().await;
4056 (
4057 ec.cwd.clone(),
4058 ec.prev_cwd.clone(),
4059 ec.aliases.clone(),
4060 ec.ignore_config.clone(),
4061 ec.output_limit.clone(),
4062 )
4063 };
4064
4065 // Capture result without `?` — restore state unconditionally
4066 let run_result = self.execute_block_capturing(stmts).await;
4067
4068 // Restore scope and cwd regardless of success/failure
4069 {
4070 let mut scope = self.scope.write().await;
4071 *scope = *saved_scope;
4072 if let Ok(ref r) = run_result {
4073 scope.set_last_result(r.clone());
4074 }
4075 }
4076 {
4077 let mut ec = self.exec_ctx.write().await;
4078 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4079 ec.cwd = cwd;
4080 ec.prev_cwd = prev_cwd;
4081 ec.aliases = aliases;
4082 ec.ignore_config = ignore_config;
4083 ec.output_limit = output_limit;
4084 }
4085
4086 // Now propagate the error
4087 let result = run_result?;
4088
4089 // A held body stops the enclosing statement before its
4090 // missing output is spliced in (spec §I.5) — same conversion
4091 // and stash as the bare `$(…)` arm.
4092
4093 // Embedding binary into a string is a text context: fail loud
4094 // rather than splice in U+FFFD garbage.
4095 match result.try_text_out() {
4096 // Text wins when present — unchanged behavior.
4097 Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4098 // `.out` is empty: a builtin/tool that set only structured
4099 // `.data` must not silently evaporate to "" (SILENT DATA
4100 // LOSS). Render it the same way a bare `"$x"`
4101 // collection-valued variable renders — compact JSON for
4102 // lists/records, plain form for scalars — by reusing
4103 // `value_to_string` (the exact `StringPart::Var` helper
4104 // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4105 // identically. No trailing-newline trim here: that's a
4106 // text-path artifact, not applicable to a freshly
4107 // rendered JSON/scalar string.
4108 Ok(_) => match &result.data {
4109 Some(data) => Ok(value_to_string(data)),
4110 None => Ok(String::new()),
4111 },
4112 Err(e) => anyhow::bail!(
4113 "command substitution in a string produced binary data ({e}) — \
4114 pipe through base64/xxd"
4115 ),
4116 }
4117 }
4118 StringPart::LastExitCode => {
4119 let scope = self.scope.read().await;
4120 Ok(scope.last_result().code.to_string())
4121 }
4122 StringPart::CurrentPid => {
4123 let scope = self.scope.read().await;
4124 Ok(scope.pid().to_string())
4125 }
4126 }
4127 })
4128 }
4129
4130 /// Update the last result in scope.
4131 async fn update_last_result(&self, result: &ExecResult) {
4132 let mut scope = self.scope.write().await;
4133 scope.set_last_result(result.clone());
4134 }
4135
4136 /// Drain accumulated pipeline stderr into a result.
4137 ///
4138 /// Called after each sub-statement inside control structures (`if`, `for`,
4139 /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4140 /// than batching until the entire structure finishes.
4141 async fn drain_stderr_into(&self, result: &mut ExecResult) {
4142 let drained = {
4143 let mut receiver = self.stderr_receiver.lock().await;
4144 receiver.drain_lossy()
4145 };
4146 if !drained.is_empty() {
4147 if !result.err.is_empty() && !result.err.ends_with('\n') {
4148 result.err.push('\n');
4149 }
4150 result.err.push_str(&drained);
4151 }
4152 }
4153
4154 /// Execute a user-defined function with local variable scoping.
4155 ///
4156 /// Functions push a new scope frame for local variables. Variables declared
4157 /// with `local` are scoped to the function; other assignments modify outer
4158 /// scopes (or create in root if new).
4159 async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4160 let _depth = self.enter_recursion("a shell function")?;
4161
4162 // 1. Build function args from AST args (async to support command substitution)
4163 let tool_args = self.build_args_async(args, None).await?;
4164
4165 // 2. Push a new scope frame for local variables
4166 {
4167 let mut scope = self.scope.write().await;
4168 scope.push_frame();
4169 }
4170
4171 // 3. Save current positional parameters and set new ones for this function
4172 let saved_positional = {
4173 let mut scope = self.scope.write().await;
4174 let saved = scope.save_positional();
4175
4176 // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4177 let positional_args: Vec<String> = tool_args.positional
4178 .iter()
4179 .map(value_to_string)
4180 .collect();
4181 scope.set_positional(&def.name, positional_args);
4182
4183 saved
4184 };
4185
4186 // 3. Execute body statements with control flow handling
4187 // Accumulate output across statements (like sh)
4188 // Accumulate stdout as raw bytes so a binary-producing statement in a
4189 // function body survives instead of being lossy-decoded here.
4190 let mut accumulated_out: Vec<u8> = Vec::new();
4191 let mut accumulated_err = String::new();
4192 let mut last_code = 0i64;
4193 let mut last_data: Option<Value> = None;
4194
4195 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4196 match r.out_bytes() {
4197 Some(b) => buf.extend_from_slice(b),
4198 None => buf.extend_from_slice(r.text_out().as_bytes()),
4199 }
4200 }
4201
4202 // Track execution error for propagation after cleanup
4203 let mut exec_error: Option<anyhow::Error> = None;
4204 let mut exit_code: Option<i64> = None;
4205
4206 for stmt in &def.body {
4207 match self.execute_stmt_flow(stmt).await {
4208 Ok(flow) => {
4209 // Drain pipeline stderr after each sub-statement.
4210 let drained = {
4211 let mut receiver = self.stderr_receiver.lock().await;
4212 receiver.drain_lossy()
4213 };
4214 if !drained.is_empty() {
4215 accumulated_err.push_str(&drained);
4216 }
4217
4218 match flow {
4219 ControlFlow::Normal(r) => {
4220 push_out(&mut accumulated_out, &r);
4221 accumulated_err.push_str(&r.err);
4222 last_code = r.code;
4223 last_data = r.data;
4224 }
4225 ControlFlow::Return { value } => {
4226 push_out(&mut accumulated_out, &value);
4227 accumulated_err.push_str(&value.err);
4228 last_code = value.code;
4229 last_data = value.data;
4230 break;
4231 }
4232 ControlFlow::Exit { code, result: r } => {
4233 push_out(&mut accumulated_out, &r);
4234 accumulated_err.push_str(&r.err);
4235 exit_code = Some(code);
4236 break;
4237 }
4238 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4239 push_out(&mut accumulated_out, &r);
4240 accumulated_err.push_str(&r.err);
4241 last_code = r.code;
4242 last_data = r.data;
4243 }
4244 }
4245 }
4246 Err(e) => {
4247 exec_error = Some(e);
4248 break;
4249 }
4250 }
4251 }
4252
4253 // 4. Pop scope frame and restore original positional parameters (unconditionally)
4254 {
4255 let mut scope = self.scope.write().await;
4256 scope.pop_frame();
4257 scope.set_positional(saved_positional.0, saved_positional.1);
4258 }
4259
4260 // 5. Propagate error or exit after cleanup
4261 if let Some(e) = exec_error {
4262 return Err(e);
4263 }
4264 let code = exit_code.unwrap_or(last_code);
4265 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4266 result.err = accumulated_err;
4267 result.data = last_data;
4268 Ok(result)
4269 }
4270
4271 fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4272 let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4273 let guard = RecursionGuard { counter: &self.recursion_depth };
4274 if depth > MAX_RECURSION_DEPTH {
4275 return Err(anyhow::anyhow!(
4276 "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4277 a runaway or mutually recursive script (deeply nested $(…), or \
4278 functions/scripts that call each other without a base case) was \
4279 stopped before it could overflow the stack"
4280 ));
4281 }
4282 Ok(guard)
4283 }
4284
4285 async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4286 let _depth = self.enter_recursion("command substitution")?;
4287 // Accumulate stdout as raw bytes so a binary-producing statement
4288 // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4289 // caller can preserve it. The final result is text iff valid UTF-8.
4290 let mut accumulated_out: Vec<u8> = Vec::new();
4291 let mut accumulated_err = String::new();
4292 let mut last_code = 0i64;
4293 let mut last_data: Option<Value> = None;
4294
4295 // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4296 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4297 match r.out_bytes() {
4298 Some(b) => buf.extend_from_slice(b),
4299 None => buf.extend_from_slice(r.text_out().as_bytes()),
4300 }
4301 }
4302
4303 for stmt in stmts {
4304 let flow = self.execute_stmt_flow(stmt).await?;
4305
4306 // Drain pipeline stderr after each sub-statement (incremental, like
4307 // the control-structure and function-body executors).
4308 let drained = {
4309 let mut receiver = self.stderr_receiver.lock().await;
4310 receiver.drain_lossy()
4311 };
4312 if !drained.is_empty() {
4313 accumulated_err.push_str(&drained);
4314 }
4315
4316 match flow {
4317 ControlFlow::Normal(r)
4318 | ControlFlow::Break { result: r, .. }
4319 | ControlFlow::Continue { result: r, .. } => {
4320 push_out(&mut accumulated_out, &r);
4321 accumulated_err.push_str(&r.err);
4322 last_code = r.code;
4323 last_data = r.data;
4324 }
4325 ControlFlow::Return { value } => {
4326 push_out(&mut accumulated_out, &value);
4327 accumulated_err.push_str(&value.err);
4328 last_code = value.code;
4329 last_data = value.data;
4330 break;
4331 }
4332 ControlFlow::Exit { code, result: r } => {
4333 push_out(&mut accumulated_out, &r);
4334 accumulated_err.push_str(&r.err);
4335 last_code = code;
4336 break;
4337 }
4338 }
4339 }
4340
4341 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4342 result.err = accumulated_err;
4343 result.data = last_data;
4344 Ok(result)
4345 }
4346
4347 /// Execute the `source` / `.` command to include and run a script.
4348 ///
4349 /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4350 /// allowing the sourced script to set variables and modify shell state.
4351 async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4352 // `source`/`.` is the fourth dynamic re-entry point: it runs the
4353 // sourced file's statements inline via `execute_stmt_flow`, so a file
4354 // that sources itself recurses unbounded just like a runaway function
4355 // (GH #46). It's intercepted as a special form *before* the other
4356 // guarded paths, so it needs its own guard.
4357 let _depth = self.enter_recursion("source")?;
4358
4359 // Get the file path from the first positional argument
4360 let tool_args = self.build_args_async(args, None).await?;
4361 let path = match tool_args.positional.first() {
4362 Some(Value::String(s)) => s.clone(),
4363 Some(v) => value_to_string(v),
4364 None => {
4365 return Ok(ExecResult::failure(1, "source: missing filename"));
4366 }
4367 };
4368
4369 // Resolve path relative to cwd
4370 let full_path = {
4371 let ctx = self.exec_ctx.read().await;
4372 if path.starts_with('/') {
4373 std::path::PathBuf::from(&path)
4374 } else {
4375 ctx.cwd.join(&path)
4376 }
4377 };
4378
4379 // Read file content via backend
4380 let content = {
4381 let ctx = self.exec_ctx.read().await;
4382 match ctx.backend.read(&full_path, None).await {
4383 Ok(bytes) => {
4384 String::from_utf8(bytes).map_err(|e| {
4385 anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4386 })?
4387 }
4388 Err(e) => {
4389 return Ok(ExecResult::failure(
4390 1,
4391 format!("source: {}: {}", path, e),
4392 ));
4393 }
4394 }
4395 };
4396
4397 // Parse the content
4398 let program = match crate::parser::parse(&content) {
4399 Ok(p) => p,
4400 Err(errors) => {
4401 let msg = errors
4402 .iter()
4403 .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4404 .collect::<Vec<_>>()
4405 .join("\n");
4406 return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4407 }
4408 };
4409
4410 // Execute each statement in the CURRENT scope (not isolated), accumulating
4411 // stdout/stderr across statements like `execute_user_tool` — a sourced
4412 // script's earlier statements must not be silently dropped in favor of
4413 // just the last one.
4414 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4415 match r.out_bytes() {
4416 Some(b) => buf.extend_from_slice(b),
4417 None => buf.extend_from_slice(r.text_out().as_bytes()),
4418 }
4419 }
4420
4421 let mut accumulated_out: Vec<u8> = Vec::new();
4422 let mut accumulated_err = String::new();
4423 let mut last_code = 0i64;
4424 let mut last_data: Option<Value> = None;
4425
4426 for stmt in program.statements {
4427 if matches!(stmt, crate::ast::Stmt::Empty) {
4428 continue;
4429 }
4430
4431 match self.execute_stmt_flow(&stmt).await {
4432 Ok(flow) => {
4433 let drained = {
4434 let mut receiver = self.stderr_receiver.lock().await;
4435 receiver.drain_lossy()
4436 };
4437 if !drained.is_empty() {
4438 accumulated_err.push_str(&drained);
4439 }
4440 match flow {
4441 ControlFlow::Normal(r) => {
4442 push_out(&mut accumulated_out, &r);
4443 accumulated_err.push_str(&r.err);
4444 last_code = r.code;
4445 last_data = r.data.clone();
4446 self.update_last_result(&r).await;
4447 }
4448 ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4449 return Err(anyhow::anyhow!(
4450 "source: {}: unexpected break/continue outside loop",
4451 path
4452 ));
4453 }
4454 ControlFlow::Return { value } => {
4455 push_out(&mut accumulated_out, &value);
4456 accumulated_err.push_str(&value.err);
4457 let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4458 .with_code(value.code);
4459 result.err = accumulated_err;
4460 result.data = value.data;
4461 return Ok(result);
4462 }
4463 ControlFlow::Exit { code, result: r } => {
4464 push_out(&mut accumulated_out, &r);
4465 accumulated_err.push_str(&r.err);
4466 let mut result =
4467 ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4468 result.err = accumulated_err;
4469 result.data = last_data;
4470 return Ok(result);
4471 }
4472 }
4473 }
4474 Err(e) => {
4475 return Err(e.context(format!("source: {}", path)));
4476 }
4477 }
4478 }
4479
4480 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4481 result.err = accumulated_err;
4482 result.data = last_data;
4483 Ok(result)
4484 }
4485
4486 /// Try to execute a script from PATH directories.
4487 ///
4488 /// Searches PATH for `{name}.kai` files and executes them in isolated scope
4489 /// (like user-defined tools). Returns None if no script is found.
4490 async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4491 // Held across the PATH probe *and* body execution: a `.kai` sourcing a
4492 // `.kai` re-enters here, and that nesting is what must be bounded (#46).
4493 // A non-script command pays only a transient, balanced increment during
4494 // the probe before falling through to the external path.
4495 let _depth = self.enter_recursion("a .kai script")?;
4496
4497 // Get PATH from scope (default to "/bin")
4498 let path_value = {
4499 let scope = self.scope.read().await;
4500 scope
4501 .get("PATH")
4502 .map(value_to_string)
4503 .unwrap_or_else(|| "/bin".to_string())
4504 };
4505
4506 // Search PATH directories for script
4507 for dir in path_value.split(':') {
4508 if dir.is_empty() {
4509 continue;
4510 }
4511
4512 // Build script path: {dir}/{name}.kai
4513 let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
4514
4515 // Check if script exists
4516 let exists = {
4517 let ctx = self.exec_ctx.read().await;
4518 ctx.backend.exists(&script_path).await
4519 };
4520
4521 if !exists {
4522 continue;
4523 }
4524
4525 // Read script content
4526 let content = {
4527 let ctx = self.exec_ctx.read().await;
4528 match ctx.backend.read(&script_path, None).await {
4529 Ok(bytes) => match String::from_utf8(bytes) {
4530 Ok(s) => s,
4531 Err(e) => {
4532 return Ok(Some(ExecResult::failure(
4533 1,
4534 format!("{}: invalid UTF-8: {}", script_path.display(), e),
4535 )));
4536 }
4537 },
4538 Err(e) => {
4539 return Ok(Some(ExecResult::failure(
4540 1,
4541 format!("{}: {}", script_path.display(), e),
4542 )));
4543 }
4544 }
4545 };
4546
4547 // Parse the script
4548 let program = match crate::parser::parse(&content) {
4549 Ok(p) => p,
4550 Err(errors) => {
4551 let msg = errors
4552 .iter()
4553 .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
4554 .collect::<Vec<_>>()
4555 .join("\n");
4556 return Ok(Some(ExecResult::failure(1, msg)));
4557 }
4558 };
4559
4560 // Build tool_args from args (async for command substitution support)
4561 let tool_args = self.build_args_async(args, None).await?;
4562
4563 // Create isolated scope (like user tools). The trash rail is NOT
4564 // session state a script may shed: a `.kai` script starting from
4565 // a blank scope would otherwise overwrite and delete without the
4566 // recovery net `set -o trash` promised. Carry it.
4567 let mut isolated_scope = Scope::new();
4568 {
4569 let scope = self.scope.read().await;
4570 isolated_scope.set_pid(scope.pid());
4571 isolated_scope.set_trash_enabled(scope.trash_enabled());
4572 isolated_scope.set_trash_max_size(scope.trash_max_size());
4573 }
4574
4575 // Set up positional parameters ($0 = script name, $1, $2, ... = args)
4576 let positional_args: Vec<String> = tool_args.positional
4577 .iter()
4578 .map(value_to_string)
4579 .collect();
4580 isolated_scope.set_positional(name, positional_args);
4581
4582 // Save current scope and swap with isolated scope
4583 let original_scope = {
4584 let mut scope = self.scope.write().await;
4585 std::mem::replace(&mut *scope, isolated_scope)
4586 };
4587
4588 // Execute script statements — accumulate stdout/stderr across
4589 // statements like `execute_user_tool`, rather than keeping only the
4590 // last one's result.
4591 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4592 match r.out_bytes() {
4593 Some(b) => buf.extend_from_slice(b),
4594 None => buf.extend_from_slice(r.text_out().as_bytes()),
4595 }
4596 }
4597
4598 let mut accumulated_out: Vec<u8> = Vec::new();
4599 let mut accumulated_err = String::new();
4600 let mut last_code = 0i64;
4601 let mut last_data: Option<Value> = None;
4602 let mut exec_error: Option<anyhow::Error> = None;
4603 let mut exit_code: Option<i64> = None;
4604
4605 for stmt in program.statements {
4606 if matches!(stmt, crate::ast::Stmt::Empty) {
4607 continue;
4608 }
4609
4610 match self.execute_stmt_flow(&stmt).await {
4611 Ok(flow) => {
4612 let drained = {
4613 let mut receiver = self.stderr_receiver.lock().await;
4614 receiver.drain_lossy()
4615 };
4616 if !drained.is_empty() {
4617 accumulated_err.push_str(&drained);
4618 }
4619 match flow {
4620 ControlFlow::Normal(r) => {
4621 push_out(&mut accumulated_out, &r);
4622 accumulated_err.push_str(&r.err);
4623 last_code = r.code;
4624 last_data = r.data;
4625 }
4626 ControlFlow::Return { value } => {
4627 push_out(&mut accumulated_out, &value);
4628 accumulated_err.push_str(&value.err);
4629 last_code = value.code;
4630 last_data = value.data;
4631 break;
4632 }
4633 ControlFlow::Exit { code, result: r } => {
4634 push_out(&mut accumulated_out, &r);
4635 accumulated_err.push_str(&r.err);
4636 exit_code = Some(code);
4637 break;
4638 }
4639 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4640 push_out(&mut accumulated_out, &r);
4641 accumulated_err.push_str(&r.err);
4642 last_code = r.code;
4643 last_data = r.data;
4644 }
4645 }
4646 }
4647 Err(e) => {
4648 exec_error = Some(e);
4649 break;
4650 }
4651 }
4652 }
4653
4654 // Restore original scope unconditionally
4655 {
4656 let mut scope = self.scope.write().await;
4657 *scope = original_scope;
4658 }
4659
4660 // Propagate error or exit after cleanup
4661 if let Some(e) = exec_error {
4662 return Err(e.context(format!("script: {}", script_path.display())));
4663 }
4664 let code = exit_code.unwrap_or(last_code);
4665 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4666 result.err = accumulated_err;
4667 result.data = last_data;
4668 return Ok(Some(result));
4669 }
4670
4671 // No script found
4672 Ok(None)
4673 }
4674
4675 /// Try to execute an external command from PATH.
4676 ///
4677 /// This is the fallback when no builtin or user-defined tool matches.
4678 /// External commands receive a clean argv (flags preserved in their original format).
4679 ///
4680 /// # Requirements
4681 /// - Command must be found in PATH
4682 /// - Current working directory must be on a real filesystem (not virtual like /v)
4683 ///
4684 /// # Returns
4685 /// - `Ok(Some(result))` if command was found and executed
4686 /// - `Ok(None)` if command was not found in PATH
4687 /// - `Err` on execution errors
4688 #[cfg(not(feature = "subprocess"))]
4689 async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<Option<ExecResult>> {
4690 Ok(None)
4691 }
4692
4693 /// Try to execute an external command from PATH.
4694 #[cfg(feature = "subprocess")]
4695 #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
4696 async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
4697 // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
4698 // populates from the inbound ctx.cancel on every dispatch. This is
4699 // what makes the `timeout` builtin's swapped child token reach the
4700 // wait_or_kill discipline below — reading `self.cancel_token` would
4701 // give the kernel-wide token and miss the timeout's child cascade.
4702 let cancel = {
4703 let ec = self.exec_ctx.read().await;
4704 ec.cancel.clone()
4705 };
4706 let kill_grace = self.kill_grace;
4707 if !self.allow_external_commands {
4708 return Ok(None);
4709 }
4710
4711 // Get the shell's cwd and its real filesystem location, if any. A
4712 // `None` real path means the cwd is virtual (a CoW overlay, an
4713 // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
4714 // process to run. Don't bail out here: a bare command name that isn't
4715 // in PATH at all is a genuine "not found" regardless of cwd, and the
4716 // virtual-cwd error would blame the wrong thing for that case. Once
4717 // the command actually resolves, `real_cwd` is checked again below
4718 // and the honest reason is given then (issue #181).
4719 let (cwd, real_cwd) = {
4720 let ctx = self.exec_ctx.read().await;
4721 (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
4722 };
4723
4724 let executable = if name.contains('/') {
4725 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
4726 let resolved = if std::path::Path::new(name).is_absolute() {
4727 std::path::PathBuf::from(name)
4728 } else {
4729 match &real_cwd {
4730 Some(real_cwd) => real_cwd.join(name),
4731 // A relative path can't be resolved without a real cwd to
4732 // join against, so we can't even tell whether it would
4733 // exist — name the actual blocker instead of a
4734 // misleading "No such file or directory".
4735 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4736 }
4737 };
4738 if !resolved.exists() {
4739 return Ok(Some(ExecResult::failure(
4740 127,
4741 format!("{}: No such file or directory", name),
4742 )));
4743 }
4744 if !resolved.is_file() {
4745 return Ok(Some(ExecResult::failure(
4746 126,
4747 format!("{}: Is a directory", name),
4748 )));
4749 }
4750 #[cfg(unix)]
4751 {
4752 use std::os::unix::fs::PermissionsExt;
4753 let mode = std::fs::metadata(&resolved)
4754 .map(|m| m.permissions().mode())
4755 .unwrap_or(0);
4756 if mode & 0o111 == 0 {
4757 return Ok(Some(ExecResult::failure(
4758 126,
4759 format!("{}: Permission denied", name),
4760 )));
4761 }
4762 }
4763 resolved.to_string_lossy().into_owned()
4764 } else {
4765 // Get PATH from scope only. The kernel never reads OS env: a
4766 // frontend that wants host PATH seeds it via initial_vars (the REPL
4767 // does, with os_env_vars()). No PATH in scope → nothing resolves.
4768 let path_var = {
4769 let scope = self.scope.read().await;
4770 scope.get("PATH").map(value_to_string).unwrap_or_default()
4771 };
4772
4773 // Resolve command in PATH
4774 match resolve_in_path(name, &path_var) {
4775 Some(path) => path,
4776 None => return Ok(None), // Not found - let caller handle error
4777 }
4778 };
4779
4780 // The executable resolved — found in PATH, or a path that exists and
4781 // is executable — but there's still nowhere to run it without a real
4782 // cwd to spawn the child process in.
4783 let real_cwd = match real_cwd {
4784 Some(p) => p,
4785 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
4786 };
4787
4788 tracing::debug!(executable = %executable, "resolved external command");
4789
4790 // Build flat argv (preserves flag format)
4791 let argv = self.build_args_flat(args).await?;
4792
4793 // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
4794 // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
4795 // byte vector. Take both out under the lock but do NOT drain here — a
4796 // pipe read can block on its producer (a still-running upstream stage),
4797 // so draining before spawn would serialize the pipeline (deadlocking
4798 // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
4799 // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
4800 // are mutually exclusive in practice; prefer the pipe.
4801 let (pipe_stdin, stdin_bytes) = {
4802 let mut ctx = self.exec_ctx.write().await;
4803 (ctx.pipe_stdin.take(), ctx.take_stdin())
4804 };
4805 let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
4806
4807 // Build and spawn the command
4808 use tokio::process::Command;
4809
4810 let mut cmd = Command::new(&executable);
4811 cmd.args(&argv);
4812 cmd.current_dir(&real_cwd);
4813
4814 // Hermetic env: child sees only kaish's exported vars, not the kaish
4815 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
4816 // populate it via KernelConfig::initial_vars at construction.
4817 cmd.env_clear();
4818 {
4819 let scope = self.scope.read().await;
4820 let exported = scope.exported_vars();
4821 // A structured value can't cross the process boundary; refuse rather
4822 // than silently JSON-serialize it into the child's environment.
4823 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
4824 return Err(anyhow::anyhow!(msg));
4825 }
4826 for (var_name, value) in exported {
4827 // Binary can't cross the process boundary as an env var value
4828 // either — loud, not the `[binary: N bytes]` placeholder
4829 // silently exported in its place (kept in sync with
4830 // dispatch.rs::try_external and env.rs::execute_with_env).
4831 let value_str = crate::interpreter::value_to_text_sink_named(
4832 &value,
4833 "an exported environment variable value",
4834 )
4835 .map_err(|e| anyhow::anyhow!("{e}"))?;
4836 cmd.env(var_name, value_str);
4837 }
4838 }
4839
4840 // Handle stdin
4841 cmd.stdin(if has_stdin {
4842 std::process::Stdio::piped()
4843 } else if self.interactive {
4844 std::process::Stdio::inherit()
4845 } else {
4846 std::process::Stdio::null()
4847 });
4848
4849 // In interactive mode, standalone or last-in-pipeline commands inherit
4850 // the terminal's stdout/stderr so output streams in real-time.
4851 // First/middle commands must capture stdout for the pipe — same as bash.
4852 let pipeline_position = {
4853 let ctx = self.exec_ctx.read().await;
4854 ctx.pipeline_position
4855 };
4856 let inherit_output = self.interactive
4857 && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
4858
4859 if inherit_output {
4860 cmd.stdout(std::process::Stdio::inherit());
4861 cmd.stderr(std::process::Stdio::inherit());
4862 } else {
4863 cmd.stdout(std::process::Stdio::piped());
4864 cmd.stderr(std::process::Stdio::piped());
4865 }
4866
4867 // On Unix, always put the child in its own process group so cancellation
4868 // can `killpg` the whole tree (the child plus any grandchildren).
4869 // Restoring default tty-related signal handlers stays gated on
4870 // job-control mode — those only matter when the child has a controlling
4871 // terminal.
4872 #[cfg(unix)]
4873 {
4874 let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
4875 // Read before the fork: the child compares `getppid()` against it to
4876 // catch a parent that died inside the fork/prctl window.
4877 let kill_on_parent_death = {
4878 let ec = self.exec_ctx.read().await;
4879 ec.kill_children_on_parent_death
4880 };
4881 let parent_pid = std::process::id();
4882 // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
4883 // async-signal-safe per POSIX; safe to call between fork and exec.
4884 #[allow(unsafe_code)]
4885 unsafe {
4886 cmd.pre_exec(move || {
4887 // Own process group — for kill scope.
4888 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
4889 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
4890 if kill_on_parent_death {
4891 crate::dispatch::arm_parent_death_signal(parent_pid)?;
4892 }
4893 if restore_jc_signals {
4894 use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
4895 let mut sa: nix::libc::sigaction = std::mem::zeroed();
4896 sa.sa_sigaction = SIG_DFL;
4897 if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
4898 return Err(std::io::Error::last_os_error());
4899 }
4900 if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
4901 return Err(std::io::Error::last_os_error());
4902 }
4903 if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
4904 return Err(std::io::Error::last_os_error());
4905 }
4906 if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
4907 return Err(std::io::Error::last_os_error());
4908 }
4909 }
4910 Ok(())
4911 });
4912 }
4913 }
4914
4915 // Backstop for kill on drop in case our explicit kill path is bypassed
4916 // (panic, early return, etc) on the **capture** wait path. We do NOT
4917 // set this on the JC inherit path: that uses sync `waitpid` outside
4918 // tokio's view of the child, so on drop tokio would try to kill an
4919 // already-reaped (possibly-reused) PID. The JC path has its own
4920 // cancel handling via the side-task watcher.
4921 let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
4922 if !in_jc_inherit_path {
4923 cmd.kill_on_drop(true);
4924 }
4925
4926 // Spawn the process. Capture a `KillTarget` immediately so cancel/
4927 // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
4928 // to this process's generation, immune to PID reuse if the OS reaps
4929 // the child before our kill syscalls fire.
4930 let mut child = match cmd.spawn() {
4931 Ok(child) => child,
4932 Err(e) => {
4933 return Ok(Some(ExecResult::failure(
4934 127,
4935 format!("{}: {}", name, e),
4936 )));
4937 }
4938 };
4939 let kill_target = crate::pidfd::KillTarget::from_child(&child);
4940
4941 // If this external runs on behalf of a background job, record its
4942 // process group on the job so `kill -<sig> %N` can signal the real
4943 // process directly (STOP/CONT/USR1/…, not just terminate). The child
4944 // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
4945 if let Some(job_id) = self.bg_job_id
4946 && let Some(pid) = child.id()
4947 {
4948 self.jobs.add_pgid(job_id, pid).await;
4949 }
4950
4951 // Same seam, for output: a background job's streams outlive this one
4952 // command, so the drain tasks below tee into them and the job closes
4953 // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
4954 // `cargo build &` is still building (GH #240 removed the node rather
4955 // than wire this tee; the tee is the half that was missing).
4956 let job_streams = match self.bg_job_id {
4957 Some(job_id) => self.jobs.streams(job_id).await,
4958 None => None,
4959 };
4960
4961 // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
4962 // detached task (bounded memory, no pre-drain) so an upstream stage and
4963 // this child run concurrently — and a child that never reads stdin (or
4964 // is killed) just breaks the copy, which stops. A buffered byte vector
4965 // is written verbatim (no text detour), so binary stdin survives.
4966 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
4967 child.stdin.take().map(|mut child_stdin| {
4968 // A buffered prefix and a live pipe are one stream, not two
4969 // candidates. After `read x`, the bytes `read` over-read sit in
4970 // the buffer and the rest is still in the pipe; picking the pipe
4971 // and dropping the buffer would silently skip the front of the
4972 // child's input.
4973 let prefix = stdin_bytes;
4974 tokio::spawn(async move {
4975 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4976 if let Some(data) = prefix
4977 && child_stdin.write_all(&data).await.is_err()
4978 {
4979 return; // child closed stdin; dropping it signals EOF
4980 }
4981 let mut buf = [0u8; 8192];
4982 loop {
4983 match pipe_in.read(&mut buf).await {
4984 Ok(0) => break, // EOF
4985 Ok(n) => {
4986 if child_stdin.write_all(&buf[..n]).await.is_err() {
4987 break; // child closed stdin
4988 }
4989 }
4990 Err(_) => break,
4991 }
4992 }
4993 // Dropping child_stdin signals EOF to the child.
4994 })
4995 })
4996 } else if let Some(data) = stdin_bytes {
4997 // Write the buffered bytes from a detached task too — NOT inline.
4998 // An inline write blocks once the stdin pipe fills, and the output
4999 // drain hasn't spawned yet, so a child that emits a lot before
5000 // consuming all its input (every pipe buffer full) deadlocks. A
5001 // write error here is normal, not a failure: a child that closes
5002 // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5003 // signals EOF.
5004 child.stdin.take().map(|mut child_stdin| {
5005 tokio::spawn(async move {
5006 use tokio::io::AsyncWriteExt;
5007 let _ = child_stdin.write_all(&data).await;
5008 })
5009 })
5010 } else {
5011 None
5012 };
5013
5014 // Abort the stdin-copy task on EVERY exit path (the capture path, both
5015 // interactive `inherit_output` returns, and any early error return).
5016 // Once the child is reaped the copy has nothing left to deliver; if it
5017 // were left parked on `pipe_in.read()` it would leak and hold the
5018 // upstream pipe reader open. A drop guard is the single place that
5019 // covers all returns — explicit per-return aborts were error-prone (an
5020 // earlier version missed the two inherit_output returns).
5021 struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5022 impl Drop for AbortStdinCopyOnDrop {
5023 fn drop(&mut self) {
5024 if let Some(t) = self.0.take() {
5025 t.abort();
5026 }
5027 }
5028 }
5029 let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5030
5031 if inherit_output {
5032 // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5033 #[cfg(unix)]
5034 if let Some(ref term) = self.terminal_state {
5035 let child_id = child.id().unwrap_or(0);
5036 let pid = nix::unistd::Pid::from_raw(child_id as i32);
5037 let pgid = pid; // child is its own pgid leader
5038
5039 // Give the terminal to the child's process group
5040 if let Err(e) = term.give_terminal_to(pgid) {
5041 tracing::warn!("failed to give terminal to child: {}", e);
5042 }
5043
5044 let term_clone = term.clone();
5045 let cmd_name = name.to_string();
5046 let cmd_display = format!("{} {}", name, argv.join(" "));
5047 let jobs = self.jobs.clone();
5048
5049 // Side task that watches for cancellation while the blocking
5050 // waitpid runs. On cancel, it SIGTERMs the process group, waits
5051 // the grace period, then SIGKILLs. The blocking waitpid returns
5052 // when the child dies. AbortOnDrop guard cancels the watcher
5053 // on the success path so it doesn't keep running after wait
5054 // returns naturally.
5055 //
5056 // `wait_complete` shrinks the PID-reuse race: the watcher
5057 // checks it before each kill syscall and bails out if
5058 // wait_for_foreground has already reaped the child. This
5059 // doesn't fully eliminate the race (atomic load + kill is
5060 // not atomic with the OS reap+reuse), but narrows the window
5061 // to nanoseconds — enough to be ignorable in practice.
5062 let wait_complete = std::sync::Arc::new(
5063 std::sync::atomic::AtomicBool::new(false)
5064 );
5065 let cancel_watcher = {
5066 let cancel = cancel.clone();
5067 let wc = wait_complete.clone();
5068 // Ownership transfer: the JC path's sync wait inside
5069 // block_in_place owns the child's reaping, so the
5070 // cancel_watcher drives the kill side via KillTarget
5071 // (pidfd-bound on Linux). When kill_target is None
5072 // (older kernel + open failure, or non-Linux), falls
5073 // through to the older PID-based path the closure
5074 // captures from `pid`.
5075 let target = kill_target.as_ref().map(|t| {
5076 // Re-borrow the components we need into Owned-ish form
5077 // so the spawned task is 'static. We can't move
5078 // KillTarget directly because try_execute_external
5079 // still uses it after the spawn — but on the JC path
5080 // there is no further use after the watcher spawn,
5081 // so a clone-of-pid + owned None pidfd is safe.
5082 // Simpler: signal via the existing target by cloning
5083 // a fresh pidfd; the original keeps its handle.
5084 // Pidfd is just an OwnedFd — not Clone — so do it
5085 // by re-opening from the pid. Fall back if reopen
5086 // fails (race already reaped → best-effort kill).
5087 crate::pidfd::KillTarget::from_pid(t.pid())
5088 });
5089 tokio::spawn(async move {
5090 cancel.cancelled().await;
5091 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5092 use nix::sys::signal::Signal;
5093 if let Some(t) = &target {
5094 t.signal(Signal::SIGTERM);
5095 t.signal_pg(Signal::SIGTERM);
5096 } else {
5097 let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5098 let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5099 }
5100 if kill_grace > Duration::ZERO {
5101 tokio::time::sleep(kill_grace).await;
5102 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5103 }
5104 if let Some(t) = &target {
5105 t.signal(Signal::SIGKILL);
5106 t.signal_pg(Signal::SIGKILL);
5107 } else {
5108 let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5109 let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5110 }
5111 })
5112 };
5113 struct AbortOnDrop(tokio::task::JoinHandle<()>);
5114 impl Drop for AbortOnDrop {
5115 fn drop(&mut self) {
5116 self.0.abort();
5117 }
5118 }
5119 let _watcher_guard = AbortOnDrop(cancel_watcher);
5120
5121 let wait_complete_setter = wait_complete.clone();
5122 let code = tokio::task::block_in_place(move || {
5123 let result = term_clone.wait_for_foreground(pid);
5124 // Mark wait done before the watcher might fire.
5125 wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5126
5127 // Always reclaim the terminal
5128 if let Err(e) = term_clone.reclaim_terminal() {
5129 tracing::warn!("failed to reclaim terminal: {}", e);
5130 }
5131
5132 match result {
5133 crate::terminal::WaitResult::Exited(code) => code as i64,
5134 crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5135 crate::terminal::WaitResult::Stopped(_sig) => {
5136 // Register as a stopped job
5137 let rt = tokio::runtime::Handle::current();
5138 let job_id = rt.block_on(jobs.register_stopped(
5139 cmd_display,
5140 child_id,
5141 child_id, // pgid = pid for group leader
5142 ));
5143 eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5144 148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5145 }
5146 }
5147 });
5148
5149 return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5150 }
5151
5152 // Non-job-control path with inherited stdio.
5153 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5154 Ok(s) => s,
5155 Err(e) => {
5156 return Ok(Some(ExecResult::failure(
5157 1,
5158 format!("{}: failed to wait: {}", name, e),
5159 )));
5160 }
5161 };
5162
5163 let code = exit_code_from_status(&status);
5164
5165 // stdout/stderr already went to the terminal
5166 Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5167 } else {
5168 // Capture output via bounded streams
5169 let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5170 let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5171
5172 let stdout_pipe = child.stdout.take();
5173 let stderr_pipe = child.stderr.take();
5174
5175 let stdout_clone = stdout_stream.clone();
5176 let stderr_clone = stderr_stream.clone();
5177
5178 // Only the stage whose stdout *is* the job's stdout tees: in
5179 // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5180 // the pipeline's intermediate data into the node alongside its
5181 // real output. stderr has no such routing — every stage's stderr
5182 // is the job's stderr — so it tees from any position.
5183 let stdout_tee = job_streams.as_ref().and_then(|s| {
5184 matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5185 .then(|| s.stdout.clone())
5186 });
5187 let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5188
5189 let stdout_task = stdout_pipe.map(|pipe| {
5190 tokio::spawn(async move {
5191 drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5192 })
5193 });
5194
5195 let stderr_task = stderr_pipe.map(|pipe| {
5196 tokio::spawn(async move {
5197 drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5198 })
5199 });
5200
5201 let cancelled_before_wait = cancel.is_cancelled();
5202 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5203 Ok(s) => s,
5204 Err(e) => {
5205 // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5206 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5207 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5208 return Ok(Some(ExecResult::failure(
5209 1,
5210 format!("{}: failed to wait: {}", name, e),
5211 )));
5212 }
5213 };
5214
5215 // On cancel, abort the drain tasks (the child's pipes are gone;
5216 // late output is lost but predictable death beats partial capture).
5217 // On normal exit, await drains so we don't lose buffered output.
5218 if cancelled_before_wait || cancel.is_cancelled() {
5219 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5220 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5221 } else {
5222 if let Some(task) = stdout_task {
5223 // Ignore join error — the drain task logs its own errors
5224 let _ = task.await;
5225 }
5226 if let Some(task) = stderr_task {
5227 let _ = task.await;
5228 }
5229 }
5230
5231 let code = exit_code_from_status(&status);
5232
5233 // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5234 // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5235 // intact. stderr stays text. See docs/binary-data.md.
5236 let stdout = stdout_stream.read().await;
5237 let mut stderr = stderr_stream.read_string().await;
5238 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5239
5240 // Both streams are fixed-size rings regardless of `ctx.output_limit`
5241 // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5242 // when enabled). With the limit disabled — the repl/embedded/test
5243 // default — an overflow here used to be silent: `write` evicted the
5244 // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5245 // counter, so a >10MB stdout reported clean success with its head
5246 // quietly gone (GH #191). Surface it loudly instead.
5247 if stderr_stream.has_overflowed().await {
5248 let stats = stderr_stream.stats().await;
5249 stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5250 }
5251 if stdout_stream.has_overflowed().await {
5252 // The marker goes in stderr, never prepended into `result`'s
5253 // stdout payload: stdout may be binary
5254 // (`success_text_or_bytes` yields a `Bytes` result for
5255 // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5256 // string-formatting a marker into it would lossily reinterpret
5257 // bytes as text, introducing a SECOND, different kind of
5258 // corruption on top of the eviction itself.
5259 //
5260 // Only stdout overflow flips `did_spill` — exit-code integrity
5261 // tracks stdout, matching the enabled-limit path's contract
5262 // (stderr overflow alone doesn't remap the exit code).
5263 let stats = stdout_stream.stats().await;
5264 stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5265 result.did_spill = true;
5266 }
5267 result.err = stderr;
5268 Ok(Some(result))
5269 }
5270 }
5271
5272 // --- Variable Access ---
5273
5274 /// Get a variable value.
5275 pub async fn get_var(&self, name: &str) -> Option<Value> {
5276 let scope = self.scope.read().await;
5277 scope.get(name).cloned()
5278 }
5279
5280 /// Check if error-exit mode is enabled (for testing).
5281 #[cfg(test)]
5282 pub async fn error_exit_enabled(&self) -> bool {
5283 let scope = self.scope.read().await;
5284 scope.error_exit_enabled()
5285 }
5286
5287 /// Set a variable value.
5288 pub async fn set_var(&self, name: &str, value: Value) {
5289 let mut scope = self.scope.write().await;
5290 scope.set(name.to_string(), value);
5291 }
5292
5293 /// Set positional parameters ($0 script name and $1-$9 args).
5294 pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5295 let mut scope = self.scope.write().await;
5296 scope.set_positional(script_name, args);
5297 }
5298
5299 /// List all variables.
5300 pub async fn list_vars(&self) -> Vec<(String, Value)> {
5301 let scope = self.scope.read().await;
5302 scope.all()
5303 }
5304
5305 /// List exported variables (name, value), sorted by name. These are the
5306 /// vars a child process would see (see `dispatch`'s hermetic env build).
5307 pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5308 let scope = self.scope.read().await;
5309 scope.exported_vars()
5310 }
5311
5312 // --- CWD ---
5313
5314 /// Get current working directory.
5315 pub async fn cwd(&self) -> PathBuf {
5316 self.exec_ctx.read().await.cwd.clone()
5317 }
5318
5319 /// Set current working directory.
5320 pub async fn set_cwd(&self, path: PathBuf) {
5321 let mut ctx = self.exec_ctx.write().await;
5322 ctx.set_cwd(path);
5323 }
5324
5325 /// Set the working directory only if `path` resolves to a directory in the
5326 /// kernel's backend — the same namespace `cd` validates against. Unlike a
5327 /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5328 /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5329 /// disappeared. Returns whether the cwd was changed.
5330 pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5331 // Clone the backend Arc out before the stat so we never hold the
5332 // exec_ctx lock across the await.
5333 let backend = self.exec_ctx.read().await.backend.clone();
5334 let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5335 if is_dir {
5336 self.exec_ctx.write().await.set_cwd(path);
5337 }
5338 is_dir
5339 }
5340
5341 // --- Last Result ---
5342
5343 /// Get the last result ($?).
5344 pub async fn last_result(&self) -> ExecResult {
5345 let scope = self.scope.read().await;
5346 scope.last_result().clone()
5347 }
5348
5349 // --- Tools ---
5350
5351 /// Check if a user-defined function exists.
5352 pub async fn has_function(&self, name: &str) -> bool {
5353 self.user_tools.read().await.contains_key(name)
5354 }
5355
5356 /// Get available tool schemas.
5357 pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5358 self.tools.schemas()
5359 }
5360
5361 /// Classify how the kernel will resolve a command name.
5362 ///
5363 /// This is the supported, single source of truth for command resolution that
5364 /// embedders should call instead of re-deriving the rules. Walk a parsed
5365 /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5366 /// this per command name to bucket each into builtin / user-function /
5367 /// special-form / dynamic / external — for example a consent gate that blocks
5368 /// a script until external commands are approved.
5369 ///
5370 /// The classification mirrors the interpreter's real resolution order
5371 /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5372 /// short-circuit first, then **aliases are expanded** (bounded recursion,
5373 /// re-checking special-forms each step, exactly as execution does), then user
5374 /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5375 /// name that is a variable or command-substitution expansion (`$cmd`,
5376 /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5377 /// be resolved statically.
5378 ///
5379 /// Aliases are resolved against the kernel's current alias table, so an
5380 /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5381 /// thing it would actually run. The safe direction of any residual imprecision
5382 /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5383 /// `.kai`/backend-tool resolution are reported `External` even though some of
5384 /// those resolve in-process, so a consent gate over-gates rather than letting
5385 /// a `PATH` escape slip through.
5386 pub async fn classify_command(&self, name: &str) -> CommandKind {
5387 // Resolve the command head the way `execute_command_depth` does: a
5388 // special-form short-circuits before any alias lookup, otherwise expand
5389 // aliases (bounded, recursive) and re-check from the top. A dynamic name
5390 // can't be resolved at all.
5391 let mut name = name.to_string();
5392 let mut alias_depth = 0u8;
5393 loop {
5394 if !crate::validator::is_static_command_name(&name) {
5395 return CommandKind::Dynamic;
5396 }
5397 if crate::validator::is_runtime_special_form(&name) {
5398 return CommandKind::Special;
5399 }
5400 if alias_depth >= 10 {
5401 break;
5402 }
5403 let alias_value = {
5404 let ctx = self.exec_ctx.read().await;
5405 ctx.aliases.get(&name).cloned()
5406 };
5407 // Expand to the alias's head command. An empty alias value (no head)
5408 // is ignored by execution, so resolution continues with this name.
5409 match alias_value
5410 .as_deref()
5411 .and_then(|v| v.split_whitespace().next())
5412 {
5413 Some(head) => {
5414 name = head.to_string();
5415 alias_depth += 1;
5416 }
5417 None => break,
5418 }
5419 }
5420
5421 let is_user_tool = self.user_tools.read().await.contains_key(&name);
5422 let is_builtin = self.tools.contains(&name);
5423 crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5424 }
5425
5426 // --- Jobs ---
5427
5428 /// Get job manager.
5429 pub fn jobs(&self) -> Arc<JobManager> {
5430 self.jobs.clone()
5431 }
5432
5433 // --- VFS ---
5434
5435 /// Get VFS router.
5436 pub fn vfs(&self) -> Arc<VfsRouter> {
5437 self.vfs.clone()
5438 }
5439
5440 // --- State ---
5441
5442 /// Reset kernel to initial state.
5443 ///
5444 /// Clears in-memory variables and resets cwd to root. History is not
5445 /// cleared (it persists across resets). The kernel's `$$` identity, the
5446 /// trash-on-delete configuration, and any frontend-seeded `initial_vars`
5447 /// (HOME/PATH/etc, from `KernelConfig`) are re-applied to the fresh
5448 /// scope rather than silently reverting to defaults — an embedder that
5449 /// opted into trash must not find it quietly disabled after a `reset()`
5450 /// between requests.
5451 ///
5452 /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
5453 /// reset, not a session boundary for `&`. A job started before `reset()`
5454 /// keeps running, stays in `jobs`, and the job ID counter keeps counting
5455 /// up. An embedder treating `reset()` as "new session" (a fresh MCP
5456 /// conversation reusing one kernel, say) inherits every job the previous
5457 /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
5458 /// that inheritance is not wanted.
5459 pub async fn reset(&self) -> Result<()> {
5460 {
5461 let mut scope = self.scope.write().await;
5462 let pid = scope.pid();
5463 let trash_enabled = scope.trash_enabled();
5464 let mut fresh = Scope::new();
5465 fresh.set_pid(pid);
5466 for (name, value) in self.initial_vars.clone() {
5467 fresh.set_exported(name, value);
5468 }
5469 // The pin travels with the policy it pins — a `reset()` between
5470 // requests that dropped it would hand the next request an
5471 // unpinned session (spec §F.3 item 3).
5472 fresh.set_trash_enabled(trash_enabled);
5473 *scope = fresh;
5474 }
5475 {
5476 let mut ctx = self.exec_ctx.write().await;
5477 ctx.cwd = PathBuf::from("/");
5478 }
5479 Ok(())
5480 }
5481
5482 /// Trip the cancellation token of every tracked background job (`&`) —
5483 /// whether or not `shutdown` follows.
5484 ///
5485 /// This is the same lever `kill %N` uses: a *running* job's in-process
5486 /// future exits at its next checkpoint, and any external children it
5487 /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
5488 /// status `Killed` once it unwinds. For an already-finished job the
5489 /// token trip is a no-op — its future has already resolved and the job
5490 /// keeps reporting its terminal status. This only
5491 /// *starts* cancellation, it does not wait (pair with
5492 /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
5493 /// unwind, bounded as [`Self::shutdown`] does).
5494 ///
5495 /// A job registered by an embedder via [`JobManager::register`] with no
5496 /// cancel token attached has no lever to cancel — silently skipped here,
5497 /// same as `kill %N`'s own "no cancellation token" case.
5498 ///
5499 /// Returns how many jobs a token was actually tripped for.
5500 pub async fn cancel_all_jobs(&self) -> usize {
5501 let ids = self.jobs.list_ids().await;
5502 let mut cancelled = 0;
5503 for id in ids {
5504 if self.jobs.mark_killed_and_cancel(id, false).await {
5505 cancelled += 1;
5506 }
5507 }
5508 cancelled
5509 }
5510
5511 /// Shut down the kernel.
5512 ///
5513 /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
5514 /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
5515 /// gives a single target (GH #244) — for it to actually unwind. The
5516 /// waits are sequential, so the worst case is additive: N jobs that all
5517 /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
5518 /// that unwind promptly (the normal case) cost only their own unwind
5519 /// time. Before this fix `shutdown` called `wait_all()` with no timeout
5520 /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
5521 ///
5522 /// A job that has not unwound by its deadline is abandoned: logged via
5523 /// `tracing::warn!` and left running detached until the tokio runtime
5524 /// itself goes away. There is no further lever once `shutdown()` has
5525 /// returned — this method does not hang, but it also does not guarantee
5526 /// every job actually stopped.
5527 ///
5528 /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
5529 /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
5530 /// `Arc::try_unwrap`, since the work here only touches the shared
5531 /// `Arc<JobManager>`, never kernel state that would need exclusive
5532 /// ownership.
5533 pub async fn shutdown(&self) -> Result<()> {
5534 let ids = self.jobs.list_ids().await;
5535 self.cancel_all_jobs().await;
5536
5537 let bound = self.jobs.kill_grace() + Duration::from_secs(3);
5538 for id in ids {
5539 if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
5540 tracing::warn!(
5541 job_id = %id,
5542 bound_secs = bound.as_secs_f64(),
5543 "kernel shutdown: job did not exit within the grace period after \
5544 cancellation — abandoning it"
5545 );
5546 }
5547 }
5548 Ok(())
5549 }
5550
5551 /// Dispatch a single command using the full resolution chain.
5552 ///
5553 /// This is the core of `CommandDispatcher` — it syncs state between the
5554 /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
5555 /// then delegates to `execute_command` for the actual dispatch.
5556 ///
5557 /// State flow:
5558 /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
5559 /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
5560 /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
5561 async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
5562 // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
5563 // its inner command via ctx.dispatcher) routes through THIS kernel,
5564 // not a stale parent. Critical for forks: the fork's builtins must
5565 // use the fork's dispatcher, not the parent's.
5566 if let Some(d) = self.dispatcher() {
5567 ctx.dispatcher = Some(d);
5568 }
5569
5570 // 1. Sync ctx → self internals
5571 {
5572 let mut scope = self.scope.write().await;
5573 *scope = ctx.scope.clone();
5574 }
5575 {
5576 let mut ec = self.exec_ctx.write().await;
5577 ec.cwd = ctx.cwd.clone();
5578 ec.prev_cwd = ctx.prev_cwd.clone();
5579 ec.stdin = ctx.stdin.take();
5580 ec.stdin_data = ctx.stdin_data.take();
5581 // The structured-data sideband receiver (set by the concurrent
5582 // pipeline runner on the stage ctx) must reach the tool's snapshot
5583 // too — same reason as the pipe endpoints below. Without this a
5584 // pipeline consumer never sees the producer's `.data`.
5585 ec.stdin_data_rx = ctx.stdin_data_rx.take();
5586 // Streaming pipe endpoints and kernel stderr must flow to the
5587 // tool via self.exec_ctx — execute_command reads that, not the
5588 // passed-in ctx. Without moving these, concurrent pipeline
5589 // stages dispatched via a fork get pipe_stdin = None and
5590 // silently read nothing.
5591 ec.pipe_stdin = ctx.pipe_stdin.take();
5592 ec.pipe_stdout = ctx.pipe_stdout.take();
5593 if let Some(stderr) = ctx.stderr.clone() {
5594 ec.stderr = Some(stderr);
5595 }
5596 ec.aliases = ctx.aliases.clone();
5597 ec.ignore_config = ctx.ignore_config.clone();
5598 ec.output_limit = ctx.output_limit.clone();
5599 ec.pipeline_position = ctx.pipeline_position;
5600 // Sync the cancel token from ctx → ec. Builtins like `timeout`
5601 // swap ctx.cancel to a derived child token before re-dispatching;
5602 // execute_command's snapshot reads ec.cancel (kept aligned by
5603 // this sync), so try_execute_external sees the right token.
5604 ec.cancel = ctx.cancel.clone();
5605 // Same alignment for the watchdog: a fork dispatching through its
5606 // own kernel must hand the shared script clock to the snapshot so
5607 // patient holds in forked stages suspend the right timer.
5608 ec.watchdog = ctx.watchdog.clone();
5609 }
5610
5611 // 2. Execute via the full dispatch chain
5612 let result = self.execute_command(&cmd.name, &cmd.args).await?;
5613
5614 // 3. Sync self → ctx
5615 {
5616 let scope = self.scope.read().await;
5617 ctx.scope = scope.clone();
5618 }
5619 {
5620 let mut ec = self.exec_ctx.write().await;
5621 ctx.cwd = ec.cwd.clone();
5622 ctx.prev_cwd = ec.prev_cwd.clone();
5623 ctx.aliases = ec.aliases.clone();
5624 ctx.ignore_config = ec.ignore_config.clone();
5625 ctx.output_limit = ec.output_limit.clone();
5626 // Return any pipe endpoints that the tool didn't consume.
5627 // `take()` here keeps the fork's exec_ctx in a clean state for
5628 // the next dispatch — these are per-command and shouldn't leak
5629 // between calls.
5630 ctx.pipe_stdin = ec.pipe_stdin.take();
5631 ctx.pipe_stdout = ec.pipe_stdout.take();
5632 // Unconsumed buffered stdin comes back the same way, and for a
5633 // sharper reason than symmetry: a partial read (`read` takes one
5634 // line) leaves its remainder in `ec`, and the caller's own
5635 // end-of-statement sync writes `ctx.stdin` back over `ec.stdin`.
5636 // Without this the caller writes its stale `None` over the
5637 // remainder and the rest of the stream is gone.
5638 ctx.stdin = ec.stdin.take();
5639 // Same take-don't-clone discipline as stdin, and for the same
5640 // reason: these belong to exactly one dispatch, and a copy left
5641 // behind would let the next command adopt it.
5642 }
5643
5644 Ok(result)
5645 }
5646}
5647
5648/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
5649/// shared arg-binding core behind both `Kernel::build_args_async`
5650/// (production: full recursion through the async pipeline, command
5651/// substitution, real glob expansion) and the reduced sync evaluator behind
5652/// scatter/gather's own option parsing and the `#[cfg(test)]`
5653/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
5654/// `SyncEvalSource`). GH #188 closes the drift class between those two
5655/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
5656/// is now the ONLY implementation; only expression evaluation, which is
5657/// capability-bound (recursing into command substitution needs a live async
5658/// pipeline the reduced context doesn't have), still has two providers.
5659#[async_trait]
5660pub(crate) trait ArgValueSource: Send + Sync {
5661 /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
5662 /// this evaluator" — the reduced sync evaluator's bash-compatible
5663 /// "coalesce" convention for an unset bare variable, or an expression
5664 /// form it doesn't support (a binary op) — and the caller drops the
5665 /// argument the same way an unset bare variable always has. The real
5666 /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
5667 /// evaluate.
5668 async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
5669
5670 /// Expand a bare glob-pattern positional to display strings, or `None`
5671 /// if this evaluator doesn't expand globs here (disabled, or the reduced
5672 /// sync context, which never has — matching its documented "no
5673 /// filesystem walk before worker forks" limit). `bind_tool_args` falls
5674 /// back to `eval` (which hands back the pattern text as a literal
5675 /// string) when this returns `None`. An enabled expansion that matches
5676 /// nothing is a genuine error, not `Ok(None)`.
5677 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
5678
5679 /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
5680 /// — the reduced sync evaluator's existing behavior (it never expanded
5681 /// `~`).
5682 async fn home(&self) -> Option<String>;
5683}
5684
5685#[async_trait]
5686impl ArgValueSource for Kernel {
5687 async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
5688 Ok(Some(self.eval_expr_async(expr).await?))
5689 }
5690
5691 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
5692 let glob_enabled = self.scope.read().await.glob_enabled();
5693 if !glob_enabled {
5694 return Ok(None);
5695 }
5696 let (paths, cwd) = {
5697 let ctx = self.exec_ctx.read().await;
5698 let paths = ctx
5699 .expand_glob(pattern)
5700 .await
5701 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
5702 let cwd = ctx.resolve_path(".");
5703 (paths, cwd)
5704 };
5705 if paths.is_empty() {
5706 anyhow::bail!("no matches: {}", pattern);
5707 }
5708 let display = paths
5709 .into_iter()
5710 .map(|path| {
5711 if !pattern.starts_with('/') {
5712 path.strip_prefix(&cwd)
5713 .unwrap_or(&path)
5714 .to_string_lossy()
5715 .into_owned()
5716 } else {
5717 path.to_string_lossy().into_owned()
5718 }
5719 })
5720 .collect();
5721 Ok(Some(display))
5722 }
5723
5724 async fn home(&self) -> Option<String> {
5725 self.scope_home().await
5726 }
5727}
5728
5729/// Pull `consumes` positional args after a non-bool flag and stash them on
5730/// `tool_args.named` under the canonical param name. Shared core behind
5731/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
5732/// function's doc comment for the unification story (GH #188).
5733///
5734/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
5735/// single scalar value (last write wins on the rare duplicate).
5736/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
5737/// inside `named[canonical] = Value::Json(Array(...))`, preserving
5738/// invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
5739/// a repeated single-value flag must keep every value, not silently drop
5740/// all but the last (a "no silent corruption" violation).
5741/// - `consumes > 1` accumulates each occurrence as an inner
5742/// `serde_json::Value::Array` inside `named[canonical] =
5743/// Value::Json(Array(...))`, preserving invocation order. This is the
5744/// shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
5745///
5746/// Errors loudly if the flag is missing required positionals — matches
5747/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
5748/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
5749/// can't represent — Kernel's evaluator never returns this) falls back to a
5750/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
5751/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
5752/// error rather than a silently-partial array.
5753#[allow(clippy::too_many_arguments)]
5754async fn consume_flag_positionals(
5755 source: &dyn ArgValueSource,
5756 home: Option<&str>,
5757 args: &[Arg],
5758 flag_name: &str,
5759 canonical: &str,
5760 consumes: usize,
5761 repeatable: bool,
5762 positional_indices: &[usize],
5763 consumed: &mut std::collections::HashSet<usize>,
5764 current_idx: usize,
5765 tool_args: &mut ToolArgs,
5766) -> Result<()> {
5767 let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
5768 for _ in 0..consumes.max(1) {
5769 // A `key=value` (WordAssign) token is consumable only by a
5770 // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
5771 // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
5772 // filter` would reassemble `x=1` into the first slot and steal the
5773 // filter into the second. Multi-value flags take plain positionals.
5774 let allow_word_assign = consumes <= 1;
5775 let next_pos = positional_indices
5776 .iter()
5777 .find(|idx| {
5778 **idx > current_idx
5779 && !consumed.contains(idx)
5780 && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
5781 })
5782 .copied();
5783 match next_pos {
5784 Some(pos_idx) => match &args[pos_idx] {
5785 Arg::Positional(expr) => match source.eval(expr).await? {
5786 Some(value) => {
5787 let value = apply_tilde_expansion(value, home);
5788 collected.push(value);
5789 consumed.insert(pos_idx);
5790 }
5791 None if collected.is_empty() => {
5792 tool_args.flags.insert(flag_name.to_string());
5793 return Ok(());
5794 }
5795 None => anyhow::bail!(
5796 "--{flag_name}: could not evaluate argument {} in this context",
5797 collected.len() + 1
5798 ),
5799 },
5800 // `-v a=1`: reassemble the `key=value` token as the flag's
5801 // scalar value (see `positional_indices` construction).
5802 Arg::WordAssign { key, value } => match source.eval(value).await? {
5803 Some(val) => {
5804 let val = apply_tilde_expansion(val, home);
5805 // Loud on binary (GH #116): `-v a=$BIN` must not silently
5806 // reassemble the `[binary: N bytes]` placeholder into the
5807 // flag's value — same text-sink boundary as the primary
5808 // sinks fixed in #93 item 1.
5809 let val_str = crate::interpreter::value_to_text_sink_named(
5810 &val,
5811 "a key=value argument",
5812 )
5813 .map_err(|e| anyhow::anyhow!("{e}"))?;
5814 collected.push(Value::String(format!("{key}={val_str}")));
5815 consumed.insert(pos_idx);
5816 }
5817 None if collected.is_empty() => {
5818 tool_args.flags.insert(flag_name.to_string());
5819 return Ok(());
5820 }
5821 None => anyhow::bail!(
5822 "--{flag_name}: could not evaluate argument {} in this context",
5823 collected.len() + 1
5824 ),
5825 },
5826 _ => {}
5827 },
5828 None => {
5829 if consumes <= 1 && collected.is_empty() {
5830 // Back-compat: a flag with no follow-up positional
5831 // becomes a bare flag. `--path` with nothing after
5832 // lands in `flags`, same as before this refactor.
5833 tool_args.flags.insert(flag_name.to_string());
5834 return Ok(());
5835 }
5836 anyhow::bail!(
5837 "--{flag_name} requires {consumes} argument{}, got {}",
5838 if consumes == 1 { "" } else { "s" },
5839 collected.len()
5840 );
5841 }
5842 }
5843 }
5844
5845 if consumes <= 1 {
5846 if let Some(v) = collected.pop() {
5847 if repeatable {
5848 push_repeatable_value(tool_args, flag_name, canonical, v)?;
5849 } else {
5850 tool_args.named.insert(canonical.to_string(), v);
5851 }
5852 }
5853 return Ok(());
5854 }
5855
5856 // Multi-consume: accumulate under named[canonical] as array-of-arrays.
5857 let occ: Vec<serde_json::Value> = collected
5858 .into_iter()
5859 .map(|v| crate::interpreter::value_to_json(&v))
5860 .collect();
5861 let entry = tool_args
5862 .named
5863 .entry(canonical.to_string())
5864 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
5865 if let Value::Json(serde_json::Value::Array(outer)) = entry {
5866 outer.push(serde_json::Value::Array(occ));
5867 } else {
5868 anyhow::bail!(
5869 "--{flag_name}: named[{canonical}] already holds a non-array value"
5870 );
5871 }
5872 Ok(())
5873}
5874
5875/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
5876/// (GH #188) shared by `Kernel::build_args_async` (production) and the
5877/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
5878/// scatter/gather's own option parsing and the `#[cfg(test)]`
5879/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
5880/// pass: Kernel's evaluates full expressions (including `$(...)` command
5881/// substitution) and expands real globs/tilde; the reduced one can't recurse
5882/// into the async pipeline this early (scatter/gather's own flags bind
5883/// before any worker forks) so it evaluates a smaller expression subset and
5884/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
5885///
5886/// If a schema is provided, uses it to determine argument types:
5887/// - For `--flag` where schema says type is non-bool: consume next
5888/// positional(s) as value(s) (`consumes`/`repeatable`-aware).
5889/// - For `--flag` where schema says type is bool (or unknown): treat as a
5890/// boolean flag.
5891///
5892/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
5893pub(crate) async fn bind_tool_args(
5894 args: &[Arg],
5895 schema: Option<&crate::tools::ToolSchema>,
5896 source: &dyn ArgValueSource,
5897) -> Result<ToolArgs> {
5898 let mut tool_args = ToolArgs::new();
5899 let home = source.home().await;
5900
5901 // A glob-passthrough tool (`glob`) consumes patterns as data: skip
5902 // argv glob expansion so the pattern reaches the tool as written —
5903 // otherwise `glob **/*.rs` binds the first *matching path* as its
5904 // pattern. The eval fallback turns `Expr::GlobPattern` into its
5905 // literal string.
5906 let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
5907
5908 // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
5909 // in source order with types preserved — operators (`-f`, `=`, `!`) as
5910 // strings, operands keeping their `Value` — leaving `flags`/`named`
5911 // empty. A position-sensitive command needs the *true* argv: an operand
5912 // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
5913 // hoisted into the unordered flag set the normal binder splits into.
5914 // Globs still expand and `~` still resolves, matching normal positional
5915 // binding — so `test -f *.rs` errors on too many args, not a literal
5916 // pattern stat.
5917 if schema.is_some_and(|s| s.raw_argv) {
5918 for arg in args {
5919 match arg {
5920 Arg::Positional(expr) => {
5921 let glob = if let Expr::GlobPattern(p) = expr {
5922 (!glob_passthrough).then(|| p.clone())
5923 } else {
5924 None
5925 };
5926 if let Some(pattern) = glob {
5927 match source.expand_glob(&pattern).await? {
5928 Some(paths) => {
5929 for path in paths {
5930 tool_args.positional.push(Value::String(path));
5931 }
5932 }
5933 None => {
5934 let value = source.eval(expr).await?.ok_or_else(|| {
5935 anyhow::anyhow!(
5936 "raw-argv positional could not be evaluated in this context"
5937 )
5938 })?;
5939 let value = apply_tilde_expansion(value, home.as_deref());
5940 tool_args.positional.push(value);
5941 }
5942 }
5943 } else {
5944 let value = source.eval(expr).await?.ok_or_else(|| {
5945 anyhow::anyhow!(
5946 "raw-argv positional could not be evaluated in this context"
5947 )
5948 })?;
5949 let value = apply_tilde_expansion(value, home.as_deref());
5950 tool_args.positional.push(value);
5951 }
5952 }
5953 Arg::ShortFlag(name) => {
5954 tool_args.positional.push(Value::String(format!("-{name}")));
5955 }
5956 Arg::LongFlag(name) => {
5957 tool_args.positional.push(Value::String(format!("--{name}")));
5958 }
5959 Arg::Named { key, value } => {
5960 let val = source.eval(value).await?.ok_or_else(|| {
5961 anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
5962 })?;
5963 let val = apply_tilde_expansion(val, home.as_deref());
5964 // Loud on binary (GH #116): `test --k=$BIN` must not
5965 // silently reassemble the placeholder into the raw-argv
5966 // positional stream `test` binds against.
5967 let val_str = crate::interpreter::value_to_text_sink_named(
5968 &val,
5969 "a --key=value argument",
5970 )
5971 .map_err(|e| anyhow::anyhow!("{e}"))?;
5972 tool_args
5973 .positional
5974 .push(Value::String(format!("--{key}={val_str}")));
5975 }
5976 Arg::WordAssign { key, value } => {
5977 let val = source.eval(value).await?.ok_or_else(|| {
5978 anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
5979 })?;
5980 let val = apply_tilde_expansion(val, home.as_deref());
5981 // Loud on binary (GH #116): same reasoning as the Named
5982 // arm above, for the bare `key=value` raw-argv form.
5983 let val_str = crate::interpreter::value_to_text_sink_named(
5984 &val,
5985 "a key=value argument",
5986 )
5987 .map_err(|e| anyhow::anyhow!("{e}"))?;
5988 tool_args
5989 .positional
5990 .push(Value::String(format!("{key}={val_str}")));
5991 }
5992 Arg::DoubleDash => {
5993 tool_args.positional.push(Value::String("--".to_string()));
5994 }
5995 }
5996 }
5997 return Ok(tool_args);
5998 }
5999
6000 // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
6001 // schemas; pick the leaf the leading positionals route to and bind
6002 // flags against *its* params. Flat tools return the root. select_leaf
6003 // errors (fail loud) if a computed positional sits where a subcommand
6004 // selector is required.
6005 let leaf = match schema {
6006 Some(s) => Some(select_leaf(s, args)?),
6007 None => None,
6008 };
6009 // Bind against the leaf's params, but MERGE the root schema's params on
6010 // top as "global" flags: a value-flag declared at the tool's top level
6011 // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
6012 // it trails the subcommand path (`kj context retag a b --confirm <n>`).
6013 // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
6014 // merge is a harmless no-op.
6015 let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
6016 if let Some(l) = leaf {
6017 param_lookup.extend(schema_param_lookup(l));
6018 }
6019 // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
6020 // not the leaf — it's a property of the command, not the subcommand.
6021 let accepts_word_assign = schema
6022 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
6023 .unwrap_or(false);
6024
6025 // Track which positional indices have been consumed as flag values
6026 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
6027 let mut past_double_dash = false;
6028
6029 // Indices a value-flag may consume as its value. Positionals always
6030 // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
6031 // itself treat `key=value` as an assignment (everything but
6032 // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
6033 // `-v`, rather than skipping it and grabbing the next positional (the
6034 // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
6035 // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
6036 let positional_indices: Vec<usize> = args
6037 .iter()
6038 .enumerate()
6039 .filter_map(|(i, a)| {
6040 let consumable = matches!(a, Arg::Positional(_))
6041 || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
6042 consumable.then_some(i)
6043 })
6044 .collect();
6045
6046 let mut i = 0;
6047 while i < args.len() {
6048 match &args[i] {
6049 Arg::DoubleDash => {
6050 past_double_dash = true;
6051 }
6052 Arg::Positional(expr) => {
6053 if !consumed.contains(&i) {
6054 // Glob expansion: bare glob patterns expand to matching files
6055 if let Expr::GlobPattern(pattern) = expr {
6056 if !glob_passthrough {
6057 if let Some(paths) = source.expand_glob(pattern).await? {
6058 for path in paths {
6059 tool_args.positional.push(Value::String(path));
6060 }
6061 i += 1;
6062 continue;
6063 }
6064 }
6065 }
6066 if let Some(value) = source.eval(expr).await? {
6067 let value = apply_tilde_expansion(value, home.as_deref());
6068 tool_args.positional.push(value);
6069 }
6070 }
6071 }
6072 Arg::Named { key, value } => {
6073 if let Some(val) = source.eval(value).await? {
6074 let val = apply_tilde_expansion(val, home.as_deref());
6075 // A repeatable flag in `--flag=value` form must accumulate too,
6076 // not overwrite — otherwise `--expression=A --expression=B`
6077 // would silently keep only B, and mixing with the `-e` space
6078 // form would clobber the array. Route it through the same
6079 // accumulator the space form uses.
6080 let is_declared_value_flag = param_lookup
6081 .get(key.as_str())
6082 .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6083 if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6084 push_repeatable_value(&mut tool_args, key, canonical, val)?;
6085 } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6086 // Flagify at bind time (GH #189): `--flag=true`/
6087 // `--flag=false` binds the same way the bare
6088 // `--flag`/its absence already do (true → flag
6089 // presence, false → dropped) instead of landing in
6090 // `named` as a literal `Value::Bool` that a clap
6091 // `bool` field's `SetTrue` action rejects
6092 // (`seq --json=true` used to exit 2 with a clap
6093 // parse error). Covers both a schema-declared bool
6094 // param AND an undeclared flag — `--json` itself is
6095 // deliberately excluded from every builtin's schema
6096 // (`clap_schema::is_skipped`), so this is what makes
6097 // `--json=true` work universally instead of only for
6098 // the builtins that happen to call
6099 // `ToolArgs::flagify_bool_named` themselves. A
6100 // declared VALUE-taking flag's own `=true` literal
6101 // (`spawn --command=true`) is excluded by
6102 // `is_declared_value_flag` and still falls to
6103 // `named` below.
6104 if let Value::Bool(true) = val {
6105 tool_args.flags.insert(key.clone());
6106 }
6107 // Value::Bool(false): absent == false, nothing to insert.
6108 } else {
6109 tool_args.named.insert(key.clone(), val);
6110 }
6111 }
6112 }
6113 Arg::WordAssign { key, value } => {
6114 // Already pulled in as a preceding value-flag's argument
6115 // (`awk -v a=1`); don't also emit it as a positional.
6116 if consumed.contains(&i) {
6117 i += 1;
6118 continue;
6119 }
6120 if let Some(val) = source.eval(value).await? {
6121 let val = apply_tilde_expansion(val, home.as_deref());
6122 // Past `--`, EVERY token is raw data — including for
6123 // export/alias, whose `key=value` is normally a shell
6124 // assignment (GH #189). `export -- A=1` must bind `A=1`
6125 // as a literal positional, not silently re-enter the
6126 // named-assignment path `past_double_dash` exists to
6127 // suppress for flags right above this arm.
6128 if accepts_word_assign && !past_double_dash {
6129 tool_args.named.insert(key.clone(), val);
6130 } else {
6131 // Stringify "key=value" and pass as a positional.
6132 // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6133 // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6134 // must not silently become a path/operand literally named
6135 // `foo=[binary: N bytes]`.
6136 let val_str = crate::interpreter::value_to_text_sink_named(
6137 &val,
6138 "a key=value argument",
6139 )
6140 .map_err(|e| anyhow::anyhow!("{e}"))?;
6141 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6142 }
6143 }
6144 }
6145 Arg::ShortFlag(name) => {
6146 if past_double_dash {
6147 tool_args.positional.push(Value::String(format!("-{name}")));
6148 } else if name.len() == 1 {
6149 let flag_name = name.as_str();
6150 let lookup = param_lookup.get(flag_name);
6151
6152 // Same ambiguity guard as the `LongFlag` arm below (GH
6153 // #189 item 4): an undeclared short flag immediately
6154 // followed by an unconsumed positional under a
6155 // map_positionals (backend/MCP) schema is exactly as
6156 // ambiguous as the long-flag case — kaish can't tell a
6157 // space-form value (`-t explorer`) from a bool flag
6158 // sitting before a real positional (`-f file.txt`).
6159 // Unlike `--flag`, there is no `-f=value` escape hatch to
6160 // suggest: a glued `-f=val` is two tokens with a dangling
6161 // `=` that the parser's no-token-pasting guard already
6162 // rejects — the only fix is declaring the flag.
6163 let ambiguous_value = (lookup.is_none()
6164 && leaf.is_some_and(|s| s.map_positionals)
6165 && !consumed.contains(&(i + 1)))
6166 .then(|| match args.get(i + 1) {
6167 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6168 Some(s.clone())
6169 }
6170 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6171 _ => None,
6172 })
6173 .flatten();
6174 if let Some(val) = ambiguous_value {
6175 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6176 anyhow::bail!(
6177 "{tool}: -{name} is not a declared flag, so the \
6178 space-separated value ({val:?}) would be silently \
6179 dropped. Have {tool} declare -{name} in its schema \
6180 (short flags have no -{name}=value form to fall \
6181 back on)."
6182 );
6183 }
6184
6185 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6186
6187 if is_bool {
6188 tool_args.flags.insert(flag_name.to_string());
6189 } else {
6190 // Non-bool: consume `consumes` positionals as value(s)
6191 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6192 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6193 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6194 consume_flag_positionals(
6195 source,
6196 home.as_deref(),
6197 args,
6198 name,
6199 canonical,
6200 consumes,
6201 repeatable,
6202 &positional_indices,
6203 &mut consumed,
6204 i,
6205 &mut tool_args,
6206 )
6207 .await?;
6208 }
6209 } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
6210 // Multi-char short flag matches a schema param (POSIX style: -name value)
6211 if is_bool_type(typ) {
6212 tool_args.flags.insert(canonical.to_string());
6213 } else {
6214 consume_flag_positionals(
6215 source,
6216 home.as_deref(),
6217 args,
6218 name,
6219 canonical,
6220 consumes,
6221 repeatable,
6222 &positional_indices,
6223 &mut consumed,
6224 i,
6225 &mut tool_args,
6226 )
6227 .await?;
6228 }
6229 } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
6230 .get(&name[..1])
6231 .filter(|(_, typ, ..)| !is_bool_type(typ))
6232 {
6233 // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
6234 // `grep -A1`, `sed -e1d`. The first char is a declared
6235 // value-taking short flag, so the rest of the token is its
6236 // value — the coreutils idiom. The lexer's flag char class is
6237 // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
6238 // (safe to slice) and the tail is a plain literal.
6239 bind_glued_short_value(
6240 &mut tool_args,
6241 &name[..1],
6242 canonical,
6243 consumes,
6244 repeatable,
6245 name[1..].to_string(),
6246 )?;
6247 } else {
6248 // Multi-char combined short flags. Bool flags stack
6249 // (`-la`), but the FIRST value-taking flag reached
6250 // consumes the rest of the token as its glued value
6251 // (`-ivC3` → C=3) or, if it is the last char, the next
6252 // positional (`grep -ivC 3` → C=3). Before this, a
6253 // trailing value-flag was silently treated as a bool,
6254 // stranding its argument as a stray positional (arity
6255 // error). Undeclared/bool chars stay bare flags, so a
6256 // schemaless tool keeps the old all-boolean behavior.
6257 // The first char being value-taking is handled by the
6258 // glued arm above, so it never reaches here. The flag
6259 // char class is ASCII, so byte indexing is char indexing
6260 // (no `Vec<char>` allocation needed).
6261 let bytes = name.as_bytes();
6262 let mut p = 0;
6263 while p < bytes.len() {
6264 let key = &name[p..p + 1];
6265 match param_lookup.get(key) {
6266 Some(&(canonical, typ, consumes, repeatable))
6267 if !is_bool_type(typ) =>
6268 {
6269 let glued = name[p + 1..].to_string();
6270 if glued.is_empty() {
6271 // Value flag is the last char: take the
6272 // next positional. `consume_flag_positionals`
6273 // respects `consumes`.
6274 consume_flag_positionals(
6275 source,
6276 home.as_deref(),
6277 args,
6278 key,
6279 canonical,
6280 consumes,
6281 repeatable,
6282 &positional_indices,
6283 &mut consumed,
6284 i,
6285 &mut tool_args,
6286 )
6287 .await?;
6288 } else {
6289 bind_glued_short_value(
6290 &mut tool_args,
6291 key,
6292 canonical,
6293 consumes,
6294 repeatable,
6295 glued,
6296 )?;
6297 }
6298 break;
6299 }
6300 _ => {
6301 tool_args.flags.insert(key.to_string());
6302 p += 1;
6303 }
6304 }
6305 }
6306 }
6307 }
6308 Arg::LongFlag(name) => {
6309 if past_double_dash {
6310 tool_args.positional.push(Value::String(format!("--{name}")));
6311 } else {
6312 let lookup = param_lookup.get(name.as_str());
6313 // An *undeclared* long flag under a `map_positionals`
6314 // (backend/MCP) schema that is immediately followed by an
6315 // unconsumed positional is ambiguous: kaish can't tell the
6316 // space-form value (`--type explorer`) from a bool flag
6317 // before a real positional (`--force file.txt`). Defaulting
6318 // to bool here silently divorces the value and misroutes it
6319 // — a privilege-escalation-by-typo against deny-by-default
6320 // embedders. Fail loud instead of guessing.
6321 let ambiguous_value = (lookup.is_none()
6322 && leaf.is_some_and(|s| s.map_positionals)
6323 && !consumed.contains(&(i + 1)))
6324 .then(|| match args.get(i + 1) {
6325 // Echo a concrete value for a copy-pasteable fix
6326 // when it's a plain literal; fall back to VALUE.
6327 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6328 Some(s.clone())
6329 }
6330 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6331 _ => None,
6332 })
6333 .flatten();
6334 if let Some(val) = ambiguous_value {
6335 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6336 anyhow::bail!(
6337 "{tool}: --{name} is not a declared flag, so the \
6338 space-separated value would be silently dropped. \
6339 Use --{name}={val}, or have {tool} declare --{name} \
6340 in its schema."
6341 );
6342 }
6343 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6344
6345 if is_bool {
6346 tool_args.flags.insert(name.clone());
6347 } else {
6348 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
6349 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6350 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6351 consume_flag_positionals(
6352 source,
6353 home.as_deref(),
6354 args,
6355 name,
6356 canonical,
6357 consumes,
6358 repeatable,
6359 &positional_indices,
6360 &mut consumed,
6361 i,
6362 &mut tool_args,
6363 )
6364 .await?;
6365 }
6366 }
6367 }
6368 }
6369 i += 1;
6370 }
6371
6372 // Map remaining positionals to unfilled non-bool schema params (in order).
6373 // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
6374 // Positionals that appeared after `--` are never mapped (they're raw data).
6375 // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
6376 // Keyed off the routed leaf so a subcommand tool maps against the active
6377 // leaf's params (kj leaves keep map_positionals=false → block skipped).
6378 if let Some(schema) = leaf.filter(|s| s.map_positionals) {
6379 let pre_dash_count = if past_double_dash {
6380 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
6381 positional_indices.iter()
6382 .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
6383 .count()
6384 } else {
6385 tool_args.positional.len()
6386 };
6387
6388 let mut remaining = Vec::new();
6389 let mut positional_iter = tool_args.positional.drain(..).enumerate();
6390
6391 for param in &schema.params {
6392 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
6393 continue;
6394 }
6395 if is_bool_type(¶m.param_type) {
6396 continue;
6397 }
6398 loop {
6399 match positional_iter.next() {
6400 Some((idx, val)) if idx < pre_dash_count => {
6401 tool_args.named.insert(param.name.clone(), val);
6402 break;
6403 }
6404 Some((_, val)) => {
6405 remaining.push(val);
6406 }
6407 None => break,
6408 }
6409 }
6410 }
6411
6412 remaining.extend(positional_iter.map(|(_, v)| v));
6413 tool_args.positional = remaining;
6414 }
6415
6416 Ok(tool_args)
6417}
6418
6419#[async_trait]
6420impl CommandDispatcher for Kernel {
6421 /// Dispatch a command through the Kernel's full resolution chain.
6422 ///
6423 /// This is the single path for all command execution when called from
6424 /// the pipeline runner. It provides the full dispatch chain:
6425 /// user tools → builtins → .kai scripts → external commands → backend tools.
6426 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6427 self.dispatch_command(cmd, ctx).await
6428 }
6429
6430 /// Evaluate an expression through the kernel's async chain, including
6431 /// command substitution. Delegates to `eval_expr_async`, which snapshots
6432 /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
6433 /// only command output escapes. The `ctx` is unused here because the
6434 /// kernel evaluates against its own session state (a fork carries the
6435 /// pipeline stage's snapshot); var refs resolve against that scope.
6436 async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
6437 self.eval_expr_async(expr).await
6438 }
6439
6440 /// Produce a forked dispatcher with independent mutable state (detached).
6441 ///
6442 /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
6443 /// recursing into the trait method we're defining) and coerces the
6444 /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
6445 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
6446 let fork: Arc<Kernel> = Kernel::fork(self).await;
6447 fork
6448 }
6449
6450 /// Produce a forked dispatcher with cancellation cascading from this kernel.
6451 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
6452 let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
6453 fork
6454 }
6455}
6456
6457/// Apply the requested output format to a builtin's result, unless the tool
6458/// owns its own output — and even then, only on success.
6459///
6460/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
6461/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
6462/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
6463/// gather never render a structured error themselves, so a failure
6464/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
6465/// never "already rendered" by the tool. Skipping `apply_output_format` on
6466/// that path just leaked the raw diagnostic under `--json` instead of the
6467/// uniform `{"error","code"}` envelope every other builtin's failure gets
6468/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
6469/// whole owns_output error-path class). Gating the skip on `result.ok()`
6470/// keeps the intentional success-path opt-out while closing that gap.
6471fn finalize_output(
6472 result: ExecResult,
6473 format: Option<crate::interpreter::OutputFormat>,
6474 owns_output: bool,
6475) -> ExecResult {
6476 match format {
6477 Some(_) if owns_output && result.ok() => result,
6478 Some(format) => apply_output_format(result, format),
6479 None => result,
6480 }
6481}
6482
6483/// Accumulate output from one result into another.
6484///
6485/// Appends stdout and stderr verbatim and updates the exit code to match the
6486/// new result. Used to preserve output from multiple statements, loop
6487/// iterations, and command chains. No separator is inserted between outputs —
6488/// each command's output concatenates raw, matching bash (`printf a; printf b`
6489/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
6490/// when a command emits its own, as `echo` does).
6491fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
6492 // Materialize lazy OutputData into .out before accumulating.
6493 // Without this, the first command's output stays in .output while
6494 // the second's text gets appended to .out, losing the first.
6495 accumulated.materialize();
6496 match new.out_bytes() {
6497 // A binary result must not be lossy-decoded by text_out(): concatenate
6498 // raw bytes so the combined output stays binary (this is the path every
6499 // top-level statement's result flows through). See docs/binary-data.md.
6500 Some(new_bytes) => {
6501 let mut combined: Vec<u8> = match accumulated.out_bytes() {
6502 Some(b) => b.to_vec(),
6503 None => accumulated.text_out().into_owned().into_bytes(),
6504 };
6505 combined.extend_from_slice(new_bytes);
6506 accumulated.set_out_bytes(combined);
6507 }
6508 None => accumulated.push_out(&new.text_out()),
6509 }
6510 accumulated.err.push_str(&new.err);
6511 accumulated.code = new.code;
6512 accumulated.data = new.data.clone();
6513 accumulated.did_spill = new.did_spill;
6514 accumulated.original_code = new.original_code;
6515 accumulated.content_type = new.content_type.clone();
6516 accumulated.baggage.clone_from(&new.baggage);
6517}
6518
6519/// Fold a block's accumulated output into a signal that is leaving the block.
6520///
6521/// Any block that builds up a result — a loop body, an `if`/`case` branch, the
6522/// left side of a `&&`/`||` chain — hands that result back when it finishes.
6523/// When `break`/`continue`/`return`/`exit` leaves early instead, the signal
6524/// replaces the result on the way up, so output printed before the signal
6525/// would otherwise be discarded. Leaving early stops the block; it does not
6526/// unprint what already ran. The block's output comes first (it ran before the
6527/// signal was raised), then the signal's already-carried output.
6528fn fold_block_output_into_flow(block_output: ExecResult, flow: &mut ControlFlow) {
6529 let carried = match flow {
6530 ControlFlow::Break { result, .. }
6531 | ControlFlow::Continue { result, .. }
6532 | ControlFlow::Exit { result, .. } => result,
6533 ControlFlow::Return { value } => value,
6534 ControlFlow::Normal(_) => return,
6535 };
6536 let mut merged = block_output;
6537 accumulate_result(&mut merged, carried);
6538 *carried = merged;
6539}
6540
6541/// Accumulate the output a break/continue signal carried (from inner loops it
6542/// propagated through) into the loop that finally handles it, so it survives
6543/// into that loop's result.
6544fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
6545 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
6546 accumulate_result(accumulated, result);
6547 }
6548}
6549
6550/// Check if a value is truthy.
6551fn is_truthy(value: &Value) -> bool {
6552 match value {
6553 Value::Null => false,
6554 Value::Bool(b) => *b,
6555 Value::Int(i) => *i != 0,
6556 Value::Float(f) => *f != 0.0,
6557 Value::String(s) => !s.is_empty(),
6558 Value::Json(json) => match json {
6559 serde_json::Value::Null => false,
6560 serde_json::Value::Array(arr) => !arr.is_empty(),
6561 serde_json::Value::Object(obj) => !obj.is_empty(),
6562 serde_json::Value::Bool(b) => *b,
6563 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
6564 serde_json::Value::String(s) => !s.is_empty(),
6565 },
6566 Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
6567 }
6568}
6569
6570/// Apply tilde expansion to a value.
6571///
6572/// Only string values starting with `~` are expanded. `home` is the session
6573/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
6574/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
6575fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
6576 match value {
6577 Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
6578 _ => value,
6579 }
6580}
6581
6582/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
6583/// how the lexer tokenizes the equivalent minimally-quoted command string —
6584/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
6585/// (`build_args_async`) verbatim instead of carrying a parallel one that could
6586/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
6587/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
6588///
6589/// Classification matches the lexer's word classes:
6590/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
6591/// the binder's `past_double_dash` arms, exactly as for the string door).
6592/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
6593/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
6594/// (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
6595/// they fall through to a positional, not a flag).
6596/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
6597/// binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
6598/// `key=value` positional, per the command's word-assign allowlist).
6599/// - everything else → a literal [`Arg::Positional`].
6600///
6601/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
6602/// positional — it can never be a flag — and rides through as-is. That is the
6603/// typed passthrough the string-native door cannot offer.
6604pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
6605 argv.iter().map(classify_argv_token).collect()
6606}
6607
6608fn classify_argv_token(token: &Value) -> Arg {
6609 let Value::String(s) = token else {
6610 return Arg::Positional(Expr::Literal(token.clone()));
6611 };
6612
6613 if s == "--" {
6614 return Arg::DoubleDash;
6615 }
6616
6617 // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
6618 // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
6619 // literal word (GH #137), matching this classifier's own literal
6620 // fallback — so they fall through to a literal positional rather than a
6621 // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
6622 if let Some(rest) = s.strip_prefix("--") {
6623 if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
6624 return match rest.split_once('=') {
6625 Some((key, val)) => Arg::Named {
6626 key: key.to_string(),
6627 value: Expr::Literal(Value::String(val.to_string())),
6628 },
6629 None => Arg::LongFlag(rest.to_string()),
6630 };
6631 }
6632 } else if let Some(rest) = s.strip_prefix('-') {
6633 // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
6634 // token carrying any other char — notably `=` (`-k=v` is a parse error in
6635 // the string door) — or a leading digit (`-1` lexes as a number) is not a
6636 // short-flag word, so it falls through to a literal positional instead of
6637 // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
6638 if is_short_flag_body(rest) {
6639 return Arg::ShortFlag(rest.to_string());
6640 }
6641 }
6642
6643 if let Some((key, val)) = s.split_once('=') {
6644 if is_shell_identifier(key) {
6645 return Arg::WordAssign {
6646 key: key.to_string(),
6647 value: Expr::Literal(Value::String(val.to_string())),
6648 };
6649 }
6650 }
6651
6652 Arg::Positional(Expr::Literal(Value::String(s.clone())))
6653}
6654
6655/// A short-flag word: a leading ASCII letter, then only ASCII
6656/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
6657/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
6658/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
6659/// (`=` is the assignment operator — a parse error in the string door), and
6660/// any non-ASCII tail (never produced by the lexer, and not safe for the
6661/// combined-short-flag binder's byte-index slicing) do not, so they fall
6662/// through to a literal positional instead of a malformed `ShortFlag`.
6663fn is_short_flag_body(s: &str) -> bool {
6664 s.starts_with(|c: char| c.is_ascii_alphabetic())
6665 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
6666}
6667
6668/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
6669fn is_shell_identifier(s: &str) -> bool {
6670 let mut chars = s.chars();
6671 match chars.next() {
6672 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
6673 _ => return false,
6674 }
6675 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
6676}
6677
6678/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
6679/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
6680/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
6681/// must keep every value, not silently drop all but the last. Used by every flag
6682/// surface that can carry the same flag twice — the space form
6683/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
6684/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
6685/// ordered array.
6686pub(crate) fn push_repeatable_value(
6687 tool_args: &mut ToolArgs,
6688 flag_name: &str,
6689 canonical: &str,
6690 v: Value,
6691) -> anyhow::Result<()> {
6692 let occ = crate::interpreter::value_to_json(&v);
6693 let entry = tool_args
6694 .named
6695 .entry(canonical.to_string())
6696 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6697 if let Value::Json(serde_json::Value::Array(items)) = entry {
6698 items.push(occ);
6699 Ok(())
6700 } else {
6701 anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
6702 }
6703}
6704
6705/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
6706/// is one token, so it carries a single value: a repeatable flag accumulates
6707/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
6708/// first-char glued arm and the combined-bundle arm so the two can't drift on
6709/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
6710/// is a loud error, not a silent single-value bind.
6711pub(crate) fn bind_glued_short_value(
6712 tool_args: &mut ToolArgs,
6713 flag_name: &str,
6714 canonical: &str,
6715 consumes: usize,
6716 repeatable: bool,
6717 value: String,
6718) -> anyhow::Result<()> {
6719 if consumes > 1 {
6720 anyhow::bail!(
6721 "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
6722 );
6723 }
6724 if repeatable {
6725 push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
6726 } else {
6727 tool_args
6728 .named
6729 .insert(canonical.to_string(), Value::String(value));
6730 Ok(())
6731 }
6732}
6733
6734/// Map a child's exit status to a shell-style exit code.
6735///
6736/// `ExitStatus::code()` is `None` when the process died from a signal rather
6737/// than exiting normally; in that case this maps to POSIX's `128 + signal`
6738/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
6739/// number. Shared by both external-command spawn sites — production
6740/// (`try_execute_external`, below) and the test-only twin
6741/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
6742/// this mapping again (GH #133 item 1).
6743#[cfg(feature = "subprocess")]
6744pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
6745 status.code().unwrap_or_else(|| {
6746 #[cfg(unix)]
6747 {
6748 use std::os::unix::process::ExitStatusExt;
6749 128 + status.signal().unwrap_or(0)
6750 }
6751 #[cfg(not(unix))]
6752 {
6753 -1
6754 }
6755 }) as i64
6756}
6757
6758/// Wait for a child to exit, killing it if `cancel` fires first.
6759///
6760/// `target` carries a Linux pidfd (when available) for race-free direct-child
6761/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
6762/// parameter is ignored and we use tokio's cross-platform `start_kill`.
6763#[cfg(all(unix, feature = "subprocess"))]
6764pub(crate) async fn wait_or_kill(
6765 child: &mut tokio::process::Child,
6766 target: Option<&crate::pidfd::KillTarget>,
6767 cancel: &tokio_util::sync::CancellationToken,
6768 grace: Duration,
6769) -> std::io::Result<std::process::ExitStatus> {
6770 tokio::select! {
6771 biased;
6772 status = child.wait() => status,
6773 _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
6774 }
6775}
6776
6777#[cfg(all(not(unix), feature = "subprocess"))]
6778pub(crate) async fn wait_or_kill(
6779 child: &mut tokio::process::Child,
6780 _target: Option<&()>,
6781 cancel: &tokio_util::sync::CancellationToken,
6782 _grace: Duration,
6783) -> std::io::Result<std::process::ExitStatus> {
6784 tokio::select! {
6785 biased;
6786 status = child.wait() => status,
6787 _ = cancel.cancelled() => {
6788 let _ = child.start_kill();
6789 child.wait().await
6790 }
6791 }
6792}
6793
6794/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
6795///
6796/// Direct-child kill goes through `target.signal()`, which on Linux uses a
6797/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
6798/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
6799#[cfg(all(unix, feature = "subprocess"))]
6800pub(crate) async fn kill_with_grace(
6801 child: &mut tokio::process::Child,
6802 target: Option<&crate::pidfd::KillTarget>,
6803 grace: Duration,
6804) -> std::io::Result<std::process::ExitStatus> {
6805 use nix::sys::signal::Signal;
6806
6807 if let Some(t) = target {
6808 t.signal(Signal::SIGTERM);
6809 t.signal_pg(Signal::SIGTERM);
6810 if grace > Duration::ZERO
6811 && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
6812 {
6813 return status;
6814 }
6815 t.signal(Signal::SIGKILL);
6816 t.signal_pg(Signal::SIGKILL);
6817 }
6818 child.wait().await
6819}
6820
6821#[cfg(test)]
6822#[allow(clippy::unwrap_used, clippy::expect_used)]
6823mod argv_classify_tests {
6824 use super::*;
6825
6826 /// A normalized, comparable view of one `Arg` representing its *logical
6827 /// argument* (what the command observably receives), not its exact AST shape:
6828 ///
6829 /// - Value-bearing arms compare by *stringified* value, so the parser's
6830 /// number coercion (`-1`→`Int(-1)`) vs the classifier's literal
6831 /// (`String("-1")`) count as the same argument.
6832 /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
6833 /// For every command except the `export`/`alias` allowlist, a bareword
6834 /// `key=value` is stringified straight back to a `"key=value"` positional
6835 /// (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
6836 /// converge observably even when they disagree on the AST tag — e.g. the
6837 /// lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
6838 /// `WordAssign`, where the classifier (bash-correctly) makes a positional.
6839 /// The genuine `WordAssign` *detection* on a real identifier LHS is pinned
6840 /// separately by `classifies_each_word_class`.
6841 ///
6842 /// Returns `None` for shapes we deliberately don't compare:
6843 /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
6844 /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
6845 /// lex to `Int`, dropping the literal text, where the classifier keeps the
6846 /// string. That divergence is *intentional* — `execute_argv` preserves a
6847 /// literal numeric string (pass `Value::Int` for a number), the string door
6848 /// can only guess — so the property skips it rather than demanding the
6849 /// classifier replicate a lossy coercion. Numeric edges are pinned exactly
6850 /// by `classifies_each_word_class`.
6851 fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
6852 // Only a *string*-valued literal is comparable; a coerced number is not.
6853 let lit = |e: &Expr| match e {
6854 Expr::Literal(Value::String(s)) => Some(s.clone()),
6855 _ => None,
6856 };
6857 Some(match arg {
6858 Arg::DoubleDash => ("dash", String::new(), String::new()),
6859 Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
6860 Arg::LongFlag(s) => ("long", s.clone(), String::new()),
6861 Arg::Positional(e) => ("pos", String::new(), lit(e)?),
6862 Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
6863 Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
6864 })
6865 }
6866
6867 /// Classify a single string token the way `execute_argv` would.
6868 fn classify(token: &str) -> Arg {
6869 classify_argv_token(&Value::String(token.to_string()))
6870 }
6871
6872 #[test]
6873 fn classifies_each_word_class() {
6874 assert_eq!(classify("--"), Arg::DoubleDash);
6875 assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
6876 assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
6877 assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
6878 assert_eq!(
6879 classify("--key=value"),
6880 Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
6881 );
6882 assert_eq!(
6883 classify("NAME=val"),
6884 Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
6885 );
6886 // Digits after the first flag char are ordinary (kept verbatim).
6887 assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
6888 assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
6889 // Leading-digit dash is a number to the lexer, not a flag → positional.
6890 assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
6891 // Numeric strings keep their literal text — `execute_argv` does NOT
6892 // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
6893 // who wants a number passes `Value::Int`; a string stays the string.
6894 assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
6895 assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
6896 // A lone dash (stdin convention) is a positional, not a flag.
6897 assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
6898 // Non-identifier LHS is not an assignment.
6899 assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
6900 assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
6901 }
6902
6903 #[test]
6904 fn typed_values_pass_through_as_literal_positionals() {
6905 // The whole point of the `&[Value]` signature: a non-string value is a
6906 // literal positional carrying the *exact* value, never stringified and
6907 // never flag-interpreted.
6908 let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
6909 assert_eq!(
6910 classify_argv_token(&bytes),
6911 Arg::Positional(Expr::Literal(bytes.clone()))
6912 );
6913 let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
6914 assert_eq!(
6915 classify_argv_token(&json),
6916 Arg::Positional(Expr::Literal(json.clone()))
6917 );
6918 // An integer token that *looks* like a flag is still a positional value
6919 // (only strings are inspected for a leading dash).
6920 assert_eq!(
6921 classify_argv_token(&Value::Int(-9)),
6922 Arg::Positional(Expr::Literal(Value::Int(-9)))
6923 );
6924 }
6925
6926 #[test]
6927 fn double_dash_only_matches_exactly() {
6928 // `--` is the marker; `--x` is a long flag. `---` is not a flag word
6929 // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
6930 // as a single argv token here it's likewise literal.
6931 assert_eq!(classify("--"), Arg::DoubleDash);
6932 assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
6933 assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
6934 }
6935
6936 #[test]
6937 fn malformed_flag_words_fall_back_to_literal_positionals() {
6938 // A token that isn't a well-formed flag word must NOT be silently misbound
6939 // into the arg binder (house rule: loud/visible over silent-wrong). Each
6940 // of these is a parse error or different tokenization in the string door,
6941 // so the argv door keeps them as literal positionals.
6942 let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
6943 // `=` is not in the short-flag char class (`-k=v` parse-errors in the
6944 // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
6945 assert_eq!(classify("-k=v"), pos("-k=v"));
6946 assert_eq!(classify("-="), pos("-="));
6947 // Empty long-flag key.
6948 assert_eq!(classify("--=v"), pos("--=v"));
6949 // `--` followed by a non-letter is not a long flag.
6950 assert_eq!(classify("--1"), pos("--1"));
6951 // A bare dash and a number-dash are positionals (covered above too).
6952 assert_eq!(classify("-"), pos("-"));
6953 assert_eq!(classify("-9"), pos("-9"));
6954 // A non-ASCII tail is not part of the lexer's short-flag char class
6955 // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
6956 // absorbs) — classifying it as `ShortFlag` would hand the combined
6957 // short-flag binder a byte string it (correctly, for real ASCII flag
6958 // words) slices by *byte* index, panicking on a multi-byte char
6959 // boundary. Fall back to a literal positional instead.
6960 assert_eq!(classify("-lé"), pos("-lé"));
6961 assert_eq!(classify("-é"), pos("-é"));
6962 }
6963
6964 #[tokio::test]
6965 async fn non_ascii_short_flag_bundle_does_not_panic() {
6966 // Regression: `execute_argv`'s combined-short-flag loop assumed the
6967 // flag body was ASCII (safe to byte-slice) because the lexer's
6968 // grammar guarantees that on the *string* door. The argv door's
6969 // classifier let a non-ASCII tail through as `ShortFlag`, so
6970 // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
6971 let kernel = Kernel::transient().expect("failed to create kernel");
6972 let result = kernel
6973 .execute_argv("ls", &[Value::String("-lé".into())])
6974 .await
6975 .expect("execute_argv must not panic on a non-ASCII short-flag token");
6976 // Not a well-formed flag word, so it's a literal positional — `ls`
6977 // then reports it as a missing path rather than mangling flags.
6978 assert_ne!(result.code, 0);
6979 }
6980
6981 proptest::proptest! {
6982 /// The core correctness claim: the classifier mirrors the lexer/parser
6983 /// on metacharacter-free tokens. For any such single token, the `Arg`
6984 /// the classifier produces matches the one the real parser produces for
6985 /// the equivalent one-word command — so `execute_argv` reusing the
6986 /// string door's binder is sound. (First proptest in the workspace.)
6987 #[test]
6988 fn classifier_matches_parser_on_clean_tokens(
6989 // No digits: this property tests the *classification* boundary
6990 // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
6991 // positional), not numeric coercion. The lexer coerces digit runs to
6992 // `Int`/`Float` and drops the literal text (even inside a colon-merged
6993 // word: `00:` → `0:`); the classifier intentionally preserves the raw
6994 // string. Those numeric edges are pinned exactly by the unit tests.
6995 token in "[a-zA-Z_=./@:+-]{1,8}"
6996 ) {
6997 let parsed = match parse(&format!("cmd {token}")) {
6998 Ok(p) => p,
6999 Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
7000 };
7001 let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
7002 return Ok(());
7003 };
7004 // Only compare when the token lexed as exactly one argument.
7005 let [arg] = cmd.args.as_slice() else { return Ok(()); };
7006
7007 let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
7008 return Ok(()); // a non-literal parsed Expr we don't model — skip
7009 };
7010 proptest::prop_assert_eq!(
7011 ours, theirs,
7012 "classifier diverged from parser on token {:?}", token
7013 );
7014 }
7015 }
7016}
7017
7018#[cfg(all(test, feature = "subprocess"))]
7019#[allow(clippy::expect_used)]
7020mod tests {
7021 use super::*;
7022
7023 #[tokio::test]
7024 async fn test_kernel_transient() {
7025 let kernel = Kernel::transient().expect("failed to create kernel");
7026 assert_eq!(kernel.name(), "transient");
7027 }
7028
7029 #[tokio::test]
7030 async fn test_kernel_execute_echo() {
7031 let kernel = Kernel::transient().expect("failed to create kernel");
7032 let result = kernel.execute("echo hello").await.expect("execution failed");
7033 assert!(result.ok());
7034 assert_eq!(result.text_out().trim(), "hello");
7035 }
7036
7037 #[tokio::test]
7038 async fn test_multiple_statements_accumulate_output() {
7039 let kernel = Kernel::transient().expect("failed to create kernel");
7040 let result = kernel
7041 .execute("echo one\necho two\necho three")
7042 .await
7043 .expect("execution failed");
7044 assert!(result.ok());
7045 // Should have all three outputs separated by newlines
7046 assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
7047 assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
7048 assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
7049 }
7050
7051 #[tokio::test]
7052 async fn test_and_chain_accumulates_output() {
7053 let kernel = Kernel::transient().expect("failed to create kernel");
7054 let result = kernel
7055 .execute("echo first && echo second")
7056 .await
7057 .expect("execution failed");
7058 assert!(result.ok());
7059 assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
7060 assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
7061 }
7062
7063 #[tokio::test]
7064 async fn test_for_loop_accumulates_output() {
7065 let kernel = Kernel::transient().expect("failed to create kernel");
7066 let result = kernel
7067 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
7068 .await
7069 .expect("execution failed");
7070 assert!(result.ok());
7071 assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
7072 assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
7073 assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
7074 }
7075
7076 #[tokio::test]
7077 async fn test_while_loop_accumulates_output() {
7078 let kernel = Kernel::transient().expect("failed to create kernel");
7079 let result = kernel
7080 .execute(r#"
7081 N=3
7082 while [[ ${N} -gt 0 ]]; do
7083 echo "N=${N}"
7084 N=$((N - 1))
7085 done
7086 "#)
7087 .await
7088 .expect("execution failed");
7089 assert!(result.ok());
7090 assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7091 assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7092 assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7093 }
7094
7095 #[tokio::test]
7096 async fn test_kernel_set_var() {
7097 let kernel = Kernel::transient().expect("failed to create kernel");
7098
7099 kernel.execute("X=42").await.expect("set failed");
7100
7101 let value = kernel.get_var("X").await;
7102 assert_eq!(value, Some(Value::Int(42)));
7103 }
7104
7105 #[tokio::test]
7106 async fn test_kernel_var_expansion() {
7107 let kernel = Kernel::transient().expect("failed to create kernel");
7108
7109 kernel.execute("NAME=\"world\"").await.expect("set failed");
7110 let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7111
7112 assert!(result.ok());
7113 assert_eq!(result.text_out().trim(), "hello world");
7114 }
7115
7116 #[tokio::test]
7117 async fn test_kernel_last_result() {
7118 let kernel = Kernel::transient().expect("failed to create kernel");
7119
7120 kernel.execute("echo test").await.expect("echo failed");
7121
7122 let last = kernel.last_result().await;
7123 assert!(last.ok());
7124 assert_eq!(last.text_out().trim(), "test");
7125 }
7126
7127 #[tokio::test]
7128 async fn test_kernel_tool_not_found() {
7129 let kernel = Kernel::transient().expect("failed to create kernel");
7130
7131 let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
7132 assert!(!result.ok());
7133 assert_eq!(result.code, 127);
7134 assert!(result.err.contains("command not found"));
7135 }
7136
7137 #[tokio::test]
7138 async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
7139 // The embedder seam: a backend-registered tool (kaijutsu, an MCP
7140 // engine, …) returns a `ToolResult` with structured `data` — this
7141 // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
7142 // and `for r in $(embedder_tool)` see the typed value, not just
7143 // stdout text.
7144 use crate::backend::testing::MockBackend;
7145 use crate::backend::ToolResult;
7146 let (mock, _calls) = MockBackend::new();
7147 let backend = mock.with_tool_result(|_name| {
7148 let mut baggage = std::collections::BTreeMap::new();
7149 baggage.insert("trace_id".to_string(), "abc123".to_string());
7150 // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
7151 // construct via with_data + the with_* setters, not a struct literal.
7152 Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
7153 .with_content_type("application/json")
7154 .with_baggage(baggage))
7155 });
7156 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7157 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7158 .expect("with_backend kernel");
7159
7160 let result = kernel
7161 .execute("embedder_tool")
7162 .await
7163 .expect("execution failed");
7164 assert!(result.ok(), "backend tool call should succeed: {result:?}");
7165 assert_eq!(
7166 result.data,
7167 Some(Value::Json(serde_json::json!({"key": "value"}))),
7168 "backend tool's structured data must survive into ExecResult, not be dropped"
7169 );
7170 assert_eq!(
7171 result.content_type.as_deref(),
7172 Some("application/json"),
7173 "backend tool's content_type must survive into ExecResult"
7174 );
7175 assert_eq!(
7176 result.baggage.get("trace_id").map(String::as_str),
7177 Some("abc123"),
7178 "backend tool's baggage must survive into ExecResult"
7179 );
7180 }
7181
7182 #[tokio::test]
7183 async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
7184 // A backend tool that IS found but fails during execution (`Io`,
7185 // `PermissionDenied`, …) must surface its real error, not get
7186 // misreported as exit-127 "command not found" — that masks a genuine
7187 // failure as a lookup miss.
7188 use crate::backend::testing::MockBackend;
7189 let (mock, _calls) = MockBackend::new();
7190 let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
7191 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
7192 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
7193 .expect("with_backend kernel");
7194
7195 let result = kernel
7196 .execute("embedder_tool")
7197 .await
7198 .expect("execution failed");
7199 assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
7200 assert!(!result.ok());
7201 assert!(
7202 result.err.contains("disk exploded"),
7203 "the real backend error must be visible, not masked: {result:?}"
7204 );
7205 }
7206
7207 #[tokio::test]
7208 async fn test_external_command_true() {
7209 // Use REPL config for passthrough filesystem access
7210 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7211
7212 // /bin/true should be available on any Unix system
7213 let result = kernel.execute("true").await.expect("execution failed");
7214 // This should use the builtin true, which returns 0
7215 assert!(result.ok(), "true should succeed: {:?}", result);
7216 }
7217
7218 #[tokio::test]
7219 async fn test_external_command_basic() {
7220 // Use REPL config for passthrough filesystem access
7221 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
7222
7223 // Test with /bin/echo which is external
7224 // Note: kaish has a builtin echo, so this will use the builtin
7225 // Let's test with a command that's not a builtin
7226 // Actually, let's just test that PATH resolution works by checking the PATH var
7227 let path_var = std::env::var("PATH").unwrap_or_default();
7228 eprintln!("System PATH: {}", path_var);
7229
7230 // Set PATH in kernel to ensure it's available
7231 kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
7232
7233 // Now try an external command like /usr/bin/env
7234 // But env is also a builtin... let's try uname
7235 let result = kernel.execute("uname").await.expect("execution failed");
7236 eprintln!("uname result: {:?}", result);
7237 // uname should succeed if external commands work
7238 assert!(result.ok() || result.code == 127, "uname: {:?}", result);
7239 }
7240
7241 #[tokio::test]
7242 async fn test_kernel_reset() {
7243 let kernel = Kernel::transient().expect("failed to create kernel");
7244
7245 kernel.execute("X=1").await.expect("set failed");
7246 assert!(kernel.get_var("X").await.is_some());
7247
7248 kernel.reset().await.expect("reset failed");
7249 assert!(kernel.get_var("X").await.is_none());
7250 }
7251
7252 #[tokio::test]
7253 async fn test_kernel_reset_preserves_pid_and_initial_vars() {
7254 let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
7255 .expect("failed to create kernel");
7256
7257 let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7258 assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
7259
7260 kernel.reset().await.expect("reset failed");
7261
7262 let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
7263 assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
7264 assert_eq!(
7265 kernel.get_var("HOME").await,
7266 Some(Value::String("/home/probe".into())),
7267 "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
7268 );
7269 }
7270
7271 #[tokio::test]
7272 async fn test_kernel_cwd() {
7273 let kernel = Kernel::transient().expect("failed to create kernel");
7274
7275 // Transient kernel uses sandboxed mode with cwd=$HOME
7276 let cwd = kernel.cwd().await;
7277 let home = std::env::var("HOME")
7278 .map(PathBuf::from)
7279 .unwrap_or_else(|_| PathBuf::from("/"));
7280 assert_eq!(cwd, home);
7281
7282 kernel.set_cwd(PathBuf::from("/tmp")).await;
7283 assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
7284 }
7285
7286 #[tokio::test]
7287 async fn test_kernel_list_vars() {
7288 let kernel = Kernel::transient().expect("failed to create kernel");
7289
7290 kernel.execute("A=1").await.ok();
7291 kernel.execute("B=2").await.ok();
7292
7293 let vars = kernel.list_vars().await;
7294 assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
7295 assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
7296 }
7297
7298 #[tokio::test]
7299 async fn test_is_truthy() {
7300 assert!(!is_truthy(&Value::Null));
7301 assert!(!is_truthy(&Value::Bool(false)));
7302 assert!(is_truthy(&Value::Bool(true)));
7303 assert!(!is_truthy(&Value::Int(0)));
7304 assert!(is_truthy(&Value::Int(1)));
7305 assert!(!is_truthy(&Value::String("".into())));
7306 assert!(is_truthy(&Value::String("x".into())));
7307 }
7308
7309 #[tokio::test]
7310 async fn test_jq_in_pipeline() {
7311 let kernel = Kernel::transient().expect("failed to create kernel");
7312 // kaish uses double quotes only; escape inner quotes
7313 let result = kernel
7314 .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
7315 .await
7316 .expect("execution failed");
7317 assert!(result.ok(), "jq pipeline failed: {}", result.err);
7318 assert_eq!(result.text_out().trim(), "Alice");
7319 }
7320
7321 #[tokio::test]
7322 async fn test_user_defined_tool() {
7323 let kernel = Kernel::transient().expect("failed to create kernel");
7324
7325 // Define a function
7326 kernel
7327 .execute(r#"greet() { echo "Hello, $1!" }"#)
7328 .await
7329 .expect("function definition failed");
7330
7331 // Call the function
7332 let result = kernel
7333 .execute(r#"greet "World""#)
7334 .await
7335 .expect("function call failed");
7336
7337 assert!(result.ok(), "greet failed: {}", result.err);
7338 assert_eq!(result.text_out().trim(), "Hello, World!");
7339 }
7340
7341 #[tokio::test]
7342 async fn test_user_tool_positional_args() {
7343 let kernel = Kernel::transient().expect("failed to create kernel");
7344
7345 // Define a function with positional param
7346 kernel
7347 .execute(r#"greet() { echo "Hi $1" }"#)
7348 .await
7349 .expect("function definition failed");
7350
7351 // Call with positional argument
7352 let result = kernel
7353 .execute(r#"greet "Amy""#)
7354 .await
7355 .expect("function call failed");
7356
7357 assert!(result.ok(), "greet failed: {}", result.err);
7358 assert_eq!(result.text_out().trim(), "Hi Amy");
7359 }
7360
7361 #[tokio::test]
7362 async fn test_function_shared_scope() {
7363 let kernel = Kernel::transient().expect("failed to create kernel");
7364
7365 // Set a variable in parent scope
7366 kernel
7367 .execute(r#"SECRET="hidden""#)
7368 .await
7369 .expect("set failed");
7370
7371 // Define a function that accesses and modifies parent variable
7372 kernel
7373 .execute(r#"access_parent() {
7374 echo "${SECRET}"
7375 SECRET="modified"
7376 }"#)
7377 .await
7378 .expect("function definition failed");
7379
7380 // Call the function - it SHOULD see SECRET (shared scope like sh)
7381 let result = kernel.execute("access_parent").await.expect("function call failed");
7382
7383 // Function should have access to parent scope
7384 assert!(
7385 result.text_out().contains("hidden"),
7386 "Function should access parent scope, got: {}",
7387 result.text_out()
7388 );
7389
7390 // Function should have modified the parent variable
7391 let secret = kernel.get_var("SECRET").await;
7392 assert_eq!(
7393 secret,
7394 Some(Value::String("modified".into())),
7395 "Function should modify parent scope"
7396 );
7397 }
7398
7399 #[tokio::test]
7400 #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
7401 async fn test_exec_builtin() {
7402 let kernel = Kernel::transient().expect("failed to create kernel");
7403 // argv is now a space-separated string or JSON array string
7404 let result = kernel
7405 .execute(r#"exec command="/bin/echo" argv="hello world""#)
7406 .await
7407 .expect("exec failed");
7408
7409 assert!(result.ok(), "exec failed: {}", result.err);
7410 assert_eq!(result.text_out().trim(), "hello world");
7411 }
7412
7413 #[tokio::test]
7414 async fn test_while_false_never_runs() {
7415 let kernel = Kernel::transient().expect("failed to create kernel");
7416
7417 // A while loop with false condition should never run
7418 let result = kernel
7419 .execute(r#"
7420 while false; do
7421 echo "should not run"
7422 done
7423 "#)
7424 .await
7425 .expect("while false failed");
7426
7427 assert!(result.ok());
7428 assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
7429 }
7430
7431 #[tokio::test]
7432 async fn test_while_string_comparison() {
7433 let kernel = Kernel::transient().expect("failed to create kernel");
7434
7435 // Set a flag
7436 kernel.execute(r#"FLAG="go""#).await.expect("set failed");
7437
7438 // Use string comparison as condition (shell-compatible [[ ]] syntax)
7439 // Note: Put echo last so we can check the output
7440 let result = kernel
7441 .execute(r#"
7442 while [[ ${FLAG} == "go" ]]; do
7443 FLAG="stop"
7444 echo "running"
7445 done
7446 "#)
7447 .await
7448 .expect("while with string cmp failed");
7449
7450 assert!(result.ok());
7451 assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
7452
7453 // Verify flag was changed
7454 let flag = kernel.get_var("FLAG").await;
7455 assert_eq!(flag, Some(Value::String("stop".into())));
7456 }
7457
7458 #[tokio::test]
7459 async fn test_while_numeric_comparison() {
7460 let kernel = Kernel::transient().expect("failed to create kernel");
7461
7462 // Test > comparison (shell-compatible [[ ]] with -gt)
7463 kernel.execute("N=5").await.expect("set failed");
7464
7465 // Note: Put echo last so we can check the output
7466 let result = kernel
7467 .execute(r#"
7468 while [[ ${N} -gt 3 ]]; do
7469 N=3
7470 echo "N was greater"
7471 done
7472 "#)
7473 .await
7474 .expect("while with > failed");
7475
7476 assert!(result.ok());
7477 assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
7478 }
7479
7480 #[tokio::test]
7481 async fn test_break_in_while_loop() {
7482 let kernel = Kernel::transient().expect("failed to create kernel");
7483
7484 let result = kernel
7485 .execute(r#"
7486 I=0
7487 while true; do
7488 I=1
7489 echo "before break"
7490 break
7491 echo "after break"
7492 done
7493 "#)
7494 .await
7495 .expect("while with break failed");
7496
7497 assert!(result.ok());
7498 assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
7499 assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
7500
7501 // Verify we exited the loop
7502 let i = kernel.get_var("I").await;
7503 assert_eq!(i, Some(Value::Int(1)));
7504 }
7505
7506 #[tokio::test]
7507 async fn test_continue_in_while_loop() {
7508 let kernel = Kernel::transient().expect("failed to create kernel");
7509
7510 // Test continue in a while loop where variables persist
7511 // We use string state transition: "start" -> "middle" -> "end"
7512 // continue on "middle" should skip to next iteration
7513 // Shell-compatible: use [[ ]] for comparisons
7514 let result = kernel
7515 .execute(r#"
7516 STATE="start"
7517 AFTER_CONTINUE="no"
7518 while [[ ${STATE} != "done" ]]; do
7519 if [[ ${STATE} == "start" ]]; then
7520 STATE="middle"
7521 continue
7522 AFTER_CONTINUE="yes"
7523 fi
7524 if [[ ${STATE} == "middle" ]]; then
7525 STATE="done"
7526 fi
7527 done
7528 "#)
7529 .await
7530 .expect("while with continue failed");
7531
7532 assert!(result.ok());
7533
7534 // STATE should be "done" (we completed the loop)
7535 let state = kernel.get_var("STATE").await;
7536 assert_eq!(state, Some(Value::String("done".into())));
7537
7538 // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
7539 let after = kernel.get_var("AFTER_CONTINUE").await;
7540 assert_eq!(after, Some(Value::String("no".into())));
7541 }
7542
7543 #[tokio::test]
7544 async fn test_break_with_level() {
7545 let kernel = Kernel::transient().expect("failed to create kernel");
7546
7547 // Nested loop with break 2 to exit both loops
7548 // We verify by checking OUTER value:
7549 // - If break 2 works, OUTER stays at 1 (set before for loop)
7550 // - If break 2 fails, OUTER becomes 2 (set after for loop)
7551 let result = kernel
7552 .execute(r#"
7553 OUTER=0
7554 while true; do
7555 OUTER=1
7556 for X in "1 2"; do
7557 break 2
7558 done
7559 OUTER=2
7560 done
7561 "#)
7562 .await
7563 .expect("nested break failed");
7564
7565 assert!(result.ok());
7566
7567 // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
7568 let outer = kernel.get_var("OUTER").await;
7569 assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
7570 }
7571
7572 #[tokio::test]
7573 async fn test_return_from_tool() {
7574 let kernel = Kernel::transient().expect("failed to create kernel");
7575
7576 // Define a function that returns early
7577 kernel
7578 .execute(r#"early_return() {
7579 if [[ $1 == 1 ]]; then
7580 return 42
7581 fi
7582 echo "not returned"
7583 }"#)
7584 .await
7585 .expect("function definition failed");
7586
7587 // Call with arg=1 should return with exit code 42
7588 // (POSIX shell behavior: return N sets exit code, doesn't output N)
7589 let result = kernel
7590 .execute("early_return 1")
7591 .await
7592 .expect("function call failed");
7593
7594 // Exit code should be 42 (non-zero, so not ok())
7595 assert_eq!(result.code, 42);
7596 // Output should be empty (we returned before echo)
7597 assert!(result.text_out().is_empty());
7598 }
7599
7600 #[tokio::test]
7601 async fn test_return_without_value() {
7602 let kernel = Kernel::transient().expect("failed to create kernel");
7603
7604 // Define a function that returns without a value
7605 kernel
7606 .execute(r#"early_exit() {
7607 if [[ $1 == "stop" ]]; then
7608 return
7609 fi
7610 echo "continued"
7611 }"#)
7612 .await
7613 .expect("function definition failed");
7614
7615 // Call with arg="stop" should return early
7616 let result = kernel
7617 .execute(r#"early_exit "stop""#)
7618 .await
7619 .expect("function call failed");
7620
7621 assert!(result.ok());
7622 assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
7623 }
7624
7625 #[tokio::test]
7626 async fn test_exit_stops_execution() {
7627 let kernel = Kernel::transient().expect("failed to create kernel");
7628
7629 // exit should stop further execution
7630 kernel
7631 .execute(r#"
7632 BEFORE="yes"
7633 exit 0
7634 AFTER="yes"
7635 "#)
7636 .await
7637 .expect("execution failed");
7638
7639 // BEFORE should be set, AFTER should not
7640 let before = kernel.get_var("BEFORE").await;
7641 assert_eq!(before, Some(Value::String("yes".into())));
7642
7643 let after = kernel.get_var("AFTER").await;
7644 assert!(after.is_none(), "AFTER should not be set after exit");
7645 }
7646
7647 #[tokio::test]
7648 async fn test_exit_with_code() {
7649 let kernel = Kernel::transient().expect("failed to create kernel");
7650
7651 // exit with code should propagate the exit code
7652 let result = kernel
7653 .execute("exit 42")
7654 .await
7655 .expect("exit failed");
7656
7657 assert_eq!(result.code, 42);
7658 assert!(result.text_out().is_empty(), "exit should not produce stdout");
7659 }
7660
7661 #[tokio::test]
7662 async fn test_set_e_stops_on_failure() {
7663 let kernel = Kernel::transient().expect("failed to create kernel");
7664
7665 // Enable error-exit mode
7666 kernel.execute("set -e").await.expect("set -e failed");
7667
7668 // Run a sequence where the middle command fails
7669 kernel
7670 .execute(r#"
7671 STEP1="done"
7672 false
7673 STEP2="done"
7674 "#)
7675 .await
7676 .expect("execution failed");
7677
7678 // STEP1 should be set, but STEP2 should NOT be set (exit on false)
7679 let step1 = kernel.get_var("STEP1").await;
7680 assert_eq!(step1, Some(Value::String("done".into())));
7681
7682 let step2 = kernel.get_var("STEP2").await;
7683 assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
7684 }
7685
7686 #[tokio::test]
7687 async fn test_set_plus_e_disables_error_exit() {
7688 let kernel = Kernel::transient().expect("failed to create kernel");
7689
7690 // Enable then disable error-exit mode
7691 kernel.execute("set -e").await.expect("set -e failed");
7692 kernel.execute("set +e").await.expect("set +e failed");
7693
7694 // Now failure should NOT stop execution
7695 kernel
7696 .execute(r#"
7697 STEP1="done"
7698 false
7699 STEP2="done"
7700 "#)
7701 .await
7702 .expect("execution failed");
7703
7704 // Both should be set since +e disables error exit
7705 let step1 = kernel.get_var("STEP1").await;
7706 assert_eq!(step1, Some(Value::String("done".into())));
7707
7708 let step2 = kernel.get_var("STEP2").await;
7709 assert_eq!(step2, Some(Value::String("done".into())));
7710 }
7711
7712 #[tokio::test]
7713 async fn test_set_ignores_unknown_options() {
7714 let kernel = Kernel::transient().expect("failed to create kernel");
7715
7716 // Bash idiom: set -euo pipefail (we support -e, ignore the rest)
7717 let result = kernel
7718 .execute("set -e -u -o pipefail")
7719 .await
7720 .expect("set with unknown options failed");
7721
7722 assert!(result.ok(), "set should succeed with unknown options");
7723
7724 // -e should still be enabled
7725 kernel
7726 .execute(r#"
7727 BEFORE="yes"
7728 false
7729 AFTER="yes"
7730 "#)
7731 .await
7732 .ok();
7733
7734 let after = kernel.get_var("AFTER").await;
7735 assert!(after.is_none(), "-e should be enabled despite unknown options");
7736 }
7737
7738 #[tokio::test]
7739 async fn test_set_no_args_shows_settings() {
7740 let kernel = Kernel::transient().expect("failed to create kernel");
7741
7742 // Enable -e
7743 kernel.execute("set -e").await.expect("set -e failed");
7744
7745 // Call set with no args to see settings
7746 let result = kernel.execute("set").await.expect("set failed");
7747
7748 assert!(result.ok());
7749 assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
7750 }
7751
7752 #[tokio::test]
7753 async fn test_set_e_in_pipeline() {
7754 let kernel = Kernel::transient().expect("failed to create kernel");
7755
7756 kernel.execute("set -e").await.expect("set -e failed");
7757
7758 // Pipeline failure should trigger exit
7759 kernel
7760 .execute(r#"
7761 BEFORE="yes"
7762 false | cat
7763 AFTER="yes"
7764 "#)
7765 .await
7766 .ok();
7767
7768 let before = kernel.get_var("BEFORE").await;
7769 assert_eq!(before, Some(Value::String("yes".into())));
7770
7771 // AFTER should not be set if pipeline failure triggers exit
7772 // Note: The exit code of a pipeline is the exit code of the last command
7773 // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
7774 // To test pipeline failure, we need the last command to fail.
7775 }
7776
7777 #[tokio::test]
7778 async fn test_set_e_with_and_chain() {
7779 let kernel = Kernel::transient().expect("failed to create kernel");
7780
7781 kernel.execute("set -e").await.expect("set -e failed");
7782
7783 // Commands in && chain should not trigger -e on the first failure
7784 // because && explicitly handles the error
7785 kernel
7786 .execute(r#"
7787 RESULT="initial"
7788 false && RESULT="chained"
7789 RESULT="continued"
7790 "#)
7791 .await
7792 .ok();
7793
7794 // In bash, commands in && don't trigger -e. The chain handles the failure.
7795 // Our implementation may differ - let's verify current behavior.
7796 let result = kernel.get_var("RESULT").await;
7797 // If we follow bash semantics, RESULT should be "continued"
7798 // If we trigger -e on the false, RESULT stays "initial"
7799 assert!(result.is_some(), "RESULT should be set");
7800 }
7801
7802 #[tokio::test]
7803 async fn test_set_e_exits_in_for_loop() {
7804 let kernel = Kernel::transient().expect("failed to create kernel");
7805
7806 kernel.execute("set -e").await.expect("set -e failed");
7807
7808 kernel
7809 .execute(r#"
7810 REACHED="no"
7811 for x in 1 2 3; do
7812 false
7813 REACHED="yes"
7814 done
7815 "#)
7816 .await
7817 .ok();
7818
7819 // With set -e, false should trigger exit; REACHED should remain "no"
7820 let reached = kernel.get_var("REACHED").await;
7821 assert_eq!(reached, Some(Value::String("no".into())),
7822 "set -e should exit on failure in for loop body");
7823 }
7824
7825 #[tokio::test]
7826 async fn test_for_loop_continues_without_set_e() {
7827 let kernel = Kernel::transient().expect("failed to create kernel");
7828
7829 // Without set -e, for loop should continue normally
7830 kernel
7831 .execute(r#"
7832 COUNT=0
7833 for x in 1 2 3; do
7834 false
7835 COUNT=$((COUNT + 1))
7836 done
7837 "#)
7838 .await
7839 .ok();
7840
7841 let count = kernel.get_var("COUNT").await;
7842 // Arithmetic produces Int values; accept either Int or String representation
7843 let count_val = match &count {
7844 Some(Value::Int(n)) => *n,
7845 Some(Value::String(s)) => s.parse().unwrap_or(-1),
7846 _ => -1,
7847 };
7848 assert_eq!(count_val, 3,
7849 "without set -e, loop should complete all iterations (got {:?})", count);
7850 }
7851
7852 // ═══════════════════════════════════════════════════════════════════════════
7853 // Source Tests
7854 // ═══════════════════════════════════════════════════════════════════════════
7855
7856 #[tokio::test]
7857 async fn test_source_sets_variables() {
7858 let kernel = Kernel::transient().expect("failed to create kernel");
7859
7860 // Write a script to the VFS
7861 kernel
7862 .execute(r#"write "/test.kai" 'FOO="bar"'"#)
7863 .await
7864 .expect("write failed");
7865
7866 // Source the script
7867 let result = kernel
7868 .execute(r#"source "/test.kai""#)
7869 .await
7870 .expect("source failed");
7871
7872 assert!(result.ok(), "source should succeed");
7873
7874 // Variable should be set in current scope
7875 let foo = kernel.get_var("FOO").await;
7876 assert_eq!(foo, Some(Value::String("bar".into())));
7877 }
7878
7879 #[tokio::test]
7880 async fn test_source_with_dot_alias() {
7881 let kernel = Kernel::transient().expect("failed to create kernel");
7882
7883 // Write a script to the VFS
7884 kernel
7885 .execute(r#"write "/vars.kai" 'X=42'"#)
7886 .await
7887 .expect("write failed");
7888
7889 // Source using . alias
7890 let result = kernel
7891 .execute(r#". "/vars.kai""#)
7892 .await
7893 .expect(". failed");
7894
7895 assert!(result.ok(), ". should succeed");
7896
7897 // Variable should be set in current scope
7898 let x = kernel.get_var("X").await;
7899 assert_eq!(x, Some(Value::Int(42)));
7900 }
7901
7902 #[tokio::test]
7903 async fn test_source_not_found() {
7904 let kernel = Kernel::transient().expect("failed to create kernel");
7905
7906 // Try to source a non-existent file
7907 let result = kernel
7908 .execute(r#"source "/nonexistent.kai""#)
7909 .await
7910 .expect("source should not fail with error");
7911
7912 assert!(!result.ok(), "source of non-existent file should fail");
7913 assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
7914 }
7915
7916 #[tokio::test]
7917 async fn test_source_missing_filename() {
7918 let kernel = Kernel::transient().expect("failed to create kernel");
7919
7920 // Call source with no arguments
7921 let result = kernel
7922 .execute("source")
7923 .await
7924 .expect("source should not fail with error");
7925
7926 assert!(!result.ok(), "source without filename should fail");
7927 assert!(result.err.contains("missing filename"), "error should mention missing filename");
7928 }
7929
7930 #[tokio::test]
7931 async fn test_source_executes_multiple_statements() {
7932 let kernel = Kernel::transient().expect("failed to create kernel");
7933
7934 // Write a script with multiple statements
7935 kernel
7936 .execute(r#"write "/multi.kai" 'A=1
7937B=2
7938C=3'"#)
7939 .await
7940 .expect("write failed");
7941
7942 // Source it
7943 kernel
7944 .execute(r#"source "/multi.kai""#)
7945 .await
7946 .expect("source failed");
7947
7948 // All variables should be set
7949 assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
7950 assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
7951 assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
7952 }
7953
7954 #[tokio::test]
7955 async fn test_source_can_define_functions() {
7956 let kernel = Kernel::transient().expect("failed to create kernel");
7957
7958 // Write a script that defines a function
7959 kernel
7960 .execute(r#"write "/functions.kai" 'greet() {
7961 echo "Hello, $1!"
7962}'"#)
7963 .await
7964 .expect("write failed");
7965
7966 // Source it
7967 kernel
7968 .execute(r#"source "/functions.kai""#)
7969 .await
7970 .expect("source failed");
7971
7972 // Use the defined function
7973 let result = kernel
7974 .execute(r#"greet "World""#)
7975 .await
7976 .expect("greet failed");
7977
7978 assert!(result.ok());
7979 assert!(result.text_out().contains("Hello, World!"));
7980 }
7981
7982 #[tokio::test]
7983 async fn test_source_inherits_error_exit() {
7984 let kernel = Kernel::transient().expect("failed to create kernel");
7985
7986 // Enable error exit
7987 kernel.execute("set -e").await.expect("set -e failed");
7988
7989 // Write a script that has a failure
7990 kernel
7991 .execute(r#"write "/fail.kai" 'BEFORE="yes"
7992false
7993AFTER="yes"'"#)
7994 .await
7995 .expect("write failed");
7996
7997 // Source it (should exit on false due to set -e)
7998 kernel
7999 .execute(r#"source "/fail.kai""#)
8000 .await
8001 .ok();
8002
8003 // BEFORE should be set, AFTER should NOT be set due to error exit
8004 let before = kernel.get_var("BEFORE").await;
8005 assert_eq!(before, Some(Value::String("yes".into())));
8006
8007 // Note: This test depends on whether error exit is checked within source
8008 // Currently our implementation checks per-statement in the main kernel
8009 }
8010
8011 // ═══════════════════════════════════════════════════════════════════════════
8012 // set -e with && / || chains
8013 // ═══════════════════════════════════════════════════════════════════════════
8014
8015 #[tokio::test]
8016 async fn test_set_e_and_chain_left_fails() {
8017 // set -e; false && echo hi; REACHED=1 → REACHED should be set
8018 let kernel = Kernel::transient().expect("failed to create kernel");
8019 kernel.execute("set -e").await.expect("set -e failed");
8020
8021 kernel
8022 .execute("false && echo hi; REACHED=1")
8023 .await
8024 .expect("execution failed");
8025
8026 let reached = kernel.get_var("REACHED").await;
8027 assert_eq!(
8028 reached,
8029 Some(Value::Int(1)),
8030 "set -e should not trigger on left side of &&"
8031 );
8032 }
8033
8034 #[tokio::test]
8035 async fn test_set_e_and_chain_right_fails() {
8036 // set -e; true && false; REACHED=1 → REACHED should NOT be set
8037 let kernel = Kernel::transient().expect("failed to create kernel");
8038 kernel.execute("set -e").await.expect("set -e failed");
8039
8040 kernel
8041 .execute("true && false; REACHED=1")
8042 .await
8043 .expect("execution failed");
8044
8045 let reached = kernel.get_var("REACHED").await;
8046 assert!(
8047 reached.is_none(),
8048 "set -e should trigger when right side of && fails"
8049 );
8050 }
8051
8052 #[tokio::test]
8053 async fn test_set_e_or_chain_recovers() {
8054 // set -e; false || echo recovered; REACHED=1 → REACHED should be set
8055 let kernel = Kernel::transient().expect("failed to create kernel");
8056 kernel.execute("set -e").await.expect("set -e failed");
8057
8058 kernel
8059 .execute("false || echo recovered; REACHED=1")
8060 .await
8061 .expect("execution failed");
8062
8063 let reached = kernel.get_var("REACHED").await;
8064 assert_eq!(
8065 reached,
8066 Some(Value::Int(1)),
8067 "set -e should not trigger when || recovers the failure"
8068 );
8069 }
8070
8071 #[tokio::test]
8072 async fn test_set_e_or_chain_both_fail() {
8073 // set -e; false || false; REACHED=1 → REACHED should NOT be set
8074 let kernel = Kernel::transient().expect("failed to create kernel");
8075 kernel.execute("set -e").await.expect("set -e failed");
8076
8077 kernel
8078 .execute("false || false; REACHED=1")
8079 .await
8080 .expect("execution failed");
8081
8082 let reached = kernel.get_var("REACHED").await;
8083 assert!(
8084 reached.is_none(),
8085 "set -e should trigger when || chain ultimately fails"
8086 );
8087 }
8088
8089 // ═══════════════════════════════════════════════════════════════════════════
8090 // Cancellation Tests
8091 // ═══════════════════════════════════════════════════════════════════════════
8092
8093 /// Helper: schedule a cancel after a delay from a background thread.
8094 /// Uses std::thread because cancel() is sync and Kernel is not Send.
8095 fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
8096 let k = Arc::clone(kernel);
8097 std::thread::spawn(move || {
8098 std::thread::sleep(delay);
8099 k.cancel();
8100 });
8101 }
8102
8103 #[tokio::test]
8104 async fn test_cancel_interrupts_for_loop() {
8105 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8106
8107 // Schedule cancel after a short delay from a background OS thread
8108 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8109
8110 // #149: a bare `X=$i` body has no await point, so the for-loop's
8111 // cancellation checkpoint (checked once per iteration, see the
8112 // `Stmt::For` arm above) never gets a chance to run mid-body — under
8113 // host load, 100_000 trivial iterations could complete and return
8114 // before the background thread's 10ms sleep ever elapsed, racing a
8115 // natural exit-0 completion against the scheduled cancel. Rather than
8116 // widen the margin (there's no bound on how slow "under load" can be),
8117 // make completion deterministically impossible inside the test
8118 // window: `sleep` is a real interruptible await point (it races
8119 // `tokio::time::sleep` against the same cancellation token — see
8120 // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
8121 // cancellation somewhere to land almost immediately AND, at enough
8122 // iterations, makes natural completion take far longer than the
8123 // bounded wait below. The outer timeout is the "must not hang CI if
8124 // cancellation is broken" backstop: it fails loudly well before the
8125 // loop could ever finish on its own.
8126 const ITERATIONS: u32 = 2000;
8127 const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
8128 let bound = std::time::Duration::from_secs(10);
8129 let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
8130
8131 let result = tokio::time::timeout(bound, kernel.execute(&script))
8132 .await
8133 .unwrap_or_else(|_| {
8134 panic!(
8135 "for-loop did not return within {bound:?} — cancellation support looks \
8136 broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
8137 longer than this bound)",
8138 ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
8139 )
8140 })
8141 .expect("execute failed");
8142
8143 assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
8144
8145 // The loop variable should be set to something well short of the full
8146 // iteration count — i.e. cancellation landed long before the loop
8147 // could complete on its own.
8148 let x = kernel.get_var("X").await;
8149 if let Some(Value::Int(n)) = x {
8150 assert!(
8151 n < i64::from(ITERATIONS),
8152 "loop should have been interrupted before finishing, got X={n}"
8153 );
8154 }
8155 }
8156
8157 #[tokio::test]
8158 async fn test_cancel_interrupts_while_loop() {
8159 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8160 kernel.execute("COUNT=0").await.expect("init failed");
8161
8162 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
8163
8164 let result = kernel
8165 .execute("while true; do COUNT=$((COUNT + 1)); done")
8166 .await
8167 .expect("execute failed");
8168
8169 assert_eq!(result.code, 130);
8170
8171 let count = kernel.get_var("COUNT").await;
8172 if let Some(Value::Int(n)) = count {
8173 assert!(n > 0, "loop should have run at least once");
8174 }
8175 }
8176
8177 #[tokio::test]
8178 async fn test_reset_after_cancel() {
8179 // After cancellation, the next execute() should work normally
8180 let kernel = Kernel::transient().expect("failed to create kernel");
8181 kernel.cancel(); // cancel with nothing running
8182
8183 let result = kernel.execute("echo hello").await.expect("execute failed");
8184 assert!(result.ok(), "execute after cancel should succeed");
8185 assert_eq!(result.text_out().trim(), "hello");
8186 }
8187
8188 #[tokio::test]
8189 async fn test_cancel_interrupts_statement_sequence() {
8190 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
8191
8192 // Schedule cancel after the first statement runs but before sleep finishes
8193 schedule_cancel(&kernel, std::time::Duration::from_millis(50));
8194
8195 let result = kernel
8196 .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
8197 .await
8198 .expect("execute failed");
8199
8200 assert_eq!(result.code, 130);
8201
8202 // STEP should be 1 (set before sleep), not 2 or 3
8203 let step = kernel.get_var("STEP").await;
8204 assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
8205 }
8206
8207 // ═══════════════════════════════════════════════════════════════════════════
8208 // Case Statement Tests
8209 // ═══════════════════════════════════════════════════════════════════════════
8210
8211 #[tokio::test]
8212 async fn test_case_simple_match() {
8213 let kernel = Kernel::transient().expect("failed to create kernel");
8214
8215 let result = kernel
8216 .execute(r#"
8217 case "hello" in
8218 hello) echo "matched hello" ;;
8219 world) echo "matched world" ;;
8220 esac
8221 "#)
8222 .await
8223 .expect("case failed");
8224
8225 assert!(result.ok());
8226 assert_eq!(result.text_out().trim(), "matched hello");
8227 }
8228
8229 #[tokio::test]
8230 async fn test_case_wildcard_match() {
8231 let kernel = Kernel::transient().expect("failed to create kernel");
8232
8233 let result = kernel
8234 .execute(r#"
8235 case "main.rs" in
8236 *.py) echo "Python" ;;
8237 *.rs) echo "Rust" ;;
8238 *) echo "Unknown" ;;
8239 esac
8240 "#)
8241 .await
8242 .expect("case failed");
8243
8244 assert!(result.ok());
8245 assert_eq!(result.text_out().trim(), "Rust");
8246 }
8247
8248 #[tokio::test]
8249 async fn test_case_default_match() {
8250 let kernel = Kernel::transient().expect("failed to create kernel");
8251
8252 let result = kernel
8253 .execute(r#"
8254 case "unknown.xyz" in
8255 *.py) echo "Python" ;;
8256 *.rs) echo "Rust" ;;
8257 *) echo "Default" ;;
8258 esac
8259 "#)
8260 .await
8261 .expect("case failed");
8262
8263 assert!(result.ok());
8264 assert_eq!(result.text_out().trim(), "Default");
8265 }
8266
8267 #[tokio::test]
8268 async fn test_case_no_match() {
8269 let kernel = Kernel::transient().expect("failed to create kernel");
8270
8271 // Case with no default branch and no match
8272 let result = kernel
8273 .execute(r#"
8274 case "nope" in
8275 "yes") echo "yes" ;;
8276 "no") echo "no" ;;
8277 esac
8278 "#)
8279 .await
8280 .expect("case failed");
8281
8282 assert!(result.ok());
8283 assert!(result.text_out().is_empty(), "no match should produce empty output");
8284 }
8285
8286 #[tokio::test]
8287 async fn test_case_with_variable() {
8288 let kernel = Kernel::transient().expect("failed to create kernel");
8289
8290 kernel.execute(r#"LANG="rust""#).await.expect("set failed");
8291
8292 let result = kernel
8293 .execute(r#"
8294 case ${LANG} in
8295 python) echo "snake" ;;
8296 rust) echo "crab" ;;
8297 go) echo "gopher" ;;
8298 esac
8299 "#)
8300 .await
8301 .expect("case failed");
8302
8303 assert!(result.ok());
8304 assert_eq!(result.text_out().trim(), "crab");
8305 }
8306
8307 #[tokio::test]
8308 async fn test_case_multiple_patterns() {
8309 let kernel = Kernel::transient().expect("failed to create kernel");
8310
8311 let result = kernel
8312 .execute(r#"
8313 case "yes" in
8314 "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
8315 "n"|"no"|"N"|"NO") echo "negative" ;;
8316 esac
8317 "#)
8318 .await
8319 .expect("case failed");
8320
8321 assert!(result.ok());
8322 assert_eq!(result.text_out().trim(), "affirmative");
8323 }
8324
8325 #[tokio::test]
8326 async fn test_case_glob_question_mark() {
8327 let kernel = Kernel::transient().expect("failed to create kernel");
8328
8329 let result = kernel
8330 .execute(r#"
8331 case "test1" in
8332 test?) echo "matched test?" ;;
8333 *) echo "default" ;;
8334 esac
8335 "#)
8336 .await
8337 .expect("case failed");
8338
8339 assert!(result.ok());
8340 assert_eq!(result.text_out().trim(), "matched test?");
8341 }
8342
8343 #[tokio::test]
8344 async fn test_case_char_class() {
8345 let kernel = Kernel::transient().expect("failed to create kernel");
8346
8347 let result = kernel
8348 .execute(r#"
8349 case "Yes" in
8350 [Yy]*) echo "yes-like" ;;
8351 [Nn]*) echo "no-like" ;;
8352 esac
8353 "#)
8354 .await
8355 .expect("case failed");
8356
8357 assert!(result.ok());
8358 assert_eq!(result.text_out().trim(), "yes-like");
8359 }
8360
8361 // ═══════════════════════════════════════════════════════════════════════════
8362 // Cat Stdin Tests
8363 // ═══════════════════════════════════════════════════════════════════════════
8364
8365 #[tokio::test]
8366 async fn test_cat_from_pipeline() {
8367 let kernel = Kernel::transient().expect("failed to create kernel");
8368
8369 let result = kernel
8370 .execute(r#"echo "piped text" | cat"#)
8371 .await
8372 .expect("cat pipeline failed");
8373
8374 assert!(result.ok(), "cat failed: {}", result.err);
8375 assert_eq!(result.text_out().trim(), "piped text");
8376 }
8377
8378 #[tokio::test]
8379 async fn test_cat_from_pipeline_multiline() {
8380 let kernel = Kernel::transient().expect("failed to create kernel");
8381
8382 let result = kernel
8383 .execute(r#"echo "line1\nline2" | cat -n"#)
8384 .await
8385 .expect("cat pipeline failed");
8386
8387 assert!(result.ok(), "cat failed: {}", result.err);
8388 assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
8389 }
8390
8391 // ═══════════════════════════════════════════════════════════════════════════
8392 // Heredoc Tests
8393 // ═══════════════════════════════════════════════════════════════════════════
8394
8395 #[tokio::test]
8396 async fn test_heredoc_basic() {
8397 let kernel = Kernel::transient().expect("failed to create kernel");
8398
8399 let result = kernel
8400 .execute("cat <<EOF\nhello\nEOF")
8401 .await
8402 .expect("heredoc failed");
8403
8404 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8405 assert_eq!(result.text_out().trim(), "hello");
8406 }
8407
8408 #[tokio::test]
8409 async fn test_arithmetic_in_string() {
8410 let kernel = Kernel::transient().expect("failed to create kernel");
8411
8412 let result = kernel
8413 .execute(r#"echo "result: $((1 + 2))""#)
8414 .await
8415 .expect("arithmetic in string failed");
8416
8417 assert!(result.ok(), "echo failed: {}", result.err);
8418 assert_eq!(result.text_out().trim(), "result: 3");
8419 }
8420
8421 #[tokio::test]
8422 async fn test_heredoc_multiline() {
8423 let kernel = Kernel::transient().expect("failed to create kernel");
8424
8425 let result = kernel
8426 .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
8427 .await
8428 .expect("heredoc failed");
8429
8430 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
8431 assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
8432 assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
8433 assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
8434 }
8435
8436 #[tokio::test]
8437 async fn test_heredoc_variable_expansion() {
8438 // Bug N: unquoted heredoc should expand variables
8439 let kernel = Kernel::transient().expect("failed to create kernel");
8440
8441 kernel.execute("GREETING=hello").await.expect("set var");
8442
8443 let result = kernel
8444 .execute("cat <<EOF\n$GREETING world\nEOF")
8445 .await
8446 .expect("heredoc expansion failed");
8447
8448 assert!(result.ok(), "heredoc expansion failed: {}", result.err);
8449 assert_eq!(result.text_out().trim(), "hello world");
8450 }
8451
8452 #[tokio::test]
8453 async fn test_heredoc_quoted_no_expansion() {
8454 // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
8455 let kernel = Kernel::transient().expect("failed to create kernel");
8456
8457 kernel.execute("GREETING=hello").await.expect("set var");
8458
8459 let result = kernel
8460 .execute("cat <<'EOF'\n$GREETING world\nEOF")
8461 .await
8462 .expect("quoted heredoc failed");
8463
8464 assert!(result.ok(), "quoted heredoc failed: {}", result.err);
8465 assert_eq!(result.text_out().trim(), "$GREETING world");
8466 }
8467
8468 #[tokio::test]
8469 async fn test_heredoc_default_value_expansion() {
8470 // Bug N: ${VAR:-default} should expand in unquoted heredocs
8471 let kernel = Kernel::transient().expect("failed to create kernel");
8472
8473 let result = kernel
8474 .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
8475 .await
8476 .expect("heredoc default expansion failed");
8477
8478 assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
8479 assert_eq!(result.text_out().trim(), "fallback");
8480 }
8481
8482 // ═══════════════════════════════════════════════════════════════════════════
8483 // Read Builtin Tests
8484 // ═══════════════════════════════════════════════════════════════════════════
8485
8486 #[tokio::test]
8487 async fn test_read_from_pipeline() {
8488 let kernel = Kernel::transient().expect("failed to create kernel");
8489
8490 // Pipe input to read
8491 let result = kernel
8492 .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
8493 .await
8494 .expect("read pipeline failed");
8495
8496 assert!(result.ok(), "read failed: {}", result.err);
8497 assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
8498 }
8499
8500 #[tokio::test]
8501 async fn test_read_multiple_vars_from_pipeline() {
8502 let kernel = Kernel::transient().expect("failed to create kernel");
8503
8504 let result = kernel
8505 .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
8506 .await
8507 .expect("read pipeline failed");
8508
8509 assert!(result.ok(), "read failed: {}", result.err);
8510 assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
8511 }
8512
8513 // ═══════════════════════════════════════════════════════════════════════════
8514 // Shell-Style Function Tests
8515 // ═══════════════════════════════════════════════════════════════════════════
8516
8517 #[tokio::test]
8518 async fn test_posix_function_with_positional_params() {
8519 let kernel = Kernel::transient().expect("failed to create kernel");
8520
8521 // Define POSIX-style function
8522 kernel
8523 .execute(r#"greet() { echo "Hello, $1!" }"#)
8524 .await
8525 .expect("function definition failed");
8526
8527 // Call the function
8528 let result = kernel
8529 .execute(r#"greet "Amy""#)
8530 .await
8531 .expect("function call failed");
8532
8533 assert!(result.ok(), "greet failed: {}", result.err);
8534 assert_eq!(result.text_out().trim(), "Hello, Amy!");
8535 }
8536
8537 #[tokio::test]
8538 async fn test_posix_function_multiple_args() {
8539 let kernel = Kernel::transient().expect("failed to create kernel");
8540
8541 // Define function using $1 and $2
8542 kernel
8543 .execute(r#"add_greeting() { echo "$1 $2!" }"#)
8544 .await
8545 .expect("function definition failed");
8546
8547 // Call the function
8548 let result = kernel
8549 .execute(r#"add_greeting "Hello" "World""#)
8550 .await
8551 .expect("function call failed");
8552
8553 assert!(result.ok(), "function failed: {}", result.err);
8554 assert_eq!(result.text_out().trim(), "Hello World!");
8555 }
8556
8557 #[tokio::test]
8558 async fn test_bash_function_with_positional_params() {
8559 let kernel = Kernel::transient().expect("failed to create kernel");
8560
8561 // Define bash-style function (function keyword, no parens)
8562 kernel
8563 .execute(r#"function greet { echo "Hi $1" }"#)
8564 .await
8565 .expect("function definition failed");
8566
8567 // Call the function
8568 let result = kernel
8569 .execute(r#"greet "Bob""#)
8570 .await
8571 .expect("function call failed");
8572
8573 assert!(result.ok(), "greet failed: {}", result.err);
8574 assert_eq!(result.text_out().trim(), "Hi Bob");
8575 }
8576
8577 #[tokio::test]
8578 async fn test_shell_function_with_all_args() {
8579 let kernel = Kernel::transient().expect("failed to create kernel");
8580
8581 // Define function using $@ (all args)
8582 kernel
8583 .execute(r#"echo_all() { echo "args: $@" }"#)
8584 .await
8585 .expect("function definition failed");
8586
8587 // Call with multiple args
8588 let result = kernel
8589 .execute(r#"echo_all "a" "b" "c""#)
8590 .await
8591 .expect("function call failed");
8592
8593 assert!(result.ok(), "function failed: {}", result.err);
8594 assert_eq!(result.text_out().trim(), "args: a b c");
8595 }
8596
8597 #[tokio::test]
8598 async fn test_shell_function_with_arg_count() {
8599 let kernel = Kernel::transient().expect("failed to create kernel");
8600
8601 // Define function using $# (arg count)
8602 kernel
8603 .execute(r#"count_args() { echo "count: $#" }"#)
8604 .await
8605 .expect("function definition failed");
8606
8607 // Call with three args
8608 let result = kernel
8609 .execute(r#"count_args "x" "y" "z""#)
8610 .await
8611 .expect("function call failed");
8612
8613 assert!(result.ok(), "function failed: {}", result.err);
8614 assert_eq!(result.text_out().trim(), "count: 3");
8615 }
8616
8617 #[tokio::test]
8618 async fn test_shell_function_shared_scope() {
8619 let kernel = Kernel::transient().expect("failed to create kernel");
8620
8621 // Set a variable in parent scope
8622 kernel
8623 .execute(r#"PARENT_VAR="visible""#)
8624 .await
8625 .expect("set failed");
8626
8627 // Define shell function that reads and writes parent variable
8628 kernel
8629 .execute(r#"modify_parent() {
8630 echo "saw: ${PARENT_VAR}"
8631 PARENT_VAR="changed by function"
8632 }"#)
8633 .await
8634 .expect("function definition failed");
8635
8636 // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
8637 let result = kernel.execute("modify_parent").await.expect("function failed");
8638
8639 assert!(
8640 result.text_out().contains("visible"),
8641 "Shell function should access parent scope, got: {}",
8642 result.text_out()
8643 );
8644
8645 // Parent variable should be modified
8646 let var = kernel.get_var("PARENT_VAR").await;
8647 assert_eq!(
8648 var,
8649 Some(Value::String("changed by function".into())),
8650 "Shell function should modify parent scope"
8651 );
8652 }
8653
8654 // ═══════════════════════════════════════════════════════════════════════════
8655 // Script Execution via PATH Tests
8656 // ═══════════════════════════════════════════════════════════════════════════
8657
8658 #[tokio::test]
8659 async fn test_script_execution_from_path() {
8660 let kernel = Kernel::transient().expect("failed to create kernel");
8661
8662 // Create /bin directory and script
8663 kernel.execute(r#"mkdir "/bin""#).await.ok();
8664 kernel
8665 .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
8666 .await
8667 .expect("write script failed");
8668
8669 // Set PATH to /bin
8670 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8671
8672 // Call script by name (without .kai extension)
8673 let result = kernel
8674 .execute("hello")
8675 .await
8676 .expect("script execution failed");
8677
8678 assert!(result.ok(), "script failed: {}", result.err);
8679 assert_eq!(result.text_out().trim(), "Hello from script!");
8680 }
8681
8682 #[tokio::test]
8683 async fn test_script_with_args() {
8684 let kernel = Kernel::transient().expect("failed to create kernel");
8685
8686 // Create script that uses positional params
8687 kernel.execute(r#"mkdir "/bin""#).await.ok();
8688 kernel
8689 .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
8690 .await
8691 .expect("write script failed");
8692
8693 // Set PATH
8694 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
8695
8696 // Call script with arg
8697 let result = kernel
8698 .execute(r#"greet "World""#)
8699 .await
8700 .expect("script execution failed");
8701
8702 assert!(result.ok(), "script failed: {}", result.err);
8703 assert_eq!(result.text_out().trim(), "Hello, World!");
8704 }
8705
8706 #[tokio::test]
8707 async fn test_script_not_found() {
8708 let kernel = Kernel::transient().expect("failed to create kernel");
8709
8710 // Set empty PATH
8711 kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
8712
8713 // Call non-existent script
8714 let result = kernel
8715 .execute("noscript")
8716 .await
8717 .expect("execution failed");
8718
8719 assert!(!result.ok(), "should fail with command not found");
8720 assert_eq!(result.code, 127);
8721 assert!(result.err.contains("command not found"));
8722 }
8723
8724 #[tokio::test]
8725 async fn test_script_path_search_order() {
8726 let kernel = Kernel::transient().expect("failed to create kernel");
8727
8728 // Create two directories with same-named script
8729 // Note: using "myscript" not "test" to avoid conflict with test builtin
8730 kernel.execute(r#"mkdir "/first""#).await.ok();
8731 kernel.execute(r#"mkdir "/second""#).await.ok();
8732 kernel
8733 .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
8734 .await
8735 .expect("write failed");
8736 kernel
8737 .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
8738 .await
8739 .expect("write failed");
8740
8741 // Set PATH with first before second
8742 kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
8743
8744 // Should find first one
8745 let result = kernel
8746 .execute("myscript")
8747 .await
8748 .expect("script execution failed");
8749
8750 assert!(result.ok(), "script failed: {}", result.err);
8751 assert_eq!(result.text_out().trim(), "from first");
8752 }
8753
8754 // ═══════════════════════════════════════════════════════════════════════════
8755 // Special Variable Tests ($?, $$, unset vars)
8756 // ═══════════════════════════════════════════════════════════════════════════
8757
8758 #[tokio::test]
8759 async fn test_last_exit_code_success() {
8760 let kernel = Kernel::transient().expect("failed to create kernel");
8761
8762 // true exits with 0
8763 let result = kernel.execute("true; echo $?").await.expect("execution failed");
8764 assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
8765 }
8766
8767 #[tokio::test]
8768 async fn test_last_exit_code_failure() {
8769 let kernel = Kernel::transient().expect("failed to create kernel");
8770
8771 // false exits with 1
8772 let result = kernel.execute("false; echo $?").await.expect("execution failed");
8773 assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
8774 }
8775
8776 #[tokio::test]
8777 async fn test_current_pid() {
8778 let kernel = Kernel::transient().expect("failed to create kernel");
8779
8780 let result = kernel.execute("echo $$").await.expect("execution failed");
8781 // PID should be a positive number
8782 let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
8783 assert!(pid > 0, "PID should be positive");
8784 }
8785
8786 #[tokio::test]
8787 async fn test_unset_variable_expands_to_empty() {
8788 let kernel = Kernel::transient().expect("failed to create kernel");
8789
8790 // Unset variable in interpolation should be empty
8791 let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
8792 assert_eq!(result.text_out().trim(), "prefix::suffix");
8793 }
8794
8795 #[tokio::test]
8796 async fn test_eq_ne_operators() {
8797 let kernel = Kernel::transient().expect("failed to create kernel");
8798
8799 // Test -eq operator
8800 let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
8801 assert_eq!(result.text_out().trim(), "eq works");
8802
8803 // Test -ne operator
8804 let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
8805 assert_eq!(result.text_out().trim(), "ne works");
8806
8807 // Test -eq with different values
8808 let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
8809 assert_eq!(result.text_out().trim(), "correct");
8810 }
8811
8812 #[tokio::test]
8813 async fn test_escaped_dollar_in_string() {
8814 let kernel = Kernel::transient().expect("failed to create kernel");
8815
8816 // \$ should produce literal $
8817 let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
8818 assert_eq!(result.text_out().trim(), "$100");
8819 }
8820
8821 #[tokio::test]
8822 async fn test_special_vars_in_interpolation() {
8823 let kernel = Kernel::transient().expect("failed to create kernel");
8824
8825 // Test $? in string interpolation
8826 let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
8827 assert_eq!(result.text_out().trim(), "exit: 0");
8828
8829 // Test $$ in string interpolation
8830 let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
8831 assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
8832 let text = result.text_out();
8833 let pid_part = text.trim().strip_prefix("pid: ").unwrap();
8834 let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
8835 }
8836
8837 // ═══════════════════════════════════════════════════════════════════════════
8838 // Command Substitution Tests
8839 // ═══════════════════════════════════════════════════════════════════════════
8840
8841 #[tokio::test]
8842 async fn test_command_subst_assignment() {
8843 let kernel = Kernel::transient().expect("failed to create kernel");
8844
8845 // Command substitution in assignment
8846 let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
8847 assert_eq!(result.text_out().trim(), "hello");
8848 }
8849
8850 #[tokio::test]
8851 async fn test_command_subst_with_args() {
8852 let kernel = Kernel::transient().expect("failed to create kernel");
8853
8854 // Command substitution with string argument
8855 let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
8856 assert_eq!(result.text_out().trim(), "a b c");
8857 }
8858
8859 #[tokio::test]
8860 async fn test_command_subst_nested_vars() {
8861 let kernel = Kernel::transient().expect("failed to create kernel");
8862
8863 // Variables inside command substitution
8864 let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
8865 assert_eq!(result.text_out().trim(), "hello world");
8866 }
8867
8868 #[tokio::test]
8869 async fn test_background_job_basic() {
8870 use std::time::Duration;
8871
8872 let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
8873
8874 // Run a simple background command, redirecting its output to a
8875 // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
8876 // live); the redirect is what this test asserts on.
8877 let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
8878 assert!(result.ok(), "background command should succeed: {}", result.err);
8879 assert!(result.text_out().contains("[1]"), "should return job ID: {}", result.text_out());
8880
8881 // Give the job time to complete
8882 tokio::time::sleep(Duration::from_millis(100)).await;
8883
8884 // Check job status
8885 let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
8886 assert!(status.ok(), "status should succeed: {}", status.err);
8887 assert!(
8888 status.text_out().contains("done:") || status.text_out().contains("running"),
8889 "should have valid status: {}",
8890 status.text_out()
8891 );
8892
8893 // Check the redirected output
8894 let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
8895 assert!(stdout.ok());
8896 assert!(stdout.text_out().contains("hello"));
8897 }
8898
8899 #[tokio::test]
8900 async fn test_heredoc_piped_to_command() {
8901 // Bug 4: heredoc content should pipe through to next command
8902 let kernel = Kernel::transient().expect("kernel");
8903 let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
8904 assert!(result.ok(), "heredoc | cat failed: {}", result.err);
8905 assert_eq!(result.text_out().trim(), "hello world");
8906 }
8907
8908 /// A transient kernel paired with a real, auto-cleaning tempdir. The
8909 /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
8910 /// tests need actual files on disk. Hold the returned `TempDir` for the
8911 /// test's lifetime: it removes the directory tree on drop — including on
8912 /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
8913 /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
8914 /// as a string for interpolation into scripts.
8915 fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
8916 let kernel = Kernel::transient().expect("kernel");
8917 let tmp = tempfile::tempdir().expect("tempdir");
8918 let dir = tmp.path().display().to_string();
8919 (kernel, tmp, dir)
8920 }
8921
8922 #[tokio::test]
8923 async fn test_for_loop_glob_iterates() {
8924 // Bug 1: for F in $(glob ...) should iterate per file, not once
8925 let (kernel, _tmp, dir) = transient_with_tempdir();
8926 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8927 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8928 let result = kernel.execute(&format!(r#"
8929 N=0
8930 for F in $(glob "{dir}/*.txt"); do
8931 N=$((N + 1))
8932 done
8933 echo $N
8934 "#)).await.unwrap();
8935 assert!(result.ok(), "for glob failed: {}", result.err);
8936 assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
8937 }
8938
8939 #[tokio::test]
8940 async fn test_bare_glob_expansion_echo() {
8941 let (kernel, _tmp, dir) = transient_with_tempdir();
8942 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8943 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
8944 kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
8945 kernel.execute(&format!("cd {dir}")).await.unwrap();
8946 let result = kernel.execute("echo *.txt").await.unwrap();
8947 assert!(result.ok(), "echo *.txt failed: {}", result.err);
8948 let out = result.text_out();
8949 let out = out.trim();
8950 // Should contain both .txt files (order may vary)
8951 assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
8952 assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
8953 assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
8954 }
8955
8956 #[tokio::test]
8957 async fn test_bare_glob_no_matches_errors() {
8958 let (kernel, _tmp, dir) = transient_with_tempdir();
8959 kernel.execute(&format!("cd {dir}")).await.unwrap();
8960 let result = kernel.execute("echo *.nonexistent").await;
8961 match &result {
8962 Ok(exec) => {
8963 // No-match glob should produce a non-zero exit code
8964 assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
8965 assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
8966 }
8967 Err(e) => {
8968 assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
8969 }
8970 }
8971 }
8972
8973 #[tokio::test]
8974 async fn test_bare_glob_disabled_with_set() {
8975 let (kernel, _tmp, dir) = transient_with_tempdir();
8976 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8977 kernel.execute(&format!("cd {dir}")).await.unwrap();
8978 // Disable glob expansion
8979 kernel.execute("set +o glob").await.unwrap();
8980 let result = kernel.execute("echo *.txt").await.unwrap();
8981 // With glob disabled, *.txt should be passed as literal string
8982 assert!(result.ok(), "echo should succeed: {}", result.err);
8983 assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
8984 }
8985
8986 #[tokio::test]
8987 async fn test_bare_glob_quoted_not_expanded() {
8988 let (kernel, _tmp, dir) = transient_with_tempdir();
8989 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
8990 kernel.execute(&format!("cd {dir}")).await.unwrap();
8991 // Quoted globs should NOT expand
8992 let result = kernel.execute("echo \"*.txt\"").await.unwrap();
8993 assert!(result.ok(), "echo should succeed: {}", result.err);
8994 assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
8995 }
8996
8997 #[tokio::test]
8998 async fn test_bare_glob_for_loop() {
8999 let (kernel, _tmp, dir) = transient_with_tempdir();
9000 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9001 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9002 kernel.execute(&format!("cd {dir}")).await.unwrap();
9003 let result = kernel.execute(r#"
9004 N=0
9005 for f in *.txt; do
9006 N=$((N + 1))
9007 done
9008 echo $N
9009 "#).await.unwrap();
9010 assert!(result.ok(), "for loop failed: {}", result.err);
9011 assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
9012 }
9013
9014 #[tokio::test]
9015 async fn test_glob_in_assignment_is_literal() {
9016 let kernel = Kernel::transient().expect("kernel");
9017 let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
9018 assert!(result.ok());
9019 assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
9020 }
9021
9022 #[tokio::test]
9023 async fn test_glob_in_test_expr_is_literal() {
9024 let kernel = Kernel::transient().expect("kernel");
9025 let result = kernel.execute(r#"
9026 if [[ *.txt == "*.txt" ]]; then
9027 echo "match"
9028 else
9029 echo "no"
9030 fi
9031 "#).await.unwrap();
9032 assert!(result.ok());
9033 assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
9034 }
9035
9036 #[tokio::test]
9037 async fn test_command_subst_echo_not_iterable() {
9038 // Regression guard: $(echo "a b c") must remain a single string
9039 let kernel = Kernel::transient().expect("kernel");
9040 let result = kernel.execute(r#"
9041 N=0
9042 for X in $(echo "a b c"); do N=$((N + 1)); done
9043 echo $N
9044 "#).await.unwrap();
9045 assert!(result.ok());
9046 assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
9047 }
9048
9049 // -- accumulate_result / newline tests --
9050
9051 #[test]
9052 fn test_accumulate_preserves_own_newlines() {
9053 // Outputs concatenate verbatim — a command's own trailing newline is
9054 // kept, none is invented.
9055 let mut acc = ExecResult::success("line1\n");
9056 let new = ExecResult::success("line2\n");
9057 accumulate_result(&mut acc, &new);
9058 assert_eq!(&*acc.text_out(), "line1\nline2\n");
9059 assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
9060 }
9061
9062 #[test]
9063 fn test_accumulate_inserts_no_separator() {
9064 // No artificial separator: `printf a; printf b` style concatenates to
9065 // `ab`, matching bash (regression for the 2026-06-09 finding).
9066 let mut acc = ExecResult::success("line1");
9067 let new = ExecResult::success("line2");
9068 accumulate_result(&mut acc, &new);
9069 assert_eq!(&*acc.text_out(), "line1line2");
9070 }
9071
9072 #[test]
9073 fn test_accumulate_empty_into_nonempty() {
9074 let mut acc = ExecResult::success("");
9075 let new = ExecResult::success("hello\n");
9076 accumulate_result(&mut acc, &new);
9077 assert_eq!(&*acc.text_out(), "hello\n");
9078 }
9079
9080 #[test]
9081 fn test_accumulate_nonempty_into_empty() {
9082 let mut acc = ExecResult::success("hello\n");
9083 let new = ExecResult::success("");
9084 accumulate_result(&mut acc, &new);
9085 assert_eq!(&*acc.text_out(), "hello\n");
9086 }
9087
9088 #[test]
9089 fn test_accumulate_stderr_no_double_newlines() {
9090 let mut acc = ExecResult::failure(1, "err1\n");
9091 let new = ExecResult::failure(1, "err2\n");
9092 accumulate_result(&mut acc, &new);
9093 assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
9094 }
9095
9096 #[tokio::test]
9097 async fn test_multiple_echo_no_blank_lines() {
9098 let kernel = Kernel::transient().expect("kernel");
9099 let result = kernel
9100 .execute("echo one\necho two\necho three")
9101 .await
9102 .expect("execution failed");
9103 assert!(result.ok());
9104 assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
9105 }
9106
9107 #[tokio::test]
9108 async fn test_for_loop_no_blank_lines() {
9109 let kernel = Kernel::transient().expect("kernel");
9110 let result = kernel
9111 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
9112 .await
9113 .expect("execution failed");
9114 assert!(result.ok());
9115 assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
9116 }
9117
9118 #[tokio::test]
9119 async fn test_for_command_subst_no_blank_lines() {
9120 let kernel = Kernel::transient().expect("kernel");
9121 let result = kernel
9122 .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
9123 .await
9124 .expect("execution failed");
9125 assert!(result.ok());
9126 assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
9127 }
9128
9129 // ------------------------------------------------------------------
9130 // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
9131 // ------------------------------------------------------------------
9132
9133 /// Helper: a throwaway schema with one `--pair` param declared as
9134 /// consuming two positionals per occurrence. Modelled after what
9135 /// jq_native will declare for `--arg` / `--argjson`.
9136 fn multi_consume_schema() -> crate::tools::ToolSchema {
9137 use crate::tools::{ParamSchema, ToolSchema};
9138 ToolSchema::new("test", "multi-consume smoke")
9139 .param(
9140 ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
9141 .consumes(2),
9142 )
9143 }
9144
9145 fn pos(s: &str) -> Arg {
9146 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
9147 }
9148
9149 #[tokio::test]
9150 async fn build_args_multi_consume_single_occurrence() {
9151 let kernel = Kernel::transient().expect("kernel");
9152 let schema = multi_consume_schema();
9153 // Simulates: test --pair NAME VALUE filter
9154 let args = vec![
9155 Arg::LongFlag("pair".into()),
9156 pos("NAME"),
9157 pos("VALUE"),
9158 pos("filter"),
9159 ];
9160 let built = kernel
9161 .build_args_async(&args, Some(&schema))
9162 .await
9163 .expect("build_args should succeed");
9164
9165 // `--pair` + its two positionals are consumed into named["pair"],
9166 // which becomes an outer array of one inner 2-element array.
9167 let pair = built.named.get("pair").expect("named[pair] missing");
9168 match pair {
9169 Value::Json(serde_json::Value::Array(occurrences)) => {
9170 assert_eq!(occurrences.len(), 1, "expected one occurrence");
9171 match &occurrences[0] {
9172 serde_json::Value::Array(values) => {
9173 assert_eq!(values.len(), 2, "pair must have 2 values");
9174 assert_eq!(values[0], serde_json::Value::String("NAME".into()));
9175 assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
9176 }
9177 other => panic!("expected inner array, got {other:?}"),
9178 }
9179 }
9180 other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
9181 }
9182
9183 // The un-consumed positional ("filter") remains in `positional`.
9184 assert_eq!(built.positional.len(), 1);
9185 assert_eq!(built.positional[0], Value::String("filter".into()));
9186 }
9187 #[tokio::test]
9188 async fn build_args_multi_consume_two_occurrences_accumulate() {
9189 let kernel = Kernel::transient().expect("kernel");
9190 let schema = multi_consume_schema();
9191 // Simulates: test --pair A 1 --pair B 2 filter
9192 let args = vec![
9193 Arg::LongFlag("pair".into()),
9194 pos("A"),
9195 pos("1"),
9196 Arg::LongFlag("pair".into()),
9197 pos("B"),
9198 pos("2"),
9199 pos("filter"),
9200 ];
9201 let built = kernel
9202 .build_args_async(&args, Some(&schema))
9203 .await
9204 .expect("build_args should succeed");
9205
9206 let pair = built.named.get("pair").expect("named[pair] missing");
9207 match pair {
9208 Value::Json(serde_json::Value::Array(occurrences)) => {
9209 assert_eq!(occurrences.len(), 2, "expected two occurrences");
9210 // Preserved in invocation order.
9211 match &occurrences[0] {
9212 serde_json::Value::Array(values) => {
9213 assert_eq!(values[0], serde_json::Value::String("A".into()));
9214 assert_eq!(values[1], serde_json::Value::String("1".into()));
9215 }
9216 other => panic!("expected inner array, got {other:?}"),
9217 }
9218 match &occurrences[1] {
9219 serde_json::Value::Array(values) => {
9220 assert_eq!(values[0], serde_json::Value::String("B".into()));
9221 assert_eq!(values[1], serde_json::Value::String("2".into()));
9222 }
9223 other => panic!("expected inner array, got {other:?}"),
9224 }
9225 }
9226 other => panic!("expected Json(Array(...)), got {other:?}"),
9227 }
9228 }
9229
9230 // ── undeclared space-form flag under map_positionals (kj --type val) ──
9231 //
9232 // A backend/MCP tool whose schema does NOT declare a flag must not let
9233 // `--flag value` (space form) silently divorce the value: that was a
9234 // privilege-escalation-by-typo against kaijutsu.
9235 // kaish fails loud rather than guessing.
9236
9237 use crate::tools::{ParamSchema, ToolSchema};
9238
9239 /// Backend-style schema (map_positionals) declaring only a `name`
9240 /// positional — `--type` is intentionally undeclared.
9241 fn kj_like_schema() -> ToolSchema {
9242 ToolSchema::new("kj", "incomplete backend schema")
9243 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9244 .with_positional_mapping()
9245 }
9246
9247 #[tokio::test]
9248 async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
9249 let kernel = Kernel::transient().expect("kernel");
9250 let schema = kj_like_schema();
9251 // kj context create exp --type explorer
9252 let args = vec![
9253 pos("context"),
9254 pos("create"),
9255 pos("exp"),
9256 Arg::LongFlag("type".into()),
9257 pos("explorer"),
9258 ];
9259 let err = kernel
9260 .build_args_async(&args, Some(&schema))
9261 .await
9262 .expect_err("undeclared --type with a space value must fail loud");
9263 let msg = err.to_string();
9264 assert!(msg.contains("--type"), "message should name the flag: {msg}");
9265 assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
9266 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9267 }
9268
9269 #[tokio::test]
9270 async fn build_args_declared_space_flag_still_binds() {
9271 let kernel = Kernel::transient().expect("kernel");
9272 // Same tool, but now the schema DECLARES --type as a string param.
9273 let schema = ToolSchema::new("kj", "complete schema")
9274 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
9275 .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
9276 .with_positional_mapping();
9277 let args = vec![
9278 pos("exp"),
9279 Arg::LongFlag("type".into()),
9280 pos("explorer"),
9281 ];
9282 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9283 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9284 }
9285
9286 #[tokio::test]
9287 async fn build_args_equals_form_binds_for_undeclared_flag() {
9288 let kernel = Kernel::transient().expect("kernel");
9289 let schema = kj_like_schema();
9290 // The unambiguous `=` form must keep working even when undeclared.
9291 let args = vec![
9292 pos("exp"),
9293 Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
9294 ];
9295 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9296 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9297 }
9298
9299 #[tokio::test]
9300 async fn build_args_undeclared_bool_flag_at_end_is_ok() {
9301 let kernel = Kernel::transient().expect("kernel");
9302 let schema = kj_like_schema();
9303 // No positional follows --force → unambiguously a bare flag.
9304 let args = vec![pos("exp"), Arg::LongFlag("force".into())];
9305 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9306 assert!(built.flags.contains("force"));
9307 }
9308
9309 #[tokio::test]
9310 async fn build_args_undeclared_flag_before_another_flag_is_ok() {
9311 let kernel = Kernel::transient().expect("kernel");
9312 let schema = kj_like_schema();
9313 // --verbose is followed by a flag, not a positional → not ambiguous.
9314 let args = vec![
9315 Arg::LongFlag("verbose".into()),
9316 Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
9317 ];
9318 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9319 assert!(built.flags.contains("verbose"));
9320 }
9321
9322 #[tokio::test]
9323 async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
9324 let kernel = Kernel::transient().expect("kernel");
9325 // Builtins set map_positionals=false; the ambiguity guard must not
9326 // fire there (clap validates their flags separately).
9327 let schema = ToolSchema::new("frobnicate", "builtin-style")
9328 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9329 let args = vec![Arg::LongFlag("frob".into()), pos("value")];
9330 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9331 assert!(built.flags.contains("frob"));
9332 }
9333
9334 // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
9335 //
9336 // The long-flag guard above was closed by GH #188; an undeclared SHORT
9337 // flag under a map_positionals schema was still silently defaulting to
9338 // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
9339 // same way the long-flag case used to.
9340
9341 #[tokio::test]
9342 async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
9343 let kernel = Kernel::transient().expect("kernel");
9344 let schema = kj_like_schema();
9345 // kj exp -t explorer
9346 let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
9347 let err = kernel
9348 .build_args_async(&args, Some(&schema))
9349 .await
9350 .expect_err("undeclared -t with a space value must fail loud");
9351 let msg = err.to_string();
9352 assert!(msg.contains("-t"), "message should name the flag: {msg}");
9353 assert!(msg.contains("kj"), "message should name the tool: {msg}");
9354 }
9355
9356 #[tokio::test]
9357 async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
9358 let kernel = Kernel::transient().expect("kernel");
9359 // Builtins set map_positionals=false; the ambiguity guard must not
9360 // fire there.
9361 let schema = ToolSchema::new("frobnicate", "builtin-style")
9362 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
9363 let args = vec![Arg::ShortFlag("t".into()), pos("value")];
9364 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
9365 assert!(built.flags.contains("t"));
9366 }
9367
9368 // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
9369 //
9370 // A tool exposing a subcommand tree binds flags against the *routed leaf's*
9371 // params, not the root's. The subcommand-path positionals stay positional
9372 // (kj re-parses them with its own clap), and a value flag declared only on
9373 // a deep leaf still binds in space form.
9374
9375 /// kj → context (alias ctx) → create{--type value, --force bool}.
9376 /// map_positionals defaults false on every node (builtin/kj style).
9377 fn kj_tree_schema() -> ToolSchema {
9378 ToolSchema::new("kj", "subcommand tool").subcommand(
9379 ToolSchema::new("context", "context ops")
9380 .with_command_aliases(["ctx"])
9381 .subcommand(
9382 ToolSchema::new("create", "create context")
9383 .param(ParamSchema::new("type", "string").with_aliases(["t"]))
9384 .param(ParamSchema::new("force", "bool")),
9385 ),
9386 )
9387 }
9388
9389 #[tokio::test]
9390 async fn build_args_binds_deep_leaf_value_flag_space_form() {
9391 let kernel = Kernel::transient().expect("kernel");
9392 let schema = kj_tree_schema();
9393 // kj context create --type explorer
9394 let args = vec![
9395 pos("context"),
9396 pos("create"),
9397 Arg::LongFlag("type".into()),
9398 pos("explorer"),
9399 ];
9400 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9401 // --type (declared only on the create leaf) binds in space form.
9402 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9403 // The subcommand path survives as positionals for kj to re-parse.
9404 let positionals: Vec<&str> = built
9405 .positional
9406 .iter()
9407 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9408 .collect();
9409 assert_eq!(positionals, vec!["context", "create"]);
9410 }
9411
9412 #[tokio::test]
9413 async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
9414 let kernel = Kernel::transient().expect("kernel");
9415 let schema = kj_tree_schema();
9416 // kj context create --force somearg → --force is a leaf bool flag,
9417 // it must NOT consume `somearg`.
9418 let args = vec![
9419 pos("context"),
9420 pos("create"),
9421 Arg::LongFlag("force".into()),
9422 pos("somearg"),
9423 ];
9424 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9425 assert!(built.flags.contains("force"), "force should be a bare flag");
9426 let positionals: Vec<&str> = built
9427 .positional
9428 .iter()
9429 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
9430 .collect();
9431 assert_eq!(positionals, vec!["context", "create", "somearg"]);
9432 }
9433
9434 #[tokio::test]
9435 async fn build_args_alias_routed_leaf_binds_value_flag() {
9436 let kernel = Kernel::transient().expect("kernel");
9437 let schema = kj_tree_schema();
9438 // kj ctx create -t explorer → command alias + short flag alias.
9439 let args = vec![
9440 pos("ctx"),
9441 pos("create"),
9442 Arg::ShortFlag("t".into()),
9443 pos("explorer"),
9444 ];
9445 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
9446 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
9447 }
9448
9449 #[tokio::test]
9450 async fn build_args_computed_subcommand_selector_fails_loud() {
9451 let kernel = Kernel::transient().expect("kernel");
9452 let schema = kj_tree_schema();
9453 // kj $(echo context) — routing can't see the value; fail loud.
9454 let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
9455 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
9456 )]))];
9457 let err = kernel
9458 .build_args_async(&args, Some(&schema))
9459 .await
9460 .expect_err("computed subcommand selector must error");
9461 assert!(
9462 err.to_string().contains("subcommand name is required"),
9463 "got: {err}"
9464 );
9465 }
9466
9467 // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
9468
9469 #[test]
9470 fn finalize_output_renders_when_kernel_owns_it() {
9471 use crate::interpreter::{OutputData, OutputFormat};
9472 let r = ExecResult::with_output(OutputData::text("RAW"));
9473 let out = finalize_output(r, Some(OutputFormat::Json), false);
9474 // Kernel renders the typed OutputData → JSON; text is no longer bare.
9475 assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
9476 }
9477
9478 #[test]
9479 fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
9480 use crate::interpreter::{OutputData, OutputFormat};
9481 let r = ExecResult::with_output(OutputData::text("RAW"));
9482 let out = finalize_output(r, Some(OutputFormat::Json), true);
9483 // owns_output + success: the tool already rendered; kernel leaves bytes
9484 // untouched.
9485 assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
9486 }
9487
9488 #[test]
9489 fn finalize_output_renders_owns_output_failure() {
9490 // scatter/gather (the only owns_output tools) never render their own
9491 // JSONL/array on a FAILURE path — their error returns are plain-text
9492 // `ExecResult::failure(code, msg)`, identical in shape to any other
9493 // builtin's. owns_output means "the tool already rendered its own
9494 // SUCCESS output", not "never touch this tool's bytes" — a failure
9495 // must still get the uniform --json error envelope like every other
9496 // builtin (kaibo review finding on merged PR #215; confirmed
9497 // pre-existing for scatter/gather's whole error-path class, including
9498 // the clap-parse-failure path).
9499 use crate::interpreter::OutputFormat;
9500 let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
9501 let out = finalize_output(r, Some(OutputFormat::Json), true);
9502 let parsed: serde_json::Value =
9503 serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
9504 assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
9505 assert_eq!(parsed["code"], 2);
9506 }
9507
9508 #[test]
9509 fn finalize_output_no_format_is_noop() {
9510 use crate::interpreter::OutputData;
9511 let r = ExecResult::with_output(OutputData::text("RAW"));
9512 let out = finalize_output(r, None, false);
9513 assert_eq!(out.text_out(), "RAW");
9514 }
9515
9516 // ── initial_vars + execute_with_vars + hermetic env ───────────────────
9517
9518 #[tokio::test]
9519 async fn test_initial_vars_set_and_exported() {
9520 let config = KernelConfig::transient()
9521 .with_var("INIT_FOO", Value::String("bar".into()));
9522 let kernel = Kernel::new(config).expect("failed to create kernel");
9523
9524 assert_eq!(
9525 kernel.get_var("INIT_FOO").await,
9526 Some(Value::String("bar".into()))
9527 );
9528 assert!(
9529 kernel.scope.read().await.is_exported("INIT_FOO"),
9530 "initial_vars entries must be marked exported"
9531 );
9532 }
9533
9534 #[tokio::test]
9535 async fn test_execute_with_vars_overlay_visible() {
9536 let kernel = Kernel::transient().expect("failed to create kernel");
9537 let mut overlay = HashMap::new();
9538 overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
9539
9540 let result = kernel
9541 .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
9542 .await
9543 .expect("execute failed");
9544
9545 assert!(result.ok());
9546 assert_eq!(result.text_out().trim(), "yes");
9547 }
9548
9549 #[tokio::test]
9550 async fn test_execute_with_vars_overlay_cleanup() {
9551 let kernel = Kernel::transient().expect("failed to create kernel");
9552 let mut overlay = HashMap::new();
9553 overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
9554
9555 kernel
9556 .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
9557 .await
9558 .expect("execute failed");
9559
9560 assert_eq!(kernel.get_var("EPHEMERAL").await, None);
9561 assert!(
9562 !kernel.scope.read().await.is_exported("EPHEMERAL"),
9563 "overlay-only export must be cleared on return"
9564 );
9565 }
9566
9567 #[tokio::test]
9568 async fn test_execute_with_vars_does_not_clobber_existing_export() {
9569 let kernel = Kernel::transient().expect("failed to create kernel");
9570 kernel
9571 .execute("export OUTER=outer")
9572 .await
9573 .expect("export failed");
9574
9575 let mut overlay = HashMap::new();
9576 overlay.insert("OUTER".to_string(), Value::String("inner".into()));
9577 let result = kernel
9578 .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
9579 .await
9580 .expect("execute failed");
9581 assert_eq!(result.text_out().trim(), "inner");
9582
9583 assert_eq!(
9584 kernel.get_var("OUTER").await,
9585 Some(Value::String("outer".into())),
9586 "outer value must reappear after pop"
9587 );
9588 assert!(
9589 kernel.scope.read().await.is_exported("OUTER"),
9590 "outer export must survive overlay"
9591 );
9592 }
9593
9594 #[tokio::test]
9595 async fn test_execute_with_vars_inner_assignment_is_local() {
9596 let kernel = Kernel::transient().expect("failed to create kernel");
9597 let mut overlay = HashMap::new();
9598 overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
9599
9600 // Variable assignment inside a single statement uses set() (innermost
9601 // frame), not set_global() — this matches bash function-local semantics.
9602 // We explicitly use `local FOO=...` style by relying on the pushed
9603 // frame; the assignment in the script body modifies the same frame.
9604 let result = kernel
9605 .execute_with_options(
9606 r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
9607 ExecuteOptions::new().with_vars(overlay),
9608 )
9609 .await
9610 .expect("execute failed");
9611 assert!(result.ok());
9612
9613 // After the call the frame is popped, so LOCAL_FOO is gone regardless
9614 // of how the script reassigned it.
9615 assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
9616 }
9617
9618 #[tokio::test]
9619 async fn test_external_command_sees_exported_var() {
9620 let kernel = Kernel::transient().expect("failed to create kernel");
9621 // PATH must be in scope to resolve the external `printenv` — the kernel
9622 // never falls back to OS PATH. Seeding it via a scope assignment mirrors
9623 // what a frontend does through initial_vars.
9624 let path = std::env::var("PATH").unwrap_or_default();
9625 let result = kernel
9626 .execute(&format!(
9627 "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
9628 ))
9629 .await
9630 .expect("execute failed");
9631
9632 assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
9633 assert_eq!(result.text_out().trim(), "bar");
9634 }
9635
9636 #[tokio::test]
9637 async fn test_external_command_does_not_see_unexported_var() {
9638 let kernel = Kernel::transient().expect("failed to create kernel");
9639
9640 // Set without exporting; printenv must not see it (exit code != 0,
9641 // empty stdout per printenv semantics).
9642 let result = kernel
9643 .execute("EXT_BAR=hidden; printenv EXT_BAR")
9644 .await
9645 .expect("execute failed");
9646
9647 assert!(!result.ok(), "printenv should fail when var is unexported");
9648 assert!(
9649 result.text_out().trim().is_empty(),
9650 "no stdout when var is missing, got: {}",
9651 result.text_out()
9652 );
9653 }
9654
9655 #[tokio::test]
9656 async fn test_external_command_does_not_see_os_env() {
9657 // The kernel is hermetic: it never reads std::env::vars() and only
9658 // exports what it has been told to export. Cargo always sets PATH for
9659 // tests, so PATH is reliably present in the OS env — but a transient
9660 // kernel doesn't seed it into initial_vars, so `printenv PATH` from
9661 // inside the kernel must fail.
9662 assert!(
9663 std::env::var_os("PATH").is_some(),
9664 "test precondition: cargo should set PATH"
9665 );
9666
9667 let kernel = Kernel::transient().expect("failed to create kernel");
9668 let result = kernel
9669 .execute("printenv PATH")
9670 .await
9671 .expect("execute failed");
9672
9673 assert!(
9674 !result.ok(),
9675 "printenv PATH must fail in hermetic kernel, got stdout={:?}",
9676 result.text_out()
9677 );
9678 assert!(
9679 result.text_out().trim().is_empty(),
9680 "no PATH in subprocess env, got stdout={:?}",
9681 result.text_out()
9682 );
9683 }
9684
9685 #[tokio::test]
9686 async fn test_execute_with_vars_overlay_reaches_subprocess() {
9687 let kernel = Kernel::transient().expect("failed to create kernel");
9688 let mut overlay = HashMap::new();
9689 overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
9690 // PATH in the overlay so the external `printenv` resolves (no OS fallback).
9691 overlay.insert(
9692 "PATH".to_string(),
9693 Value::String(std::env::var("PATH").unwrap_or_default()),
9694 );
9695
9696 let result = kernel
9697 .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
9698 .await
9699 .expect("execute failed");
9700
9701 assert!(
9702 result.ok(),
9703 "printenv should succeed: code={} stdout={:?} stderr={:?}",
9704 result.code,
9705 result.text_out(),
9706 result.err
9707 );
9708 assert_eq!(result.text_out().trim(), "subproc");
9709 }
9710
9711 #[tokio::test]
9712 async fn test_classify_command_builtin() {
9713 let kernel = Kernel::transient().expect("failed to create kernel");
9714 assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
9715 assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
9716 }
9717
9718 #[tokio::test]
9719 async fn test_classify_command_special_forms() {
9720 let kernel = Kernel::transient().expect("failed to create kernel");
9721 for name in ["true", "false", "source", "."] {
9722 assert_eq!(
9723 kernel.classify_command(name).await,
9724 CommandKind::Special,
9725 "{name} should be a special-form",
9726 );
9727 }
9728 }
9729
9730 #[tokio::test]
9731 async fn test_classify_command_dynamic() {
9732 let kernel = Kernel::transient().expect("failed to create kernel");
9733 assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
9734 assert_eq!(
9735 kernel.classify_command("$(pick)").await,
9736 CommandKind::Dynamic
9737 );
9738 }
9739
9740 #[tokio::test]
9741 async fn test_classify_command_external() {
9742 let kernel = Kernel::transient().expect("failed to create kernel");
9743 // Not a builtin, user function, or special-form → escapes to PATH.
9744 assert_eq!(
9745 kernel.classify_command("definitely_not_a_kaish_builtin").await,
9746 CommandKind::External
9747 );
9748 // `readonly` is *not* a kaish special-form despite the validator's
9749 // warning heuristic — at runtime it resolves to an external command, so
9750 // a consent gate must see it as External (regression guard against the
9751 // validator/runtime divergence).
9752 assert_eq!(
9753 kernel.classify_command("readonly").await,
9754 CommandKind::External
9755 );
9756 assert!(kernel.classify_command("readonly").await.escapes_kernel());
9757 }
9758
9759 #[tokio::test]
9760 async fn test_classify_command_user_tool_shadows_builtin() {
9761 let kernel = Kernel::transient().expect("failed to create kernel");
9762 kernel
9763 .execute(r#"greet() { echo "hi" }"#)
9764 .await
9765 .expect("function definition failed");
9766 assert_eq!(
9767 kernel.classify_command("greet").await,
9768 CommandKind::UserTool
9769 );
9770
9771 // A user function named after a builtin classifies as UserTool, matching
9772 // the interpreter's user-tools-first resolution.
9773 kernel
9774 .execute(r#"cat() { echo "shadowed" }"#)
9775 .await
9776 .expect("function definition failed");
9777 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9778 }
9779
9780 #[tokio::test]
9781 async fn test_classify_command_alias_to_external_is_external() {
9782 let kernel = Kernel::transient().expect("failed to create kernel");
9783 // An alias whose head is an external binary must NOT report as the
9784 // builtin it shadows — execution expands the alias, so a consent gate
9785 // would otherwise be told an external command is internal.
9786 kernel
9787 .execute("alias cat='/usr/bin/whatever'")
9788 .await
9789 .expect("alias failed");
9790 assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
9791 assert!(kernel.classify_command("cat").await.escapes_kernel());
9792 }
9793
9794 #[tokio::test]
9795 async fn test_classify_command_alias_to_builtin() {
9796 let kernel = Kernel::transient().expect("failed to create kernel");
9797 kernel.execute("alias g=grep").await.expect("alias failed");
9798 assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
9799 }
9800
9801 #[tokio::test]
9802 async fn test_classify_command_alias_to_special_form() {
9803 let kernel = Kernel::transient().expect("failed to create kernel");
9804 kernel.execute("alias t=true").await.expect("alias failed");
9805 assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
9806 }
9807
9808 #[tokio::test]
9809 async fn test_classify_command_braced_var_is_dynamic() {
9810 let kernel = Kernel::transient().expect("failed to create kernel");
9811 // The string API can be handed a `${VAR}` head; it must not be mistaken
9812 // for an external named literally "${VAR}".
9813 assert_eq!(
9814 kernel.classify_command("${CMD}").await,
9815 CommandKind::Dynamic
9816 );
9817 }
9818
9819 /// Drift guard: `classify_command` must agree with what the executor
9820 /// (`execute_command_depth`) actually resolves. The classifier duplicates the
9821 /// interpreter's resolution rules (special-form set, user-tools-before-builtins
9822 /// precedence, alias expansion); without this test those copies could diverge
9823 /// silently — the exact failure class `classify_command` exists to prevent,
9824 /// just moved inside the kernel. Each case asserts the classification AND
9825 /// observes the real resolution, so a future change to one side without the
9826 /// other fails here.
9827 #[tokio::test]
9828 async fn classify_command_matches_executor() {
9829 let kernel = Kernel::transient().expect("failed to create kernel");
9830
9831 // (1) Special-forms. `SpecialForm::from_name` is the single source of
9832 // truth: classify reports Special via it, and the executor matches the
9833 // enum exhaustively, so const↔behavior parity is compile-enforced (a new
9834 // form won't build until both sides handle it). This test pins the other
9835 // half — that each form classifies Special AND actually short-circuits at
9836 // runtime rather than escaping to `PATH`. Every form is executed (not just
9837 // `true`/`false`): an external miss in this PATH-less kernel would be exit
9838 // 127, so a non-127 result that matches the form's own behavior proves the
9839 // short-circuit fired.
9840 for name in ["true", "false", "source", "."] {
9841 assert_eq!(
9842 kernel.classify_command(name).await,
9843 CommandKind::Special,
9844 "{name} should classify Special",
9845 );
9846 }
9847 assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
9848 assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
9849 // `source`/`.` short-circuit to execute_source, which (no filename) fails
9850 // with its own message — exit 1, never the 127 of an unresolved external.
9851 for name in ["source", "."] {
9852 let r = kernel.execute(name).await.expect("run source form");
9853 assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
9854 assert!(
9855 r.err.contains("source: missing filename"),
9856 "{name} did not route to execute_source: {:?}",
9857 r.err,
9858 );
9859 }
9860
9861 // (2) Builtin: classify Builtin AND the executor runs the builtin.
9862 assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
9863 let r = kernel.execute("echo hi").await.expect("run echo");
9864 assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
9865
9866 // (3) User function shadows a builtin: classify UserTool AND the executor
9867 // runs the function body, not the `cat` builtin.
9868 kernel
9869 .execute(r#"cat() { echo SHADOWED }"#)
9870 .await
9871 .expect("define cat()");
9872 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
9873 let r = kernel.execute("cat").await.expect("run shadowed cat");
9874 assert_eq!(
9875 r.text_out().trim(),
9876 "SHADOWED",
9877 "executor ran the builtin instead of the shadowing function",
9878 );
9879
9880 // (4) Alias whose head is external: classify External AND the executor
9881 // resolves through the alias to a missing external (not a builtin).
9882 kernel
9883 .execute("alias x='/nonexistent/binary'")
9884 .await
9885 .expect("define alias x");
9886 assert_eq!(kernel.classify_command("x").await, CommandKind::External);
9887 let r = kernel.execute("x").await.expect("run alias x");
9888 assert!(
9889 !r.ok(),
9890 "alias to a missing external should fail, not resolve internally",
9891 );
9892 }
9893}