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::error::{classify_execute_error, KernelError};
104use 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};
105use crate::parser::parse;
106use crate::scheduler::{is_bool_type, schema_param_lookup, select_leaf, stderr_stream, JobManager, PipelineRunner, StderrReceiver};
107#[cfg(feature = "subprocess")]
108use crate::scheduler::{drain_to_stream_teed, BoundedStream, DEFAULT_STREAM_MAX_SIZE};
109use crate::tools::{
110 external_commands_unavailable_error, global_flag_value_is_truthy, register_builtins,
111 ExecContext, ExternalCommandOutcome, ExternalCommandsUnavailable, GlobalFlags, ToolArgs,
112 ToolRegistry,
113};
114#[cfg(feature = "subprocess")]
115use crate::tools::{resolve_in_path, virtual_cwd_error};
116use crate::validator::{Severity, Validator};
117#[cfg(feature = "localfs")]
118use crate::vfs::LocalFs;
119use crate::vfs::{BuiltinFs, DevFs, JobFs, MemoryFs, VfsRouter};
120use kaish_vfs::ByteBudget;
121#[cfg(all(feature = "localfs", feature = "overlay"))]
122use kaish_vfs::OverlayFs;
123
124/// VFS mount mode determines how the local filesystem is exposed.
125///
126/// Different modes trade off convenience vs. security:
127/// - `Passthrough` gives native path access (best for human REPL use)
128/// - `Sandboxed` restricts access to a subtree (safer for agents)
129/// - `NoLocal` provides complete isolation (tests, pure memory mode)
130#[derive(Debug, Clone)]
131#[non_exhaustive]
132pub enum VfsMountMode {
133 /// LocalFs at "/" — native paths work directly.
134 ///
135 /// Full filesystem access. Use for human-operated REPL sessions where
136 /// native paths like `/home/user/project` should just work.
137 ///
138 /// Mounts:
139 /// - `/` → LocalFs("/")
140 /// - `/v` → MemoryFs (blob storage)
141 #[cfg(feature = "localfs")]
142 Passthrough,
143
144 /// Transparent sandbox — paths look native but access is restricted.
145 ///
146 /// The local filesystem is mounted at its real path (e.g., `/home/user`),
147 /// so `/home/user/src/project` just works. But paths outside the sandbox
148 /// root are not accessible.
149 ///
150 /// **Note:** This only restricts VFS (builtin) operations. External commands
151 /// bypass the sandbox entirely — see [`KernelConfig::allow_external_commands`].
152 ///
153 /// Mounts:
154 /// - `/` → MemoryFs (catches paths outside sandbox)
155 /// - `{root}` → LocalFs(root) (e.g., `/home/user` → LocalFs)
156 /// - `/tmp` → LocalFs("/tmp")
157 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
158 /// - `/v` → MemoryFs (blob storage)
159 #[cfg(feature = "localfs")]
160 Sandboxed {
161 /// Root path for local filesystem. Defaults to `$HOME`.
162 /// Can be restricted further, e.g., `~/src`.
163 root: Option<PathBuf>,
164 },
165
166 /// No local filesystem. Memory only.
167 ///
168 /// Complete isolation — no access to the host filesystem.
169 /// Useful for tests or pure sandboxed execution.
170 ///
171 /// Output spill is forced to [`SpillMode::Memory`](crate::output_limit::SpillMode::Memory)
172 /// for this mode at kernel construction: with no host filesystem mounted,
173 /// large output must not write a host spill file (`paths::spill_dir()`
174 /// bypasses the VFS). This overrides any explicit `SpillMode::Disk`.
175 ///
176 /// Mounts:
177 /// - `/` → MemoryFs
178 /// - `/tmp` → MemoryFs
179 /// - `/v` → MemoryFs
180 /// - `/dev` → DevFs (synthetic /dev/null, /dev/zero)
181 NoLocal,
182}
183
184#[allow(clippy::derivable_impls)] // native has multiple variants; not derivable cross-feature
185impl Default for VfsMountMode {
186 fn default() -> Self {
187 #[cfg(feature = "localfs")]
188 { VfsMountMode::Sandboxed { root: None } }
189 #[cfg(not(feature = "localfs"))]
190 { VfsMountMode::NoLocal }
191 }
192}
193
194/// Configuration for kernel initialization.
195#[derive(Clone)]
196pub struct KernelConfig {
197 /// Name of this kernel (for identification).
198 pub name: String,
199
200 /// VFS mount mode — controls how local filesystem is exposed.
201 pub vfs_mode: VfsMountMode,
202
203 /// Initial working directory (VFS path).
204 pub cwd: PathBuf,
205
206 /// Whether to skip pre-execution validation.
207 ///
208 /// When false (default), scripts are validated before execution to catch
209 /// errors early. Set to true to skip validation for performance or to
210 /// allow dynamic/external commands.
211 pub skip_validation: bool,
212
213 /// When true, standalone external commands inherit stdio for real-time output.
214 ///
215 /// Set by script runner and REPL for human-visible output.
216 /// Not set by MCP server (output must be captured for structured responses).
217 pub interactive: bool,
218
219 /// Ignore file configuration for file-walking tools.
220 pub ignore_config: crate::ignore_config::IgnoreConfig,
221
222 /// Output size limit configuration for agent safety.
223 pub output_limit: crate::output_limit::OutputLimitConfig,
224
225 /// Whether external command execution (PATH lookup, `exec`, `spawn`) is allowed.
226 ///
227 /// When `true` (default), commands not found as builtins are resolved via PATH
228 /// and executed as child processes. When `false`, only kaish builtins and
229 /// backend-registered tools are available.
230 ///
231 /// **Security:** External commands bypass the VFS sandbox entirely — they see
232 /// the real filesystem, network, and environment. Set to `false` when running
233 /// untrusted input.
234 pub allow_external_commands: bool,
235
236
237 /// Enable trash-on-delete for rm (set -o trash).
238 ///
239 /// When enabled, small files are moved to freedesktop.org Trash instead of
240 /// being permanently deleted. Can also be enabled at runtime with `set -o trash`
241 /// or via `KAISH_TRASH=1`.
242 pub trash_enabled: bool,
243
244 /// Enable errexit (`set -e`) at kernel construction (set -o errexit default).
245 ///
246 /// When enabled, a script aborts at the first statement that exits
247 /// nonzero instead of continuing to the next one. **Off by default** —
248 /// standard shell behavior, so an embedder upgrading kaish sees no
249 /// change. There is one piece of state behind this
250 /// (`Scope::error_exit_enabled`), seeded from this field and mutated at
251 /// runtime by `set -e`/`set +e`: this only picks the *starting* value,
252 /// a script's own `set -e`/`set +e` still applies afterward regardless
253 /// of what this was, and `set -o` always reports the true, single
254 /// answer no matter which one set it. `ExecuteOptions::errexit`
255 /// overrides this for one call.
256 pub errexit_enabled: bool,
257
258 /// Variables to populate the root scope with at construction, all marked
259 /// for export to child processes.
260 ///
261 /// The kernel itself is hermetic — it never reads `std::env::vars()` —
262 /// so frontends that want OS-env passthrough (REPL, MCP) populate this
263 /// from `std::env::vars()`. Embedders that want isolation pass nothing
264 /// (or only the keys they curate).
265 pub initial_vars: HashMap<String, Value>,
266
267 /// Default per-request timeout. When `Some`, every `execute_with_options`
268 /// call without an explicit `ExecuteOptions::timeout` uses this duration.
269 /// When elapsed, the kernel cancels the request, kills any external
270 /// children with the configured grace, and returns exit code 124.
271 ///
272 /// `None` means no default timeout — only explicit per-call timeouts apply.
273 pub request_timeout: Option<Duration>,
274
275 /// Grace period between SIGTERM and SIGKILL when killing an external
276 /// child on cancellation or timeout.
277 ///
278 /// Defaults to 2 seconds. Set to `Duration::ZERO` to escalate immediately
279 /// to SIGKILL. Long-shutdown processes (databases, etc.) may need more.
280 pub kill_grace: Duration,
281
282 /// Cap on memory-resident bytes across all kernel-owned `MemoryFs` mounts.
283 ///
284 /// One shared `ByteBudget` (labeled `"vfs-memory"`) is created at kernel
285 /// construction and handed to every `MemoryFs` the kernel builds in
286 /// `setup_vfs` (Passthrough `/v`; Sandboxed `/` and `/v`; NoLocal `/`,
287 /// `/tmp`, `/v`). Writes that would exceed the cap fail loudly with
288 /// `StorageFull` — an in-band error a model reads and adapts to; fail
289 /// loud over quietly eating RAM.
290 ///
291 /// **Why the agent preset is bounded by default:** an agent embedder
292 /// typically creates a fresh kernel per `execute()` call, so the 64 MiB cap
293 /// is per-call, not per-session. Embedders that know their workload needs
294 /// more opt out with `without_vfs_budget()` or raise the cap with
295 /// `with_vfs_budget(bytes)` — protection on by default, opt out knowingly.
296 /// All other profiles default to `None` (unbounded).
297 ///
298 /// Follows the same pattern as `OutputLimitConfig`: agent preset bounded, rest unbounded.
299 pub vfs_budget_bytes: Option<u64>,
300
301 /// Enable copy-on-write overlay mode (opt-in).
302 ///
303 /// When `true`, the primary local filesystem mount is wrapped in an
304 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
305 /// Use `kaish-vfs status/diff/commit/reset` to inspect and manage the
306 /// overlay transaction.
307 ///
308 /// **Passthrough:** `/` becomes `OverlayFs over LocalFs::read_only("/")`.
309 /// **Sandboxed{root}:** the `{root}` mount becomes
310 /// `OverlayFs over LocalFs::read_only(root)`; the `/tmp` and XDG runtime
311 /// mounts stay as real `LocalFs` (real writes escape the transaction —
312 /// see `docs/kaish-overlayfs.md` for the escape-hatch inventory).
313 /// **NoLocal:** incompatible — construction fails loudly (everything is
314 /// already virtual; an overlay adds no value and no lower layer to wrap).
315 /// **with_backend:** incompatible — the embedder controls the VFS; the
316 /// kernel cannot wrap it without bypassing the embedder's semantics.
317 ///
318 /// **Not default-on for the agent preset:** each `execute()` call gets a fresh kernel,
319 /// making the overlay a per-call transaction — `kaish-vfs commit` must run
320 /// in the same call as the writes, or the transaction is discarded on drop.
321 /// Frontends (REPL, MCP) expose `--overlay` as an explicit opt-in flag.
322 pub overlay: bool,
323
324 /// The [`JobManager`] this kernel adopts. `None` — the default — builds a
325 /// fresh one, so every kernel owns its own job table.
326 ///
327 /// Supply one to share a single job table across kernels. An embedder that
328 /// builds a kernel per request (kaijutsu builds one per tool call) has no
329 /// other way to keep a `cmd &` job reachable: ids, status, and output
330 /// streams all live on the manager, so a per-kernel manager takes them
331 /// down with the kernel that made it. One manager held by the embedder and
332 /// handed to every kernel keeps `&` usable across calls, and keeps job ids
333 /// unique because they are minted from the manager's own counter.
334 ///
335 /// **A shared manager carries shared settings.** `kill_grace` and
336 /// `persist_output_files` are stamped onto the manager at kernel
337 /// construction, so the last kernel built wins for both: a hermetic kernel
338 /// (`NoLocal`, or any `with_backend` kernel) turns `persist_output_files`
339 /// off for every kernel on that manager, and each kernel's
340 /// [`Self::kill_grace`] overwrites the previous one's. Share a manager
341 /// between kernels configured alike, or accept the last writer.
342 ///
343 /// Set through [`Self::with_job_manager`].
344 pub job_manager: Option<Arc<JobManager>>,
345
346 /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on every external command this kernel
347 /// spawns, so the OS kills the child the instant this process dies —
348 /// **for any reason, including `kill -9`, a segfault, or an OOM kill.**
349 ///
350 /// Off by default; on for [`Self::agent`] and [`Self::agent_with_root`],
351 /// the same "protection on by default for the agent preset, opt in
352 /// elsewhere" split [`Self::vfs_budget_bytes`] uses.
353 ///
354 /// **Why not unconditional.** kaish already puts every child in its own
355 /// process group and kills through a pidfd on cancel, and drops it with
356 /// `kill_on_drop`. All three need this process to still be running code,
357 /// so none of them survive a hard kill — that is the gap this closes. But
358 /// closing it costs something a human at a REPL may not want: an armed
359 /// child cannot outlive its shell, at all, and the child has no way to
360 /// opt out from inside (unlike SIGHUP, which `nohup`/`disown` exist to
361 /// escape). A REPL user who backgrounds a long download and exits expects
362 /// it to keep going. An agent embedder expects the opposite — an
363 /// invisible orphaned `cargo build` is the failure — so the presets
364 /// differ rather than one behavior being forced on both.
365 ///
366 /// **Linux only.** macOS has no `PR_SET_PDEATHSIG` and no equivalent that
367 /// works without a live parent (`kqueue`'s `NOTE_EXIT` needs a watcher
368 /// process). This flag is accepted and has no effect there, rather than
369 /// being faked with something weaker.
370 ///
371 /// Set through [`Self::with_kill_children_on_parent_death`].
372 pub kill_children_on_parent_death: bool,
373}
374
375/// Get the default sandbox root ($HOME).
376#[cfg(feature = "localfs")]
377fn default_sandbox_root() -> PathBuf {
378 std::env::var("HOME")
379 .map(PathBuf::from)
380 .unwrap_or_else(|_| PathBuf::from("/"))
381}
382
383impl Default for KernelConfig {
384 fn default() -> Self {
385 #[cfg(feature = "localfs")]
386 {
387 let home = default_sandbox_root();
388 Self {
389 name: "default".to_string(),
390 vfs_mode: VfsMountMode::Sandboxed { root: None },
391 cwd: home,
392 skip_validation: false,
393 interactive: false,
394 ignore_config: crate::ignore_config::IgnoreConfig::none(),
395 output_limit: crate::output_limit::OutputLimitConfig::none(),
396 allow_external_commands: cfg!(feature = "subprocess"),
397 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
398 errexit_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 #[cfg(not(feature = "localfs"))]
409 {
410 Self {
411 name: "default".to_string(),
412 vfs_mode: VfsMountMode::NoLocal,
413 cwd: PathBuf::from("/"),
414 skip_validation: false,
415 interactive: false,
416 ignore_config: crate::ignore_config::IgnoreConfig::none(),
417 output_limit: crate::output_limit::OutputLimitConfig::none(),
418 allow_external_commands: false,
419 trash_enabled: false,
420 errexit_enabled: false,
421 initial_vars: HashMap::new(),
422 request_timeout: None,
423 kill_grace: Duration::from_secs(2),
424 vfs_budget_bytes: None,
425 overlay: false,
426 job_manager: None,
427 kill_children_on_parent_death: false,
428 }
429 }
430 }
431}
432
433impl KernelConfig {
434 /// Create a transient kernel config (sandboxed, for temporary use).
435 #[cfg(feature = "localfs")]
436 pub fn transient() -> Self {
437 let home = default_sandbox_root();
438 Self {
439 name: "transient".to_string(),
440 vfs_mode: VfsMountMode::Sandboxed { root: None },
441 cwd: home,
442 skip_validation: false,
443 interactive: false,
444 ignore_config: crate::ignore_config::IgnoreConfig::none(),
445 output_limit: crate::output_limit::OutputLimitConfig::none(),
446 allow_external_commands: cfg!(feature = "subprocess"),
447 trash_enabled: false,
448 errexit_enabled: false,
449 initial_vars: HashMap::new(),
450 request_timeout: None,
451 kill_grace: Duration::from_secs(2),
452 vfs_budget_bytes: None,
453 overlay: false,
454 job_manager: None,
455 kill_children_on_parent_death: false,
456 }
457 }
458
459 /// Create a transient kernel config (isolated, no-default-features).
460 #[cfg(not(feature = "localfs"))]
461 pub fn transient() -> Self {
462 Self::isolated()
463 }
464
465 /// Create a kernel config with the given name (sandboxed by default).
466 #[cfg(feature = "localfs")]
467 pub fn named(name: &str) -> Self {
468 let home = default_sandbox_root();
469 Self {
470 name: name.to_string(),
471 vfs_mode: VfsMountMode::Sandboxed { root: None },
472 cwd: home,
473 skip_validation: false,
474 interactive: false,
475 ignore_config: crate::ignore_config::IgnoreConfig::none(),
476 output_limit: crate::output_limit::OutputLimitConfig::none(),
477 allow_external_commands: cfg!(feature = "subprocess"),
478 trash_enabled: false,
479 errexit_enabled: false,
480 initial_vars: HashMap::new(),
481 request_timeout: None,
482 kill_grace: Duration::from_secs(2),
483 vfs_budget_bytes: None,
484 overlay: false,
485 job_manager: None,
486 kill_children_on_parent_death: false,
487 }
488 }
489
490 /// Create a kernel config with the given name (isolated, no-default-features).
491 #[cfg(not(feature = "localfs"))]
492 pub fn named(name: &str) -> Self {
493 Self {
494 name: name.to_string(),
495 ..Self::isolated()
496 }
497 }
498
499 /// Create a REPL config with passthrough filesystem access.
500 ///
501 /// Native paths like `/home/user/project` work directly.
502 /// The cwd is set to the actual current working directory.
503 #[cfg(feature = "localfs")]
504 pub fn repl() -> Self {
505 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
506 Self {
507 name: "repl".to_string(),
508 vfs_mode: VfsMountMode::Passthrough,
509 cwd,
510 skip_validation: false,
511 interactive: false,
512 // Ignore-aware by default (GH #134): .gitignore + default ignores
513 // at Advisory scope — `--no-ignore` / `kaish-ignore clear` recover.
514 ignore_config: crate::ignore_config::IgnoreConfig::interactive(),
515 output_limit: crate::output_limit::OutputLimitConfig::none(),
516 allow_external_commands: cfg!(feature = "subprocess"),
517 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
518 errexit_enabled: false,
519 initial_vars: HashMap::new(),
520 request_timeout: None,
521 kill_grace: Duration::from_secs(2),
522 vfs_budget_bytes: None,
523 overlay: false,
524 job_manager: None,
525 kill_children_on_parent_death: false,
526 }
527 }
528
529 /// Create a sandboxed-agent config with sandboxed filesystem access.
530 ///
531 /// The preset for embedding kaish as an untrusted agent's shell (e.g. an MCP
532 /// server like kaibo/kaijutsu): sandboxed VFS, non-interactive, bounded
533 /// memory and output. Local filesystem is accessible at its real path (e.g.,
534 /// `/home/user`), but sandboxed to `$HOME`. Paths outside the sandbox are not
535 /// accessible through builtins. External commands still access the real
536 /// filesystem — use `.with_allow_external_commands(false)` to block them.
537 ///
538 /// VFS memory is bounded at 64 MiB per `execute()` call by default (an agent
539 /// embedder typically creates a fresh kernel per call). Raise or remove with
540 /// `with_vfs_budget` / `without_vfs_budget`.
541 #[cfg(feature = "localfs")]
542 pub fn agent() -> Self {
543 let home = default_sandbox_root();
544 Self {
545 name: "agent".to_string(),
546 vfs_mode: VfsMountMode::Sandboxed { root: None },
547 cwd: home,
548 skip_validation: false,
549 interactive: false,
550 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
551 output_limit: crate::output_limit::OutputLimitConfig::agent(),
552 allow_external_commands: cfg!(feature = "subprocess"),
553 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
554 errexit_enabled: false,
555 initial_vars: HashMap::new(),
556 request_timeout: None,
557 kill_grace: Duration::from_secs(2),
558 vfs_budget_bytes: Some(64 * 1024 * 1024),
559 overlay: false,
560 job_manager: None,
561 // An agent embedder must never leave an invisible `cargo build` running
562 // after its process is hard-killed; see the field doc for why this is
563 // not the default everywhere.
564 kill_children_on_parent_death: true,
565 }
566 }
567
568 /// Create a sandboxed-agent config with a custom sandbox root.
569 ///
570 /// Use this to restrict access to a subdirectory like `~/src`.
571 ///
572 /// VFS memory is bounded at 64 MiB per `execute()` call by default.
573 /// Raise or remove with `with_vfs_budget` / `without_vfs_budget`.
574 #[cfg(feature = "localfs")]
575 pub fn agent_with_root(root: PathBuf) -> Self {
576 Self {
577 name: "agent".to_string(),
578 vfs_mode: VfsMountMode::Sandboxed { root: Some(root.clone()) },
579 cwd: root,
580 skip_validation: false,
581 interactive: false,
582 ignore_config: crate::ignore_config::IgnoreConfig::agent(),
583 output_limit: crate::output_limit::OutputLimitConfig::agent(),
584 allow_external_commands: cfg!(feature = "subprocess"),
585 trash_enabled: std::env::var("KAISH_TRASH").is_ok_and(|v| v == "1"),
586 errexit_enabled: false,
587 initial_vars: HashMap::new(),
588 request_timeout: None,
589 kill_grace: Duration::from_secs(2),
590 vfs_budget_bytes: Some(64 * 1024 * 1024),
591 overlay: false,
592 job_manager: None,
593 // Same reasoning as `agent()`.
594 kill_children_on_parent_death: true,
595 }
596 }
597
598 /// Create a config with no local filesystem (memory only).
599 ///
600 /// Complete isolation: no local filesystem and external commands are disabled.
601 /// Useful for tests or pure sandboxed execution.
602 pub fn isolated() -> Self {
603 Self {
604 name: "isolated".to_string(),
605 vfs_mode: VfsMountMode::NoLocal,
606 cwd: PathBuf::from("/"),
607 skip_validation: false,
608 interactive: false,
609 ignore_config: crate::ignore_config::IgnoreConfig::none(),
610 output_limit: crate::output_limit::OutputLimitConfig::none(),
611 allow_external_commands: false,
612 trash_enabled: false,
613 errexit_enabled: false,
614 initial_vars: HashMap::new(),
615 request_timeout: None,
616 kill_grace: Duration::from_secs(2),
617 vfs_budget_bytes: None,
618 overlay: false,
619 job_manager: None,
620 kill_children_on_parent_death: false,
621 }
622 }
623
624 /// Set the VFS mount mode.
625 pub fn with_vfs_mode(mut self, mode: VfsMountMode) -> Self {
626 self.vfs_mode = mode;
627 self
628 }
629
630 /// Set the initial working directory.
631 pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
632 self.cwd = cwd;
633 self
634 }
635
636 /// Skip pre-execution validation.
637 pub fn with_skip_validation(mut self, skip: bool) -> Self {
638 self.skip_validation = skip;
639 self
640 }
641
642 /// Enable interactive mode (external commands inherit stdio).
643 pub fn with_interactive(mut self, interactive: bool) -> Self {
644 self.interactive = interactive;
645 self
646 }
647
648 /// Set the ignore file configuration.
649 pub fn with_ignore_config(mut self, config: crate::ignore_config::IgnoreConfig) -> Self {
650 self.ignore_config = config;
651 self
652 }
653
654 /// Set the output limit configuration.
655 pub fn with_output_limit(mut self, config: crate::output_limit::OutputLimitConfig) -> Self {
656 self.output_limit = config;
657 self
658 }
659
660 /// Set whether external command execution is allowed.
661 ///
662 /// When `false`, commands not found as builtins report that external
663 /// commands are disabled on this shell — distinct from "command not
664 /// found", which stays reserved for a name that genuinely isn't
665 /// resolvable — instead of searching PATH. Backend-registered tools
666 /// (MCP, an embedder's own registry) are unaffected and still resolve.
667 /// The `exec` and `spawn` builtins also refuse, with the same wording.
668 /// Use this to prevent VFS sandbox bypass via external binaries.
669 pub fn with_allow_external_commands(mut self, allow: bool) -> Self {
670 self.allow_external_commands = allow;
671 self
672 }
673
674 /// Enable or disable trash-on-delete at startup.
675 pub fn with_trash(mut self, enabled: bool) -> Self {
676 self.trash_enabled = enabled;
677 self
678 }
679
680 /// Enable or disable errexit (`set -e`) at startup. See `errexit_enabled`
681 /// for precedence against `ExecuteOptions::errexit` and runtime `set -e`.
682 pub fn with_errexit(mut self, enabled: bool) -> Self {
683 self.errexit_enabled = enabled;
684 self
685 }
686
687 /// Add a single initial variable; marked exported when the kernel boots.
688 ///
689 /// Repeated calls add (last write wins on key collision).
690 pub fn with_var(mut self, name: impl Into<String>, value: Value) -> Self {
691 self.initial_vars.insert(name.into(), value);
692 self
693 }
694
695 /// Replace the entire initial-vars map. All entries are marked exported.
696 pub fn with_initial_vars(mut self, vars: HashMap<String, Value>) -> Self {
697 self.initial_vars = vars;
698 self
699 }
700
701 /// Extend the initial-vars map with the given entries (last write wins).
702 pub fn with_vars(mut self, vars: HashMap<String, Value>) -> Self {
703 self.initial_vars.extend(vars);
704 self
705 }
706
707 /// Set the default per-request timeout (kernel-wide).
708 ///
709 /// Each `execute_with_options` call without an explicit timeout uses
710 /// this. On elapsed, the kernel cancels and returns exit code 124.
711 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
712 self.request_timeout = Some(timeout);
713 self
714 }
715
716 /// Set the SIGTERM-to-SIGKILL grace period for child kills.
717 pub fn with_kill_grace(mut self, grace: Duration) -> Self {
718 self.kill_grace = grace;
719 self
720 }
721
722 /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands so a hard-killed
723 /// kaish process cannot orphan them (Linux only — read
724 /// [`Self::kill_children_on_parent_death`] for the tradeoff and the macOS
725 /// gap).
726 pub fn with_kill_children_on_parent_death(mut self, on: bool) -> Self {
727 self.kill_children_on_parent_death = on;
728 self
729 }
730
731 /// Adopt an embedder-owned [`JobManager`] instead of building a fresh one,
732 /// so background jobs outlive the kernel that started them. Read
733 /// [`Self::job_manager`] before sharing one manager between kernels that
734 /// are configured differently.
735 pub fn with_job_manager(mut self, jobs: Arc<JobManager>) -> Self {
736 self.job_manager = Some(jobs);
737 self
738 }
739
740 /// Cap VFS memory-resident bytes at `bytes` across all kernel-owned
741 /// `MemoryFs` mounts. A shared `ByteBudget` labeled `"vfs-memory"` is
742 /// created at kernel construction and passed to every `MemoryFs` the
743 /// kernel builds (see `setup_vfs` and `with_backend`).
744 ///
745 /// Writes that would exceed the cap fail loudly with `StorageFull` — an
746 /// in-band error a model reads and adapts to; fail loud over quietly eating
747 /// RAM. Use `without_vfs_budget` to remove the cap entirely.
748 pub fn with_vfs_budget(mut self, bytes: u64) -> Self {
749 self.vfs_budget_bytes = Some(bytes);
750 self
751 }
752
753 /// Remove the VFS memory budget — all `MemoryFs` mounts are unbounded.
754 ///
755 /// Use when the caller knows the workload and the default 64 MiB cap
756 /// (set by `KernelConfig::agent`) is too conservative.
757 pub fn without_vfs_budget(mut self) -> Self {
758 self.vfs_budget_bytes = None;
759 self
760 }
761
762 /// Enable or disable copy-on-write overlay mode.
763 ///
764 /// When `true`, the primary local filesystem mount is wrapped in an
765 /// `OverlayFs` so writes are virtual — the lower layer is never touched.
766 /// Incompatible with `VfsMountMode::NoLocal` (fails loudly at construction)
767 /// and `with_backend` kernels (same — the embedder controls the VFS).
768 pub fn with_overlay(mut self, overlay: bool) -> Self {
769 self.overlay = overlay;
770 self
771 }
772
773}
774
775
776/// Handle to an active overlay session, kept on the kernel and shared to
777/// `ExecContext` so the `kaish-vfs` builtin can reach the `OverlayFs`.
778///
779/// The `mount_path` is the VFS prefix the overlay was mounted under (e.g.
780/// `/home/user`); `commit_root` is the real filesystem path the overlay's
781/// lower is backed by (used as the target for `kaish-vfs commit`).
782#[cfg(all(feature = "localfs", feature = "overlay"))]
783#[derive(Clone)]
784pub struct OverlayHandle {
785 /// The mounted `OverlayFs`, Arc-shared so the builtin can call inspection
786 /// methods without holding a VfsRouter lock.
787 pub fs: Arc<OverlayFs>,
788 /// VFS path this overlay is mounted at (e.g. `/home/user`).
789 pub mount_path: PathBuf,
790 /// Real filesystem root to commit into. Same as the lower's root.
791 pub commit_root: PathBuf,
792}
793
794/// The Kernel (核) — executes kaish code.
795///
796/// This is the primary interface for running kaish commands. It owns all
797/// the runtime state: variables, tools, VFS, jobs, and persistence.
798pub struct Kernel {
799 /// Kernel name.
800 name: String,
801 /// Variable scope.
802 scope: RwLock<Scope>,
803 /// Tool registry.
804 tools: Arc<ToolRegistry>,
805 /// User-defined tools (from `tool name { body }` statements).
806 user_tools: RwLock<HashMap<String, ToolDef>>,
807 /// Virtual filesystem router.
808 vfs: Arc<VfsRouter>,
809 /// Background job manager.
810 jobs: Arc<JobManager>,
811 /// Pipeline runner.
812 runner: PipelineRunner,
813 /// Execution context (cwd, stdin, etc.).
814 exec_ctx: RwLock<ExecContext>,
815 /// Frontend-seeded variables (HOME/PATH/etc, from `KernelConfig::initial_vars`),
816 /// retained past construction so `reset()` can re-seed them into the fresh
817 /// scope instead of silently dropping them.
818 initial_vars: HashMap<String, Value>,
819 /// Whether to skip pre-execution validation.
820 skip_validation: bool,
821 /// When true, standalone external commands inherit stdio for real-time output.
822 interactive: bool,
823 /// Whether external command execution is allowed.
824 allow_external_commands: bool,
825 /// Shared memory budget for all kernel-owned `MemoryFs` mounts.
826 ///
827 /// `None` when `KernelConfig::vfs_budget_bytes` was `None` (unbounded).
828 /// `Some` is Arc-cloned into forks so all concurrent execution draws from
829 /// the same pool — a background job's writes reduce the same cap as
830 /// foreground writes, which is the correct behaviour.
831 vfs_budget: Option<Arc<kaish_vfs::ByteBudget>>,
832 /// Active overlay session handle, if this kernel was constructed with
833 /// `overlay: true`. Arc-shared so `ExecContext` (and thus the
834 /// `kaish-vfs` builtin) can inspect and mutate the overlay without
835 /// holding a kernel write lock. Propagated to forks via `fork_inner`
836 /// and `child_for_pipeline` so `kaish-vfs` works inside background
837 /// jobs, scatter workers, and pipeline stages.
838 #[cfg(all(feature = "localfs", feature = "overlay"))]
839 overlay_handle: Option<Arc<OverlayHandle>>,
840 /// Default per-request timeout (None = no default).
841 request_timeout: Option<Duration>,
842 /// SIGTERM-to-SIGKILL grace period for child kills.
843 kill_grace: Duration,
844 /// Receiver for the kernel stderr stream.
845 ///
846 /// Pipeline stages write to the corresponding `StderrStream` (set on ExecContext).
847 /// The kernel drains this after each statement in `execute_streaming`.
848 stderr_receiver: tokio::sync::Mutex<StderrReceiver>,
849 /// Cancellation token for interrupting execution (Ctrl-C).
850 ///
851 /// Protected by `std::sync::Mutex` (not tokio) because the SIGINT handler
852 /// needs sync access. Each `execute()` call gets a fresh child token;
853 /// `cancel()` cancels the current token and replaces it.
854 cancel_token: std::sync::Mutex<tokio_util::sync::CancellationToken>,
855 /// Per-call polled interrupt check (`ExecuteOptions::interrupt`),
856 /// installed for the duration of an `execute_with_options` call and
857 /// cleared on exit. Consulted by `is_cancelled()` so every existing
858 /// cancellation checkpoint gains interrupt awareness without new wiring.
859 /// std Mutex for the same sync-access reason as `cancel_token`.
860 interrupt: std::sync::Mutex<Option<std::sync::Arc<dyn Fn() -> bool + Send + Sync>>>,
861 /// Terminal state for job control (interactive mode only, Unix only).
862 #[cfg(all(unix, feature = "subprocess"))]
863 terminal_state: Option<Arc<crate::terminal::TerminalState>>,
864 /// Weak self-reference for handing out `Arc<dyn CommandDispatcher>`.
865 ///
866 /// Set by `into_arc()`. Allows builtins to re-dispatch inner commands
867 /// through the full Kernel resolution chain.
868 self_weak: std::sync::OnceLock<std::sync::Weak<Self>>,
869 /// Background job this kernel (a fork) is executing on behalf of, if any.
870 /// Set on the fork created by `execute_background` and inherited by all its
871 /// sub-forks (pipeline stages, scatter workers), so an external command
872 /// spawned anywhere under a background job can record its process group on
873 /// that job for `kill -<sig> %N`. `None` for foreground execution.
874 bg_job_id: Option<crate::scheduler::JobId>,
875 /// Serializes concurrent `execute()` / `execute_streaming()` callers on
876 /// this Kernel instance. Tokio's Mutex is fair (FIFO) and acts as the
877 /// queue. Background jobs, scatter workers, and concurrent pipeline
878 /// stages do NOT take this lock — they run against a *forked* Kernel
879 /// (see [`Kernel::fork`]) so they never contend with the foreground.
880 execute_lock: tokio::sync::Mutex<()>,
881 /// Current dynamic statement-engine re-entry depth — incremented on entry
882 /// to command substitution, a shell-function call, or a `.kai` source, and
883 /// decremented (via an RAII guard, so cancellation stays balanced) on exit.
884 /// Checked against [`MAX_RECURSION_DEPTH`] to turn a stack overflow into a
885 /// loud error (GH #46). Per-Kernel: a fork starts fresh at 0 because it
886 /// runs on its own stack. Atomic only for `Send`/`Sync`; within one Kernel
887 /// the recursion chain is single-threaded (top-level `execute` is
888 /// serialized by `execute_lock`; concurrency happens on forks).
889 recursion_depth: AtomicUsize,
890}
891
892/// RAII balance for [`Kernel::recursion_depth`]: increments on construction
893/// (in `enter_recursion`) and decrements on drop, so a cancelled or
894/// error-unwound re-entry can never leave the counter inflated (which would
895/// spuriously trip later, unrelated recursions).
896struct RecursionGuard<'a> {
897 counter: &'a AtomicUsize,
898}
899
900impl Drop for RecursionGuard<'_> {
901 fn drop(&mut self) {
902 self.counter.fetch_sub(1, Ordering::Relaxed);
903 }
904}
905
906/// Internal result of [`Kernel::setup_vfs`].
907struct VfsSetupResult {
908 vfs: VfsRouter,
909 budget: Option<Arc<ByteBudget>>,
910 #[cfg(all(feature = "localfs", feature = "overlay"))]
911 overlay_handle: Option<Arc<OverlayHandle>>,
912}
913
914impl Kernel {
915 /// Create a new kernel with the given configuration.
916 pub fn new(config: KernelConfig) -> Result<Self> {
917 let mut setup = Self::setup_vfs(&config)?;
918 // An embedder-supplied manager keeps `cmd &` jobs alive across kernels
919 // (see `KernelConfig::job_manager`); with none, this kernel owns its
920 // own job table exactly as before.
921 let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
922 // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
923 // kill builtin bounds its wait-for-death on the same number (GH #244).
924 jobs.set_kill_grace(config.kill_grace);
925
926 // Mount JobFs for job observability at /v/jobs
927 setup.vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
928
929 #[cfg(all(feature = "localfs", feature = "overlay"))]
930 let overlay_handle = setup.overlay_handle.take();
931
932 // Mode-based construction: the kernel owns its host mounts, so whether
933 // host side channels are allowed is decided by the VFS mode inside
934 // `assemble` (NoLocal forbids them).
935 let kernel = Self::assemble(config, setup.vfs, jobs, false, setup.budget, |_| {}, |vfs_ref, tools| {
936 ExecContext::with_vfs_and_tools(vfs_ref.clone(), tools.clone())
937 })?;
938
939 #[cfg(all(feature = "localfs", feature = "overlay"))]
940 {
941 let mut kernel = kernel;
942 kernel.overlay_handle = overlay_handle;
943 // Also set it on the ExecContext so builtins can access it.
944 if let Some(ref handle) = kernel.overlay_handle {
945 kernel.exec_ctx.get_mut().overlay_handle = Some(Arc::clone(handle));
946 }
947 return Ok(kernel);
948 }
949
950 #[allow(unreachable_code)]
951 Ok(kernel)
952 }
953
954 /// Set up VFS based on mount mode.
955 ///
956 /// Returns the router, the budget handle (if bounded), and an optional
957 /// overlay handle when `config.overlay` is true. The budget is Arc-shared:
958 /// every `MemoryFs` the kernel creates here holds a clone of the same
959 /// `Arc<ByteBudget>`, so the total charged against it is the sum of all
960 /// in-memory content across all kernel-owned memory mounts.
961 ///
962 /// # Errors
963 /// Returns `Err` if `config.overlay` is true and the mode is `NoLocal`
964 /// (overlay is meaningless when everything is already virtual — there is
965 /// no real lower layer to wrap). The caller (`Kernel::new`) propagates
966 /// this as an `anyhow::Error`.
967 fn setup_vfs(config: &KernelConfig) -> Result<VfsSetupResult> {
968 let mut vfs = VfsRouter::new();
969
970 // One budget for all memory mounts this kernel owns — labeled so the
971 // error message tells the user exactly which knob to raise.
972 let budget: Option<Arc<ByteBudget>> = config
973 .vfs_budget_bytes
974 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
975
976 /// Helper: construct a `MemoryFs` wired to `budget` if present.
977 fn mem(budget: &Option<Arc<ByteBudget>>) -> MemoryFs {
978 match budget {
979 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
980 None => MemoryFs::new(),
981 }
982 }
983
984 // Overlay handle — populated below if config.overlay is true.
985 #[cfg(all(feature = "localfs", feature = "overlay"))]
986 let mut overlay_handle: Option<Arc<OverlayHandle>> = None;
987
988 match &config.vfs_mode {
989 #[cfg(feature = "localfs")]
990 VfsMountMode::Passthrough => {
991 #[cfg(feature = "overlay")]
992 if config.overlay {
993 // Wrap "/" in an OverlayFs so writes are virtual.
994 let lower = Arc::new(LocalFs::read_only(PathBuf::from("/")));
995 let overlay_fs = Arc::new(match &budget {
996 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
997 None => OverlayFs::over(lower),
998 });
999 let handle = Arc::new(OverlayHandle {
1000 fs: Arc::clone(&overlay_fs),
1001 mount_path: PathBuf::from("/"),
1002 commit_root: PathBuf::from("/"),
1003 });
1004 vfs.mount_arc("/", overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1005 overlay_handle = Some(handle);
1006 } else {
1007 // LocalFs at "/" — native paths work directly
1008 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
1009 }
1010 #[cfg(not(feature = "overlay"))]
1011 {
1012 if config.overlay {
1013 return Err(anyhow::anyhow!(
1014 "overlay=true requires the `overlay` feature, but this build \
1015 was compiled without it. Recompile with --features overlay \
1016 (or the default feature set) to enable overlay mode."
1017 ));
1018 }
1019 // LocalFs at "/" — native paths work directly
1020 vfs.mount("/", LocalFs::new(PathBuf::from("/")));
1021 }
1022 // Memory for blobs
1023 vfs.mount("/v", mem(&budget));
1024 }
1025 #[cfg(feature = "localfs")]
1026 VfsMountMode::Sandboxed { root } => {
1027 // Memory at root for safety (catches paths outside sandbox).
1028 // Note: /tmp and the XDG runtime dir are LocalFs — writes
1029 // there escape the VFS budget and are NOT virtual. This is
1030 // intentional: /tmp interop with other processes matters more
1031 // than accounting for scratch files there.
1032 vfs.mount("/", mem(&budget));
1033 vfs.mount("/v", mem(&budget));
1034
1035 // Synthetic /dev: the host's real /dev isn't reachable here, so
1036 // /dev/null and /dev/zero are software-backed (see DevFs).
1037 vfs.mount("/dev", DevFs::new());
1038
1039 // Real /tmp for interop with other processes
1040 vfs.mount("/tmp", LocalFs::new(PathBuf::from("/tmp")));
1041
1042 // Mount XDG runtime dir for spill files and socket access
1043 let runtime = crate::paths::xdg_runtime_dir();
1044 if runtime.exists() {
1045 let runtime_str = runtime.to_string_lossy().to_string();
1046 vfs.mount(&runtime_str, LocalFs::new(runtime));
1047 }
1048
1049 // Resolve the sandbox root (defaults to $HOME)
1050 let local_root = root.clone().unwrap_or_else(|| {
1051 std::env::var("HOME")
1052 .map(PathBuf::from)
1053 .unwrap_or_else(|_| PathBuf::from("/"))
1054 });
1055
1056 let mount_point = local_root.to_string_lossy().to_string();
1057
1058 #[cfg(feature = "overlay")]
1059 if config.overlay {
1060 // Wrap the sandbox root in an OverlayFs.
1061 let lower = Arc::new(LocalFs::read_only(local_root.clone()));
1062 let overlay_fs = Arc::new(match &budget {
1063 Some(b) => OverlayFs::over_with_budget(lower, Arc::clone(b)),
1064 None => OverlayFs::over(lower),
1065 });
1066 let handle = Arc::new(OverlayHandle {
1067 fs: Arc::clone(&overlay_fs),
1068 mount_path: PathBuf::from(&mount_point),
1069 commit_root: local_root,
1070 });
1071 vfs.mount_arc(&mount_point, overlay_fs as Arc<dyn kaish_vfs::Filesystem>);
1072 overlay_handle = Some(handle);
1073 } else {
1074 // Mount at the real path for transparent access
1075 // e.g., /home/atobey → LocalFs("/home/atobey")
1076 // so /home/atobey/src/kaish just works
1077 vfs.mount(&mount_point, LocalFs::new(local_root));
1078 }
1079 #[cfg(not(feature = "overlay"))]
1080 {
1081 if config.overlay {
1082 return Err(anyhow::anyhow!(
1083 "overlay=true requires the `overlay` feature, but this build \
1084 was compiled without it. Recompile with --features overlay \
1085 (or the default feature set) to enable overlay mode."
1086 ));
1087 }
1088 // Mount at the real path for transparent access
1089 vfs.mount(&mount_point, LocalFs::new(local_root));
1090 }
1091 }
1092 VfsMountMode::NoLocal => {
1093 if config.overlay {
1094 return Err(anyhow::anyhow!(
1095 "overlay=true is incompatible with VfsMountMode::NoLocal: \
1096 everything is already virtual, there is no real lower layer \
1097 to wrap. Use with_overlay(false) or switch to a Passthrough \
1098 or Sandboxed VFS mode."
1099 ));
1100 }
1101 // Pure memory mode — no local filesystem
1102 vfs.mount("/", mem(&budget));
1103 vfs.mount("/tmp", mem(&budget));
1104 vfs.mount("/v", mem(&budget));
1105 // Synthetic /dev so /dev/null and /dev/zero work hermetically.
1106 vfs.mount("/dev", DevFs::new());
1107 }
1108 }
1109
1110 Ok(VfsSetupResult {
1111 vfs,
1112 budget,
1113 #[cfg(all(feature = "localfs", feature = "overlay"))]
1114 overlay_handle,
1115 })
1116 }
1117
1118 /// Create a transient kernel (no persistence).
1119 pub fn transient() -> Result<Self> {
1120 Self::new(KernelConfig::transient())
1121 }
1122
1123 /// Create a kernel with a custom backend and `/v/*` virtual path support.
1124 ///
1125 /// This is the constructor for embedding kaish in other systems that provide
1126 /// their own storage backend (e.g., CRDT-backed storage in kaijutsu).
1127 ///
1128 /// A `VirtualOverlayBackend` routes paths automatically:
1129 /// - `/v/*` → Internal VFS (JobFs at `/v/jobs`, MemoryFs at `/v/blobs`)
1130 /// - `/dev` → DevFs (synthetic `/dev/null`, `/dev/zero`, `/dev/random`,
1131 /// `/dev/urandom`) — kernel-owned so it works even when your backend is
1132 /// read-only
1133 /// - Everything else → Your custom backend
1134 ///
1135 /// The optional `configure_vfs` closure lets you add additional virtual mounts
1136 /// (e.g., `/v/docs` for CRDT blocks) after the built-in mounts are set up.
1137 ///
1138 /// **Note:** The config's `vfs_mode` is ignored — all non-`/v/*` path routing
1139 /// is handled by your custom backend. The config is only used for `name`, `cwd`,
1140 /// `skip_validation`, and `interactive`.
1141 ///
1142 /// # Example
1143 ///
1144 /// ```ignore
1145 /// // Simple: default /v/* mounts only
1146 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})?;
1147 ///
1148 /// // With custom mounts
1149 /// let kernel = Kernel::with_backend(backend, config, |vfs| {
1150 /// vfs.mount_arc("/v/docs", docs_fs);
1151 /// vfs.mount_arc("/v/g", git_fs);
1152 /// }, |_| {})?;
1153 ///
1154 /// // With custom tools
1155 /// let kernel = Kernel::with_backend(backend, config, |_| {}, |tools| {
1156 /// tools.register(MyCustomTool::new());
1157 /// })?;
1158 /// ```
1159 pub fn with_backend(
1160 backend: Arc<dyn KernelBackend>,
1161 config: KernelConfig,
1162 configure_vfs: impl FnOnce(&mut VfsRouter),
1163 configure_tools: impl FnOnce(&mut ToolRegistry),
1164 ) -> Result<Self> {
1165 use crate::backend::VirtualOverlayBackend;
1166
1167 // overlay=true is incompatible with with_backend: the embedder controls
1168 // the VFS and the kernel cannot wrap it without bypassing the embedder's
1169 // semantics. Fail loudly rather than silently ignoring the flag.
1170 if config.overlay {
1171 return Err(anyhow::anyhow!(
1172 "overlay=true is incompatible with Kernel::with_backend: the embedder \
1173 controls the VFS; the kernel cannot wrap it with an OverlayFs without \
1174 bypassing the embedder's storage semantics. Use KernelConfig::with_overlay(false)."
1175 ));
1176 }
1177
1178 let mut vfs = VfsRouter::new();
1179 // See `Kernel::new` — the embedder's manager wins here too.
1180 let jobs = config.job_manager.clone().unwrap_or_else(|| Arc::new(JobManager::new()));
1181 // Mirror the cascade's SIGTERM->SIGKILL grace onto the manager so the
1182 // kill builtin bounds its wait-for-death on the same number (GH #244).
1183 jobs.set_kill_grace(config.kill_grace);
1184
1185 // Create the budget from config so `with_vfs_budget` / `without_vfs_budget`
1186 // work for `with_backend` callers too. The /v/blobs MemoryFs is the only
1187 // kernel-owned memory mount here — embedders own the rest of the VFS.
1188 let vfs_budget: Option<Arc<ByteBudget>> = config
1189 .vfs_budget_bytes
1190 .map(|bytes| Arc::new(ByteBudget::labeled(bytes, "vfs-memory")));
1191
1192 vfs.mount("/v/jobs", JobFs::new(jobs.clone()));
1193 let blobs_fs = match &vfs_budget {
1194 Some(b) => MemoryFs::with_budget(Arc::clone(b)),
1195 None => MemoryFs::new(),
1196 };
1197 vfs.mount("/v/blobs", blobs_fs);
1198
1199 // /dev/null and friends are software-backed (see DevFs) and must not
1200 // depend on the embedder's backend — a read-only embedder backend
1201 // (e.g. kaijutsu's read-only host root) would otherwise reject writes
1202 // to /dev/null as a filesystem error instead of discarding them.
1203 vfs.mount("/dev", DevFs::new());
1204
1205 // Let caller add custom mounts (e.g., /v/docs, /v/g)
1206 configure_vfs(&mut vfs);
1207
1208 // A custom-backend kernel owns no host mounts — the embedder supplies
1209 // the entire VFS — so any kernel write to a host filesystem via
1210 // `std::fs` (output spill, job output files) bypasses that VFS and its
1211 // read-only guarantees. Forbid host side channels unconditionally.
1212 Self::assemble(config, vfs, jobs, true, vfs_budget, configure_tools, |vfs_arc: &Arc<VfsRouter>, _: &Arc<ToolRegistry>| {
1213 let overlay: Arc<dyn KernelBackend> =
1214 Arc::new(VirtualOverlayBackend::new(backend, vfs_arc.clone()));
1215 ExecContext::with_backend(overlay)
1216 })
1217 }
1218
1219 /// Shared assembly: wires up tools, runner, scope, and ExecContext.
1220 ///
1221 /// The `make_ctx` closure receives the VFS and tools so backends that need
1222 /// them (like `LocalBackend::with_tools`) can capture them. Custom backends
1223 /// that already have their own storage can ignore these parameters.
1224 fn assemble(
1225 config: KernelConfig,
1226 mut vfs: VfsRouter,
1227 jobs: Arc<JobManager>,
1228 no_host_filesystem: bool,
1229 vfs_budget: Option<Arc<ByteBudget>>,
1230 configure_tools: impl FnOnce(&mut ToolRegistry),
1231 make_ctx: impl FnOnce(&Arc<VfsRouter>, &Arc<ToolRegistry>) -> ExecContext,
1232 ) -> Result<Self> {
1233 // A kernel with no host filesystem of its own must never write to one
1234 // through a side channel. Two paths bypass the VFS by going straight to
1235 // `std::fs`: output spill (`paths::spill_dir()` → host temp/cache) and
1236 // background-job output files (`Job::write_output_file` → host temp).
1237 // Both would punch through the isolation, so force them off:
1238 // in-memory truncation for spill, no host file for job output.
1239 //
1240 // This is true for a `NoLocal` kernel (mounts nothing) and for any
1241 // `with_backend` kernel (`no_host_filesystem` — the embedder owns the
1242 // VFS, so the kernel controls no host mounts and any host write is a
1243 // bypass). Overrides an explicit `SpillMode::Disk`, which is nonsensical
1244 // when there is no kernel-owned host filesystem to spill to.
1245 let no_host_side_channel =
1246 no_host_filesystem || matches!(config.vfs_mode, VfsMountMode::NoLocal);
1247
1248 let KernelConfig { name, cwd, skip_validation, interactive, ignore_config, mut output_limit, allow_external_commands, trash_enabled, errexit_enabled, initial_vars, request_timeout, kill_grace, kill_children_on_parent_death, .. } = config;
1249
1250 if no_host_side_channel {
1251 output_limit.set_spill_mode(crate::output_limit::SpillMode::Memory);
1252 jobs.set_persist_output_files(false);
1253 }
1254
1255 let mut tools = ToolRegistry::new();
1256 register_builtins(&mut tools);
1257 configure_tools(&mut tools);
1258 let tools = Arc::new(tools);
1259
1260 // Mount BuiltinFs so `ls /v/bin` lists builtins
1261 vfs.mount("/v/bin", BuiltinFs::new(tools.clone()));
1262
1263 let vfs = Arc::new(vfs);
1264
1265 let runner = PipelineRunner::new(tools.clone());
1266
1267 let (stderr_writer, stderr_receiver) = stderr_stream();
1268
1269 let mut exec_ctx = make_ctx(&vfs, &tools);
1270 let initial_cwd = cwd.clone();
1271 exec_ctx.set_cwd(cwd);
1272 exec_ctx.kill_children_on_parent_death = kill_children_on_parent_death;
1273 exec_ctx.set_job_manager(jobs.clone());
1274 exec_ctx.set_tool_schemas(tools.schemas());
1275 exec_ctx.set_tools(tools.clone());
1276 #[cfg(feature = "os-integration")]
1277 exec_ctx.set_trash_backend(Arc::new(crate::trash_system::SystemTrash));
1278 exec_ctx.stderr = Some(stderr_writer);
1279 exec_ctx.ignore_config = ignore_config;
1280 exec_ctx.output_limit = output_limit;
1281 exec_ctx.allow_external_commands = allow_external_commands;
1282 exec_ctx.vfs_budget = vfs_budget.clone();
1283
1284 Ok(Self {
1285 name,
1286 scope: RwLock::new({
1287 let mut scope = Scope::new();
1288 scope.set_pid(KERNEL_COUNTER.fetch_add(1, Ordering::Relaxed));
1289 // HOME is NOT read from the host env here — the kernel is
1290 // hermetic. Frontends (REPL, MCP) seed it via `initial_vars`
1291 // below (from `std::env::vars()`); a hermetic embedder leaves
1292 // `initial_vars` empty and gets no HOME (tilde stays literal).
1293 // Apply caller-supplied initial variables, all marked exported.
1294 // Frontends (REPL, MCP) populate this from std::env::vars()
1295 // for shell-like UX; embedders that want hermetic behavior
1296 // simply leave it empty.
1297 for (name, value) in initial_vars.clone() {
1298 scope.set_exported(name, value);
1299 }
1300 scope.set_trash_enabled(trash_enabled);
1301 scope.set_error_exit(errexit_enabled);
1302 // `$PWD` before any `cd`. Seeded HERE, not just on `exec_ctx`:
1303 // this is the scope execution reads, and `exec_ctx.scope` is
1304 // overwritten from it at every dispatch, so a value written
1305 // only there never survives to be read.
1306 scope.set_global(
1307 "PWD",
1308 Value::String(initial_cwd.to_string_lossy().into_owned()),
1309 );
1310 // `$OLDPWD` is DROPPED rather than seeded. There is no previous
1311 // directory yet, and an inherited one describes the invoking
1312 // shell's history, not this session's — `cd -` already refuses
1313 // with "OLDPWD not set", and the variable must not contradict
1314 // it by naming a directory `cd -` will not go to.
1315 scope.remove("OLDPWD");
1316 scope
1317 }),
1318 initial_vars,
1319 tools,
1320 user_tools: RwLock::new(HashMap::new()),
1321 vfs,
1322 jobs,
1323 runner,
1324 exec_ctx: RwLock::new(exec_ctx),
1325 skip_validation,
1326 interactive,
1327 allow_external_commands,
1328 vfs_budget,
1329 request_timeout,
1330 kill_grace,
1331 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1332 cancel_token: std::sync::Mutex::new(tokio_util::sync::CancellationToken::new()),
1333 interrupt: std::sync::Mutex::new(None),
1334 #[cfg(all(unix, feature = "subprocess"))]
1335 terminal_state: None,
1336 self_weak: std::sync::OnceLock::new(),
1337 execute_lock: tokio::sync::Mutex::new(()),
1338 recursion_depth: AtomicUsize::new(0),
1339 bg_job_id: None,
1340 // Overlay handle is set by Kernel::new after assemble returns;
1341 // assemble itself doesn't know the handle (it's constructed in setup_vfs).
1342 // with_backend always has None (overlay=true is rejected above).
1343 #[cfg(all(feature = "localfs", feature = "overlay"))]
1344 overlay_handle: None,
1345 })
1346 }
1347
1348 /// Plan every statement of `source` without executing anything —
1349 /// [`plan_program`](crate::ast::plan::plan_program) as a method, so an
1350 /// embedder holding a kernel can pair the plans with `get_var` lookups
1351 /// against this kernel's live state.
1352 ///
1353 /// # Errors
1354 ///
1355 /// Returns the parse errors when `source` does not parse.
1356 pub fn plan_program(
1357 &self,
1358 source: &str,
1359 ) -> Result<Vec<crate::ast::plan::PlannedStatement>, Vec<crate::parser::ParseError>> {
1360 crate::ast::plan::plan_program(source)
1361 }
1362
1363 /// Expand one heredoc body against a scope the caller supplies —
1364 /// [`expand_fragment`](crate::fragment::expand_fragment) as a method.
1365 ///
1366 /// The scope is the caller's, not this kernel's: pair it with `get_var`
1367 /// when the session's values are the ones to judge against, and supply
1368 /// different values when they are not. Nothing executes, and a `$(…)` in
1369 /// the body comes back as a [`Hole`](kaish_types::plan::Hole) rather than
1370 /// running here.
1371 ///
1372 /// # Errors
1373 ///
1374 /// Returns a [`FragmentError`](crate::fragment::FragmentError) when the
1375 /// source does not parse, the address names no heredoc, or the body reads
1376 /// something the supplied scope does not carry.
1377 pub fn expand_fragment(
1378 &self,
1379 source: &str,
1380 addr: kaish_types::plan::FragmentAddr,
1381 scope: &[(String, Value)],
1382 ) -> Result<kaish_types::plan::Expansion, crate::fragment::FragmentError> {
1383 crate::fragment::expand_fragment(source, addr, scope)
1384 }
1385
1386 /// Get the kernel name.
1387 pub fn name(&self) -> &str {
1388 &self.name
1389 }
1390
1391 /// Wrap this Kernel in an Arc and initialize its self-reference.
1392 ///
1393 /// This enables the Kernel to hand out `Arc<dyn CommandDispatcher>` references
1394 /// to child contexts, allowing builtins like `timeout` to dispatch inner
1395 /// commands through the full resolution chain (user tools → builtins →
1396 /// .kai scripts → external commands).
1397 pub fn into_arc(self) -> Arc<Self> {
1398 let arc = Arc::new(self);
1399 let _ = arc.self_weak.set(Arc::downgrade(&arc));
1400 arc
1401 }
1402
1403 /// Fork a subsidiary kernel for concurrent execution.
1404 ///
1405 /// The fork is a fully-functional `Kernel` that:
1406 /// - **Snapshots** per-session state from the parent: scope (COW — cheap),
1407 /// user-defined tools, cwd, aliases, ignore config, etc. Mutations on
1408 /// the fork do NOT propagate back to the parent — matching bash
1409 /// subshell / background-job semantics.
1410 /// - **Shares** read-mostly resources with the parent via `Arc`: the tool
1411 /// registry, the VFS router, and the job manager. A job registered by
1412 /// the fork is visible to the parent's `jobs` builtin, and the fork
1413 /// sees the same VFS mounts.
1414 /// - **Owns** its own `stderr_receiver`, `cancel_token`, and
1415 /// `execute_lock`. It is never the TTY owner, so `interactive` is
1416 /// `false` and `terminal_state` is `None`.
1417 ///
1418 /// The returned Arc has its `self_weak` populated (via `into_arc`), so
1419 /// nested dispatch through `ctx.dispatcher` (e.g. the `timeout` builtin)
1420 /// routes through the fork itself, not the parent — which is essential
1421 /// for concurrency safety.
1422 ///
1423 /// Use this for **detached** background concurrency where the fork should
1424 /// survive parent cancellation: the `&` background-job operator and any
1425 /// other "fire and forget" worker. The fork gets a fresh, independent
1426 /// cancellation token.
1427 ///
1428 /// For foreground concurrency (scatter workers, concurrent pipeline
1429 /// stages, `$(...)` cmdsubs) where parent timeout/cancel must cascade
1430 /// into the fork's external children, use [`Self::fork_attached`].
1431 pub async fn fork(&self) -> Arc<Self> {
1432 self.fork_inner(tokio_util::sync::CancellationToken::new(), self.bg_job_id)
1433 .await
1434 }
1435
1436 /// Fork attached to the parent's cancellation.
1437 ///
1438 /// Same as [`Self::fork`] but the fork's `cancel_token` is a child of
1439 /// the parent's. When the parent cancels (request timeout, embedder
1440 /// `Kernel::cancel`, etc.), the fork's token also cancels, which in
1441 /// turn kills any external children spawned in the fork via the
1442 /// `wait_or_kill` / SIGTERM-grace-SIGKILL path.
1443 pub async fn fork_attached(&self) -> Arc<Self> {
1444 let child_token = {
1445 #[allow(clippy::expect_used)]
1446 let parent = self.cancel_token.lock().expect("cancel_token poisoned");
1447 parent.child_token()
1448 };
1449 self.fork_inner(child_token, self.bg_job_id).await
1450 }
1451
1452 /// Fork for a background job, stamping the job id so external commands
1453 /// spawned anywhere beneath it record their process groups on that job
1454 /// (for `kill -<sig> %N`). The caller owns `cancel` so it can also drive
1455 /// `JobManager::cancel`.
1456 pub async fn fork_for_background(
1457 &self,
1458 cancel: tokio_util::sync::CancellationToken,
1459 job_id: crate::scheduler::JobId,
1460 ) -> Arc<Self> {
1461 self.fork_inner(cancel, Some(job_id)).await
1462 }
1463
1464 /// Shared fork implementation. Caller decides the cancellation token and
1465 /// which background job (if any) this fork runs on behalf of.
1466 async fn fork_inner(
1467 &self,
1468 cancel: tokio_util::sync::CancellationToken,
1469 bg_job_id: Option<crate::scheduler::JobId>,
1470 ) -> Arc<Self> {
1471 let scope_snapshot = self.scope.read().await.clone();
1472 let user_tools_snapshot = self.user_tools.read().await.clone();
1473
1474 // Snapshot exec_ctx by cloning the cloneable fields, then override
1475 // the ones that should not carry over (stderr channel, dispatcher,
1476 // interactive flag, terminal state, cancel — set from `cancel` arg).
1477 let mut fork_ctx = {
1478 let parent_ctx = self.exec_ctx.read().await;
1479 parent_ctx.child_for_pipeline()
1480 };
1481 let (stderr_writer, stderr_receiver) = stderr_stream();
1482 fork_ctx.stderr = Some(stderr_writer);
1483 // Clear dispatcher; dispatch_command will repopulate it to point at
1484 // the fork on the first dispatch call.
1485 fork_ctx.dispatcher = None;
1486 fork_ctx.interactive = false;
1487 fork_ctx.cancel = cancel.clone();
1488 #[cfg(all(unix, feature = "subprocess"))]
1489 {
1490 fork_ctx.terminal_state = None;
1491 }
1492
1493 let fork = Self {
1494 name: format!("{}:fork", self.name),
1495 scope: RwLock::new(scope_snapshot),
1496 initial_vars: self.initial_vars.clone(),
1497 tools: Arc::clone(&self.tools),
1498 user_tools: RwLock::new(user_tools_snapshot),
1499 vfs: Arc::clone(&self.vfs),
1500 jobs: Arc::clone(&self.jobs),
1501 runner: self.runner.clone(),
1502 exec_ctx: RwLock::new(fork_ctx),
1503 skip_validation: self.skip_validation,
1504 // Forks are never the TTY owner — they run in the background.
1505 interactive: false,
1506 allow_external_commands: self.allow_external_commands,
1507 // Arc-clone the budget so the fork draws from the same pool as the
1508 // parent — background jobs and scatter workers count against the same
1509 // cap as foreground writes.
1510 vfs_budget: self.vfs_budget.clone(),
1511 request_timeout: self.request_timeout,
1512 kill_grace: self.kill_grace,
1513 stderr_receiver: tokio::sync::Mutex::new(stderr_receiver),
1514 cancel_token: std::sync::Mutex::new(cancel),
1515 interrupt: std::sync::Mutex::new(None),
1516 #[cfg(all(unix, feature = "subprocess"))]
1517 terminal_state: None,
1518 self_weak: std::sync::OnceLock::new(),
1519 execute_lock: tokio::sync::Mutex::new(()),
1520 // A fork runs on a fresh stack (spawned task) — its recursion
1521 // budget is independent of the parent's current depth (GH #46).
1522 recursion_depth: AtomicUsize::new(0),
1523 // A fork surfaces its own holds; the parent's slot stays put.
1524 bg_job_id,
1525 // Arc-clone the overlay handle so forks (background jobs, scatter
1526 // workers, pipeline stages) can reach the same overlay transaction
1527 // via `kaish-vfs status/diff/commit/reset`.
1528 #[cfg(all(feature = "localfs", feature = "overlay"))]
1529 overlay_handle: self.overlay_handle.clone(),
1530 };
1531
1532 fork.into_arc()
1533 }
1534
1535 /// Get an `Arc<dyn CommandDispatcher>` to this Kernel, if wrapped via `into_arc()`.
1536 ///
1537 /// Returns `None` if the Kernel was not wrapped, or if all strong references
1538 /// have been dropped (the `Weak` can no longer upgrade).
1539 pub fn dispatcher(&self) -> Option<Arc<dyn CommandDispatcher>> {
1540 self.self_weak
1541 .get()
1542 .and_then(|weak| weak.upgrade())
1543 .map(|arc| arc as Arc<dyn CommandDispatcher>)
1544 }
1545
1546 /// Initialize terminal state for interactive job control.
1547 ///
1548 /// Call this after kernel creation when running as an interactive REPL
1549 /// and stdin is a TTY. Sets up process groups and signal handling.
1550 #[cfg(all(unix, feature = "subprocess"))]
1551 pub fn init_terminal(&mut self) {
1552 if !self.interactive {
1553 return;
1554 }
1555 match crate::terminal::TerminalState::init() {
1556 Ok(state) => {
1557 let state = Arc::new(state);
1558 self.terminal_state = Some(state.clone());
1559 // Set on exec_ctx so builtins (fg, bg, kill) can access it
1560 self.exec_ctx.get_mut().terminal_state = Some(state);
1561 tracing::debug!("terminal job control initialized");
1562 }
1563 Err(e) => {
1564 tracing::warn!("failed to initialize terminal job control: {}", e);
1565 }
1566 }
1567 }
1568
1569 /// Replace or remove the trash backend used by `rm` and `kaish-trash`.
1570 ///
1571 /// The kernel installs the OS trash (`SystemTrash`) automatically when
1572 /// built with the `os-integration` feature. Embedders and tests can swap
1573 /// in a custom [`crate::trash::TrashBackend`], or pass `None` to remove
1574 /// it — with trash enabled but no backend present, `rm` fails loud
1575 /// rather than falling through to permanent delete.
1576 pub fn set_trash_backend(&mut self, backend: Option<Arc<dyn crate::trash::TrashBackend>>) {
1577 self.exec_ctx.get_mut().trash_backend = backend;
1578 }
1579
1580 /// Cancel the current execution.
1581 ///
1582 /// This cancels the current cancellation token, causing any execution
1583 /// loop to exit at the next checkpoint with exit code 130 (SIGINT).
1584 /// A fresh token is installed for the next `execute()` call.
1585 pub fn cancel(&self) {
1586 #[allow(clippy::expect_used)]
1587 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1588 token.cancel();
1589 }
1590
1591 /// Check if the current execution has been cancelled.
1592 ///
1593 /// Also the polling point for `ExecuteOptions::interrupt`: when the
1594 /// embedder's check reports true, the internal token fires here, so every
1595 /// call site of this method is an interrupt checkpoint for free.
1596 pub fn is_cancelled(&self) -> bool {
1597 let interrupted = {
1598 #[allow(clippy::expect_used)]
1599 let check = self.interrupt.lock().expect("interrupt poisoned");
1600 check.as_ref().is_some_and(|f| f())
1601 };
1602 if interrupted {
1603 self.cancel();
1604 }
1605 #[allow(clippy::expect_used)]
1606 let token = self.cancel_token.lock().expect("cancel_token poisoned");
1607 token.is_cancelled()
1608 }
1609
1610 /// Reset the cancellation token (called at the start of each execute).
1611 fn reset_cancel(&self) -> tokio_util::sync::CancellationToken {
1612 #[allow(clippy::expect_used)]
1613 let mut token = self.cancel_token.lock().expect("cancel_token poisoned");
1614 if token.is_cancelled() {
1615 *token = tokio_util::sync::CancellationToken::new();
1616 }
1617 token.clone()
1618 }
1619
1620 /// Acquire the per-Kernel execute lock, warning on contention.
1621 ///
1622 /// Tokio's Mutex is fair (FIFO) so callers queue in arrival order. When
1623 /// the lock is already held, emit a warning so the silent serialization
1624 /// is observable in logs — if you need real parallelism, fork the kernel.
1625 async fn acquire_execute_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1626 match self.execute_lock.try_lock() {
1627 Ok(guard) => guard,
1628 Err(_) => {
1629 tracing::warn!(
1630 target: "kaish::kernel::concurrency",
1631 kernel = %self.name,
1632 "execute() contended — serializing concurrent caller; \
1633 use Kernel::fork() for parallelism instead of sharing"
1634 );
1635 self.execute_lock.lock().await
1636 }
1637 }
1638 }
1639
1640 /// Execute kaish source code with default options.
1641 ///
1642 /// Equivalent to `execute_with_options(input, ExecuteOptions::default())`.
1643 /// Returns the result of the last statement executed.
1644 ///
1645 /// # Errors
1646 ///
1647 /// Returns [`KernelError`] when the program was rejected before running
1648 /// (a lex/parse failure or a validator rejection) or faulted while
1649 /// running. See [`KernelError::is_rejected`] to route on that
1650 /// distinction. A nonzero exit from the *script itself* — a failed
1651 /// command, `set -e` — is not an `Err`; it comes back as `Ok` with the
1652 /// exit code folded into the returned [`ExecResult`].
1653 pub async fn execute(&self, input: &str) -> Result<ExecResult, KernelError> {
1654 self.run_inner(input, ExecuteOptions::default(), None, None)
1655 .await
1656 .map_err(classify_execute_error)
1657 }
1658
1659 /// Argv-native peer of [`Self::execute`] — run one command whose arguments
1660 /// are **already tokenized**.
1661 ///
1662 /// `execute(&str)` is string-native: it lexes and parses its input. A caller
1663 /// that already holds OS/structured argv (a busybox-style multicall binary, a
1664 /// structured embedder like kaijutsu) would otherwise have to re-quote argv
1665 /// into a string just to have the lexer split it apart again — a round-trip
1666 /// that is lossy for typed values, since `to_argv()` stringifies
1667 /// [`Value::Bytes`]/[`Value::Json`]. `execute_argv` skips it.
1668 ///
1669 /// **Tokens are literal.** No glob expansion, no `$VAR` interpolation, no
1670 /// command substitution, no word splitting — the "single-quoted word"
1671 /// semantics taken to its end. `execute_argv("echo", &[Value::String("*.txt"
1672 /// .into())])` emits `*.txt`; it does not glob. (One shared-binder expansion
1673 /// does still apply, for consistency with the string door: a leading `~` is
1674 /// expanded against the session `HOME` — kaish expands `~` uniformly, so the
1675 /// two doors agree. Pass a pre-resolved path if you need it byte-literal.) A
1676 /// non-string `Value`
1677 /// (`Bytes`/`Json`/`Int`) lands directly in `ToolArgs.positional`, so typed
1678 /// data survives without a `to_argv()` round-trip. (Caveat: the two-layer
1679 /// clap arg model means a builtin that re-parses its own `to_argv()` still
1680 /// sees a stringified value; the typed-passthrough win fully lands only for
1681 /// builtins that read `args.positional` directly — the documented pattern.)
1682 ///
1683 /// This is a *peer*, not a subset: a command string can carry pipelines,
1684 /// `&&`/`||`, control flow and `$()` that have no argv encoding, so the two
1685 /// doors converge **late** (at the shared dispatch chain) rather than one
1686 /// wrapping the other. From argv classification onward `execute_argv` reuses
1687 /// the exact path a `Stmt::Command` takes — command resolution (aliases, user
1688 /// tools, `.kai` scripts, externals, backend tools), arg binding, and the
1689 /// `--json` transform — so an `ls --json` still applies output formatting. The kernel's
1690 /// pre-execution *syntax* validator does not run: argv has no shell syntax to
1691 /// validate (a tool's own `validate()`/clap parse still runs at dispatch).
1692 ///
1693 /// Concurrent callers serialize on the same execute lock as [`Self::execute`],
1694 /// and the kernel's configured `request_timeout` applies (a hung builtin or
1695 /// external is interrupted at the deadline with exit code 124, the same as the
1696 /// string door). There is no per-call options surface yet — a future
1697 /// `execute_argv_with_options` would carry per-call timeout/cancel/vars/cwd.
1698 ///
1699 /// # Errors
1700 ///
1701 /// Returns [`KernelError`], always [`KernelError::Execution`] — argv has
1702 /// no shell syntax to reject, so `execute_argv` never returns
1703 /// [`KernelError::Parse`] or [`KernelError::Validation`] (a tool's own
1704 /// `validate()`/clap parse at dispatch still surfaces here, as an
1705 /// execution failure).
1706 #[tracing::instrument(level = "info", skip(self, argv), fields(cmd = name, argc = argv.len()))]
1707 pub async fn execute_argv(&self, name: &str, argv: &[Value]) -> Result<ExecResult, KernelError> {
1708 let _guard = self.acquire_execute_lock().await;
1709 self.execute_argv_locked(name, argv).await.map_err(classify_execute_error)
1710 }
1711
1712 /// [`Self::execute_argv`]'s body, with the execute lock assumed **held**.
1713 async fn execute_argv_locked(&self, name: &str, argv: &[Value]) -> Result<ExecResult> {
1714 // Fresh cancel surface for this call: `execute_pipeline` reads
1715 // `self.cancel_token`, so a stale cancelled token from a prior call must be
1716 // replaced first. The returned clone is the token the watchdog cancels on
1717 // an elapsed deadline (it shares state with what `execute_pipeline` reads),
1718 // cascading SIGTERM/SIGKILL to any external child.
1719 let cancel = self.reset_cancel();
1720
1721 // Honor the kernel-configured request timeout for parity with `execute`.
1722 let timeout = self.request_timeout;
1723 if timeout == Some(Duration::ZERO) {
1724 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
1725 }
1726
1727 let command = crate::ast::Command {
1728 name: name.to_string(),
1729 args: argv_to_args(argv),
1730 redirects: Vec::new(),
1731 };
1732
1733 let pipeline = crate::ast::Pipeline {
1734 stages: vec![crate::ast::PipelineStage::Command(command)],
1735 background: false,
1736 };
1737 let work = async {
1738 let result = self.execute_pipeline(&pipeline).await?;
1739 // A gate raised while evaluating inside the dispatched tool — a
1740 // user tool body's `$(…)` — surfaces as this call's own held
1741 // result, and must not strand in the slot for the next serialized
1742 // call to mis-take.
1743 Ok(result)
1744 };
1745 let result = self.run_under_watchdog(timeout, &cancel, work).await?;
1746 self.update_last_result(&result).await;
1747 Ok(result)
1748 }
1749
1750 /// Run `work` under the movable-deadline watchdog for `timeout`, shared by the
1751 /// string door ([`Self::execute_with_options`]) and the argv door
1752 /// ([`Self::execute_argv`]).
1753 ///
1754 /// Mirrors the watchdog into `exec_ctx` (so a builtin can suspend the script
1755 /// clock via `ctx.patient`), and when a timeout is set, spawns the watchdog
1756 /// racing `cancel` — on an elapsed deadline `cancel` fires (cascading
1757 /// SIGTERM/SIGKILL to external children via `wait_or_kill`) and the result's
1758 /// code becomes 124. With `timeout == None`, runs `work` directly. Clears the
1759 /// watchdog handle from `exec_ctx` on the way out (a patient hold against a
1760 /// stale handle would silently suspend nothing). Callers must short-circuit a
1761 /// `Some(Duration::ZERO)` timeout (return 124 without spawning) before calling.
1762 async fn run_under_watchdog<F>(
1763 &self,
1764 timeout: Option<Duration>,
1765 cancel: &tokio_util::sync::CancellationToken,
1766 work: F,
1767 ) -> Result<ExecResult>
1768 where
1769 F: std::future::Future<Output = Result<ExecResult>>,
1770 {
1771 // Assigned unconditionally (clearing any stale handle); None without a timeout.
1772 let watchdog = timeout.map(|d| Arc::new(crate::watchdog::Watchdog::new(d)));
1773 {
1774 let mut ec = self.exec_ctx.write().await;
1775 ec.watchdog = watchdog.clone();
1776 }
1777
1778 let result = if let Some(d) = timeout {
1779 #[allow(clippy::expect_used)]
1780 let watchdog = watchdog.clone().expect("watchdog constructed when timeout is set");
1781 let elapsed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1782 let timer = tokio::spawn(watchdog.run(elapsed.clone(), cancel.clone()));
1783 let r = work.await;
1784 timer.abort();
1785 match r {
1786 Ok(mut res) => {
1787 if elapsed.load(std::sync::atomic::Ordering::SeqCst) {
1788 res.code = 124;
1789 if res.err.is_empty() {
1790 res.err =
1791 ExecResult::terminate_diagnostic(format!("timeout: timed out after {:?}", d));
1792 }
1793 }
1794 Ok(res)
1795 }
1796 Err(e) => Err(e),
1797 }
1798 } else {
1799 work.await
1800 };
1801
1802 // The timer task is gone (fired or aborted); drop the stale handle.
1803 {
1804 let mut ec = self.exec_ctx.write().await;
1805 ec.watchdog = None;
1806 }
1807 result
1808 }
1809
1810 /// Execute with per-call options. The primary entry point for embedders
1811 /// that don't need per-statement output streaming.
1812 ///
1813 /// `opts` carries timeout, transient vars overlay, optional cwd override,
1814 /// and optional embedder-owned cancellation token. See [`ExecuteOptions`]
1815 /// for semantics. For streaming, use [`Self::execute_with_options_streaming`].
1816 ///
1817 /// **Cancellation:** if `opts.cancel_token` is `Some`, it is *raced*
1818 /// against the kernel's internal token. Either firing cancels and kills
1819 /// external children. The embedder's token is read-only — kernel
1820 /// timeouts do NOT propagate into it. Distinguish via the returned
1821 /// `code`: 124 = timeout, 130 = cancellation.
1822 ///
1823 /// **Timeout:** `opts.timeout` overrides `KernelConfig::request_timeout`.
1824 /// `Some(Duration::ZERO)` returns 124 immediately without spawning.
1825 ///
1826 /// Concurrent callers on the same Kernel serialize on the kernel-wide
1827 /// execute lock. For true parallelism, call [`Kernel::fork`] (detached)
1828 /// or [`Kernel::fork_attached`] (cancellation cascades from this kernel).
1829 ///
1830 /// # Errors
1831 ///
1832 /// Returns [`KernelError`] when the program was rejected before running
1833 /// or faulted while running — see [`KernelError::is_rejected`].
1834 pub async fn execute_with_options(
1835 &self,
1836 input: &str,
1837 opts: ExecuteOptions,
1838 ) -> Result<ExecResult, KernelError> {
1839 self.run_inner(input, opts, None, None).await.map_err(classify_execute_error)
1840 }
1841
1842 /// Same as [`Self::execute_with_options`] but with a per-statement output
1843 /// callback. The callback fires after each top-level statement so the
1844 /// embedder (REPL, MCP streaming) can flush output incrementally.
1845 ///
1846 /// # Errors
1847 ///
1848 /// See [`Self::execute_with_options`].
1849 pub async fn execute_with_options_streaming(
1850 &self,
1851 input: &str,
1852 opts: ExecuteOptions,
1853 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1854 ) -> Result<ExecResult, KernelError> {
1855 self.run_inner(input, opts, None, Some(on_output)).await.map_err(classify_execute_error)
1856 }
1857
1858 /// Execute with a **lazy** standard input fed as a [`PipeReader`](crate::PipeReader).
1859 ///
1860 /// Unlike [`ExecuteOptions::with_stdin`] (a pre-read buffer), this never
1861 /// forces the input to be drained before execution: the reader seeds the
1862 /// first top-level command's `pipe_stdin`, and a command that does not read
1863 /// stdin (`echo`) returns without touching it. This is the seam a
1864 /// non-interactive frontend uses to forward an *open* process stdin without
1865 /// hanging on a pipe that never sends EOF (`sleep 10 | kaish -c 'echo hi'`).
1866 ///
1867 /// Embedders that already hold a complete buffer (text or binary) should
1868 /// prefer the simpler [`ExecuteOptions::with_stdin`] path instead.
1869 ///
1870 /// # Errors
1871 ///
1872 /// See [`Self::execute_with_options`].
1873 pub async fn execute_with_pipe_stdin(
1874 &self,
1875 input: &str,
1876 opts: ExecuteOptions,
1877 pipe_stdin: crate::scheduler::PipeReader,
1878 ) -> Result<ExecResult, KernelError> {
1879 self.run_inner(input, opts, Some(pipe_stdin), None).await.map_err(classify_execute_error)
1880 }
1881
1882 /// Streaming counterpart to [`Self::execute_with_pipe_stdin`] — the REPL
1883 /// `-c`/script frontend uses this to print output incrementally while
1884 /// feeding a lazy process-stdin pipe.
1885 ///
1886 /// # Errors
1887 ///
1888 /// See [`Self::execute_with_options`].
1889 pub async fn execute_with_pipe_stdin_streaming(
1890 &self,
1891 input: &str,
1892 opts: ExecuteOptions,
1893 pipe_stdin: crate::scheduler::PipeReader,
1894 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1895 ) -> Result<ExecResult, KernelError> {
1896 self.run_inner(input, opts, Some(pipe_stdin), Some(on_output))
1897 .await
1898 .map_err(classify_execute_error)
1899 }
1900
1901 /// Execute kaish source code with a transient overlay of exported variables.
1902 ///
1903 /// Deprecated thin wrapper over [`Self::execute_with_options`]. New code
1904 /// should use that method directly:
1905 /// `execute_with_options(input, ExecuteOptions::new().with_vars(vars))`.
1906 #[deprecated(note = "use Kernel::execute_with_options with ExecuteOptions::with_vars")]
1907 pub async fn execute_with_vars(
1908 &self,
1909 input: &str,
1910 vars: HashMap<String, Value>,
1911 ) -> Result<ExecResult, KernelError> {
1912 self.run_inner(input, ExecuteOptions::new().with_vars(vars), None, None)
1913 .await
1914 .map_err(classify_execute_error)
1915 }
1916
1917 /// Execute kaish source code with a per-statement callback.
1918 ///
1919 /// Deprecated thin wrapper. New code should use
1920 /// [`Self::execute_with_options_streaming`].
1921 #[deprecated(note = "use Kernel::execute_with_options_streaming")]
1922 pub async fn execute_streaming(
1923 &self,
1924 input: &str,
1925 on_output: &mut (dyn FnMut(&ExecResult) + Send),
1926 ) -> Result<ExecResult, KernelError> {
1927 self.run_inner(input, ExecuteOptions::default(), None, Some(on_output))
1928 .await
1929 .map_err(classify_execute_error)
1930 }
1931
1932 /// Link embedder trace context, then run [`Self::execute_with_options_inner`].
1933 ///
1934 /// The `#[instrument]` execution span resolves its parent from the *current*
1935 /// OpenTelemetry context (see `tracing-opentelemetry`'s `parent_context`),
1936 /// captured when the span is first entered — not when the future is
1937 /// constructed. So a thread-local `attach()` scoped to construction is too
1938 /// early to be seen (the integration test confirms this). `with_context`
1939 /// re-attaches the embedder's context on *every* poll of the inner future,
1940 /// so the context is current at first-enter and survives runtime thread
1941 /// hops. With no embedder trace context, the future runs unwrapped.
1942 async fn run_inner(
1943 &self,
1944 input: &str,
1945 opts: ExecuteOptions,
1946 pipe_stdin: Option<crate::scheduler::PipeReader>,
1947 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1948 ) -> Result<ExecResult> {
1949 use opentelemetry::context::FutureExt;
1950
1951 // Capture the embedder's baggage before `opts` is consumed so it can be
1952 // echoed back onto the result on egress (see `merge_egress_baggage`).
1953 let embedder_baggage = opts.baggage.clone();
1954
1955 let result = match crate::telemetry::extract_parent(&opts) {
1956 Some(parent) => self
1957 .execute_with_options_inner(input, opts, pipe_stdin, on_output)
1958 .with_context(parent)
1959 .await,
1960 None => self.execute_with_options_inner(input, opts, pipe_stdin, on_output).await,
1961 };
1962
1963 result.map(|mut r| {
1964 crate::telemetry::merge_egress_baggage(&mut r, embedder_baggage);
1965 r
1966 })
1967 }
1968
1969 /// Shared body for `execute`, `execute_with_options(_streaming)`, and
1970 /// the deprecated wrappers. Owns the per-call cancel token, vars overlay,
1971 /// cwd override, and timeout race.
1972 #[tracing::instrument(level = "info", skip(self, opts, pipe_stdin, on_output), fields(input_len = input.len()))]
1973 async fn execute_with_options_inner(
1974 &self,
1975 input: &str,
1976 opts: ExecuteOptions,
1977 pipe_stdin: Option<crate::scheduler::PipeReader>,
1978 on_output: Option<&mut (dyn FnMut(&ExecResult) + Send)>,
1979 ) -> Result<ExecResult> {
1980 let _guard = self.acquire_execute_lock().await;
1981
1982 // Always reset to a fresh internal token; this is the kernel's own
1983 // cancel surface for embedders calling `Kernel::cancel()`. The
1984 // embedder-supplied `opts.cancel_token` is a *read-only input* — it
1985 // is NOT written into `self.cancel_token`, because doing so would
1986 // (a) leak the embedder's token past this call's lifetime,
1987 // (b) re-route a later `Kernel::cancel()` into the embedder's token,
1988 // (c) extend the token's lifetime via the kernel's strong clone.
1989 let internal = self.reset_cancel();
1990
1991 // Install the per-call polled interrupt for `is_cancelled()` to
1992 // consult. The guard clears it on every exit path — a stale check
1993 // must not outlive its call and fire into a later one.
1994 struct ClearInterrupt<'a>(&'a Kernel);
1995 impl Drop for ClearInterrupt<'_> {
1996 fn drop(&mut self) {
1997 if let Ok(mut slot) = self.0.interrupt.lock() {
1998 *slot = None;
1999 }
2000 }
2001 }
2002 {
2003 #[allow(clippy::expect_used)]
2004 let mut slot = self.interrupt.lock().expect("interrupt poisoned");
2005 *slot = opts.interrupt.clone();
2006 }
2007 let _interrupt_guard = ClearInterrupt(self);
2008
2009 // Race the embedder token against the kernel's internal token via a
2010 // tracked watcher task. We hold the JoinHandle so we can abort the
2011 // task at function exit — otherwise it would wait forever for either
2012 // token to fire and leak per call.
2013 let (effective_cancel, watcher_handle): (
2014 tokio_util::sync::CancellationToken,
2015 Option<tokio::task::JoinHandle<()>>,
2016 ) = if let Some(ext) = opts.cancel_token {
2017 let combined = tokio_util::sync::CancellationToken::new();
2018 let combined_writer = combined.clone();
2019 let i = internal.clone();
2020 let handle = tokio::spawn(async move {
2021 tokio::select! {
2022 _ = i.cancelled() => combined_writer.cancel(),
2023 _ = ext.cancelled() => combined_writer.cancel(),
2024 }
2025 });
2026 (combined, Some(handle))
2027 } else {
2028 (internal, None)
2029 };
2030
2031 // Effective timeout: per-call wins over kernel-config default.
2032 let timeout = opts.timeout.or(self.request_timeout);
2033
2034 // ZERO timeout: return 124 immediately without spawning anything.
2035 if timeout == Some(Duration::ZERO) {
2036 if let Some(h) = watcher_handle {
2037 h.abort();
2038 }
2039 return Ok(ExecResult::failure(124, "timeout: timed out after 0s".to_string()));
2040 }
2041
2042 // Apply per-call vars overlay (push frame + set_exported), wrapped in
2043 // an RAII guard so a panic inside `execute_streaming_inner` still
2044 // pops the frame and unexports the temporarily-exported names.
2045 struct VarsFrameGuard<'a> {
2046 kernel: &'a Kernel,
2047 newly_exported: Vec<String>,
2048 }
2049 impl Drop for VarsFrameGuard<'_> {
2050 fn drop(&mut self) {
2051 // Best-effort cleanup using try_write. The execute_lock held
2052 // throughout execute_with_options means there is no concurrent
2053 // foreground caller; forks have their own scope and won't
2054 // block this. blocking_write would deadlock the runtime when
2055 // called from a tokio worker thread, so we explicitly do NOT
2056 // fall back to it — if try_write fails (which we've never
2057 // seen in practice), log loudly and accept the leak rather
2058 // than deadlock the entire kernel.
2059 let Ok(mut scope) = self.kernel.scope.try_write() else {
2060 tracing::error!(
2061 "vars frame guard: scope lock unexpectedly busy; \
2062 skipping pop_frame to avoid runtime deadlock — \
2063 transient vars may leak"
2064 );
2065 return;
2066 };
2067 scope.pop_frame();
2068 for name in self.newly_exported.drain(..) {
2069 scope.unexport(&name);
2070 }
2071 }
2072 }
2073
2074 // Per-call cwd override: save current cwd, set the new one, restore
2075 // on Drop so the kernel's persistent cwd doesn't leak between calls.
2076 // Same RAII pattern as VarsFrameGuard, same blocking_write trade-off.
2077 struct CwdGuard<'a> {
2078 kernel: &'a Kernel,
2079 saved: PathBuf,
2080 }
2081 impl Drop for CwdGuard<'_> {
2082 fn drop(&mut self) {
2083 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2084 tracing::error!(
2085 "cwd guard: exec_ctx lock unexpectedly busy; \
2086 skipping cwd restore — kernel cwd may be wrong for next call"
2087 );
2088 return;
2089 };
2090 ec.cwd = std::mem::take(&mut self.saved);
2091 }
2092 }
2093 let _cwd_guard: Option<CwdGuard<'_>> = if let Some(new_cwd) = opts.cwd {
2094 let mut ec = self.exec_ctx.write().await;
2095 let saved = std::mem::replace(&mut ec.cwd, new_cwd);
2096 drop(ec);
2097 Some(CwdGuard { kernel: self, saved })
2098 } else {
2099 None
2100 };
2101
2102 // Per-call stdin: seed the persistent exec_ctx so the first top-level
2103 // command that reads stdin consumes it (it's `take()`n at dispatch).
2104 // Restore the prior value on Drop — normally `None`, so this also drops
2105 // any residual seed an stdin-less program never consumed, keeping it
2106 // from bleeding into the next call. Same RAII pattern as CwdGuard.
2107 struct StdinGuard<'a> {
2108 kernel: &'a Kernel,
2109 saved: Option<Vec<u8>>,
2110 }
2111 impl Drop for StdinGuard<'_> {
2112 fn drop(&mut self) {
2113 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2114 tracing::error!(
2115 "stdin guard: exec_ctx lock unexpectedly busy; \
2116 skipping stdin restore — stale stdin may leak to next call"
2117 );
2118 return;
2119 };
2120 ec.stdin = self.saved.take();
2121 }
2122 }
2123 let _stdin_guard: Option<StdinGuard<'_>> = if let Some(stdin) = opts.stdin {
2124 let mut ec = self.exec_ctx.write().await;
2125 let saved = ec.stdin.replace(stdin);
2126 drop(ec);
2127 Some(StdinGuard { kernel: self, saved })
2128 } else {
2129 None
2130 };
2131
2132 // Per-call *lazy* stdin: a frontend-supplied `PipeReader` seeds the
2133 // persistent exec_ctx so the first stdin-reading command drains it (it's
2134 // `take()`n at pipeline build). The RAII guard restores the prior value
2135 // on Drop (normally `None`), so an unread reader doesn't bleed into the
2136 // next call. Mirrors `StdinGuard`; the reader is non-Clone, so it moves.
2137 struct PipeStdinGuard<'a> {
2138 kernel: &'a Kernel,
2139 saved: Option<crate::scheduler::PipeReader>,
2140 }
2141 impl Drop for PipeStdinGuard<'_> {
2142 fn drop(&mut self) {
2143 let Ok(mut ec) = self.kernel.exec_ctx.try_write() else {
2144 tracing::error!(
2145 "pipe stdin guard: exec_ctx lock unexpectedly busy; \
2146 skipping restore — stale pipe stdin may leak to next call"
2147 );
2148 return;
2149 };
2150 ec.pipe_stdin = self.saved.take();
2151 }
2152 }
2153 let _pipe_stdin_guard: Option<PipeStdinGuard<'_>> = if let Some(reader) = pipe_stdin {
2154 let mut ec = self.exec_ctx.write().await;
2155 let saved = ec.pipe_stdin.replace(reader);
2156 drop(ec);
2157 Some(PipeStdinGuard { kernel: self, saved })
2158 } else {
2159 None
2160 };
2161
2162 let _vars_guard: Option<VarsFrameGuard<'_>> = if !opts.vars.is_empty() {
2163 let mut scope = self.scope.write().await;
2164 scope.push_frame();
2165 let mut newly = Vec::with_capacity(opts.vars.len());
2166 for (name, value) in opts.vars {
2167 if !scope.is_exported(&name) {
2168 newly.push(name.clone());
2169 }
2170 scope.set_exported(name, value);
2171 }
2172 drop(scope);
2173 Some(VarsFrameGuard { kernel: self, newly_exported: newly })
2174 } else {
2175 None
2176 };
2177
2178 // Per-call errexit override (`ExecuteOptions::errexit`): save the
2179 // kernel's current errexit state, apply the override, restore the
2180 // saved value on Drop so it doesn't leak into the next call. `None`
2181 // leaves errexit exactly as the kernel already has it — no save, no
2182 // restore. Same RAII pattern as CwdGuard/StdinGuard.
2183 struct ErrexitGuard<'a> {
2184 kernel: &'a Kernel,
2185 saved: bool,
2186 }
2187 impl Drop for ErrexitGuard<'_> {
2188 fn drop(&mut self) {
2189 let Ok(mut scope) = self.kernel.scope.try_write() else {
2190 tracing::error!(
2191 "errexit guard: scope lock unexpectedly busy; \
2192 skipping errexit restore — override may leak to next call"
2193 );
2194 return;
2195 };
2196 scope.set_error_exit(self.saved);
2197 }
2198 }
2199 let _errexit_guard: Option<ErrexitGuard<'_>> = if let Some(enabled) = opts.errexit {
2200 let mut scope = self.scope.write().await;
2201 // The RAW flag, not `error_exit_enabled()`: that one is false
2202 // while errexit is suppressed inside a `&&`/`||` left side, and
2203 // restoring from it would turn `set -e` off for good.
2204 let saved = scope.error_exit_flag();
2205 scope.set_error_exit(enabled);
2206 drop(scope);
2207 Some(ErrexitGuard { kernel: self, saved })
2208 } else {
2209 None
2210 };
2211
2212 // Sync the effective cancel into self.exec_ctx so try_execute_external
2213 // (which reads via self.cancel_token) sees cancellation. We also need
2214 // builtins to see it via ctx.cancel — handled in execute_command.
2215 // For simplicity here we mirror effective_cancel into self.cancel_token
2216 // for the duration of this call, then restore the internal token at
2217 // the end (so a later Kernel::cancel still hits our internal surface).
2218 {
2219 #[allow(clippy::expect_used)]
2220 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2221 *cur = effective_cancel.clone();
2222 }
2223
2224 // Run the script under the movable-deadline watchdog (shared with the
2225 // argv door). The watchdog task cancels `effective_cancel` on an elapsed
2226 // deadline; the cascade fires SIGTERM/SIGKILL on any external children via
2227 // the wait_or_kill discipline in try_execute_external. `Some(ZERO)` was
2228 // already handled by the early return above.
2229 let mut noop_cb: Box<dyn FnMut(&ExecResult) + Send> = Box::new(|_| {});
2230 let cb_ref: &mut (dyn FnMut(&ExecResult) + Send) = match on_output {
2231 Some(cb) => cb,
2232 None => &mut *noop_cb,
2233 };
2234
2235 let result = self
2236 .run_under_watchdog(timeout, &effective_cancel, self.execute_streaming_inner(input, cb_ref))
2237 .await;
2238
2239 // Restore self.cancel_token to a fresh, uncancelled token so the
2240 // embedder's view of `Kernel::cancel()` stays predictable on the
2241 // next call (it cancels the kernel's own token, not whatever was
2242 // left over from this call's combined token).
2243 {
2244 #[allow(clippy::expect_used)]
2245 let mut cur = self.cancel_token.lock().expect("cancel_token poisoned");
2246 *cur = tokio_util::sync::CancellationToken::new();
2247 }
2248
2249 // Tear down the embedder-token race watcher (if any). Leaving it
2250 // alive would idle forever waiting for tokens that may never fire.
2251 if let Some(h) = watcher_handle {
2252 h.abort();
2253 }
2254
2255 // VarsFrameGuard drops here on the success path and on early-return
2256 // paths above (error path included). Panic safety preserved.
2257 result
2258 }
2259
2260 /// The actual body of `execute_streaming`, run while holding the execute lock.
2261 ///
2262 /// Split out so internal kernel paths that are already under the lock can
2263 /// call this without deadlocking on re-entry. External callers must go
2264 /// through [`Self::execute_streaming`] so they acquire the lock.
2265 async fn execute_streaming_inner(
2266 &self,
2267 input: &str,
2268 on_output: &mut (dyn FnMut(&ExecResult) + Send),
2269 ) -> Result<ExecResult> {
2270 let program = parse(input).map_err(|errors| {
2271 let msg = errors
2272 .iter()
2273 .map(|e| e.format(input))
2274 .collect::<Vec<_>>()
2275 .join("\n");
2276 let message = format!("parse error:\n{}", msg);
2277 // Tagged so `classify_execute_error` can recover the structured
2278 // rejection at the public execute-surface boundary; every other
2279 // `?` in this function propagates a plain, untagged `anyhow::Error`.
2280 anyhow::Error::from(KernelError::Parse { errors, message })
2281 })?;
2282
2283 // AST display mode: show AST instead of executing
2284 {
2285 let scope = self.scope.read().await;
2286 if scope.show_ast() {
2287 let output = format!("{:#?}\n", program);
2288 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(output)));
2289 }
2290 }
2291
2292 // Pre-execution validation. Most warnings stay trace-only (every
2293 // external command fires an `UndefinedCommand` warning), but a warning
2294 // whose code opts into agent surfacing is collected here and prepended
2295 // to the result's stderr at each return point below.
2296 let mut surfaced_warnings = String::new();
2297 if !self.skip_validation {
2298 // Catalog first: neither guard should ride the other's await, and
2299 // `validate()` is synchronous, so neither rides one after this.
2300 let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
2301 let user_tools = self.user_tools.read().await;
2302 let validator = Validator::new(&self.tools, &user_tools, &catalog);
2303 let issues = validator.validate(&program);
2304
2305 // Collect errors (warnings are logged but don't prevent execution)
2306 let errors: Vec<_> = issues
2307 .iter()
2308 .filter(|i| i.severity == Severity::Error)
2309 .collect();
2310
2311 if !errors.is_empty() {
2312 let error_msg = errors
2313 .iter()
2314 .map(|e| e.format(input))
2315 .collect::<Vec<_>>()
2316 .join("\n");
2317 let message = format!("validation failed:\n{}", error_msg);
2318 let issues: Vec<crate::validator::ValidationIssue> =
2319 errors.into_iter().cloned().collect();
2320 // Tagged the same way as the parse rejection above.
2321 return Err(anyhow::Error::from(KernelError::Validation { issues, message }));
2322 }
2323
2324 // Log warnings via tracing (trace level to avoid noise); surface the
2325 // opted-in ones to the agent so the guidance is actually seen.
2326 for warning in issues.iter().filter(|i| i.severity == Severity::Warning) {
2327 tracing::trace!("validation: {}", warning.format(input));
2328 if warning.code.surfaces_to_agent() {
2329 surfaced_warnings.push_str(&warning.format(input));
2330 surfaced_warnings.push('\n');
2331 }
2332 }
2333 }
2334
2335 // Surface opted-in validation warnings to the streaming frontend once,
2336 // before any command output. The streaming consumer (`-c`, REPL) prints
2337 // per `on_output` and ignores the returned aggregate err; non-streaming
2338 // callers (`kernel.execute`) use a noop callback and read the aggregate
2339 // `result.err` (prepended at each return below). The two paths are
2340 // disjoint, so this prints the advisory exactly once on each.
2341 if !surfaced_warnings.is_empty() {
2342 let mut advisory = ExecResult::success("");
2343 advisory.err = surfaced_warnings.clone();
2344 on_output(&advisory);
2345 }
2346
2347 let mut result = ExecResult::success("");
2348
2349 // Reset cancellation token for this execution.
2350 let cancel = self.reset_cancel();
2351
2352 for stmt in program.statements.into_iter() {
2353 if matches!(stmt, Stmt::Empty) {
2354 continue;
2355 }
2356
2357 // Cancellation checkpoint
2358 if cancel.is_cancelled() {
2359 result.code = 130;
2360 return Ok(result);
2361 }
2362
2363 // The statement tap and gate (spec §C.6) — one of exactly two
2364 // sites. It runs before `execute_stmt_flow`, so a held statement
2365 // has run *nothing*: no substitution, no redirect opened, no
2366 let flow_result = self.execute_stmt_flow(&stmt).await;
2367 let flow = flow_result?;
2368
2369 // Drain any stderr written by pipeline stages during this statement.
2370 // This captures stderr from intermediate pipeline stages that would
2371 // otherwise be lost (only the last stage's result is returned).
2372 let drained_stderr = {
2373 let mut receiver = self.stderr_receiver.lock().await;
2374 receiver.drain_lossy()
2375 };
2376
2377 match flow {
2378 ControlFlow::Normal(mut r) => {
2379 if !drained_stderr.is_empty() {
2380 if !r.err.is_empty() && !r.err.ends_with('\n') {
2381 r.err.push('\n');
2382 }
2383 // Prepend pipeline stderr before the last stage's stderr
2384 let combined = format!("{}{}", drained_stderr, r.err);
2385 r.err = combined;
2386 }
2387 on_output(&r);
2388 // Carry the last statement's structured output for MCP TOON encoding.
2389 // Must be done here (not in accumulate_result) because accumulate_result
2390 // is also used in loops where per-iteration output would be wrong.
2391 let last_output = r.output().cloned();
2392 accumulate_result(&mut result, &r);
2393 result.set_output(last_output);
2394 }
2395 ControlFlow::Exit { code, result: carried } => {
2396 if !drained_stderr.is_empty() {
2397 result.err.push_str(&drained_stderr);
2398 }
2399 // Output produced before the exit — e.g. by the loop the
2400 // `exit` ran inside — arrives on the signal. Emit it like
2401 // any other statement's, then let `code` decide the status.
2402 on_output(&carried);
2403 accumulate_result(&mut result, &carried);
2404 result.code = code;
2405 if !surfaced_warnings.is_empty() {
2406 result.err = format!("{surfaced_warnings}{}", result.err);
2407 }
2408 return Ok(result);
2409 }
2410 ControlFlow::Return { mut value } => {
2411 if !drained_stderr.is_empty() {
2412 value.err = format!("{}{}", drained_stderr, value.err);
2413 }
2414 on_output(&value);
2415 // A top-level `return` stops the script, like `exit` —
2416 // it must not discard prior statements' accumulated
2417 // output nor let execution continue past it.
2418 accumulate_result(&mut result, &value);
2419 if !surfaced_warnings.is_empty() {
2420 result.err = format!("{surfaced_warnings}{}", result.err);
2421 }
2422 return Ok(result);
2423 }
2424 ControlFlow::Break { result: mut r, .. } | ControlFlow::Continue { result: mut r, .. } => {
2425 if !drained_stderr.is_empty() {
2426 r.err = format!("{}{}", drained_stderr, r.err);
2427 }
2428 on_output(&r);
2429 accumulate_result(&mut result, &r);
2430 }
2431 }
2432 }
2433
2434 if !surfaced_warnings.is_empty() {
2435 result.err = format!("{surfaced_warnings}{}", result.err);
2436 }
2437 Ok(result)
2438 }
2439
2440 /// Execute a single statement, returning control flow information.
2441 fn execute_stmt_flow<'a>(
2442 &'a self,
2443 stmt: &'a Stmt,
2444 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ControlFlow>> + Send + 'a>> {
2445 // No per-statement span here: `execute_stmt_flow` is the largest future
2446 // on the recursion ring, and wrapping it in `Instrumented<Span>` carries
2447 // the span's state through every `.await` at every level, costing native
2448 // stack per level (GH #48). Coarser spans on the outer execute entries
2449 // remain. See item 3 of the #48 burndown.
2450 Box::pin(async move {
2451 match stmt {
2452 Stmt::Assignment(assign) => {
2453 // An assignment with no command name takes the exit status of
2454 // the last command substitution in its value, or 0 if there
2455 // was none (bash's rule, re-probed). Clear the note first so
2456 // a substitution from an earlier statement cannot leak in —
2457 // `false; x=5` must be 0, not stale.
2458 {
2459 let mut scope = self.scope.write().await;
2460 scope.clear_cmdsubst_code();
2461 }
2462 // Use async evaluator to support command substitution
2463 let value = self.eval_expr_async(&assign.value).await
2464 .context("failed to evaluate assignment")?;
2465 let mut scope = self.scope.write().await;
2466 if assign.path.segments.len() == 1 {
2467 // Plain `NAME=value` — no subscript, so `local` applies.
2468 if assign.local {
2469 // local: set in innermost (current function) frame
2470 scope.set(assign.name(), value.clone());
2471 } else {
2472 // non-local: update existing or create in root frame
2473 scope.set_global(assign.name(), value.clone());
2474 }
2475 } else {
2476 // Subscripted lvalue (`xs[0]=v`, `user[email]=v`, …): always
2477 // mutates the existing root wherever it lives, so `local`
2478 // has nothing to declare. See docs/LANGUAGE.md,
2479 // "Assignment — bracket-path lvalues".
2480 scope.walk_write(&assign.path, value.clone()).map_err(|e| match e {
2481 PathError::UndefinedRoot(name) => anyhow::anyhow!(
2482 "{name}: undefined — create it first, e.g. `{name}={{}}` or `{name}=[]`"
2483 ),
2484 PathError::Absence(msg) | PathError::Shape(msg) => anyhow::anyhow!(msg),
2485 })?;
2486 }
2487 drop(scope);
2488
2489 // Assignments don't produce output (like sh), but they are a
2490 // command: they write `$?` and honor `set -e` (bash: `set -e;
2491 // x=$(false)` exits). The code is the last substitution's, or
2492 // 0 — this is what lets `x="$(cmd)" || x="FALLBACK"` fire.
2493 let subst_code = {
2494 let mut scope = self.scope.write().await;
2495 scope.take_cmdsubst_code()
2496 };
2497 let result = match subst_code {
2498 None | Some(0) => ExecResult::success(""),
2499 Some(code) => ExecResult::failure(code, ""),
2500 };
2501 self.update_last_result(&result).await;
2502 if !result.ok() {
2503 let scope = self.scope.read().await;
2504 if scope.error_exit_enabled() {
2505 // `-e` aborts the statement list, but the reason the
2506 // command died must survive with it — carry `result`
2507 // (its `out`/`err`/`data`) into the Exit signal instead
2508 // of `ControlFlow::exit_code`'s empty placeholder.
2509 let code = result.code;
2510 return Ok(ControlFlow::Exit { code, result });
2511 }
2512 }
2513 Ok(ControlFlow::ok(result))
2514 }
2515 Stmt::Command(cmd) => {
2516 // Route single commands through execute_pipeline for a unified path.
2517 // This ensures all commands go through the dispatcher chain.
2518 let pipeline = crate::ast::Pipeline {
2519 stages: vec![crate::ast::PipelineStage::Command(cmd.clone())],
2520 background: false,
2521 };
2522 let result = Box::pin(self.execute_pipeline(&pipeline)).await?;
2523 self.update_last_result(&result).await;
2524
2525 // Check for error exit mode (set -e)
2526 if !result.ok() {
2527 let scope = self.scope.read().await;
2528 if scope.error_exit_enabled() {
2529 // `-e` aborts the statement list, but the reason the
2530 // command died must survive with it — carry `result`
2531 // (its `out`/`err`/`data`) into the Exit signal instead
2532 // of `ControlFlow::exit_code`'s empty placeholder.
2533 let code = result.code;
2534 return Ok(ControlFlow::Exit { code, result });
2535 }
2536 }
2537
2538 Ok(ControlFlow::ok(result))
2539 }
2540 Stmt::Pipeline(pipeline) => {
2541 let result = Box::pin(self.execute_pipeline(pipeline)).await?;
2542 self.update_last_result(&result).await;
2543
2544 // Check for error exit mode (set -e)
2545 if !result.ok() {
2546 let scope = self.scope.read().await;
2547 if scope.error_exit_enabled() {
2548 // `-e` aborts the statement list, but the reason the
2549 // command died must survive with it — carry `result`
2550 // (its `out`/`err`/`data`) into the Exit signal instead
2551 // of `ControlFlow::exit_code`'s empty placeholder.
2552 let code = result.code;
2553 return Ok(ControlFlow::Exit { code, result });
2554 }
2555 }
2556
2557 Ok(ControlFlow::ok(result))
2558 }
2559 Stmt::If(if_stmt) => {
2560 // The statement's result is built BEFORE the condition runs,
2561 // because a condition's own stdout is the first thing in it —
2562 // see `eval_condition_async`. (An `elif` is a nested `Stmt::If`
2563 // in `else_branch`, so it takes this same path.)
2564 let mut result = ExecResult::success("");
2565 let cond_value = self
2566 .eval_condition_async(&if_stmt.condition, &mut result)
2567 .await?;
2568
2569 let branch = if is_truthy(&cond_value) {
2570 &if_stmt.then_branch
2571 } else {
2572 if_stmt.else_branch.as_deref().unwrap_or(&[])
2573 };
2574
2575 for stmt in branch {
2576 let flow = self.execute_stmt_flow(stmt).await?;
2577 match flow {
2578 ControlFlow::Normal(r) => {
2579 // Drain BEFORE accumulating, as the `while` arm
2580 // does: the stream holds the condition's stderr,
2581 // which was written first and must read first.
2582 // Appending `r.err` ahead of the drain put the
2583 // branch's diagnostic before the condition's.
2584 self.drain_stderr_into(&mut result).await;
2585 accumulate_result(&mut result, &r);
2586 }
2587 mut other => {
2588 self.drain_stderr_into(&mut result).await;
2589 fold_block_output_into_flow(std::mem::take(&mut result), &mut other);
2590 return Ok(other);
2591 }
2592 }
2593 }
2594 // A compound statement is a command: it writes `$?` whether or
2595 // not a body statement ran. Without this, `if false; then …; fi`
2596 // leaves the PREVIOUS statement's status visible to `$?` — a
2597 // failure that did not happen. Idempotent when a body did run:
2598 // the body's own arm already wrote the same code.
2599 self.update_last_result(&result).await;
2600 Ok(ControlFlow::ok(result))
2601 }
2602 Stmt::For(for_loop) => {
2603 // Evaluate all items and collect values for iteration
2604 // Use async evaluator to support command substitution like $(seq 1 5)
2605 let mut items: Vec<Value> = Vec::new();
2606 for item_expr in &for_loop.items {
2607 // Glob expansion in for-loop items: `for f in *.txt`
2608 if let Expr::GlobPattern(pattern) = item_expr {
2609 let glob_enabled = {
2610 let scope = self.scope.read().await;
2611 scope.glob_enabled()
2612 };
2613 if glob_enabled {
2614 let (paths, cwd) = {
2615 let ctx = self.exec_ctx.read().await;
2616 let paths = ctx.expand_glob(pattern).await
2617 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
2618 let cwd = ctx.resolve_path(".");
2619 (paths, cwd)
2620 };
2621 if paths.is_empty() {
2622 return Err(anyhow::anyhow!("no matches: {}", pattern));
2623 }
2624 for path in paths {
2625 let display = if !pattern.starts_with('/') {
2626 path.strip_prefix(&cwd)
2627 .unwrap_or(&path)
2628 .to_string_lossy().into_owned()
2629 } else {
2630 path.to_string_lossy().into_owned()
2631 };
2632 items.push(Value::String(display));
2633 }
2634 continue;
2635 }
2636 }
2637 // Track whether this item came from $(cmd); that's the
2638 // only position where multi-line stdout auto-splits per
2639 // line. Arrays still spread element-by-element; bare
2640 // $VAR is rejected upstream by validator E012. See
2641 // docs/LANGUAGE.md.
2642 let from_command_subst = matches!(item_expr, Expr::CommandSubst(_));
2643 let item = self.eval_expr_async(item_expr).await?;
2644 match item {
2645 // JSON arrays iterate over elements (preferred path
2646 // when builtins emit .data — seq, jq, cut, find, …)
2647 Value::Json(serde_json::Value::Array(arr)) => {
2648 for elem in arr {
2649 // Envelope-free: an element that happens to be
2650 // envelope-shaped (e.g. from `fromjson`) is
2651 // external data, not an internal bytes round-trip,
2652 // so it must NOT be re-decoded to Value::Bytes.
2653 items.push(json_to_value_no_envelope(elem));
2654 }
2655 }
2656 // Strings from $(cmd): empty → 0 iterations,
2657 // multi-line → split per line (trimming trailing
2658 // newlines and per-line trailing \r), single-line
2659 // → one iteration. Whitespace within a line is
2660 // NOT split — the "$VAR with spaces just works"
2661 // promise is preserved because this only fires
2662 // in CommandSubst position.
2663 Value::String(s) if from_command_subst => {
2664 let trimmed = s.trim_end_matches(['\n', '\r']);
2665 if trimmed.is_empty() {
2666 continue;
2667 }
2668 if trimmed.contains('\n') {
2669 for line in trimmed.split('\n') {
2670 let line = line.trim_end_matches('\r');
2671 items.push(Value::String(line.to_string()));
2672 }
2673 } else {
2674 items.push(Value::String(trimmed.to_string()));
2675 }
2676 }
2677 // Binary isn't iterable — fail loud rather than loop
2678 // once over an opaque byte blob.
2679 Value::Bytes(_) => {
2680 anyhow::bail!(
2681 "for: cannot iterate over binary data — decode it \
2682 (base64/xxd) first"
2683 );
2684 }
2685 // Strings not from $(cmd) stay as one value.
2686 other => items.push(other),
2687 }
2688 }
2689
2690 let mut result = ExecResult::success("");
2691 {
2692 let mut scope = self.scope.write().await;
2693 scope.push_frame();
2694 }
2695
2696 'outer: for item in items {
2697 // Cancellation checkpoint per iteration
2698 if self.is_cancelled() {
2699 {
2700 let mut scope = self.scope.write().await;
2701 scope.pop_frame();
2702 }
2703 result.code = 130;
2704 self.update_last_result(&result).await;
2705 return Ok(ControlFlow::ok(result));
2706 }
2707 {
2708 let mut scope = self.scope.write().await;
2709 scope.set(&for_loop.variable, item);
2710 }
2711 for stmt in &for_loop.body {
2712 let mut flow = match self.execute_stmt_flow(stmt).await {
2713 Ok(f) => f,
2714 Err(e) => {
2715 let mut scope = self.scope.write().await;
2716 scope.pop_frame();
2717 return Err(e);
2718 }
2719 };
2720 self.drain_stderr_into(&mut result).await;
2721 match &mut flow {
2722 ControlFlow::Normal(r) => {
2723 accumulate_result(&mut result, r);
2724 if !r.ok() {
2725 let scope = self.scope.read().await;
2726 if scope.error_exit_enabled() {
2727 drop(scope);
2728 let mut scope = self.scope.write().await;
2729 scope.pop_frame();
2730 // `result` already carries `r`'s out/err
2731 // via accumulate_result above — hand it to
2732 // the Exit signal so `-e` still aborts the
2733 // loop but the reason survives.
2734 let code = r.code;
2735 return Ok(ControlFlow::Exit {
2736 code,
2737 result: std::mem::take(&mut result),
2738 });
2739 }
2740 }
2741 }
2742 ControlFlow::Break { .. } => {
2743 if flow.decrement_level() {
2744 accumulate_flow_output(&mut result, &flow);
2745 break 'outer;
2746 }
2747 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2748 let mut scope = self.scope.write().await;
2749 scope.pop_frame();
2750 return Ok(flow);
2751 }
2752 ControlFlow::Continue { .. } => {
2753 if flow.decrement_level() {
2754 accumulate_flow_output(&mut result, &flow);
2755 continue 'outer;
2756 }
2757 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2758 let mut scope = self.scope.write().await;
2759 scope.pop_frame();
2760 return Ok(flow);
2761 }
2762 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2763 fold_block_output_into_flow(
2764 std::mem::take(&mut result),
2765 &mut flow,
2766 );
2767 let mut scope = self.scope.write().await;
2768 scope.pop_frame();
2769 return Ok(flow);
2770 }
2771 }
2772 }
2773 }
2774
2775 {
2776 let mut scope = self.scope.write().await;
2777 scope.pop_frame();
2778 }
2779 // Zero iterations still writes `$?` — see the `Stmt::If` arm.
2780 // `for x in $(grep …)` with no matches must not leave grep's 1
2781 // standing as the loop's status.
2782 self.update_last_result(&result).await;
2783 Ok(ControlFlow::ok(result))
2784 }
2785 Stmt::While(while_loop) => {
2786 let mut result = ExecResult::success("");
2787
2788 'outer: loop {
2789 // Evaluate condition - use async to support command substitution
2790 // Cancellation checkpoint per iteration
2791 if self.is_cancelled() {
2792 result.code = 130;
2793 self.update_last_result(&result).await;
2794 return Ok(ControlFlow::ok(result));
2795 }
2796
2797 // Per iteration, so the condition's stdout interleaves with
2798 // the body's rather than arriving in one block up front.
2799 let cond_value = self
2800 .eval_condition_async(&while_loop.condition, &mut result)
2801 .await?;
2802
2803 if !is_truthy(&cond_value) {
2804 break;
2805 }
2806
2807 // Execute body
2808 for stmt in &while_loop.body {
2809 let mut flow = self.execute_stmt_flow(stmt).await?;
2810 self.drain_stderr_into(&mut result).await;
2811 match &mut flow {
2812 ControlFlow::Normal(r) => {
2813 accumulate_result(&mut result, r);
2814 if !r.ok() {
2815 let scope = self.scope.read().await;
2816 if scope.error_exit_enabled() {
2817 // `result` already carries `r`'s out/err
2818 // via accumulate_result above — hand it to
2819 // the Exit signal so `-e` still aborts the
2820 // loop but the reason survives.
2821 let code = r.code;
2822 return Ok(ControlFlow::Exit {
2823 code,
2824 result: std::mem::take(&mut result),
2825 });
2826 }
2827 }
2828 }
2829 ControlFlow::Break { .. } => {
2830 if flow.decrement_level() {
2831 accumulate_flow_output(&mut result, &flow);
2832 break 'outer;
2833 }
2834 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2835 return Ok(flow);
2836 }
2837 ControlFlow::Continue { .. } => {
2838 if flow.decrement_level() {
2839 accumulate_flow_output(&mut result, &flow);
2840 continue 'outer;
2841 }
2842 fold_block_output_into_flow(std::mem::take(&mut result), &mut flow);
2843 return Ok(flow);
2844 }
2845 ControlFlow::Return { .. } | ControlFlow::Exit { .. } => {
2846 fold_block_output_into_flow(
2847 std::mem::take(&mut result),
2848 &mut flow,
2849 );
2850 return Ok(flow);
2851 }
2852 }
2853 }
2854 }
2855
2856 // A condition that is false on the first evaluation runs no
2857 // body — see the `Stmt::If` arm.
2858 self.update_last_result(&result).await;
2859 Ok(ControlFlow::ok(result))
2860 }
2861 Stmt::Case(case_stmt) => {
2862 // Evaluate the expression to match against. Text sink: a
2863 // `case $bin in ...)` pattern match on binary goes loud
2864 // rather than glob-matching against the `[binary: N bytes]`
2865 // placeholder (Decision E — same class as `==`/`in`).
2866 let match_value = {
2867 let value = self.eval_expr_async(&case_stmt.expr).await?;
2868 value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?
2869 };
2870
2871 // Try each branch until we find a match
2872 for branch in &case_stmt.branches {
2873 let matched = branch.patterns.iter().any(|pattern| {
2874 glob_match(pattern, &match_value)
2875 });
2876
2877 if matched {
2878 // Execute the branch body
2879 let mut result = ExecResult::success("");
2880 for stmt in &branch.body {
2881 let flow = self.execute_stmt_flow(stmt).await?;
2882 match flow {
2883 ControlFlow::Normal(r) => {
2884 accumulate_result(&mut result, &r);
2885 self.drain_stderr_into(&mut result).await;
2886 }
2887 mut other => {
2888 self.drain_stderr_into(&mut result).await;
2889 fold_block_output_into_flow(
2890 std::mem::take(&mut result),
2891 &mut other,
2892 );
2893 return Ok(other);
2894 }
2895 }
2896 }
2897 self.update_last_result(&result).await;
2898 return Ok(ControlFlow::ok(result));
2899 }
2900 }
2901
2902 // No match - return success with empty output (like sh), and
2903 // write it to `$?` — see the `Stmt::If` arm.
2904 let result = ExecResult::success("");
2905 self.update_last_result(&result).await;
2906 Ok(ControlFlow::ok(result))
2907 }
2908 Stmt::Break(levels) => {
2909 Ok(ControlFlow::break_n(levels.unwrap_or(1)))
2910 }
2911 Stmt::Continue(levels) => {
2912 Ok(ControlFlow::continue_n(levels.unwrap_or(1)))
2913 }
2914 Stmt::Return(expr) => {
2915 // return [N] - N becomes the exit code, NOT stdout
2916 // Shell semantics: return sets exit code, doesn't produce output
2917 let result = if let Some(e) = expr {
2918 let val = self.eval_expr_async(e).await?;
2919 let code = crate::interpreter::value_to_exit_code(&val)
2920 .map_err(|e| anyhow::anyhow!("return: {}", e))?;
2921 ExecResult::from_parts(code, String::new(), String::new(), None)
2922 } else {
2923 ExecResult::success("")
2924 };
2925 Ok(ControlFlow::return_value(result))
2926 }
2927 Stmt::Exit(expr) => {
2928 let code = if let Some(e) = expr {
2929 let val = self.eval_expr_async(e).await?;
2930 crate::interpreter::value_to_exit_code(&val)
2931 .map_err(|e| anyhow::anyhow!("exit: {}", e))?
2932 } else {
2933 0
2934 };
2935 Ok(ControlFlow::exit_code(code))
2936 }
2937 Stmt::ToolDef(tool_def) => {
2938 let mut user_tools = self.user_tools.write().await;
2939 user_tools.insert(tool_def.name.clone(), tool_def.clone());
2940 Ok(ControlFlow::ok(ExecResult::success("")))
2941 }
2942 Stmt::AndChain { left, right } => {
2943 // cmd1 && cmd2 - run cmd2 only if cmd1 succeeds (exit code 0)
2944 // Suppress errexit for the left side — && handles failure itself.
2945 {
2946 let mut scope = self.scope.write().await;
2947 scope.suppress_errexit();
2948 }
2949 let left_flow = match self.execute_stmt_flow(left).await {
2950 Ok(f) => f,
2951 Err(e) => {
2952 let mut scope = self.scope.write().await;
2953 scope.unsuppress_errexit();
2954 return Err(e);
2955 }
2956 };
2957 {
2958 let mut scope = self.scope.write().await;
2959 scope.unsuppress_errexit();
2960 }
2961 match left_flow {
2962 ControlFlow::Normal(mut left_result) => {
2963 self.drain_stderr_into(&mut left_result).await;
2964 self.update_last_result(&left_result).await;
2965 // Pending is not failure (spec §I.5) — see the
2966 // `OrChain` twin. The stash check matters here for a
2967 // hold swallowed into an apparent success below.
2968 if left_result.ok() {
2969 let right_flow = self.execute_stmt_flow(right).await?;
2970 match right_flow {
2971 ControlFlow::Normal(mut right_result) => {
2972 self.drain_stderr_into(&mut right_result).await;
2973 self.update_last_result(&right_result).await;
2974 let mut combined = left_result;
2975 accumulate_result(&mut combined, &right_result);
2976 Ok(ControlFlow::ok(combined))
2977 }
2978 mut other => {
2979 // The left side already ran and printed;
2980 // a signal out of the right side must not
2981 // unprint it.
2982 fold_block_output_into_flow(left_result, &mut other);
2983 Ok(other)
2984 }
2985 }
2986 } else {
2987 Ok(ControlFlow::ok(left_result))
2988 }
2989 }
2990 _ => Ok(left_flow),
2991 }
2992 }
2993 Stmt::OrChain { left, right } => {
2994 // cmd1 || cmd2 - run cmd2 only if cmd1 fails (non-zero exit code)
2995 // Suppress errexit for the left side — || handles failure itself.
2996 {
2997 let mut scope = self.scope.write().await;
2998 scope.suppress_errexit();
2999 }
3000 let left_flow = match self.execute_stmt_flow(left).await {
3001 Ok(f) => f,
3002 Err(e) => {
3003 let mut scope = self.scope.write().await;
3004 scope.unsuppress_errexit();
3005 return Err(e);
3006 }
3007 };
3008 {
3009 let mut scope = self.scope.write().await;
3010 scope.unsuppress_errexit();
3011 }
3012 match left_flow {
3013 ControlFlow::Normal(mut left_result) => {
3014 self.drain_stderr_into(&mut left_result).await;
3015 self.update_last_result(&left_result).await;
3016 // Pending is not failure (spec §I.5): a fallback
3017 // written for failure must not run on a decision
3018 // nobody has made yet — and running it would also
3019 // overwrite the request in the accumulated result.
3020 // The stash check covers a hold whose typed error a
3021 // layer below already stringified out of the result.
3022 // On a stash-based hold the returned `left_result` is
3023 // that stringified failure, not the held result — the
3024 // statement boundary discards it and surfaces the
3025 // slot's result instead. Do not "fix" this by taking
3026 // the slot here: only statement boundaries take it.
3027 if !left_result.ok() {
3028 let right_flow = self.execute_stmt_flow(right).await?;
3029 match right_flow {
3030 ControlFlow::Normal(mut right_result) => {
3031 self.drain_stderr_into(&mut right_result).await;
3032 self.update_last_result(&right_result).await;
3033 let mut combined = left_result;
3034 accumulate_result(&mut combined, &right_result);
3035 Ok(ControlFlow::ok(combined))
3036 }
3037 mut other => {
3038 // The left side already ran and printed;
3039 // a signal out of the right side must not
3040 // unprint it.
3041 fold_block_output_into_flow(left_result, &mut other);
3042 Ok(other)
3043 }
3044 }
3045 } else {
3046 Ok(ControlFlow::ok(left_result))
3047 }
3048 }
3049 _ => Ok(left_flow), // Propagate non-normal flow
3050 }
3051 }
3052 Stmt::Test(test_expr) => {
3053 let is_true = self.eval_test_async(test_expr).await?;
3054 let result = if is_true {
3055 ExecResult::success("")
3056 } else {
3057 ExecResult::failure(1, "")
3058 };
3059 // A bare test writes `$?` and honors `set -e` like any command
3060 // (bash: `[[ 1 = 2 ]]; echo $?` → 1). `&&`/`||` operands stay
3061 // safe: the chain arms suppress errexit around their left side,
3062 // and `if`/`while` conditions evaluate as expressions, never
3063 // through this statement arm.
3064 self.update_last_result(&result).await;
3065 if !result.ok() {
3066 let scope = self.scope.read().await;
3067 if scope.error_exit_enabled() {
3068 // `-e` aborts the statement list, but the reason the
3069 // command died must survive with it — carry `result`
3070 // (its `out`/`err`/`data`) into the Exit signal instead
3071 // of `ControlFlow::exit_code`'s empty placeholder.
3072 let code = result.code;
3073 return Ok(ControlFlow::Exit { code, result });
3074 }
3075 }
3076 Ok(ControlFlow::ok(result))
3077 }
3078 Stmt::EnvScoped { assignments, body } => {
3079 // Inline env prefix (`NAME=value ... command`): apply the
3080 // assignments as EXPORTED vars in a fresh frame so the command
3081 // — and its subprocess environment — sees them, then unwind so
3082 // they do NOT persist (bash-style command-scoped env). Values
3083 // evaluate left-to-right with earlier ones already in scope, so
3084 // `A=1 B=$A cmd` works.
3085 {
3086 let mut scope = self.scope.write().await;
3087 scope.push_frame();
3088 }
3089 let mut prior_export: Vec<(String, bool)> =
3090 Vec::with_capacity(assignments.len());
3091 let mut setup_err: Option<anyhow::Error> = None;
3092 for assign in assignments {
3093 match self.eval_expr_async(&assign.value).await {
3094 Ok(value) => {
3095 let mut scope = self.scope.write().await;
3096 prior_export
3097 .push((assign.name().to_string(), scope.is_exported(assign.name())));
3098 scope.set_exported(assign.name(), value);
3099 }
3100 Err(e) => {
3101 setup_err = Some(e);
3102 break;
3103 }
3104 }
3105 }
3106
3107 let flow = if setup_err.is_none() {
3108 self.execute_stmt_flow(body).await
3109 } else {
3110 Ok(ControlFlow::ok(ExecResult::success("")))
3111 };
3112
3113 // Unwind the env frame and restore export marks unconditionally
3114 // (names that were not exported before must not stay exported).
3115 {
3116 let mut scope = self.scope.write().await;
3117 scope.pop_frame();
3118 for (name, was_exported) in &prior_export {
3119 if !*was_exported {
3120 scope.unexport(name);
3121 }
3122 }
3123 }
3124
3125 match setup_err {
3126 Some(e) => Err(e),
3127 None => flow,
3128 }
3129 }
3130 Stmt::Empty => Ok(ControlFlow::ok(ExecResult::success(""))),
3131 }
3132 })
3133 }
3134
3135 /// Build a boxed per-command `ExecContext` snapshot from the persistent
3136 /// kernel state (`ec`/`scope`, both already locked by the caller).
3137 ///
3138 /// Sync on purpose: the ~30 field clones live in this transient frame rather
3139 /// than a coroutine slot, and the result is `Box`ed so only an 8-byte pointer
3140 /// — not the 960-byte struct — rides the dispatch await at every recursion
3141 /// level (GH #48, item 2). `pipeline_position` and `cancel` are the only
3142 /// per-site differences (the pipeline runner uses the kernel's own cancel
3143 /// token and forces `Only`; the per-command dispatch inherits `ec`'s), so
3144 /// they're parameters; every other field is snapshotted identically.
3145 fn snapshot_exec_ctx(
3146 &self,
3147 ec: &ExecContext,
3148 scope: &Scope,
3149 pipeline_position: PipelinePosition,
3150 cancel: tokio_util::sync::CancellationToken,
3151 ) -> Box<ExecContext> {
3152 Box::new(ExecContext {
3153 backend: ec.backend.clone(),
3154 scope: scope.clone(),
3155 cwd: ec.cwd.clone(),
3156 prev_cwd: ec.prev_cwd.clone(),
3157 stdin: ec.stdin.clone(),
3158 stdin_data: ec.stdin_data.clone(),
3159 stdin_data_rx: None,
3160 pipe_stdin: None,
3161 pipe_stdout: None,
3162 stderr: ec.stderr.clone(),
3163 tool_schemas: ec.tool_schemas.clone(),
3164 tools: ec.tools.clone(),
3165 job_manager: ec.job_manager.clone(),
3166 pipeline_position,
3167 interactive: self.interactive,
3168 // The kernel-wide setting; a snapshot inherits it like `interactive`.
3169 kill_children_on_parent_death: ec.kill_children_on_parent_death,
3170 aliases: ec.aliases.clone(),
3171 ignore_config: ec.ignore_config.clone(),
3172 output_limit: ec.output_limit.clone(),
3173 allow_external_commands: self.allow_external_commands,
3174 trash_backend: ec.trash_backend.clone(),
3175 #[cfg(all(unix, feature = "subprocess"))]
3176 terminal_state: ec.terminal_state.clone(),
3177 dispatcher: self.dispatcher(),
3178 cancel,
3179 output_format: None,
3180 vfs_budget: self.vfs_budget.clone(),
3181 watchdog: ec.watchdog.clone(),
3182 #[cfg(all(feature = "localfs", feature = "overlay"))]
3183 overlay_handle: self.overlay_handle.clone(),
3184 // Correlate this command's requests with the background job it
3185 // runs for, if any — the ONE place `job_id` is stamped.
3186 // A replay correlation belongs to exactly one dispatch. Moved
3187 // (not cloned) out of the parent context at the dispatch seam —
3188 // see the stdin hand-off below, which takes it under the same
3189 // write lock — so the gate this snapshot reaches is the only one
3190 // that can adopt it.
3191 // A forked or backgrounded execution keeps its parenthood: a
3192 // gate reached from inside a gated statement is nested under it
3193 // (spec §A.7).
3194 })
3195 }
3196
3197 /// Execute a pipeline.
3198 async fn execute_pipeline(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3199 if pipeline.stages.is_empty() {
3200 return Ok(ExecResult::success(""));
3201 }
3202
3203 // Handle background execution (`&` operator)
3204 if pipeline.background {
3205 return self.execute_background(pipeline).await;
3206 }
3207
3208 // All commands go through the runner with the Kernel as dispatcher.
3209 // This is the single execution path — no fast path for single commands.
3210 //
3211 // IMPORTANT: We snapshot exec_ctx into a local context and release the
3212 // lock before running. This prevents deadlocks when dispatch_command
3213 // is called from within the pipeline and recursively triggers another
3214 // pipeline (e.g., via user-defined tools).
3215 let (mut ctx, has_pipe_stdin) = {
3216 let ec = self.exec_ctx.read().await;
3217 let scope = self.scope.read().await;
3218 // A frontend-seeded lazy stdin (`execute_with_pipe_stdin`) lives in
3219 // the persistent exec_ctx; it's moved (non-Clone) into this ctx in
3220 // the consume-once block below, so note its presence here.
3221 let has_pipe_stdin = ec.pipe_stdin.is_some();
3222 // The pipeline runner drives stage 0 with the first stage's stdin
3223 // seeded from any frontend-supplied input (`ExecuteOptions::stdin`,
3224 // e.g. `printf … | kaish -c sort`) unless a redirect already set it,
3225 // and uses the kernel's own cancel token so a `cancel()` reaches the
3226 // stages. See `snapshot_exec_ctx` for why the snapshot is boxed.
3227 let cancel = {
3228 #[allow(clippy::expect_used)]
3229 let token = self.cancel_token.lock().expect("cancel_token poisoned");
3230 token.clone()
3231 };
3232 (self.snapshot_exec_ctx(&ec, &scope, PipelinePosition::Only, cancel), has_pipe_stdin)
3233 }; // locks released
3234
3235 // Consume-once: move/clear the seeded stdin sources from the persistent
3236 // exec_ctx now that this pipeline's ctx owns them, so a later statement
3237 // in the same call (`cat ; cat`) does not re-receive them — matching
3238 // shell stdin draining. `pipe_stdin` is non-Clone, so it's *moved* here
3239 // (the ctx above was built with `pipe_stdin: None`).
3240 if ctx.stdin.is_some() || ctx.stdin_data.is_some() || has_pipe_stdin {
3241 let mut ec = self.exec_ctx.write().await;
3242 ctx.pipe_stdin = ec.pipe_stdin.take();
3243 ec.stdin = None;
3244 ec.stdin_data = None;
3245 }
3246
3247 // Park the enclosing command's write end and sideband receiver here for
3248 // the duration. `ec` is one shared slot and the snapshot above zeroes
3249 // both, so a nested dispatch — `$(…)` in a command's own arguments, a
3250 // function body, a `source`d file — overwrites whatever is left in it.
3251 // `echo $(echo sub) | cat` printed nothing at exit 0;
3252 // `seq 1 3 | jq -c $(echo .)` fell back to reading the pipe as text.
3253 //
3254 // Here rather than at each re-entering caller: this is the one path
3255 // they all take. The shared slot is the actual defect — threading a
3256 // ctx through the interpreter would retire this whole dance.
3257 {
3258 let mut ec = self.exec_ctx.write().await;
3259 ctx.pipe_stdout = ec.pipe_stdout.take();
3260 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3261 }
3262
3263 let mut result = self.runner.run(&pipeline.stages, &mut ctx, self).await;
3264
3265 // `set -o pipefail`: the pipeline answers with the RIGHTMOST non-zero
3266 // stage, not the first. bash's `set -o pipefail; (exit 3) | (exit 4) |
3267 // true` is 4, and reading the first non-zero would have said 3.
3268 //
3269 // Applied BEFORE the spill contract so a spilled pipeline still reports
3270 // 3 and keeps the pipefail status as `original_code`, rather than the
3271 // last stage's — the spill remap is about output size and should
3272 // override whatever the pipeline decided its status was, not race it.
3273 //
3274 // Read back from the scope the runner just wrote rather than threaded
3275 // separately: PIPESTATUS is the one record of what each stage did, so
3276 // the mode and the variable cannot disagree about the same pipeline.
3277 if ctx.scope.pipefail_enabled() {
3278 if let Some(code) = ctx.scope.pipestatus_rightmost_failure() {
3279 result.code = code;
3280 }
3281 }
3282
3283 // Post-hoc spill check + exit-3 remap (catches builtins and fast
3284 // external commands; also catches a ring overflow that already
3285 // flipped `did_spill` even when the limit itself is disabled, GH
3286 // #191). This is the shared contract every execution surface must
3287 // apply — see `apply_spill_contract`'s doc comment (GH #212).
3288 crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
3289
3290 // Sync changes back from context
3291 {
3292 let mut ec = self.exec_ctx.write().await;
3293 ec.cwd = ctx.cwd.clone();
3294 ec.prev_cwd = ctx.prev_cwd.clone();
3295 ec.aliases = ctx.aliases.clone();
3296 ec.ignore_config = ctx.ignore_config.clone();
3297 ec.output_limit = ctx.output_limit.clone();
3298 // Unconsumed stdin goes back to the session, or it dies here with
3299 // `ctx`. A partial read (`read` takes one line) leaves the rest
3300 // split across two places: the bytes it over-read sit in `stdin`,
3301 // and the pipe still holds everything past them. Dropping the
3302 // reader discards that tail with no error — `read x; wc -c` over
3303 // 100 KiB counted 8187 bytes and said nothing.
3304 //
3305 // A multi-stage pipeline reaches here with the remainder already
3306 // returned by `run_pipeline`'s join, so this carries the
3307 // single-command and the pipeline case alike.
3308 ec.stdin = ctx.stdin.take();
3309 ec.pipe_stdin = ctx.pipe_stdin.take();
3310 // The parked handles go home. Stages get writers the runner owns,
3311 // so what is here is what was carried in.
3312 ec.pipe_stdout = ctx.pipe_stdout.take();
3313 ec.stdin_data_rx = ctx.stdin_data_rx.take();
3314 }
3315 {
3316 let mut scope = self.scope.write().await;
3317 *scope = ctx.scope.clone();
3318 }
3319
3320 Ok(result)
3321 }
3322
3323 /// Execute a pipeline in the background.
3324 ///
3325 /// The command is spawned as a tokio task and registered with the
3326 /// JobManager. The job is observable via `/v/jobs/{id}/status`,
3327 /// `/v/jobs/{id}/command`, and — while it is
3328 /// still running — `/v/jobs/{id}/stdout` and `/stderr`.
3329 ///
3330 /// GH #240 removed those two nodes because they filled once, at
3331 /// completion, while the docs promised a live stream. They are back on
3332 /// the terms the docs always claimed: `try_execute_external` tees each
3333 /// 8 KiB chunk into the job's stream as the child emits it. See
3334 /// `Job::stdout_stream` for exactly which bytes reach them.
3335 ///
3336 /// Returns immediately with a job ID like "[1]".
3337 #[tracing::instrument(level = "debug", skip(self, pipeline), fields(command_count = pipeline.stages.len()))]
3338 async fn execute_background(&self, pipeline: &crate::ast::Pipeline) -> Result<ExecResult> {
3339 use tokio::sync::oneshot;
3340
3341 // Format the command for display in /v/jobs/{id}/command
3342 let command_str = self.format_pipeline(pipeline);
3343
3344 // Create channel for result notification
3345 let (tx, rx) = oneshot::channel();
3346
3347 // Register with JobManager to get job ID and create VFS entries
3348 let job_id = self.jobs.register(command_str.clone(), rx).await;
3349
3350 // Fork the kernel for this background job. The fork snapshots the
3351 // parent's scope/cwd/aliases/user_tools so mutations stay isolated,
3352 // while sharing the job manager, VFS, and tool registry. The fork's
3353 // full dispatch chain (user tools, .kai scripts, `$(...)` in args)
3354 // is available here — something BackendDispatcher couldn't provide.
3355 //
3356 // The fork gets its own cancellation token (recorded on the job so
3357 // `kill %N` can stop the job — including a pure-builtin job with no OS
3358 // process group) and is stamped with the job id so any external
3359 // command it spawns records its process group for `kill -<sig> %N`.
3360 let cancel = tokio_util::sync::CancellationToken::new();
3361 self.jobs.set_cancel_token(job_id, cancel.clone()).await;
3362 let jobs = self.jobs.clone();
3363 let fork = self.fork_for_background(cancel, job_id).await;
3364 let runner = self.runner.clone();
3365 let stages = pipeline.stages.clone();
3366
3367 // Snapshot the fork's exec_ctx for the spawned task. We have to do
3368 // this before tokio::spawn because the fork's exec_ctx is behind a
3369 // tokio RwLock and we want the spawned task to own its ctx.
3370 let mut bg_ctx = {
3371 let ec = fork.exec_ctx.read().await;
3372 ec.child_for_pipeline()
3373 };
3374 bg_ctx.scope = fork.scope.read().await.clone();
3375 // The fork's dispatcher points at the fork itself; set it here so
3376 // builtins inside the background task (e.g. timeout) re-dispatch
3377 // through the fork, not the parent.
3378 bg_ctx.dispatcher = fork.dispatcher();
3379
3380 // Spawn the background task. Propagate the embedder's trace context
3381 // across the spawn boundary so the job's spans stay in the same trace.
3382 tokio::spawn(crate::telemetry::bind_current_context(async move {
3383 // runner.run needs a &dyn CommandDispatcher; fork.as_ref()
3384 // gives us that (Kernel implements CommandDispatcher).
3385 let mut result = runner.run(&stages, &mut bg_ctx, fork.as_ref()).await;
3386
3387 // A background task is its own statement boundary. Pipeline stages
3388 // and command substitutions flush stderr to the fork's stderr
3389 // channel exactly as they would in the foreground, but the
3390 // statement-boundary drains live in `Kernel::execute`, which this
3391 // task never runs. Drain here, or the job's stderr never reaches
3392 // its result: a substitution's failure reason is lost and
3393 // `/v/jobs/{id}/stderr` stays empty.
3394 fork.drain_stderr_into(&mut result).await;
3395
3396 // Apply the same spill/exit-3 contract the foreground path gets
3397 // (`execute_pipeline`'s `apply_spill_contract` call) — without
3398 // this, a background job whose output overflows the capture ring
3399 // or trips the output limit reports the child's ORIGINAL exit
3400 // code to JobManager, so `[N] done:0`/`Job::status()` silently
3401 // read success even though the output was capped (GH #212).
3402 crate::output_limit::apply_spill_contract(&mut result, &bg_ctx.output_limit).await;
3403
3404 // Close out `/v/jobs/{id}/stdout`/`stderr`: a stream the external
3405 // drain tasks already fed live is left alone (re-writing the
3406 // aggregate would duplicate every byte), an untouched one takes
3407 // the captured result, and both close. Before `tx.send`, so a
3408 // reader that observes a terminal `status` also observes a
3409 // finished stream — never a `done:0` job whose output is still
3410 // arriving.
3411 jobs.finalize_streams(job_id, &result).await;
3412
3413 // Send result to JobManager (ignore error if receiver dropped)
3414 let _ = tx.send(result);
3415 }));
3416
3417 // The announcement is a shell message, not command output: bash writes
3418 // it to stderr, and stdout stays clean so `$(cmd &)` captures no shell
3419 // metadata. Terminated like every kaish diagnostic (#363).
3420 let mut announcement = ExecResult::success("");
3421 announcement.err = ExecResult::terminate_diagnostic(format!("[{job_id}]"));
3422 Ok(announcement)
3423 }
3424
3425 /// Format a pipeline as a command string for display.
3426 fn format_pipeline(&self, pipeline: &crate::ast::Pipeline) -> String {
3427 pipeline
3428 .stages
3429 .iter()
3430 .map(|stage| {
3431 let cmd = match stage {
3432 crate::ast::PipelineStage::Command(cmd) => cmd,
3433 // A compound stage renders through the plan renderer,
3434 // which already knows every statement form.
3435 crate::ast::PipelineStage::Compound(stmt) => {
3436 return crate::ast::plan::render_stmt(stmt)
3437 }
3438 };
3439 let mut parts = vec![cmd.name.clone()];
3440 for arg in &cmd.args {
3441 match arg {
3442 Arg::Positional(expr) => {
3443 parts.push(self.format_expr(expr));
3444 }
3445 Arg::Named { key, value } => {
3446 parts.push(format!("--{}={}", key, self.format_expr(value)));
3447 }
3448 Arg::WordAssign { key, value } => {
3449 parts.push(format!("{}={}", key, self.format_expr(value)));
3450 }
3451 Arg::ShortFlag(name) => {
3452 parts.push(format!("-{}", name));
3453 }
3454 Arg::LongFlag(name) => {
3455 parts.push(format!("--{}", name));
3456 }
3457 Arg::DoubleDash => {
3458 parts.push("--".to_string());
3459 }
3460 }
3461 }
3462 parts.join(" ")
3463 })
3464 .collect::<Vec<_>>()
3465 .join(" | ")
3466 }
3467
3468 /// Format an expression as a string for display.
3469 fn format_expr(&self, expr: &Expr) -> String {
3470 match expr {
3471 Expr::Literal(Value::String(s)) => {
3472 if s.contains(' ') || s.contains('"') {
3473 format!("'{}'", s.replace('\'', "\\'"))
3474 } else {
3475 s.clone()
3476 }
3477 }
3478 Expr::Literal(Value::Int(i)) => i.to_string(),
3479 Expr::Literal(Value::Float(f)) => f.to_string(),
3480 Expr::Literal(Value::Bool(b)) => b.to_string(),
3481 Expr::Literal(Value::Null) => "null".to_string(),
3482 Expr::VarRef(path) => {
3483 let mut name = String::new();
3484 for (i, seg) in path.segments.iter().enumerate() {
3485 match seg {
3486 crate::ast::VarSegment::Field(f) => {
3487 if i > 0 {
3488 name.push('.');
3489 }
3490 name.push_str(f);
3491 }
3492 crate::ast::VarSegment::Index(idx) => name.push_str(&format!("[{idx}]")),
3493 crate::ast::VarSegment::Key(k) => name.push_str(&format!("[{k}]")),
3494 crate::ast::VarSegment::Dynamic(v) => name.push_str(&format!("[${v}]")),
3495 crate::ast::VarSegment::Slice(a, b) => name.push_str(&format!(
3496 "[{}:{}]",
3497 a.map(|n| n.to_string()).unwrap_or_default(),
3498 b.map(|n| n.to_string()).unwrap_or_default()
3499 )),
3500 }
3501 }
3502 format!("${{{}}}", name)
3503 }
3504 Expr::Interpolated(_) => "\"...\"".to_string(),
3505 Expr::HereDocBody { .. } => "<<heredoc".to_string(),
3506 _ => "...".to_string(),
3507 }
3508 }
3509
3510 /// Execute a single command.
3511 async fn execute_command(&self, name: &str, args: &[Arg]) -> Result<ExecResult> {
3512 self.execute_command_depth(name, args, 0).await
3513 }
3514
3515 async fn execute_command_depth(&self, name: &str, args: &[Arg], alias_depth: u8) -> Result<ExecResult> {
3516 // Dispatch breadcrumb instead of an `#[instrument]` span: this is the
3517 // most-recursed function on the ring, so wrapping its future in
3518 // `Instrumented<Span>` (plus the `err` recorder) cost native stack at
3519 // every level (GH #48, item 3). A `trace!` event records the command name
3520 // without living in the future.
3521 tracing::trace!(command = %name, alias_depth, "dispatch");
3522 // Special built-ins. `SpecialForm::from_name` is the single source of
3523 // truth (shared with `classify_command` via `is_runtime_special_form`),
3524 // and this match on the enum is *exhaustive* — adding a special-form is a
3525 // compile error until both the name mapping and the behavior here are
3526 // updated. A name that is not a special-form falls through to alias /
3527 // `/v/bin/` / user-tool / builtin / `PATH` resolution unchanged.
3528 if let Some(form) = crate::validator::SpecialForm::from_name(name) {
3529 return match form {
3530 crate::validator::SpecialForm::True => Ok(ExecResult::success("")),
3531 crate::validator::SpecialForm::False => Ok(ExecResult::failure(1, "")),
3532 crate::validator::SpecialForm::Source => Box::pin(self.execute_source(args)).await,
3533 };
3534 }
3535
3536 // Alias expansion (with recursion limit)
3537 if alias_depth < 10 {
3538 let alias_value = {
3539 let ctx = self.exec_ctx.read().await;
3540 ctx.aliases.get(name).cloned()
3541 };
3542 if let Some(alias_val) = alias_value {
3543 // Split alias value into command + args
3544 let parts: Vec<&str> = alias_val.split_whitespace().collect();
3545 if let Some((alias_cmd, alias_args)) = parts.split_first() {
3546 let mut new_args: Vec<Arg> = alias_args
3547 .iter()
3548 .map(|a| Arg::Positional(Expr::Literal(Value::String(a.to_string()))))
3549 .collect();
3550 new_args.extend_from_slice(args);
3551 return Box::pin(self.execute_command_depth(alias_cmd, &new_args, alias_depth + 1)).await;
3552 }
3553 }
3554 }
3555
3556 // Handle /v/bin/ prefix — dispatch to builtins via virtual path
3557 if let Some(builtin_name) = name.strip_prefix("/v/bin/") {
3558 return match self.tools.get(builtin_name) {
3559 Some(_) => Box::pin(self.execute_command_depth(builtin_name, args, alias_depth)).await,
3560 None => Ok(ExecResult::failure(127, format!("command not found: {}", name))),
3561 };
3562 }
3563
3564 // Check user-defined tools first
3565 {
3566 let user_tools = self.user_tools.read().await;
3567 if let Some(tool_def) = user_tools.get(name) {
3568 let tool_def = tool_def.clone();
3569 drop(user_tools);
3570 return Box::pin(self.execute_user_tool(tool_def, args)).await;
3571 }
3572 }
3573
3574 // Look up builtin tool
3575 let tool = match self.tools.get(name) {
3576 Some(t) => t,
3577 None => {
3578 // Try executing as .kai script from PATH
3579 if let Some(result) = Box::pin(self.try_execute_script(name, args)).await? {
3580 return Ok(result);
3581 }
3582 // Try executing as external command from PATH — boxed because its
3583 // future is the heaviest branch here (holds a `tokio::process::Command`,
3584 // argv, the child's stdio streams, and kill/reap drop guards); leaving
3585 // it inline fattens every `execute_command_depth` frame on the recursion
3586 // ring even when the command is a builtin.
3587 //
3588 // A refusal (compiled without `subprocess`, or configured off)
3589 // does NOT return here — a backend-registered tool of the same
3590 // name is a separate capability and still gets a chance below.
3591 // `unavailable` carries the reason forward so the final "nothing
3592 // claimed this name" message can name it, instead of the
3593 // fallthrough re-deriving the wrong "command not found".
3594 let mut unavailable = None;
3595 match Box::pin(self.try_execute_external(name, args)).await? {
3596 ExternalCommandOutcome::Ran(result) => return Ok(*result),
3597 ExternalCommandOutcome::NotFound => {}
3598 ExternalCommandOutcome::Unavailable(reason) => unavailable = Some(reason),
3599 }
3600
3601 // Try backend-registered tools (embedder engines, etc.)
3602 // Look up tool schema for positional→named mapping.
3603 // Clone backend and drop read lock before awaiting (may involve network I/O).
3604 // Backend tools expect named JSON params, so enable positional mapping.
3605 let backend = self.exec_ctx.read().await.backend.clone();
3606 let tool_schema = backend
3607 .get_tool(name)
3608 .await
3609 .unwrap_or_else(|e| {
3610 // Schema lookup failing just means positionals won't
3611 // get name-mapped below — `call_tool` is still
3612 // attempted. Trace it so the degradation is visible
3613 // rather than silently swallowed.
3614 tracing::debug!("backend get_tool error for {name}: {e}");
3615 None
3616 })
3617 .map(|t| {
3618 let mut s = t.schema;
3619 // Flat backend/MCP tools expect named JSON params, so map
3620 // bare positionals onto named params. Subcommand-aware tools
3621 // route positionals through the subcommand path and declare
3622 // map_positionals per leaf (kj keeps it false so it re-parses
3623 // the argv with its own clap) — don't blanket-override them.
3624 if s.subcommands.is_empty() {
3625 s.map_positionals = true;
3626 }
3627 s
3628 });
3629 let tool_args = self.build_args_async(args, tool_schema.as_ref()).await?;
3630 let mut ctx = self.exec_ctx.write().await;
3631 {
3632 let scope = self.scope.read().await;
3633 ctx.scope = scope.clone();
3634 }
3635 let backend = ctx.backend.clone();
3636 match backend.call_tool(name, tool_args, &mut *ctx).await {
3637 Ok(tool_result) => {
3638 let mut scope = self.scope.write().await;
3639 *scope = ctx.scope.clone();
3640 // Preserve every field (data/content_type/baggage,
3641 // not just stdout text) — this is the embedder seam:
3642 // `x=$(embedder_tool)` and structured iteration over
3643 // its result depend on `.data` surviving the crossing
3644 // back into the kernel.
3645 let mut result = ExecResult::from(tool_result);
3646 // The same rule the builtin dispatch applies, applied
3647 // here too — this seam returns before it. Without this
3648 // an embedder tool that hands back a value got its
3649 // `$(…)` capture stringified, which is precisely what
3650 // the comment above promises does not happen.
3651 let data_is_only_output = result.data.is_some()
3652 && !result.has_output()
3653 && result.text_out().is_empty();
3654 result.data_is_value |= data_is_only_output
3655 || tool_schema
3656 .as_ref()
3657 .is_some_and(|s| s.typed_substitution);
3658 return Ok(result);
3659 }
3660 Err(BackendError::ToolNotFound(_)) => {
3661 // The backend confirms no such tool exists — fall
3662 // through to "command not found" below.
3663 }
3664 Err(e) => {
3665 // The tool was found (dispatch reached real
3666 // execution) but running it failed — a genuine
3667 // execution error, not "command not found". Surface
3668 // it loudly instead of masking it as exit-127.
3669 return Ok(ExecResult::failure(1, format!("{}: {}", name, e)));
3670 }
3671 }
3672
3673 return Ok(match unavailable {
3674 Some(reason) => external_commands_unavailable_error(name, reason),
3675 None => ExecResult::failure(127, format!("command not found: {}", name)),
3676 });
3677 }
3678 };
3679
3680 // Build arguments (async to support command substitution, schema-aware
3681 // for flag values), then decide `--help` and `owns_output` — all three
3682 // read the tool's schema and nothing after this block does, so the whole
3683 // schema borrow is scoped here and cannot ride the `tool.execute` await
3684 // below (GH #48, item 7).
3685 let (tool_args, wants_help, owns_output, raw_argv, typed_substitution) = {
3686 // Prefer the kernel's schema catalog over `tool.schema()`: for a
3687 // clap-derived builtin, `schema()` rebuilds the entire clap
3688 // `Command` and reflects it into a fresh `ToolSchema` — ~34
3689 // allocations per command, 18% of all allocations in the GH #48
3690 // many-small-commands profile — to produce exactly what the catalog
3691 // already holds. The catalog is seeded from this same registry in
3692 // `Kernel::assemble` and is name-sorted, so this is a binary search
3693 // with no allocation at all. `owned` covers a tool the catalog
3694 // doesn't list (registered after assembly, or whose schema name
3695 // differs from its dispatch name): the fallback calls the same
3696 // `schema()` and is equivalent, just not free.
3697 let catalog = { self.exec_ctx.read().await.tool_schemas.clone() };
3698 let owned;
3699 let schema: &crate::tools::ToolSchema =
3700 match catalog.binary_search_by(|s| s.name.as_str().cmp(name)) {
3701 Ok(i) => &catalog[i],
3702 Err(_) => {
3703 owned = tool.schema();
3704 &owned
3705 }
3706 };
3707
3708 let tool_args = self.build_args_async(args, Some(schema)).await?;
3709
3710 // --help / -h: show the generic whole-tool help, unless either the tool's
3711 // root schema claims that flag OR the tool owns its output. Owned-output
3712 // tools re-parse their own argv and route their own `--help` — including
3713 // leaf/subcommand help — through their internal (clap) parser, so the root
3714 // schema can't express "this leaf claims help" and intercepting here would
3715 // render top-level help and return before `execute()` ever sees the
3716 // request (#51). Pass it through and let the tool render its own help.
3717 let schema_claims = |flag: &str| -> bool {
3718 let bare = flag.trim_start_matches('-');
3719 schema.params.iter().any(|p| p.matches_flag(flag) || p.matches_flag(bare))
3720 };
3721 let wants_help = !schema.owns_output
3722 && ((tool_args.flags.contains("help") && !schema_claims("help"))
3723 || (tool_args.flags.contains("h") && !schema_claims("-h")));
3724
3725 (tool_args, wants_help, schema.owns_output, schema.raw_argv, schema.typed_substitution)
3726 };
3727
3728 if wants_help {
3729 let help_topic = crate::help::HelpTopic::Tool(name.to_string());
3730 let ctx = self.exec_ctx.read().await;
3731 let content = crate::help::get_help(&help_topic, &ctx.tool_schemas);
3732 return Ok(ExecResult::with_output(crate::interpreter::OutputData::text(content)));
3733 }
3734
3735 // Snapshot exec_ctx into a local context and release the write lock
3736 // before calling tool.execute. Holding the write across tool execution
3737 // would deadlock any builtin that re-dispatches through ctx.dispatcher
3738 // (timeout, scatter) — the inner dispatch_command needs its own
3739 // exec_ctx.write() and would block forever.
3740 let mut ctx = {
3741 let ec = self.exec_ctx.write().await;
3742 let scope = self.scope.read().await;
3743 // Inherit `ec.pipeline_position` and `ec.cancel` (the latter set by
3744 // dispatch_command from the runner's ctx.cancel, so a builtin-swapped
3745 // child token — e.g. timeout's — reaches the spawned external via
3746 // wait_or_kill; it falls back to the kernel's own token on a
3747 // non-dispatch path). See `snapshot_exec_ctx` for the boxing rationale.
3748 self.snapshot_exec_ctx(&ec, &scope, ec.pipeline_position, ec.cancel.clone())
3749 }; // both locks released — tool.execute can re-dispatch safely
3750
3751 // Move stdin out of self.exec_ctx into the snapshot (consumed-by-tool
3752 // semantics): take() so a later dispatch doesn't see stale stdin.
3753 // Done after the snapshot above so we hold the write briefly.
3754 {
3755 let mut ec = self.exec_ctx.write().await;
3756 ctx.stdin = ec.stdin.take();
3757 ctx.stdin_data = ec.stdin_data.take();
3758 ctx.stdin_data_rx = ec.stdin_data_rx.take();
3759 ctx.pipe_stdin = ec.pipe_stdin.take();
3760 ctx.pipe_stdout = ec.pipe_stdout.take();
3761 // Same take-don't-clone discipline as stdin, and for the same
3762 // reason: these belong to exactly one dispatch, and a copy left
3763 // behind would let the next command adopt it.
3764 }
3765
3766 // Honor --json before the builtin runs so its setting survives a clap
3767 // parse failure (e.g. `cmd --json --bogus-flag` would otherwise drop
3768 // --json on the floor when `try_parse_from` returns Err early).
3769 // The builtin's own `parsed.global.apply(ctx)` becomes idempotent.
3770 GlobalFlags::apply_from_args(&tool_args, raw_argv, &mut *ctx);
3771
3772 let mut result = tool.execute(tool_args, &mut *ctx).await;
3773 // A command substitution binds `.data` only when it is the result's
3774 // VALUE. `--json` and the pipeline sideband read `.data` either way,
3775 // so this marks the ONE consumer whose answer is a matter of taste.
3776 //
3777 // Two ways to qualify. A tool that printed NOTHING has only its data,
3778 // so that data is trivially its value — this is `fromjson`'s shape and
3779 // an out-of-tree tool's, and it needs no declaration, which is what
3780 // keeps this from breaking embedder tools. A tool that printed text
3781 // AND attached data has to say which one it means, because the two
3782 // readings are equally defensible: `jq` means the data, `cut` means
3783 // the text it printed.
3784 let data_is_only_output = result.data.is_some()
3785 && !result.has_output()
3786 && result.text_out().is_empty();
3787 // OR, never assign: a wrapper that re-dispatches an inner command
3788 // returns the INNER result, and stamping it from the wrapper's own
3789 // schema erased what the inner tool declared —
3790 // `$(timeout 5 fromjson '[1,2]')` bound text while `$(fromjson …)`
3791 // bound a list. A result arrives here fresh from `tool.execute`, so a
3792 // marker already set is one the tool or its inner dispatch meant.
3793 result.data_is_value |= typed_substitution || data_is_only_output;
3794
3795 // Sync mutations back. Tools may have changed scope (set/cd),
3796 // cwd/prev_cwd (cd), and aliases (alias). Also return any unused pipe
3797 // endpoints to self.exec_ctx so dispatch_command's post-execute sync
3798 // hands them back to the pipeline runner — the runner uses
3799 // stage_ctx.pipe_stdout to write the result to the next stage when
3800 // the tool itself didn't take and write to it.
3801 {
3802 let mut scope = self.scope.write().await;
3803 *scope = ctx.scope.clone();
3804 }
3805 {
3806 let mut ec = self.exec_ctx.write().await;
3807 ec.cwd = ctx.cwd;
3808 ec.prev_cwd = ctx.prev_cwd;
3809 ec.aliases = ctx.aliases;
3810 // A builtin (`set -o output-limit`, `kaish-output-limit set`) can
3811 // mutate the runtime output limit; without this sync the change is
3812 // dropped here and never reaches dispatch_command's read-back, so
3813 // it would not survive past the current statement.
3814 ec.output_limit = ctx.output_limit.clone();
3815 // Same for `kaish-ignore` (add/clear/defaults/scope): this field
3816 // was missing from this sync, so every runtime ignore mutation
3817 // silently died at the end of its own statement — including the
3818 // documented `kaish-ignore add .gitignore` rc-file recipe.
3819 ec.ignore_config = ctx.ignore_config.clone();
3820 ec.pipe_stdin = ctx.pipe_stdin.take();
3821 ec.pipe_stdout = ctx.pipe_stdout.take();
3822 // What a partial read left behind goes back too: `read` takes one
3823 // line and keeps the rest, and that remainder belongs to the next
3824 // reader. Without this it dies with the tool's context and
3825 // `read x; read y` loses the second line.
3826 ec.stdin = ctx.stdin.take();
3827 // The sideband is stdin in typed form and returns by the same
3828 // rule; taken in above, an unconsumed value would die here.
3829 ec.stdin_data = ctx.stdin_data.take();
3830 ec.stdin_data_rx = ctx.stdin_data_rx.take();
3831 }
3832
3833 // Builtins parse --json via the GlobalFlags flatten in their clap
3834 // struct and write ctx.output_format. The kernel applies it — unless the
3835 // tool owns its own output (renders --json itself), in which case we
3836 // leave its bytes untouched.
3837 let result = finalize_output(result, ctx.output_format, owns_output);
3838
3839 Ok(result)
3840 }
3841
3842 /// The session `HOME` from the kernel scope, if set. Tilde expansion reads
3843 /// this rather than `std::env::var("HOME")` so the kernel stays hermetic —
3844 /// a hermetic embedder (empty `initial_vars`) gets `None`, and `~` is left
3845 /// unexpanded rather than leaking the host home directory.
3846 async fn scope_home(&self) -> Option<String> {
3847 match self.scope.read().await.get("HOME") {
3848 Some(Value::String(s)) => Some(s.clone()),
3849 _ => None,
3850 }
3851 }
3852
3853 /// Build tool arguments from AST args.
3854 ///
3855 /// Uses async evaluation to support command substitution in arguments.
3856 /// Delegates to the shared `bind_tool_args` core (GH #188): this method
3857 /// now only supplies the evaluator — `self` implements `ArgValueSource`
3858 /// against the kernel's own session state (full recursion through the
3859 /// async pipeline, real glob expansion, tilde expansion). Before this,
3860 /// `bind_tool_args`'s flag/positional-binding logic was duplicated by a
3861 /// reduced sync twin (`scheduler::pipeline::build_tool_args`, used by
3862 /// scatter/gather's own option parsing and the `#[cfg(test)]`
3863 /// `BackendDispatcher`) that could — and did — drift from this method,
3864 /// the same drift-class GH #133 fixed for the external-command spawn
3865 /// sites. Now both paths call the one `bind_tool_args` core, differing
3866 /// only in which `ArgValueSource` they hand it.
3867 async fn build_args_async(&self, args: &[Arg], schema: Option<&crate::tools::ToolSchema>) -> Result<ToolArgs> {
3868 bind_tool_args(args, schema, self).await
3869 }
3870
3871 /// Build arguments as flat string list for external commands.
3872 ///
3873 /// Unlike `build_args_async` which separates flags into a HashSet (for schema-aware builtins),
3874 /// this preserves the original flag format as strings for external commands:
3875 /// - `-l` stays as `-l`
3876 /// - `--verbose` stays as `--verbose`
3877 /// - `key=value` stays as `key=value`
3878 ///
3879 /// This is what external commands expect in their argv.
3880 #[cfg(feature = "subprocess")]
3881 async fn build_args_flat(&self, args: &[Arg]) -> Result<Vec<String>> {
3882 let mut argv = Vec::new();
3883 let home = self.scope_home().await;
3884 for arg in args {
3885 match arg {
3886 Arg::Positional(expr) => {
3887 // Glob expansion for external commands
3888 if let Expr::GlobPattern(pattern) = expr {
3889 let glob_enabled = {
3890 let scope = self.scope.read().await;
3891 scope.glob_enabled()
3892 };
3893 if glob_enabled {
3894 let (paths, cwd) = {
3895 let ctx = self.exec_ctx.read().await;
3896 let paths = ctx.expand_glob(pattern).await
3897 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
3898 let cwd = ctx.resolve_path(".");
3899 (paths, cwd)
3900 };
3901 if paths.is_empty() {
3902 return Err(anyhow::anyhow!("no matches: {}", pattern));
3903 }
3904 for path in paths {
3905 let display = if !pattern.starts_with('/') {
3906 path.strip_prefix(&cwd)
3907 .unwrap_or(&path)
3908 .to_string_lossy().into_owned()
3909 } else {
3910 path.to_string_lossy().into_owned()
3911 };
3912 argv.push(display);
3913 }
3914 continue;
3915 }
3916 }
3917 let value = self.eval_expr_async(expr).await?;
3918 // Decision D: a bare collection can't cross the external
3919 // process boundary as an argv element — refuse rather than
3920 // silently JSON-serializing it. A quoted `"$x"` already
3921 // reduced to a `Value::String` above (via `Expr::Interpolated`),
3922 // so only a live, un-interpolated `$x` trips this.
3923 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &value) {
3924 return Err(anyhow::anyhow!(msg));
3925 }
3926 let value = apply_tilde_expansion(value, home.as_deref());
3927 // External-command argv is a text sink: a bare `$BIN` binary
3928 // word goes loud, never the `[binary: N bytes]` placeholder.
3929 argv.push(value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}"))?);
3930 }
3931 Arg::Named { key, value } => {
3932 let val = self.eval_expr_async(value).await?;
3933 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3934 return Err(anyhow::anyhow!(msg));
3935 }
3936 let val = apply_tilde_expansion(val, home.as_deref());
3937 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3938 argv.push(format!("--{key}={val_str}"));
3939 }
3940 Arg::WordAssign { key, value } => {
3941 let val = self.eval_expr_async(value).await?;
3942 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &val) {
3943 return Err(anyhow::anyhow!(msg));
3944 }
3945 let val = apply_tilde_expansion(val, home.as_deref());
3946 let val_str = value_to_text_sink(&val).map_err(|e| anyhow::anyhow!("{e}"))?;
3947 argv.push(format!("{key}={val_str}"));
3948 }
3949 Arg::ShortFlag(name) => {
3950 // Preserve original format: -l, -la (combined flags)
3951 argv.push(format!("-{}", name));
3952 }
3953 Arg::LongFlag(name) => {
3954 // Preserve original format: --verbose
3955 argv.push(format!("--{}", name));
3956 }
3957 Arg::DoubleDash => {
3958 // Preserve the -- marker
3959 argv.push("--".to_string());
3960 }
3961 }
3962 }
3963 Ok(argv)
3964 }
3965
3966 /// Async expression evaluator that supports command substitution.
3967 ///
3968 /// This is used for contexts where expressions may contain `$(...)` command
3969 /// substitution. Unlike the sync `eval_expr`, this can execute pipelines.
3970 /// Evaluate an `if`/`while` condition, folding whatever its commands print
3971 /// into `out`.
3972 ///
3973 /// A condition's stdout belongs to the enclosing statement, the same rule
3974 /// `Expr::Command` already applies to its stderr. [`Self::eval_expr_async`]
3975 /// returns only a `Value`, so those bytes had nowhere to go and
3976 /// `if echo COND; then echo BODY; fi` printed only `BODY`. Handing them
3977 /// back to the caller keeps the decision there: the `if`/`while` arm folds
3978 /// them into the statement's own result, and every consumer of that result
3979 /// — a pipe, a `$(…)` capture, a redirect — carries them without learning
3980 /// what a condition is. A shared slot on `ExecContext` would have done it
3981 /// the other way; that is the pattern GH #369 exists to remove.
3982 ///
3983 /// Only the forms that can hold a command in STATEMENT position are handled
3984 /// here. Everything else is [`Self::eval_expr_async`]'s, `$(…)` above all:
3985 /// a substitution's stdout IS its value, so folding it in as well would
3986 /// print it twice.
3987 fn eval_condition_async<'a>(
3988 &'a self,
3989 expr: &'a Expr,
3990 out: &'a mut ExecResult,
3991 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
3992 Box::pin(async move {
3993 match expr {
3994 Expr::Command(cmd) => {
3995 let mut result = self.execute_command(&cmd.name, &cmd.args).await?;
3996 self.emit_cmdsubst_stderr(&result.err).await;
3997 // Truthiness comes from the command's OWN code, read before
3998 // the spill contract can remap it. A capped `if seq 1
3999 // 100000` succeeded; only its output was too big to keep,
4000 // and reading the remapped 3 would send it to `else`.
4001 let truthy = result.code == 0;
4002 // Carrying the stdout made this arm one of the surfaces
4003 // that produce a raw `ExecResult`, and it reaches
4004 // `execute_command` below the pipeline layer that applies
4005 // the contract — so it applies it here, as
4006 // `apply_spill_contract`'s "ONE seam" note requires.
4007 // Without this a condition handed back its full output with
4008 // no limit at all.
4009 let limit = self.exec_ctx.read().await.output_limit.clone();
4010 crate::output_limit::apply_spill_contract(&mut result, &limit).await;
4011 push_stdout_of(out, &result);
4012 Ok(Value::Bool(truthy))
4013 }
4014 // Short-circuits exactly as the `eval_expr_async` arm does, and
4015 // yields the operand's own value rather than a coerced bool. A
4016 // side that short-circuits never runs, so it prints nothing.
4017 Expr::BinaryOp { left, op, right } => {
4018 let left_val = self.eval_condition_async(left, &mut *out).await?;
4019 let short_circuits = match op {
4020 BinaryOp::And => !is_truthy(&left_val),
4021 BinaryOp::Or => is_truthy(&left_val),
4022 };
4023 if short_circuits {
4024 return Ok(left_val);
4025 }
4026 self.eval_condition_async(right, out).await
4027 }
4028 // The negated command still RUNS, so its output belongs to the
4029 // statement exactly as an un-negated one's does. Routing this
4030 // through `eval_expr_async` would drop it.
4031 Expr::Not(inner) => {
4032 let value = self.eval_condition_async(inner, out).await?;
4033 Ok(Value::Bool(!is_truthy(&value)))
4034 }
4035 other => self.eval_expr_async(other).await,
4036 }
4037 })
4038 }
4039
4040 fn eval_expr_async<'a>(&'a self, expr: &'a Expr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
4041 Box::pin(async move {
4042 match expr {
4043 Expr::Not(inner) => {
4044 let value = self.eval_expr_async(inner).await?;
4045 Ok(Value::Bool(!is_truthy(&value)))
4046 }
4047 Expr::Literal(value) => Ok(value.clone()),
4048 Expr::VarRef(path) => {
4049 let scope = self.scope.read().await;
4050 match scope.resolve_path(path) {
4051 Ok(v) => Ok(v),
4052 Err(PathError::UndefinedRoot(_)) => {
4053 Err(anyhow::anyhow!("undefined variable"))
4054 }
4055 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4056 Err(anyhow::anyhow!(msg))
4057 }
4058 }
4059 }
4060 Expr::Interpolated(parts) => {
4061 let mut result = String::new();
4062 for part in parts {
4063 result.push_str(&self.eval_string_part_async(part).await?);
4064 }
4065 Ok(Value::String(result))
4066 }
4067 Expr::HereDocBody { parts, strip_tabs } => {
4068 // Assemble part-by-part so `<<-` tab stripping applies to the
4069 // literal source, not to tabs from a `$var` value (bash strips
4070 // source-line tabs before parameter expansion).
4071 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
4072 for sp in parts {
4073 match &sp.part {
4074 StringPart::Literal(s) => asm.push_literal(s),
4075 other => {
4076 asm.push_interpolated(&self.eval_string_part_async(other).await?)
4077 }
4078 }
4079 }
4080 Ok(Value::String(asm.into_string()))
4081 }
4082 Expr::BinaryOp { left, op, right } => match op {
4083 BinaryOp::And => {
4084 let left_val = self.eval_expr_async(left).await?;
4085 if !is_truthy(&left_val) {
4086 return Ok(left_val);
4087 }
4088 self.eval_expr_async(right).await
4089 }
4090 BinaryOp::Or => {
4091 let left_val = self.eval_expr_async(left).await?;
4092 if is_truthy(&left_val) {
4093 return Ok(left_val);
4094 }
4095 self.eval_expr_async(right).await
4096 }
4097 },
4098 Expr::CommandSubst(stmts) => {
4099 // Snapshot scope, cwd, and session config before running —
4100 // only output escapes, not side effects like `cd`, variable
4101 // assignments, or config mutations (`kaish-ignore`,
4102 // `kaish-output-limit`, `alias`/`unalias`) — matching how
4103 // every other execution context (background forks, scatter
4104 // workers) already isolates mutations (GH #139).
4105 // Boxed: this ~470 B scope snapshot is held across the nested
4106 // `$(…)` recursion await below, so inlining it grows every
4107 // command-substitution level's future (GH #48, item 4).
4108 let saved_scope = Box::new(self.scope.read().await.clone());
4109 let saved_ec = {
4110 let ec = self.exec_ctx.read().await;
4111 (
4112 ec.cwd.clone(),
4113 ec.prev_cwd.clone(),
4114 ec.aliases.clone(),
4115 ec.ignore_config.clone(),
4116 ec.output_limit.clone(),
4117 )
4118 };
4119
4120 // Capture result without `?` — restore state unconditionally
4121 let run_result = self.execute_block_capturing(stmts).await;
4122
4123 // Restore scope and cwd regardless of success/failure
4124 {
4125 let mut scope = self.scope.write().await;
4126 *scope = *saved_scope;
4127 if let Ok(ref r) = run_result {
4128 scope.set_last_result(r.clone());
4129 scope.note_cmdsubst_code(r.code);
4130 }
4131 }
4132 {
4133 let mut ec = self.exec_ctx.write().await;
4134 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4135 ec.cwd = cwd;
4136 ec.prev_cwd = prev_cwd;
4137 ec.aliases = aliases;
4138 ec.ignore_config = ignore_config;
4139 ec.output_limit = output_limit;
4140 }
4141
4142 // A substitution's stderr belongs to the enclosing statement,
4143 // never to its value. Emit it before the value is built.
4144 if let Ok(ref r) = run_result {
4145 self.emit_cmdsubst_stderr(&r.err).await;
4146 }
4147
4148 // Now propagate the error
4149 let result = run_result?;
4150
4151 // A held body stops the enclosing statement before its
4152 // missing output is used (spec §I.5) — the request rides up
4153 // as a typed error the statement loop converts back into a
4154 // held result, and is stashed for the boundary in case an
4155 // intermediate catch stringifies the error.
4156
4157 // A binary result is preserved as bytes — never lossy-decoded to
4158 // a string. No trailing-newline trim (every byte is significant).
4159 if let Some(bytes) = result.out_bytes() {
4160 Ok(Value::Bytes(bytes.to_vec()))
4161 // Prefer structured data (enables `for i in $(cmd)` iteration)
4162 } else if let Some(data) = &result.data {
4163 Ok(data.clone())
4164 } else if let Some(output) = result.output() {
4165 // Flat non-text node lists (glob, ls, tree) → iterable array
4166 if output.is_flat() && !output.is_simple_text() && !output.root.is_empty() {
4167 let items: Vec<serde_json::Value> = output.root.iter()
4168 .map(|n| serde_json::Value::String(n.display_name().to_string()))
4169 .collect();
4170 Ok(Value::Json(serde_json::Value::Array(items)))
4171 } else {
4172 // Strip trailing newlines only (POSIX command-subst),
4173 // not all trailing whitespace — spaces/tabs are
4174 // significant. Use the exact same trim as the quoted
4175 // `"$(…)"` interpolation path (`StringPart::CommandSubst`,
4176 // `trim_end_matches('\n')`) so bare and quoted command
4177 // substitution agree.
4178 Ok(Value::String(
4179 result.text_out().trim_end_matches('\n').to_string(),
4180 ))
4181 }
4182 } else {
4183 // Otherwise return stdout as single string (NO implicit splitting)
4184 Ok(Value::String(
4185 result.text_out().trim_end_matches('\n').to_string(),
4186 ))
4187 }
4188 }
4189 Expr::Test(test_expr) => {
4190 Ok(Value::Bool(self.eval_test_async(test_expr).await?))
4191 }
4192 Expr::Positional(n) => {
4193 let scope = self.scope.read().await;
4194 match scope.get_positional(*n) {
4195 Some(s) => Ok(Value::String(s.to_string())),
4196 None => Ok(Value::String(String::new())),
4197 }
4198 }
4199 Expr::AllArgs => {
4200 let scope = self.scope.read().await;
4201 Ok(Value::String(scope.all_args().join(" ")))
4202 }
4203 Expr::ArgCount => {
4204 let scope = self.scope.read().await;
4205 Ok(Value::Int(scope.arg_count() as i64))
4206 }
4207 Expr::VarLength(path) => {
4208 let scope = self.scope.read().await;
4209 crate::interpreter::resolve_length(&scope, path)
4210 .map(Value::Int)
4211 .map_err(|msg| anyhow::anyhow!(msg))
4212 }
4213 Expr::VarWithDefault { path, default } => {
4214 // Resolve inside a scoped guard so the lock is released before the
4215 // recursive default evaluation.
4216 let resolved = {
4217 let scope = self.scope.read().await;
4218 crate::interpreter::resolve_default(&scope, path)
4219 .map_err(|msg| anyhow::anyhow!(msg))?
4220 };
4221 match resolved {
4222 Some(value) => Ok(value),
4223 None => self.eval_string_parts_async(default).await.map(Value::String),
4224 }
4225 }
4226 Expr::Arithmetic(expr_str) => {
4227 let scope = self.scope.read().await;
4228 crate::arithmetic::eval_arithmetic(expr_str, &scope)
4229 .map(Value::Int)
4230 .map_err(|e| anyhow::anyhow!("arithmetic error: {}", e))
4231 }
4232 Expr::Command(cmd) => {
4233 // A command in expression position — an `if`/`while`
4234 // condition, or a side of `&&`/`||` inside one. Its VALUE is
4235 // the exit code's truthiness, but its OUTPUT belongs to the
4236 // enclosing statement, exactly as `Expr::CommandSubst` says of
4237 // a substitution's stderr. Dropping the `ExecResult` here made
4238 // `if cat /nonexistent; then …` print nothing at all, so every
4239 // condition that failed for a reason failed silently.
4240 let result = self.execute_command(&cmd.name, &cmd.args).await?;
4241 self.emit_cmdsubst_stderr(&result.err).await;
4242 Ok(Value::Bool(result.code == 0))
4243 }
4244 Expr::LastExitCode => {
4245 let scope = self.scope.read().await;
4246 Ok(Value::Int(scope.last_result().code))
4247 }
4248 Expr::CurrentPid => {
4249 let scope = self.scope.read().await;
4250 Ok(Value::Int(scope.pid() as i64))
4251 }
4252 Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
4253 Expr::ListLiteral(elems) => {
4254 // Spread must itself be a list — a scalar/record spread is a
4255 // loud error, never silently coerced or dropped (mirrors the
4256 // sync `Evaluator::eval_list_literal`; wording shared via
4257 // `spread_non_list_message` so the two paths can't diverge).
4258 let mut out = Vec::with_capacity(elems.len());
4259 for elem in elems {
4260 match elem {
4261 ListElem::Item(e) => {
4262 let value = self.eval_expr_async(e).await?;
4263 out.push(crate::interpreter::value_to_json(&value));
4264 }
4265 ListElem::Spread(e) => {
4266 let value = self.eval_expr_async(e).await?;
4267 match value {
4268 Value::Json(serde_json::Value::Array(items)) => out.extend(items),
4269 other => return Err(anyhow::anyhow!(spread_non_list_message(&other))),
4270 }
4271 }
4272 }
4273 }
4274 Ok(Value::Json(serde_json::Value::Array(out)))
4275 }
4276 Expr::RecordLiteral(entries) => {
4277 // Insertion order preserved (workspace serde_json has
4278 // `preserve_order`); a duplicate key keeps the last value
4279 // written, matching plain map-insert semantics.
4280 let mut map = serde_json::Map::new();
4281 for entry in entries {
4282 let key = match &entry.key {
4283 RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
4284 // `{"$k": v}` resolves like any double-quoted string
4285 // (used to silently create a literal "$k" key).
4286 RecordKey::Interpolated(parts) => {
4287 self.eval_string_parts_async(parts).await?
4288 }
4289 };
4290 let value = self.eval_expr_async(&entry.value).await?;
4291 map.insert(key, crate::interpreter::value_to_json(&value));
4292 }
4293 Ok(Value::Json(serde_json::Value::Object(map)))
4294 }
4295 }
4296 })
4297 }
4298
4299 /// Async helper to evaluate multiple StringParts into a single string.
4300 fn eval_string_parts_async<'a>(&'a self, parts: &'a [StringPart]) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4301 Box::pin(async move {
4302 let mut result = String::new();
4303 for part in parts {
4304 result.push_str(&self.eval_string_part_async(part).await?);
4305 }
4306 Ok(result)
4307 })
4308 }
4309
4310 /// Async helper to evaluate a StringPart.
4311 /// Evaluate a `[[ ]]` test expression asynchronously, routing file tests
4312 /// through the VFS backend instead of using raw `std::path`.
4313 fn eval_test_async<'a>(&'a self, test_expr: &'a TestExpr) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
4314 Box::pin(async move {
4315 match test_expr {
4316 TestExpr::FileTest { op, path } => {
4317 let path_value = self.eval_expr_async(path).await?;
4318 // Expand `~` against the session HOME before stat'ing, the
4319 // same way argv positionals do — otherwise `[[ -f ~/x ]]`
4320 // stats the literal `~/x` and is always false.
4321 let home = self.scope_home().await;
4322 let path_value = apply_tilde_expansion(path_value, home.as_deref());
4323 // A binary `[[ -f $bin ]]` operand goes loud rather than
4324 // silently stat'ing a file literally named
4325 // `[binary: N bytes]` (the same path-positional guard
4326 // builtins like `stat`/`cp` use).
4327 let path_str = crate::interpreter::value_to_text_sink_named(&path_value, "a path")
4328 .map_err(|e| anyhow::anyhow!("{e}"))?;
4329 // The empty path names no file. Resolving it lands on the
4330 // working directory, so `[[ -e "" ]]` answered true — bash
4331 // says false, and so does every reading of "does this file
4332 // exist". Same guard as `test`'s `file_test`; the two
4333 // spellings of a file test must not disagree about a path,
4334 // and `test_compound_tests` pins that they agree.
4335 if path_str.is_empty() {
4336 return Ok(false);
4337 }
4338 // Resolve against the *session* cwd, not the process cwd, so a
4339 // relative `[[ -f rel ]]` honors `cd` and agrees with the
4340 // VFS-aware `test` builtin (GH #101). Backend stats a raw
4341 // relative path against the process cwd otherwise.
4342 let (resolved, backend) = {
4343 let ctx = self.exec_ctx.read().await;
4344 (ctx.resolve_path(&path_str), ctx.backend.clone())
4345 };
4346 let entry = backend.stat(&resolved).await.ok();
4347 Ok(match op {
4348 FileTestOp::Exists => entry.is_some(),
4349 FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
4350 FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
4351 FileTestOp::Readable => entry.as_ref().is_some_and(|e| {
4352 e.permissions.is_none_or(|p| p & 0o444 != 0)
4353 }),
4354 FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
4355 e.permissions.is_none_or(|p| p & 0o222 != 0)
4356 }),
4357 FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
4358 e.permissions.is_some_and(|p| p & 0o111 != 0)
4359 }),
4360 })
4361 }
4362 TestExpr::StringTest { op, value } => match op {
4363 crate::ast::StringTestOp::IsEmpty | crate::ast::StringTestOp::IsNonEmpty => {
4364 let val = self.eval_expr_async(value).await?;
4365 // Decision E: a collection operand is a loud Shape error
4366 // here too — must not diverge from the sync path in
4367 // interpreter/eval.rs (shared `scalar_test_operand_error`).
4368 let symbol = match op {
4369 crate::ast::StringTestOp::IsEmpty => "-z",
4370 crate::ast::StringTestOp::IsNonEmpty => "-n",
4371 crate::ast::StringTestOp::IsList
4372 | crate::ast::StringTestOp::IsRecord => unreachable!(),
4373 };
4374 if let Some(msg) = crate::interpreter::scalar_test_operand_error(symbol, &val) {
4375 anyhow::bail!(msg);
4376 }
4377 let s = value_to_string(&val);
4378 Ok(match op {
4379 crate::ast::StringTestOp::IsEmpty => s.is_empty(),
4380 crate::ast::StringTestOp::IsNonEmpty => !s.is_empty(),
4381 crate::ast::StringTestOp::IsList
4382 | crate::ast::StringTestOp::IsRecord => unreachable!(),
4383 })
4384 }
4385 // Shape guard: propagates eval errors like -z/-n (a bare
4386 // `$unset` is an undefined-variable error, not a silent
4387 // false). A defined-but-wrong-shaped value is false. Must
4388 // not diverge from the sync path in interpreter/eval.rs.
4389 crate::ast::StringTestOp::IsList | crate::ast::StringTestOp::IsRecord => {
4390 let val = self.eval_expr_async(value).await?;
4391 Ok(op.matches_shape(&val))
4392 }
4393 },
4394 TestExpr::Comparison { left, op, right } => {
4395 // Evaluate operands async (handles $(cmd)), then compare sync
4396 let left_val = self.eval_expr_async(left).await?;
4397 let right_val = self.eval_expr_async(right).await?;
4398 let resolved = TestExpr::Comparison {
4399 left: Box::new(Expr::Literal(left_val)),
4400 op: *op,
4401 right: Box::new(Expr::Literal(right_val)),
4402 };
4403 let expr = Expr::Test(Box::new(resolved));
4404 let mut scope = self.scope.write().await;
4405 let value = eval_expr(&expr, &mut scope)
4406 .map_err(|e| anyhow::anyhow!("{}", e))?;
4407 Ok(value_to_bool(&value))
4408 }
4409 TestExpr::And { left, right } => {
4410 if !self.eval_test_async(left).await? {
4411 Ok(false)
4412 } else {
4413 self.eval_test_async(right).await
4414 }
4415 }
4416 TestExpr::Or { left, right } => {
4417 if self.eval_test_async(left).await? {
4418 Ok(true)
4419 } else {
4420 self.eval_test_async(right).await
4421 }
4422 }
4423 TestExpr::Not { expr } => {
4424 Ok(!self.eval_test_async(expr).await?)
4425 }
4426 TestExpr::In { left, right } => {
4427 let left_val = self.eval_expr_async(left).await?;
4428 let right_val = self.eval_expr_async(right).await?;
4429 let resolved = TestExpr::In {
4430 left: Box::new(Expr::Literal(left_val)),
4431 right: Box::new(Expr::Literal(right_val)),
4432 };
4433 let expr = Expr::Test(Box::new(resolved));
4434 let mut scope = self.scope.write().await;
4435 let value = eval_expr(&expr, &mut scope)
4436 .map_err(|e| anyhow::anyhow!("{}", e))?;
4437 Ok(value_to_bool(&value))
4438 }
4439 TestExpr::NotIn { left, right } => {
4440 let left_val = self.eval_expr_async(left).await?;
4441 let right_val = self.eval_expr_async(right).await?;
4442 let resolved = TestExpr::NotIn {
4443 left: Box::new(Expr::Literal(left_val)),
4444 right: Box::new(Expr::Literal(right_val)),
4445 };
4446 let expr = Expr::Test(Box::new(resolved));
4447 let mut scope = self.scope.write().await;
4448 let value = eval_expr(&expr, &mut scope)
4449 .map_err(|e| anyhow::anyhow!("{}", e))?;
4450 Ok(value_to_bool(&value))
4451 }
4452 }
4453 })
4454 }
4455
4456 fn eval_string_part_async<'a>(&'a self, part: &'a StringPart) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
4457 Box::pin(async move {
4458 match part {
4459 StringPart::Literal(s) => Ok(s.clone()),
4460 StringPart::Var(path) => {
4461 let scope = self.scope.read().await;
4462 match scope.resolve_path(path) {
4463 // Text sink: binary goes loud, never the placeholder —
4464 // a `b=$(cat blob)` capture holds real bytes; splicing
4465 // `[binary: N bytes]` into "$b" would be silent loss.
4466 Ok(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4467 // Unset vars expand to empty; loud path errors surface.
4468 Err(PathError::UndefinedRoot(_)) => Ok(String::new()),
4469 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => {
4470 Err(anyhow::anyhow!(msg))
4471 }
4472 }
4473 }
4474 StringPart::VarWithDefault { path, default } => {
4475 let resolved = {
4476 let scope = self.scope.read().await;
4477 crate::interpreter::resolve_default(&scope, path)
4478 .map_err(|msg| anyhow::anyhow!(msg))?
4479 };
4480 match resolved {
4481 Some(value) => value_to_text_sink(&value).map_err(|e| anyhow::anyhow!("{e}")),
4482 None => self.eval_string_parts_async(default).await,
4483 }
4484 }
4485 StringPart::VarLength(path) => {
4486 let scope = self.scope.read().await;
4487 crate::interpreter::resolve_length(&scope, path)
4488 .map(|n| n.to_string())
4489 .map_err(|msg| anyhow::anyhow!(msg))
4490 }
4491 StringPart::Positional(n) => {
4492 let scope = self.scope.read().await;
4493 match scope.get_positional(*n) {
4494 Some(s) => Ok(s.to_string()),
4495 None => Ok(String::new()),
4496 }
4497 }
4498 StringPart::AllArgs => {
4499 let scope = self.scope.read().await;
4500 Ok(scope.all_args().join(" "))
4501 }
4502 StringPart::ArgCount => {
4503 let scope = self.scope.read().await;
4504 Ok(scope.arg_count().to_string())
4505 }
4506 StringPart::Arithmetic(expr) => {
4507 // Loud on purpose (GH #183): this used to be `Err(_) =>
4508 // Ok(String::new())`, silently splicing in "" for e.g.
4509 // `"$((1/0))"` — `echo "value: $((1/0))"` printed "value: "
4510 // at exit 0 instead of failing. Matches the bare (non-string)
4511 // `Expr::Arithmetic` arm above, which already propagates.
4512 let scope = self.scope.read().await;
4513 crate::arithmetic::eval_arithmetic(expr, &scope)
4514 .map(|value| value.to_string())
4515 .map_err(|e| anyhow::anyhow!("arithmetic error: {e}"))
4516 }
4517 StringPart::CommandSubst(stmts) => {
4518 // Snapshot scope, cwd, and session config — command
4519 // substitution in strings must not leak side effects (e.g.,
4520 // `"dir: $(cd /; pwd)"` must not change cwd, and
4521 // `"$(kaish-ignore clear)"` must not change the session's
4522 // ignore config) — matching how every other execution
4523 // context (background forks, scatter workers) already
4524 // isolates mutations (GH #139).
4525 // Boxed: this ~470 B scope snapshot is held across the nested
4526 // `$(…)` recursion await below, so inlining it grows every
4527 // command-substitution level's future (GH #48, item 4).
4528 let saved_scope = Box::new(self.scope.read().await.clone());
4529 let saved_ec = {
4530 let ec = self.exec_ctx.read().await;
4531 (
4532 ec.cwd.clone(),
4533 ec.prev_cwd.clone(),
4534 ec.aliases.clone(),
4535 ec.ignore_config.clone(),
4536 ec.output_limit.clone(),
4537 )
4538 };
4539
4540 // Capture result without `?` — restore state unconditionally
4541 let run_result = self.execute_block_capturing(stmts).await;
4542
4543 // Restore scope and cwd regardless of success/failure
4544 {
4545 let mut scope = self.scope.write().await;
4546 *scope = *saved_scope;
4547 if let Ok(ref r) = run_result {
4548 scope.set_last_result(r.clone());
4549 scope.note_cmdsubst_code(r.code);
4550 }
4551 }
4552 {
4553 let mut ec = self.exec_ctx.write().await;
4554 let (cwd, prev_cwd, aliases, ignore_config, output_limit) = saved_ec;
4555 ec.cwd = cwd;
4556 ec.prev_cwd = prev_cwd;
4557 ec.aliases = aliases;
4558 ec.ignore_config = ignore_config;
4559 ec.output_limit = output_limit;
4560 }
4561
4562 // A substitution's stderr belongs to the enclosing statement,
4563 // never to its value. Emit it before the value is built.
4564 if let Ok(ref r) = run_result {
4565 self.emit_cmdsubst_stderr(&r.err).await;
4566 }
4567
4568 // Now propagate the error
4569 let result = run_result?;
4570
4571 // A held body stops the enclosing statement before its
4572 // missing output is spliced in (spec §I.5) — same conversion
4573 // and stash as the bare `$(…)` arm.
4574
4575 // Embedding binary into a string is a text context: fail loud
4576 // rather than splice in U+FFFD garbage.
4577 match result.try_text_out() {
4578 // Text wins when present — unchanged behavior.
4579 Ok(s) if !s.is_empty() => Ok(s.trim_end_matches('\n').to_string()),
4580 // `.out` is empty: a builtin/tool that set only structured
4581 // `.data` must not silently evaporate to "" (SILENT DATA
4582 // LOSS). Render it the same way a bare `"$x"`
4583 // collection-valued variable renders — compact JSON for
4584 // lists/records, plain form for scalars — by reusing
4585 // `value_to_string` (the exact `StringPart::Var` helper
4586 // above) so `"$(cmd)"` and `x=$(cmd); "$x"` display
4587 // identically. No trailing-newline trim here: that's a
4588 // text-path artifact, not applicable to a freshly
4589 // rendered JSON/scalar string.
4590 Ok(_) => match &result.data {
4591 Some(data) => Ok(value_to_string(data)),
4592 None => Ok(String::new()),
4593 },
4594 Err(e) => anyhow::bail!(
4595 "command substitution in a string produced binary data ({e}) — \
4596 pipe through base64/xxd"
4597 ),
4598 }
4599 }
4600 StringPart::LastExitCode => {
4601 let scope = self.scope.read().await;
4602 Ok(scope.last_result().code.to_string())
4603 }
4604 StringPart::CurrentPid => {
4605 let scope = self.scope.read().await;
4606 Ok(scope.pid().to_string())
4607 }
4608 }
4609 })
4610 }
4611
4612 /// Update the last result in scope.
4613 async fn update_last_result(&self, result: &ExecResult) {
4614 let mut scope = self.scope.write().await;
4615 scope.set_last_result(result.clone());
4616 }
4617
4618 /// Drain accumulated pipeline stderr into a result.
4619 ///
4620 /// Called after each sub-statement inside control structures (`if`, `for`,
4621 /// `while`, `case`, `&&`, `||`) so that stderr appears incrementally rather
4622 /// than batching until the entire structure finishes.
4623 async fn drain_stderr_into(&self, result: &mut ExecResult) {
4624 let drained = {
4625 let mut receiver = self.stderr_receiver.lock().await;
4626 receiver.drain_lossy()
4627 };
4628 if !drained.is_empty() {
4629 if !result.err.is_empty() && !result.err.ends_with('\n') {
4630 result.err.push('\n');
4631 }
4632 result.err.push_str(&drained);
4633 }
4634 }
4635
4636 /// Execute a user-defined function with local variable scoping.
4637 ///
4638 /// Functions push a new scope frame for local variables. Variables declared
4639 /// with `local` are scoped to the function; other assignments modify outer
4640 /// scopes (or create in root if new).
4641 async fn execute_user_tool(&self, def: ToolDef, args: &[Arg]) -> Result<ExecResult> {
4642 let _depth = self.enter_recursion("a shell function")?;
4643
4644 // 1. Build function args from AST args (async to support command substitution)
4645 let tool_args = self.build_args_async(args, None).await?;
4646
4647 // 2. Push a new scope frame for local variables
4648 {
4649 let mut scope = self.scope.write().await;
4650 scope.push_frame();
4651 }
4652
4653 // 3. Save current positional parameters and set new ones for this function
4654 let saved_positional = {
4655 let mut scope = self.scope.write().await;
4656 let saved = scope.save_positional();
4657
4658 // Set up new positional parameters ($0 = function name, $1, $2, ... = args)
4659 let positional_args: Vec<String> = tool_args.positional
4660 .iter()
4661 .map(value_to_string)
4662 .collect();
4663 scope.set_positional(&def.name, positional_args);
4664
4665 saved
4666 };
4667
4668 // 3. Execute body statements with control flow handling
4669 // Accumulate output across statements (like sh)
4670 // Accumulate stdout as raw bytes so a binary-producing statement in a
4671 // function body survives instead of being lossy-decoded here.
4672 let mut accumulated_out: Vec<u8> = Vec::new();
4673 let mut accumulated_err = String::new();
4674 let mut last_code = 0i64;
4675 let mut last_data: Option<Value> = None;
4676
4677 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4678 match r.out_bytes() {
4679 Some(b) => buf.extend_from_slice(b),
4680 None => buf.extend_from_slice(r.text_out().as_bytes()),
4681 }
4682 }
4683
4684 // Track execution error for propagation after cleanup
4685 let mut exec_error: Option<anyhow::Error> = None;
4686 let mut exit_code: Option<i64> = None;
4687
4688 for stmt in &def.body {
4689 match self.execute_stmt_flow(stmt).await {
4690 Ok(flow) => {
4691 // Drain pipeline stderr after each sub-statement.
4692 let drained = {
4693 let mut receiver = self.stderr_receiver.lock().await;
4694 receiver.drain_lossy()
4695 };
4696 if !drained.is_empty() {
4697 accumulated_err.push_str(&drained);
4698 }
4699
4700 match flow {
4701 ControlFlow::Normal(r) => {
4702 push_out(&mut accumulated_out, &r);
4703 accumulated_err.push_str(&r.err);
4704 last_code = r.code;
4705 // A structured VIEW of printed text does not escape as the
4706 // substitution's value — `$(cut -f2 f)` is the text `cut`
4707 // printed, the same as `$(awk '{print $2}' f)`.
4708 last_data = if r.data_is_value { r.data } else { None };
4709 }
4710 ControlFlow::Return { value } => {
4711 push_out(&mut accumulated_out, &value);
4712 accumulated_err.push_str(&value.err);
4713 last_code = value.code;
4714 last_data = if value.data_is_value { value.data } else { None };
4715 break;
4716 }
4717 ControlFlow::Exit { code, result: r } => {
4718 push_out(&mut accumulated_out, &r);
4719 accumulated_err.push_str(&r.err);
4720 exit_code = Some(code);
4721 break;
4722 }
4723 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
4724 push_out(&mut accumulated_out, &r);
4725 accumulated_err.push_str(&r.err);
4726 last_code = r.code;
4727 last_data = if r.data_is_value { r.data } else { None };
4728 }
4729 }
4730 }
4731 Err(e) => {
4732 exec_error = Some(e);
4733 break;
4734 }
4735 }
4736 }
4737
4738 // 4. Pop scope frame and restore original positional parameters (unconditionally)
4739 {
4740 let mut scope = self.scope.write().await;
4741 scope.pop_frame();
4742 scope.set_positional(saved_positional.0, saved_positional.1);
4743 }
4744
4745 // 5. Propagate error or exit after cleanup
4746 if let Some(e) = exec_error {
4747 return Err(e);
4748 }
4749 let code = exit_code.unwrap_or(last_code);
4750 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
4751 result.err = accumulated_err;
4752 // Whatever survived the gate above IS a value, so the result says so
4753 // and a further `$( )` around this one keeps it typed.
4754 result.data_is_value = last_data.is_some();
4755 result.data = last_data;
4756 Ok(result)
4757 }
4758
4759 fn enter_recursion(&self, what: &str) -> Result<RecursionGuard<'_>> {
4760 let depth = self.recursion_depth.fetch_add(1, Ordering::Relaxed) + 1;
4761 let guard = RecursionGuard { counter: &self.recursion_depth };
4762 if depth > MAX_RECURSION_DEPTH {
4763 return Err(anyhow::anyhow!(
4764 "maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded in {what} — \
4765 a runaway or mutually recursive script (deeply nested $(…), or \
4766 functions/scripts that call each other without a base case) was \
4767 stopped before it could overflow the stack"
4768 ));
4769 }
4770 Ok(guard)
4771 }
4772
4773 /// Hand a finished command substitution's stderr to the kernel's stderr
4774 /// stream.
4775 ///
4776 /// bash gives `$(…)` the shell's own fd 2, so a substitution's stderr goes
4777 /// straight to the terminal and is never captured alongside its stdout.
4778 /// kaish runs the block captured, so the equivalent is to write the block's
4779 /// stderr to the same channel pipeline stages use: the enclosing
4780 /// statement's drain folds it into that statement's `err`, ahead of the
4781 /// statement's own output. `x=$(cat /nope)` kept the exit code and lost the
4782 /// reason until this existed.
4783 ///
4784 /// Nesting composes without a stack. Each level drains at its own statement
4785 /// boundary, so an inner substitution's stderr is already inside the outer
4786 /// block's result by the time this runs for the outer one — which is why it
4787 /// is written exactly once, here, rather than also accumulated by callers.
4788 /// Hand a nested command's stderr to the enclosing statement.
4789 ///
4790 /// Two callers, one rule: a command substitution's stderr is not part of
4791 /// its value, and a condition command's stderr is not part of its
4792 /// truthiness. Both belong to the statement the author wrote.
4793 async fn emit_cmdsubst_stderr(&self, err: &str) {
4794 if err.is_empty() {
4795 return;
4796 }
4797 // Terminate the chunk. Builtins are inconsistent about a trailing
4798 // newline (`cat`'s failure message has none), and two substitutions in
4799 // one statement would otherwise concatenate into a single unreadable
4800 // line: `x="$(cat /a)$(cat /b)"` produced both messages run together.
4801 // The statement drain already normalizes this boundary the same way
4802 // when it joins drained stderr to a statement's own.
4803 let terminated;
4804 let err = if err.ends_with('\n') {
4805 err
4806 } else {
4807 terminated = format!("{err}\n");
4808 &terminated
4809 };
4810 match self.exec_ctx.read().await.stderr.as_ref() {
4811 Some(stream) => stream.write_str(err),
4812 // The kernel seeds this stream in both `new` and `fork`, so it is
4813 // always present on the kernel's own context; the `Option` exists
4814 // for tool contexts built elsewhere. If it is ever absent there is
4815 // no channel to carry the bytes and no drain to collect them, which
4816 // is the same condition under which every pipeline stage's stderr
4817 // is dropped — so record it rather than failing an interactive
4818 // shell over it.
4819 None => tracing::warn!("command substitution stderr dropped: no stderr stream"),
4820 }
4821 }
4822
4823 async fn execute_block_capturing(&self, stmts: &[Stmt]) -> Result<ExecResult> {
4824 let _depth = self.enter_recursion("command substitution")?;
4825 // Accumulate stdout as raw bytes so a binary-producing statement
4826 // (`$(dd …)`, `$(base64 -d …)`) isn't lossy-decoded here before the
4827 // caller can preserve it. The final result is text iff valid UTF-8.
4828 let mut accumulated_out: Vec<u8> = Vec::new();
4829 let mut accumulated_err = String::new();
4830 let mut last_code = 0i64;
4831 let mut last_data: Option<Value> = None;
4832
4833 // Append a statement's stdout as raw bytes (binary) or its UTF-8 bytes.
4834 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4835 match r.out_bytes() {
4836 Some(b) => buf.extend_from_slice(b),
4837 None => buf.extend_from_slice(r.text_out().as_bytes()),
4838 }
4839 }
4840
4841 for stmt in stmts {
4842 let flow = self.execute_stmt_flow(stmt).await?;
4843
4844 // Drain pipeline stderr after each sub-statement (incremental, like
4845 // the control-structure and function-body executors).
4846 let drained = {
4847 let mut receiver = self.stderr_receiver.lock().await;
4848 receiver.drain_lossy()
4849 };
4850 if !drained.is_empty() {
4851 accumulated_err.push_str(&drained);
4852 }
4853
4854 match flow {
4855 ControlFlow::Normal(r)
4856 | ControlFlow::Break { result: r, .. }
4857 | ControlFlow::Continue { result: r, .. } => {
4858 push_out(&mut accumulated_out, &r);
4859 accumulated_err.push_str(&r.err);
4860 last_code = r.code;
4861 last_data = if r.data_is_value { r.data } else { None };
4862 }
4863 ControlFlow::Return { value } => {
4864 push_out(&mut accumulated_out, &value);
4865 accumulated_err.push_str(&value.err);
4866 last_code = value.code;
4867 last_data = if value.data_is_value { value.data } else { None };
4868 break;
4869 }
4870 ControlFlow::Exit { code, result: r } => {
4871 push_out(&mut accumulated_out, &r);
4872 accumulated_err.push_str(&r.err);
4873 last_code = code;
4874 break;
4875 }
4876 }
4877 }
4878
4879 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
4880 result.err = accumulated_err;
4881 // Whatever survived the gate above IS a value, so the result says so
4882 // and a further `$( )` around this one keeps it typed.
4883 result.data_is_value = last_data.is_some();
4884 result.data = last_data;
4885 Ok(result)
4886 }
4887
4888 /// Execute the `source` / `.` command to include and run a script.
4889 ///
4890 /// Unlike regular tool execution, `source` executes in the CURRENT scope,
4891 /// allowing the sourced script to set variables and modify shell state.
4892 async fn execute_source(&self, args: &[Arg]) -> Result<ExecResult> {
4893 // `source`/`.` is the fourth dynamic re-entry point: it runs the
4894 // sourced file's statements inline via `execute_stmt_flow`, so a file
4895 // that sources itself recurses unbounded just like a runaway function
4896 // (GH #46). It's intercepted as a special form *before* the other
4897 // guarded paths, so it needs its own guard.
4898 let _depth = self.enter_recursion("source")?;
4899
4900 // Get the file path from the first positional argument
4901 let tool_args = self.build_args_async(args, None).await?;
4902 let path = match tool_args.positional.first() {
4903 Some(Value::String(s)) => s.clone(),
4904 Some(v) => value_to_string(v),
4905 None => {
4906 return Ok(ExecResult::failure(1, "source: missing filename"));
4907 }
4908 };
4909
4910 // Resolve path relative to cwd
4911 let full_path = {
4912 let ctx = self.exec_ctx.read().await;
4913 if path.starts_with('/') {
4914 std::path::PathBuf::from(&path)
4915 } else {
4916 ctx.cwd.join(&path)
4917 }
4918 };
4919
4920 // Read file content via backend
4921 let content = {
4922 let ctx = self.exec_ctx.read().await;
4923 match ctx.backend.read(&full_path, None).await {
4924 Ok(bytes) => {
4925 String::from_utf8(bytes).map_err(|e| {
4926 anyhow::anyhow!("source: {}: invalid UTF-8: {}", path, e)
4927 })?
4928 }
4929 Err(e) => {
4930 return Ok(ExecResult::failure(
4931 1,
4932 format!("source: {}: {}", path, e),
4933 ));
4934 }
4935 }
4936 };
4937
4938 // Parse the content
4939 let program = match crate::parser::parse(&content) {
4940 Ok(p) => p,
4941 Err(errors) => {
4942 let msg = errors
4943 .iter()
4944 .map(|e| format!("{}:{}: {}", path, e.span.start, e.message))
4945 .collect::<Vec<_>>()
4946 .join("\n");
4947 return Ok(ExecResult::failure(1, format!("source: {}", msg)));
4948 }
4949 };
4950
4951 // Execute each statement in the CURRENT scope (not isolated), accumulating
4952 // stdout/stderr across statements like `execute_user_tool` — a sourced
4953 // script's earlier statements must not be silently dropped in favor of
4954 // just the last one.
4955 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
4956 match r.out_bytes() {
4957 Some(b) => buf.extend_from_slice(b),
4958 None => buf.extend_from_slice(r.text_out().as_bytes()),
4959 }
4960 }
4961
4962 let mut accumulated_out: Vec<u8> = Vec::new();
4963 let mut accumulated_err = String::new();
4964 let mut last_code = 0i64;
4965 let mut last_data: Option<Value> = None;
4966
4967 for stmt in program.statements {
4968 if matches!(stmt, crate::ast::Stmt::Empty) {
4969 continue;
4970 }
4971
4972 match self.execute_stmt_flow(&stmt).await {
4973 Ok(flow) => {
4974 let drained = {
4975 let mut receiver = self.stderr_receiver.lock().await;
4976 receiver.drain_lossy()
4977 };
4978 if !drained.is_empty() {
4979 accumulated_err.push_str(&drained);
4980 }
4981 match flow {
4982 ControlFlow::Normal(r) => {
4983 push_out(&mut accumulated_out, &r);
4984 accumulated_err.push_str(&r.err);
4985 last_code = r.code;
4986 last_data = if r.data_is_value { r.data.clone() } else { None };
4987 self.update_last_result(&r).await;
4988 }
4989 ControlFlow::Break { .. } | ControlFlow::Continue { .. } => {
4990 return Err(anyhow::anyhow!(
4991 "source: {}: unexpected break/continue outside loop",
4992 path
4993 ));
4994 }
4995 ControlFlow::Return { value } => {
4996 push_out(&mut accumulated_out, &value);
4997 accumulated_err.push_str(&value.err);
4998 let mut result = ExecResult::success_text_or_bytes(accumulated_out)
4999 .with_code(value.code);
5000 result.err = accumulated_err;
5001 result.data = value.data;
5002 return Ok(result);
5003 }
5004 ControlFlow::Exit { code, result: r } => {
5005 push_out(&mut accumulated_out, &r);
5006 accumulated_err.push_str(&r.err);
5007 let mut result =
5008 ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5009 result.err = accumulated_err;
5010 // Whatever survived the gate above IS a value, so the result says so
5011 // and a further `$( )` around this one keeps it typed.
5012 result.data_is_value = last_data.is_some();
5013 result.data = last_data;
5014 return Ok(result);
5015 }
5016 }
5017 }
5018 Err(e) => {
5019 return Err(e.context(format!("source: {}", path)));
5020 }
5021 }
5022 }
5023
5024 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(last_code);
5025 result.err = accumulated_err;
5026 // Whatever survived the gate above IS a value, so the result says so
5027 // and a further `$( )` around this one keeps it typed.
5028 result.data_is_value = last_data.is_some();
5029 result.data = last_data;
5030 Ok(result)
5031 }
5032
5033 /// Try to execute a script from PATH directories.
5034 ///
5035 /// Searches PATH for `{name}.kai` files and executes them in isolated scope
5036 /// (like user-defined tools). Returns None if no script is found.
5037 async fn try_execute_script(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5038 // Held across the PATH probe *and* body execution: a `.kai` sourcing a
5039 // `.kai` re-enters here, and that nesting is what must be bounded (#46).
5040 // A non-script command pays only a transient, balanced increment during
5041 // the probe before falling through to the external path.
5042 let _depth = self.enter_recursion("a .kai script")?;
5043
5044 // Get PATH from scope (default to "/bin")
5045 let path_value = {
5046 let scope = self.scope.read().await;
5047 scope
5048 .get("PATH")
5049 .map(value_to_string)
5050 .unwrap_or_else(|| "/bin".to_string())
5051 };
5052
5053 // Search PATH directories for script
5054 for dir in path_value.split(':') {
5055 if dir.is_empty() {
5056 continue;
5057 }
5058
5059 // Build script path: {dir}/{name}.kai
5060 let script_path = PathBuf::from(dir).join(format!("{}.kai", name));
5061
5062 // Check if script exists
5063 let exists = {
5064 let ctx = self.exec_ctx.read().await;
5065 ctx.backend.exists(&script_path).await
5066 };
5067
5068 if !exists {
5069 continue;
5070 }
5071
5072 // Read script content
5073 let content = {
5074 let ctx = self.exec_ctx.read().await;
5075 match ctx.backend.read(&script_path, None).await {
5076 Ok(bytes) => match String::from_utf8(bytes) {
5077 Ok(s) => s,
5078 Err(e) => {
5079 return Ok(Some(ExecResult::failure(
5080 1,
5081 format!("{}: invalid UTF-8: {}", script_path.display(), e),
5082 )));
5083 }
5084 },
5085 Err(e) => {
5086 return Ok(Some(ExecResult::failure(
5087 1,
5088 format!("{}: {}", script_path.display(), e),
5089 )));
5090 }
5091 }
5092 };
5093
5094 // Parse the script
5095 let program = match crate::parser::parse(&content) {
5096 Ok(p) => p,
5097 Err(errors) => {
5098 let msg = errors
5099 .iter()
5100 .map(|e| format!("{}:{}: {}", script_path.display(), e.span.start, e.message))
5101 .collect::<Vec<_>>()
5102 .join("\n");
5103 return Ok(Some(ExecResult::failure(1, msg)));
5104 }
5105 };
5106
5107 // Build tool_args from args (async for command substitution support)
5108 let tool_args = self.build_args_async(args, None).await?;
5109
5110 // Create isolated scope (like user tools). The trash rail and
5111 // errexit are NOT session state a script may shed: a `.kai`
5112 // script starting from a blank scope would otherwise overwrite
5113 // and delete without the recovery net `set -o trash` promised,
5114 // or run past a gating failure the caller turned errexit on for.
5115 // Carry both.
5116 let mut isolated_scope = Scope::new();
5117 {
5118 let scope = self.scope.read().await;
5119 isolated_scope.set_pid(scope.pid());
5120 isolated_scope.set_trash_enabled(scope.trash_enabled());
5121 isolated_scope.set_trash_max_size(scope.trash_max_size());
5122 isolated_scope.set_error_exit(scope.error_exit_enabled());
5123 }
5124
5125 // Set up positional parameters ($0 = script name, $1, $2, ... = args)
5126 let positional_args: Vec<String> = tool_args.positional
5127 .iter()
5128 .map(value_to_string)
5129 .collect();
5130 isolated_scope.set_positional(name, positional_args);
5131
5132 // Save current scope and swap with isolated scope
5133 let original_scope = {
5134 let mut scope = self.scope.write().await;
5135 std::mem::replace(&mut *scope, isolated_scope)
5136 };
5137
5138 // Execute script statements — accumulate stdout/stderr across
5139 // statements like `execute_user_tool`, rather than keeping only the
5140 // last one's result.
5141 fn push_out(buf: &mut Vec<u8>, r: &ExecResult) {
5142 match r.out_bytes() {
5143 Some(b) => buf.extend_from_slice(b),
5144 None => buf.extend_from_slice(r.text_out().as_bytes()),
5145 }
5146 }
5147
5148 let mut accumulated_out: Vec<u8> = Vec::new();
5149 let mut accumulated_err = String::new();
5150 let mut last_code = 0i64;
5151 let mut last_data: Option<Value> = None;
5152 let mut exec_error: Option<anyhow::Error> = None;
5153 let mut exit_code: Option<i64> = None;
5154
5155 for stmt in program.statements {
5156 if matches!(stmt, crate::ast::Stmt::Empty) {
5157 continue;
5158 }
5159
5160 match self.execute_stmt_flow(&stmt).await {
5161 Ok(flow) => {
5162 let drained = {
5163 let mut receiver = self.stderr_receiver.lock().await;
5164 receiver.drain_lossy()
5165 };
5166 if !drained.is_empty() {
5167 accumulated_err.push_str(&drained);
5168 }
5169 match flow {
5170 ControlFlow::Normal(r) => {
5171 push_out(&mut accumulated_out, &r);
5172 accumulated_err.push_str(&r.err);
5173 last_code = r.code;
5174 last_data = if r.data_is_value { r.data } else { None };
5175 }
5176 ControlFlow::Return { value } => {
5177 push_out(&mut accumulated_out, &value);
5178 accumulated_err.push_str(&value.err);
5179 last_code = value.code;
5180 last_data = if value.data_is_value { value.data } else { None };
5181 break;
5182 }
5183 ControlFlow::Exit { code, result: r } => {
5184 push_out(&mut accumulated_out, &r);
5185 accumulated_err.push_str(&r.err);
5186 exit_code = Some(code);
5187 break;
5188 }
5189 ControlFlow::Break { result: r, .. } | ControlFlow::Continue { result: r, .. } => {
5190 push_out(&mut accumulated_out, &r);
5191 accumulated_err.push_str(&r.err);
5192 last_code = r.code;
5193 last_data = if r.data_is_value { r.data } else { None };
5194 }
5195 }
5196 }
5197 Err(e) => {
5198 exec_error = Some(e);
5199 break;
5200 }
5201 }
5202 }
5203
5204 // Restore original scope unconditionally
5205 {
5206 let mut scope = self.scope.write().await;
5207 *scope = original_scope;
5208 }
5209
5210 // Propagate error or exit after cleanup
5211 if let Some(e) = exec_error {
5212 return Err(e.context(format!("script: {}", script_path.display())));
5213 }
5214 let code = exit_code.unwrap_or(last_code);
5215 let mut result = ExecResult::success_text_or_bytes(accumulated_out).with_code(code);
5216 result.err = accumulated_err;
5217 // Whatever survived the gate above IS a value, so the result says so
5218 // and a further `$( )` around this one keeps it typed.
5219 result.data_is_value = last_data.is_some();
5220 result.data = last_data;
5221 return Ok(Some(result));
5222 }
5223
5224 // No script found
5225 Ok(None)
5226 }
5227
5228 /// Try to execute an external command from PATH.
5229 ///
5230 /// This is the fallback when no builtin or user-defined tool matches.
5231 /// External commands receive a clean argv (flags preserved in their original format).
5232 ///
5233 /// # Requirements
5234 /// - Command must be found in PATH
5235 /// - Current working directory must be on a real filesystem (not virtual like /v)
5236 ///
5237 /// # Returns
5238 /// - [`ExternalCommandOutcome::Ran`] if a command was resolved and run (any exit code)
5239 /// - [`ExternalCommandOutcome::NotFound`] if nothing on PATH matches — the
5240 /// caller should still try a backend-registered tool of the same name
5241 /// - [`ExternalCommandOutcome::Unavailable`] if kaish will not attempt a
5242 /// PATH lookup or spawn at all; a backend tool of the same name is a
5243 /// separate capability and is still tried by the caller
5244 /// - `Err` on execution errors
5245 #[cfg(not(feature = "subprocess"))]
5246 async fn try_execute_external(&self, _name: &str, _args: &[Arg]) -> Result<ExternalCommandOutcome> {
5247 Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::NotCompiled))
5248 }
5249
5250 /// Try to execute an external command from PATH.
5251 #[cfg(feature = "subprocess")]
5252 async fn try_execute_external(&self, name: &str, args: &[Arg]) -> Result<ExternalCommandOutcome> {
5253 if !self.allow_external_commands {
5254 return Ok(ExternalCommandOutcome::Unavailable(ExternalCommandsUnavailable::ConfiguredOff));
5255 }
5256 Ok(match Box::pin(self.try_execute_external_on_path(name, args)).await? {
5257 Some(result) => ExternalCommandOutcome::Ran(Box::new(result)),
5258 None => ExternalCommandOutcome::NotFound,
5259 })
5260 }
5261
5262 /// The actual PATH lookup + spawn, once the caller has confirmed external
5263 /// commands are allowed at all. Unchanged from before the disabled/
5264 /// not-compiled cases were split out — still `Option`-shaped: `None`
5265 /// means "bare name, nothing on PATH", the one case where the caller
5266 /// should keep looking elsewhere.
5267 #[cfg(feature = "subprocess")]
5268 #[tracing::instrument(level = "debug", skip(self, args), fields(command = %name))]
5269 async fn try_execute_external_on_path(&self, name: &str, args: &[Arg]) -> Result<Option<ExecResult>> {
5270 // Read the cancel token from `self.exec_ctx`, which `dispatch_command`
5271 // populates from the inbound ctx.cancel on every dispatch. This is
5272 // what makes the `timeout` builtin's swapped child token reach the
5273 // wait_or_kill discipline below — reading `self.cancel_token` would
5274 // give the kernel-wide token and miss the timeout's child cascade.
5275 let cancel = {
5276 let ec = self.exec_ctx.read().await;
5277 ec.cancel.clone()
5278 };
5279 let kill_grace = self.kill_grace;
5280
5281 // Get the shell's cwd and its real filesystem location, if any. A
5282 // `None` real path means the cwd is virtual (a CoW overlay, an
5283 // in-memory VFS mount, `/dev`, …) — there's nowhere for a child OS
5284 // process to run. Don't bail out here: a bare command name that isn't
5285 // in PATH at all is a genuine "not found" regardless of cwd, and the
5286 // virtual-cwd error would blame the wrong thing for that case. Once
5287 // the command actually resolves, `real_cwd` is checked again below
5288 // and the honest reason is given then (issue #181).
5289 let (cwd, real_cwd) = {
5290 let ctx = self.exec_ctx.read().await;
5291 (ctx.cwd.clone(), ctx.backend.resolve_real_path(&ctx.cwd))
5292 };
5293
5294 let executable = if name.contains('/') {
5295 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
5296 let resolved = if std::path::Path::new(name).is_absolute() {
5297 std::path::PathBuf::from(name)
5298 } else {
5299 match &real_cwd {
5300 Some(real_cwd) => real_cwd.join(name),
5301 // A relative path can't be resolved without a real cwd to
5302 // join against, so we can't even tell whether it would
5303 // exist — name the actual blocker instead of a
5304 // misleading "No such file or directory".
5305 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5306 }
5307 };
5308 if !resolved.exists() {
5309 return Ok(Some(ExecResult::failure(
5310 127,
5311 format!("{}: No such file or directory", name),
5312 )));
5313 }
5314 if !resolved.is_file() {
5315 return Ok(Some(ExecResult::failure(
5316 126,
5317 format!("{}: Is a directory", name),
5318 )));
5319 }
5320 #[cfg(unix)]
5321 {
5322 use std::os::unix::fs::PermissionsExt;
5323 let mode = std::fs::metadata(&resolved)
5324 .map(|m| m.permissions().mode())
5325 .unwrap_or(0);
5326 if mode & 0o111 == 0 {
5327 return Ok(Some(ExecResult::failure(
5328 126,
5329 format!("{}: Permission denied", name),
5330 )));
5331 }
5332 }
5333 resolved.to_string_lossy().into_owned()
5334 } else {
5335 // Get PATH from scope only. The kernel never reads OS env: a
5336 // frontend that wants host PATH seeds it via initial_vars (the REPL
5337 // does, with os_env_vars()). No PATH in scope → nothing resolves.
5338 let path_var = {
5339 let scope = self.scope.read().await;
5340 scope.get("PATH").map(value_to_string).unwrap_or_default()
5341 };
5342
5343 // Resolve command in PATH
5344 match resolve_in_path(name, &path_var) {
5345 Some(path) => path,
5346 None => return Ok(None), // Not found - let caller handle error
5347 }
5348 };
5349
5350 // The executable resolved — found in PATH, or a path that exists and
5351 // is executable — but there's still nowhere to run it without a real
5352 // cwd to spawn the child process in.
5353 let real_cwd = match real_cwd {
5354 Some(p) => p,
5355 None => return Ok(Some(virtual_cwd_error(name, &cwd))),
5356 };
5357
5358 tracing::debug!(executable = %executable, "resolved external command");
5359
5360 // Build flat argv (preserves flag format)
5361 let argv = self.build_args_flat(args).await?;
5362
5363 // Get stdin sources: a streaming `pipe_stdin` (an inter-stage pipeline
5364 // pipe, or a frontend-seeded process-stdin pipe) and/or a buffered
5365 // byte vector. Take both out under the lock but do NOT drain here — a
5366 // pipe read can block on its producer (a still-running upstream stage),
5367 // so draining before spawn would serialize the pipeline (deadlocking
5368 // `sleep 60 | extern`). The pipe is streamed to the child *after* spawn.
5369 // `set_stdin` clears `pipe_stdin`, so a redirect-set buffer and a pipe
5370 // are mutually exclusive in practice; prefer the pipe.
5371 let (pipe_stdin, stdin_bytes) = {
5372 let mut ctx = self.exec_ctx.write().await;
5373 (ctx.pipe_stdin.take(), ctx.take_stdin())
5374 };
5375 let has_stdin = pipe_stdin.is_some() || stdin_bytes.is_some();
5376
5377 // Build and spawn the command
5378 use tokio::process::Command;
5379
5380 let mut cmd = Command::new(&executable);
5381 cmd.args(&argv);
5382 cmd.current_dir(&real_cwd);
5383
5384 // Hermetic env: child sees only kaish's exported vars, not the kaish
5385 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
5386 // populate it via KernelConfig::initial_vars at construction.
5387 cmd.env_clear();
5388 {
5389 let scope = self.scope.read().await;
5390 let exported = scope.exported_vars();
5391 // A structured value can't cross the process boundary; refuse rather
5392 // than silently JSON-serialize it into the child's environment.
5393 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
5394 return Err(anyhow::anyhow!(msg));
5395 }
5396 for (var_name, value) in exported {
5397 // Binary can't cross the process boundary as an env var value
5398 // either — loud, not the `[binary: N bytes]` placeholder
5399 // silently exported in its place (kept in sync with
5400 // dispatch.rs::try_external and env.rs::execute_with_env).
5401 let value_str = crate::interpreter::value_to_text_sink_named(
5402 &value,
5403 "an exported environment variable value",
5404 )
5405 .map_err(|e| anyhow::anyhow!("{e}"))?;
5406 cmd.env(var_name, value_str);
5407 }
5408 }
5409
5410 // Handle stdin
5411 cmd.stdin(if has_stdin {
5412 std::process::Stdio::piped()
5413 } else if self.interactive {
5414 std::process::Stdio::inherit()
5415 } else {
5416 std::process::Stdio::null()
5417 });
5418
5419 // In interactive mode, standalone or last-in-pipeline commands inherit
5420 // the terminal's stdout/stderr so output streams in real-time.
5421 // First/middle commands must capture stdout for the pipe — same as bash.
5422 let pipeline_position = {
5423 let ctx = self.exec_ctx.read().await;
5424 ctx.pipeline_position
5425 };
5426 let inherit_output = self.interactive
5427 && matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last);
5428
5429 if inherit_output {
5430 cmd.stdout(std::process::Stdio::inherit());
5431 cmd.stderr(std::process::Stdio::inherit());
5432 } else {
5433 cmd.stdout(std::process::Stdio::piped());
5434 cmd.stderr(std::process::Stdio::piped());
5435 }
5436
5437 // On Unix, always put the child in its own process group so cancellation
5438 // can `killpg` the whole tree (the child plus any grandchildren).
5439 // Restoring default tty-related signal handlers stays gated on
5440 // job-control mode — those only matter when the child has a controlling
5441 // terminal.
5442 #[cfg(unix)]
5443 {
5444 let restore_jc_signals = self.terminal_state.is_some() && inherit_output;
5445 // Read before the fork: the child compares `getppid()` against it to
5446 // catch a parent that died inside the fork/prctl window.
5447 let kill_on_parent_death = {
5448 let ec = self.exec_ctx.read().await;
5449 ec.kill_children_on_parent_death
5450 };
5451 let parent_pid = std::process::id();
5452 // SAFETY: setpgid, prctl, getppid, and sigaction(SIG_DFL) are all
5453 // async-signal-safe per POSIX; safe to call between fork and exec.
5454 #[allow(unsafe_code)]
5455 unsafe {
5456 cmd.pre_exec(move || {
5457 // Own process group — for kill scope.
5458 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
5459 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
5460 if kill_on_parent_death {
5461 crate::dispatch::arm_parent_death_signal(parent_pid)?;
5462 }
5463 if restore_jc_signals {
5464 use nix::libc::{sigaction, SIGTSTP, SIGTTOU, SIGTTIN, SIGINT, SIG_DFL};
5465 let mut sa: nix::libc::sigaction = std::mem::zeroed();
5466 sa.sa_sigaction = SIG_DFL;
5467 if sigaction(SIGTSTP, &sa, std::ptr::null_mut()) != 0 {
5468 return Err(std::io::Error::last_os_error());
5469 }
5470 if sigaction(SIGTTOU, &sa, std::ptr::null_mut()) != 0 {
5471 return Err(std::io::Error::last_os_error());
5472 }
5473 if sigaction(SIGTTIN, &sa, std::ptr::null_mut()) != 0 {
5474 return Err(std::io::Error::last_os_error());
5475 }
5476 if sigaction(SIGINT, &sa, std::ptr::null_mut()) != 0 {
5477 return Err(std::io::Error::last_os_error());
5478 }
5479 }
5480 Ok(())
5481 });
5482 }
5483 }
5484
5485 // Backstop for kill on drop in case our explicit kill path is bypassed
5486 // (panic, early return, etc) on the **capture** wait path. We do NOT
5487 // set this on the JC inherit path: that uses sync `waitpid` outside
5488 // tokio's view of the child, so on drop tokio would try to kill an
5489 // already-reaped (possibly-reused) PID. The JC path has its own
5490 // cancel handling via the side-task watcher.
5491 let in_jc_inherit_path = inherit_output && self.terminal_state.is_some();
5492 if !in_jc_inherit_path {
5493 cmd.kill_on_drop(true);
5494 }
5495
5496 // Spawn the process. Capture a `KillTarget` immediately so cancel/
5497 // timeout paths can deliver signals via pidfd (Linux ≥ 5.3) — bound
5498 // to this process's generation, immune to PID reuse if the OS reaps
5499 // the child before our kill syscalls fire.
5500 let mut child = match cmd.spawn() {
5501 Ok(child) => child,
5502 Err(e) => {
5503 return Ok(Some(ExecResult::failure(
5504 127,
5505 format!("{}: {}", name, e),
5506 )));
5507 }
5508 };
5509 let kill_target = crate::pidfd::KillTarget::from_child(&child);
5510
5511 // If this external runs on behalf of a background job, record its
5512 // process group on the job so `kill -<sig> %N` can signal the real
5513 // process directly (STOP/CONT/USR1/…, not just terminate). The child
5514 // did `setpgid(0, 0)` in pre_exec, so its PGID equals its PID.
5515 if let Some(job_id) = self.bg_job_id
5516 && let Some(pid) = child.id()
5517 {
5518 self.jobs.add_pgid(job_id, pid).await;
5519 }
5520
5521 // Same seam, for output: a background job's streams outlive this one
5522 // command, so the drain tasks below tee into them and the job closes
5523 // them itself. This is what makes `/v/jobs/{id}/stdout` grow while a
5524 // `cargo build &` is still building (GH #240 removed the node rather
5525 // than wire this tee; the tee is the half that was missing).
5526 let job_streams = match self.bg_job_id {
5527 Some(job_id) => self.jobs.streams(job_id).await,
5528 None => None,
5529 };
5530
5531 // Feed stdin. A streaming `pipe_stdin` is copied to the child by a
5532 // detached task (bounded memory, no pre-drain) so an upstream stage and
5533 // this child run concurrently — and a child that never reads stdin (or
5534 // is killed) just breaks the copy, which stops. A buffered byte vector
5535 // is written verbatim (no text detour), so binary stdin survives.
5536 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = pipe_stdin {
5537 child.stdin.take().map(|mut child_stdin| {
5538 // A buffered prefix and a live pipe are one stream, not two
5539 // candidates. After `read x`, the bytes `read` over-read sit in
5540 // the buffer and the rest is still in the pipe; picking the pipe
5541 // and dropping the buffer would silently skip the front of the
5542 // child's input.
5543 let prefix = stdin_bytes;
5544 tokio::spawn(async move {
5545 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5546 if let Some(data) = prefix
5547 && child_stdin.write_all(&data).await.is_err()
5548 {
5549 return; // child closed stdin; dropping it signals EOF
5550 }
5551 let mut buf = [0u8; 8192];
5552 loop {
5553 match pipe_in.read(&mut buf).await {
5554 Ok(0) => break, // EOF
5555 Ok(n) => {
5556 if child_stdin.write_all(&buf[..n]).await.is_err() {
5557 break; // child closed stdin
5558 }
5559 }
5560 Err(_) => break,
5561 }
5562 }
5563 // Dropping child_stdin signals EOF to the child.
5564 })
5565 })
5566 } else if let Some(data) = stdin_bytes {
5567 // Write the buffered bytes from a detached task too — NOT inline.
5568 // An inline write blocks once the stdin pipe fills, and the output
5569 // drain hasn't spawned yet, so a child that emits a lot before
5570 // consuming all its input (every pipe buffer full) deadlocks. A
5571 // write error here is normal, not a failure: a child that closes
5572 // stdin early (e.g. `head`) breaks the pipe. Dropping child_stdin
5573 // signals EOF.
5574 child.stdin.take().map(|mut child_stdin| {
5575 tokio::spawn(async move {
5576 use tokio::io::AsyncWriteExt;
5577 let _ = child_stdin.write_all(&data).await;
5578 })
5579 })
5580 } else {
5581 None
5582 };
5583
5584 // Abort the stdin-copy task on EVERY exit path (the capture path, both
5585 // interactive `inherit_output` returns, and any early error return).
5586 // Once the child is reaped the copy has nothing left to deliver; if it
5587 // were left parked on `pipe_in.read()` it would leak and hold the
5588 // upstream pipe reader open. A drop guard is the single place that
5589 // covers all returns — explicit per-return aborts were error-prone (an
5590 // earlier version missed the two inherit_output returns).
5591 struct AbortStdinCopyOnDrop(Option<tokio::task::JoinHandle<()>>);
5592 impl Drop for AbortStdinCopyOnDrop {
5593 fn drop(&mut self) {
5594 if let Some(t) = self.0.take() {
5595 t.abort();
5596 }
5597 }
5598 }
5599 let _stdin_copy_guard = AbortStdinCopyOnDrop(stdin_task);
5600
5601 if inherit_output {
5602 // Job control path: use waitpid with WUNTRACED for Ctrl-Z support
5603 #[cfg(unix)]
5604 if let Some(ref term) = self.terminal_state {
5605 let child_id = child.id().unwrap_or(0);
5606 let pid = nix::unistd::Pid::from_raw(child_id as i32);
5607 let pgid = pid; // child is its own pgid leader
5608
5609 // Give the terminal to the child's process group
5610 if let Err(e) = term.give_terminal_to(pgid) {
5611 tracing::warn!("failed to give terminal to child: {}", e);
5612 }
5613
5614 let term_clone = term.clone();
5615 let cmd_name = name.to_string();
5616 let cmd_display = format!("{} {}", name, argv.join(" "));
5617 let jobs = self.jobs.clone();
5618
5619 // Side task that watches for cancellation while the blocking
5620 // waitpid runs. On cancel, it SIGTERMs the process group, waits
5621 // the grace period, then SIGKILLs. The blocking waitpid returns
5622 // when the child dies. AbortOnDrop guard cancels the watcher
5623 // on the success path so it doesn't keep running after wait
5624 // returns naturally.
5625 //
5626 // `wait_complete` shrinks the PID-reuse race: the watcher
5627 // checks it before each kill syscall and bails out if
5628 // wait_for_foreground has already reaped the child. This
5629 // doesn't fully eliminate the race (atomic load + kill is
5630 // not atomic with the OS reap+reuse), but narrows the window
5631 // to nanoseconds — enough to be ignorable in practice.
5632 let wait_complete = std::sync::Arc::new(
5633 std::sync::atomic::AtomicBool::new(false)
5634 );
5635 let cancel_watcher = {
5636 let cancel = cancel.clone();
5637 let wc = wait_complete.clone();
5638 // Ownership transfer: the JC path's sync wait inside
5639 // block_in_place owns the child's reaping, so the
5640 // cancel_watcher drives the kill side via KillTarget
5641 // (pidfd-bound on Linux). When kill_target is None
5642 // (older kernel + open failure, or non-Linux), falls
5643 // through to the older PID-based path the closure
5644 // captures from `pid`.
5645 let target = kill_target.as_ref().map(|t| {
5646 // Re-borrow the components we need into Owned-ish form
5647 // so the spawned task is 'static. We can't move
5648 // KillTarget directly because try_execute_external
5649 // still uses it after the spawn — but on the JC path
5650 // there is no further use after the watcher spawn,
5651 // so a clone-of-pid + owned None pidfd is safe.
5652 // Simpler: signal via the existing target by cloning
5653 // a fresh pidfd; the original keeps its handle.
5654 // Pidfd is just an OwnedFd — not Clone — so do it
5655 // by re-opening from the pid. Fall back if reopen
5656 // fails (race already reaped → best-effort kill).
5657 crate::pidfd::KillTarget::from_pid(t.pid())
5658 });
5659 tokio::spawn(async move {
5660 cancel.cancelled().await;
5661 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5662 use nix::sys::signal::Signal;
5663 if let Some(t) = &target {
5664 t.signal(Signal::SIGTERM);
5665 t.signal_pg(Signal::SIGTERM);
5666 } else {
5667 let _ = nix::sys::signal::kill(pid, Signal::SIGTERM);
5668 let _ = nix::sys::signal::killpg(pid, Signal::SIGTERM);
5669 }
5670 if kill_grace > Duration::ZERO {
5671 tokio::time::sleep(kill_grace).await;
5672 if wc.load(std::sync::atomic::Ordering::SeqCst) { return; }
5673 }
5674 if let Some(t) = &target {
5675 t.signal(Signal::SIGKILL);
5676 t.signal_pg(Signal::SIGKILL);
5677 } else {
5678 let _ = nix::sys::signal::kill(pid, Signal::SIGKILL);
5679 let _ = nix::sys::signal::killpg(pid, Signal::SIGKILL);
5680 }
5681 })
5682 };
5683 struct AbortOnDrop(tokio::task::JoinHandle<()>);
5684 impl Drop for AbortOnDrop {
5685 fn drop(&mut self) {
5686 self.0.abort();
5687 }
5688 }
5689 let _watcher_guard = AbortOnDrop(cancel_watcher);
5690
5691 let wait_complete_setter = wait_complete.clone();
5692 let code = tokio::task::block_in_place(move || {
5693 let result = term_clone.wait_for_foreground(pid);
5694 // Mark wait done before the watcher might fire.
5695 wait_complete_setter.store(true, std::sync::atomic::Ordering::SeqCst);
5696
5697 // Always reclaim the terminal
5698 if let Err(e) = term_clone.reclaim_terminal() {
5699 tracing::warn!("failed to reclaim terminal: {}", e);
5700 }
5701
5702 match result {
5703 crate::terminal::WaitResult::Exited(code) => code as i64,
5704 crate::terminal::WaitResult::Signaled(sig) => 128 + sig as i64,
5705 crate::terminal::WaitResult::Stopped(_sig) => {
5706 // Register as a stopped job
5707 let rt = tokio::runtime::Handle::current();
5708 let job_id = rt.block_on(jobs.register_stopped(
5709 cmd_display,
5710 child_id,
5711 child_id, // pgid = pid for group leader
5712 ));
5713 eprintln!("\n[{}]+ Stopped\t{}", job_id, cmd_name);
5714 148 // 128 + SIGTSTP(20) on most systems, but we use a fixed value
5715 }
5716 }
5717 });
5718
5719 return Ok(Some(ExecResult::from_output(code, String::new(), String::new())));
5720 }
5721
5722 // Non-job-control path with inherited stdio.
5723 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5724 Ok(s) => s,
5725 Err(e) => {
5726 return Ok(Some(ExecResult::failure(
5727 1,
5728 format!("{}: failed to wait: {}", name, e),
5729 )));
5730 }
5731 };
5732
5733 let code = exit_code_from_status(&status);
5734
5735 // stdout/stderr already went to the terminal
5736 Ok(Some(ExecResult::from_output(code, String::new(), String::new())))
5737 } else {
5738 // Capture output via bounded streams
5739 let stdout_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5740 let stderr_stream = Arc::new(BoundedStream::new(DEFAULT_STREAM_MAX_SIZE));
5741
5742 let stdout_pipe = child.stdout.take();
5743 let stderr_pipe = child.stderr.take();
5744
5745 let stdout_clone = stdout_stream.clone();
5746 let stderr_clone = stderr_stream.clone();
5747
5748 // Only the stage whose stdout *is* the job's stdout tees: in
5749 // `a | b`, `a`'s bytes are `b`'s stdin, and teeing them would put
5750 // the pipeline's intermediate data into the node alongside its
5751 // real output. stderr has no such routing — every stage's stderr
5752 // is the job's stderr — so it tees from any position.
5753 let stdout_tee = job_streams.as_ref().and_then(|s| {
5754 matches!(pipeline_position, PipelinePosition::Only | PipelinePosition::Last)
5755 .then(|| s.stdout.clone())
5756 });
5757 let stderr_tee = job_streams.as_ref().map(|s| s.stderr.clone());
5758
5759 let stdout_task = stdout_pipe.map(|pipe| {
5760 tokio::spawn(async move {
5761 drain_to_stream_teed(pipe, stdout_clone, stdout_tee).await;
5762 })
5763 });
5764
5765 let stderr_task = stderr_pipe.map(|pipe| {
5766 tokio::spawn(async move {
5767 drain_to_stream_teed(pipe, stderr_clone, stderr_tee).await;
5768 })
5769 });
5770
5771 let cancelled_before_wait = cancel.is_cancelled();
5772 let status = match wait_or_kill(&mut child, kill_target.as_ref(), &cancel, kill_grace).await {
5773 Ok(s) => s,
5774 Err(e) => {
5775 // stdin-copy task is aborted by `_stdin_copy_guard` on return.
5776 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5777 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5778 return Ok(Some(ExecResult::failure(
5779 1,
5780 format!("{}: failed to wait: {}", name, e),
5781 )));
5782 }
5783 };
5784
5785 // On cancel, abort the drain tasks (the child's pipes are gone;
5786 // late output is lost but predictable death beats partial capture).
5787 // On normal exit, await drains so we don't lose buffered output.
5788 if cancelled_before_wait || cancel.is_cancelled() {
5789 if let Some(task) = stdout_task { task.abort(); let _ = task.await; }
5790 if let Some(task) = stderr_task { task.abort(); let _ = task.await; }
5791 } else {
5792 if let Some(task) = stdout_task {
5793 // Ignore join error — the drain task logs its own errors
5794 let _ = task.await;
5795 }
5796 if let Some(task) = stderr_task {
5797 let _ = task.await;
5798 }
5799 }
5800
5801 let code = exit_code_from_status(&status);
5802
5803 // Read stdout as RAW bytes: text if valid UTF-8, else a Bytes
5804 // result, so `curl url`, `curl url > file.bin`, etc. keep binary
5805 // intact. stderr stays text. See docs/binary-data.md.
5806 let stdout = stdout_stream.read().await;
5807 let mut stderr = stderr_stream.read_string().await;
5808 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
5809
5810 // Both streams are fixed-size rings regardless of `ctx.output_limit`
5811 // (that machinery only runs post-hoc, in `execute_pipeline`, and only
5812 // when enabled). With the limit disabled — the repl/embedded/test
5813 // default — an overflow here used to be silent: `write` evicted the
5814 // oldest bytes and bumped `bytes_evicted`, but nothing ever read that
5815 // counter, so a >10MB stdout reported clean success with its head
5816 // quietly gone (GH #191). Surface it loudly instead.
5817 if stderr_stream.has_overflowed().await {
5818 let stats = stderr_stream.stats().await;
5819 stderr = format!("{}{stderr}", stats.overflow_marker("stderr"));
5820 }
5821 if stdout_stream.has_overflowed().await {
5822 // The marker goes in stderr, never prepended into `result`'s
5823 // stdout payload: stdout may be binary
5824 // (`success_text_or_bytes` yields a `Bytes` result for
5825 // non-UTF-8 data — e.g. `curl` fetching a >10MB binary), and
5826 // string-formatting a marker into it would lossily reinterpret
5827 // bytes as text, introducing a SECOND, different kind of
5828 // corruption on top of the eviction itself.
5829 //
5830 // Only stdout overflow flips `did_spill` — exit-code integrity
5831 // tracks stdout, matching the enabled-limit path's contract
5832 // (stderr overflow alone doesn't remap the exit code).
5833 let stats = stdout_stream.stats().await;
5834 stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
5835 result.did_spill = true;
5836 }
5837 result.err = stderr;
5838 Ok(Some(result))
5839 }
5840 }
5841
5842 // --- Variable Access ---
5843
5844 /// Get a variable value.
5845 pub async fn get_var(&self, name: &str) -> Option<Value> {
5846 let scope = self.scope.read().await;
5847 scope.get(name).cloned()
5848 }
5849
5850 /// Check if error-exit mode is enabled (for testing).
5851 #[cfg(test)]
5852 pub async fn error_exit_enabled(&self) -> bool {
5853 let scope = self.scope.read().await;
5854 scope.error_exit_enabled()
5855 }
5856
5857 /// Set a variable value.
5858 pub async fn set_var(&self, name: &str, value: Value) {
5859 let mut scope = self.scope.write().await;
5860 scope.set(name.to_string(), value);
5861 }
5862
5863 /// Set positional parameters ($0 script name and $1-$9 args).
5864 pub async fn set_positional(&self, script_name: impl Into<String>, args: Vec<String>) {
5865 let mut scope = self.scope.write().await;
5866 scope.set_positional(script_name, args);
5867 }
5868
5869 /// List all variables.
5870 pub async fn list_vars(&self) -> Vec<(String, Value)> {
5871 let scope = self.scope.read().await;
5872 scope.all()
5873 }
5874
5875 /// List exported variables (name, value), sorted by name. These are the
5876 /// vars a child process would see (see `dispatch`'s hermetic env build).
5877 pub async fn exported_vars(&self) -> Vec<(String, Value)> {
5878 let scope = self.scope.read().await;
5879 scope.exported_vars()
5880 }
5881
5882 // --- CWD ---
5883
5884 /// Get current working directory.
5885 pub async fn cwd(&self) -> PathBuf {
5886 self.exec_ctx.read().await.cwd.clone()
5887 }
5888
5889 /// Set current working directory.
5890 pub async fn set_cwd(&self, path: PathBuf) {
5891 let mut ctx = self.exec_ctx.write().await;
5892 ctx.set_cwd(path);
5893 }
5894
5895 /// Set the working directory only if `path` resolves to a directory in the
5896 /// kernel's backend — the same namespace `cd` validates against. Unlike a
5897 /// raw host-FS `is_dir()` check, this correctly accepts virtual mounts
5898 /// (`/v/docs`, in-memory scratch, …) and rejects real paths that have since
5899 /// disappeared. Returns whether the cwd was changed.
5900 pub async fn try_set_cwd(&self, path: PathBuf) -> bool {
5901 // Clone the backend Arc out before the stat so we never hold the
5902 // exec_ctx lock across the await.
5903 let backend = self.exec_ctx.read().await.backend.clone();
5904 let is_dir = matches!(backend.stat(&path).await, Ok(entry) if entry.is_dir());
5905 if is_dir {
5906 self.exec_ctx.write().await.set_cwd(path);
5907 }
5908 is_dir
5909 }
5910
5911 // --- Last Result ---
5912
5913 /// Get the last result ($?).
5914 pub async fn last_result(&self) -> ExecResult {
5915 let scope = self.scope.read().await;
5916 scope.last_result().clone()
5917 }
5918
5919 // --- Tools ---
5920
5921 /// Check if a user-defined function exists.
5922 pub async fn has_function(&self, name: &str) -> bool {
5923 self.user_tools.read().await.contains_key(name)
5924 }
5925
5926 /// Get available tool schemas.
5927 pub fn tool_schemas(&self) -> Vec<crate::tools::ToolSchema> {
5928 self.tools.schemas()
5929 }
5930
5931 /// Classify how the kernel will resolve a command name.
5932 ///
5933 /// This is the supported, single source of truth for command resolution that
5934 /// embedders should call instead of re-deriving the rules. Walk a parsed
5935 /// script (`kaish_kernel::parser::parse` → `Stmt::Command` nodes) and call
5936 /// this per command name to bucket each into builtin / user-function /
5937 /// special-form / dynamic / external — for example a consent gate that blocks
5938 /// a script until external commands are approved.
5939 ///
5940 /// The classification mirrors the interpreter's real resolution order
5941 /// (`execute_command_depth`): special-forms (`true`/`false`/`source`/`.`)
5942 /// short-circuit first, then **aliases are expanded** (bounded recursion,
5943 /// re-checking special-forms each step, exactly as execution does), then user
5944 /// functions (which shadow builtins), then builtins, then a `PATH` lookup. A
5945 /// name that is a variable or command-substitution expansion (`$cmd`,
5946 /// `$(pick)`, `${x}`) classifies as [`CommandKind::Dynamic`] because it can't
5947 /// be resolved statically.
5948 ///
5949 /// Aliases are resolved against the kernel's current alias table, so an
5950 /// `alias cat=/bin/something` makes `cat` classify as `External` — the same
5951 /// thing it would actually run. The safe direction of any residual imprecision
5952 /// is `External`/`Dynamic`, never a false "internal": the `/v/bin/` prefix and
5953 /// `.kai`/backend-tool resolution are reported `External` even though some of
5954 /// those resolve in-process, so a consent gate over-gates rather than letting
5955 /// a `PATH` escape slip through.
5956 pub async fn classify_command(&self, name: &str) -> CommandKind {
5957 // Resolve the command head the way `execute_command_depth` does: a
5958 // special-form short-circuits before any alias lookup, otherwise expand
5959 // aliases (bounded, recursive) and re-check from the top. A dynamic name
5960 // can't be resolved at all.
5961 let mut name = name.to_string();
5962 let mut alias_depth = 0u8;
5963 loop {
5964 if !crate::validator::is_static_command_name(&name) {
5965 return CommandKind::Dynamic;
5966 }
5967 if crate::validator::is_runtime_special_form(&name) {
5968 return CommandKind::Special;
5969 }
5970 if alias_depth >= 10 {
5971 break;
5972 }
5973 let alias_value = {
5974 let ctx = self.exec_ctx.read().await;
5975 ctx.aliases.get(&name).cloned()
5976 };
5977 // Expand to the alias's head command. An empty alias value (no head)
5978 // is ignored by execution, so resolution continues with this name.
5979 match alias_value
5980 .as_deref()
5981 .and_then(|v| v.split_whitespace().next())
5982 {
5983 Some(head) => {
5984 name = head.to_string();
5985 alias_depth += 1;
5986 }
5987 None => break,
5988 }
5989 }
5990
5991 let is_user_tool = self.user_tools.read().await.contains_key(&name);
5992 let is_builtin = self.tools.contains(&name);
5993 crate::validator::classify_command_name(&name, is_builtin, is_user_tool)
5994 }
5995
5996 // --- Jobs ---
5997
5998 /// Get job manager.
5999 pub fn jobs(&self) -> Arc<JobManager> {
6000 self.jobs.clone()
6001 }
6002
6003 // --- VFS ---
6004
6005 /// Get VFS router.
6006 pub fn vfs(&self) -> Arc<VfsRouter> {
6007 self.vfs.clone()
6008 }
6009
6010 // --- State ---
6011
6012 /// Reset kernel to initial state.
6013 ///
6014 /// Clears in-memory variables and resets cwd to root. History is not
6015 /// cleared (it persists across resets). The kernel's `$$` identity, the
6016 /// trash-on-delete configuration, the current errexit state, and any
6017 /// frontend-seeded `initial_vars` (HOME/PATH/etc, from `KernelConfig`)
6018 /// are re-applied to the fresh scope rather than silently reverting to
6019 /// defaults — an embedder that opted into trash or errexit must not find
6020 /// either quietly disabled after a `reset()` between requests.
6021 ///
6022 /// **Background jobs are untouched** (GH #245) — `reset()` is a scope/cwd
6023 /// reset, not a session boundary for `&`. A job started before `reset()`
6024 /// keeps running, stays in `jobs`, and the job ID counter keeps counting
6025 /// up. An embedder treating `reset()` as "new session" (a fresh MCP
6026 /// conversation reusing one kernel, say) inherits every job the previous
6027 /// conversation backgrounded — call [`Self::cancel_all_jobs`] first if
6028 /// that inheritance is not wanted.
6029 pub async fn reset(&self) -> Result<()> {
6030 {
6031 let mut scope = self.scope.write().await;
6032 let pid = scope.pid();
6033 let trash_enabled = scope.trash_enabled();
6034 let errexit_enabled = scope.error_exit_enabled();
6035 let mut fresh = Scope::new();
6036 fresh.set_pid(pid);
6037 for (name, value) in self.initial_vars.clone() {
6038 fresh.set_exported(name, value);
6039 }
6040 // The pin travels with the policy it pins — a `reset()` between
6041 // requests that dropped it would hand the next request an
6042 // unpinned session (spec §F.3 item 3).
6043 fresh.set_trash_enabled(trash_enabled);
6044 // Same reasoning as trash: an embedder relying on errexit for a
6045 // gating decision must not find it quietly disabled after a
6046 // `reset()` between requests.
6047 fresh.set_error_exit(errexit_enabled);
6048 // `reset()` puts the session back at `/`, so `$PWD` says so.
6049 // `initial_vars` can carry an inherited `PWD` from the invoking
6050 // environment, which would otherwise survive the reset and name a
6051 // directory this session is no longer in. `$OLDPWD` goes for the
6052 // same reason a fresh kernel has none: there is no previous
6053 // directory, and `cd -` refuses.
6054 fresh.set_global("PWD", Value::String("/".to_string()));
6055 fresh.remove("OLDPWD");
6056 *scope = fresh;
6057 }
6058 {
6059 let mut ctx = self.exec_ctx.write().await;
6060 ctx.cwd = PathBuf::from("/");
6061 ctx.prev_cwd = None;
6062 }
6063 Ok(())
6064 }
6065
6066 /// Trip the cancellation token of every tracked background job (`&`) —
6067 /// whether or not `shutdown` follows.
6068 ///
6069 /// This is the same lever `kill %N` uses: a *running* job's in-process
6070 /// future exits at its next checkpoint, and any external children it
6071 /// spawned get the SIGTERM→SIGKILL cascade; it then stays tracked with
6072 /// status `Killed` once it unwinds. For an already-finished job the
6073 /// token trip is a no-op — its future has already resolved and the job
6074 /// keeps reporting its terminal status. This only
6075 /// *starts* cancellation, it does not wait (pair with
6076 /// [`JobManager::wait`]/`wait_all` if the caller needs to block on the
6077 /// unwind, bounded as [`Self::shutdown`] does).
6078 ///
6079 /// A job registered by an embedder via [`JobManager::register`] with no
6080 /// cancel token attached has no lever to cancel — silently skipped here,
6081 /// same as `kill %N`'s own "no cancellation token" case.
6082 ///
6083 /// Returns how many jobs a token was actually tripped for.
6084 pub async fn cancel_all_jobs(&self) -> usize {
6085 let ids = self.jobs.list_ids().await;
6086 let mut cancelled = 0;
6087 for id in ids {
6088 if self.jobs.mark_killed_and_cancel(id, false).await {
6089 cancelled += 1;
6090 }
6091 }
6092 cancelled
6093 }
6094
6095 /// Shut down the kernel.
6096 ///
6097 /// Cancels every tracked background job ([`Self::cancel_all_jobs`]), then
6098 /// waits up to `kill_grace + 3s` **per job** — the same bound `kill %N`
6099 /// gives a single target (GH #244) — for it to actually unwind. The
6100 /// waits are sequential, so the worst case is additive: N jobs that all
6101 /// ignore cancellation block shutdown for N × (kill_grace + 3s). Jobs
6102 /// that unwind promptly (the normal case) cost only their own unwind
6103 /// time. Before this fix `shutdown` called `wait_all()` with no timeout
6104 /// at all: `sleep 3600 &` then `shutdown()` blocked for an hour (GH #245).
6105 ///
6106 /// A job that has not unwound by its deadline is abandoned: logged via
6107 /// `tracing::warn!` and left running detached until the tokio runtime
6108 /// itself goes away. There is no further lever once `shutdown()` has
6109 /// returned — this method does not hang, but it also does not guarantee
6110 /// every job actually stopped.
6111 ///
6112 /// Takes `&self`, not owned `self` — an embedder holding `Arc<Kernel>`
6113 /// (e.g. `kaish-client`'s `EmbeddedClient`) can call this without
6114 /// `Arc::try_unwrap`, since the work here only touches the shared
6115 /// `Arc<JobManager>`, never kernel state that would need exclusive
6116 /// ownership.
6117 pub async fn shutdown(&self) -> Result<()> {
6118 let ids = self.jobs.list_ids().await;
6119 self.cancel_all_jobs().await;
6120
6121 let bound = self.jobs.kill_grace() + Duration::from_secs(3);
6122 for id in ids {
6123 if tokio::time::timeout(bound, self.jobs.wait(id)).await.is_err() {
6124 tracing::warn!(
6125 job_id = %id,
6126 bound_secs = bound.as_secs_f64(),
6127 "kernel shutdown: job did not exit within the grace period after \
6128 cancellation — abandoning it"
6129 );
6130 }
6131 }
6132 Ok(())
6133 }
6134
6135 /// Run a compound statement that occupies a pipeline stage.
6136 ///
6137 /// Same ctx↔exec_ctx sync as `dispatch_command`, with one deliberate
6138 /// difference: the stage's pipe writer stays behind with the runner. The
6139 /// statement buffers — its whole output comes back in the `ExecResult` and
6140 /// the runner writes it to the pipe once. Handing the writer down instead
6141 /// would give it to whichever nested command grabbed the slot first, and
6142 /// every later iteration would write nowhere.
6143 ///
6144 /// Streaming a stage would mean threading a writer through nested
6145 /// statement execution, which is the shared-slot machinery GH #369 is
6146 /// about. Revisit once the interpreter takes a ctx parameter.
6147 async fn dispatch_statement(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
6148 if let Some(d) = self.dispatcher() {
6149 ctx.dispatcher = Some(d);
6150 }
6151
6152 // 1. Sync ctx → self internals
6153 {
6154 let mut scope = self.scope.write().await;
6155 *scope = ctx.scope.clone();
6156 }
6157 {
6158 let mut ec = self.exec_ctx.write().await;
6159 ec.cwd = ctx.cwd.clone();
6160 ec.prev_cwd = ctx.prev_cwd.clone();
6161 ec.stdin = ctx.stdin.take();
6162 ec.stdin_data = ctx.stdin_data.take();
6163 ec.stdin_data_rx = ctx.stdin_data_rx.take();
6164 ec.pipe_stdin = ctx.pipe_stdin.take();
6165 // The writer is NOT handed over — see this function's doc comment.
6166 // Clearing the slot keeps a writer left by an earlier dispatch from
6167 // catching the first command inside the loop body.
6168 ec.pipe_stdout = None;
6169 if let Some(stderr) = ctx.stderr.clone() {
6170 ec.stderr = Some(stderr);
6171 }
6172 ec.aliases = ctx.aliases.clone();
6173 ec.ignore_config = ctx.ignore_config.clone();
6174 ec.output_limit = ctx.output_limit.clone();
6175 ec.pipeline_position = ctx.pipeline_position;
6176 ec.cancel = ctx.cancel.clone();
6177 ec.watchdog = ctx.watchdog.clone();
6178 }
6179
6180 // 2. Run the statement. A stage is its own execution unit, so a
6181 // `break`, `continue`, `return`, or `exit` that reaches the top of the
6182 // statement stops here rather than escaping into the enclosing script —
6183 // the same boundary bash draws by running each stage in a subshell.
6184 // Whatever output the statement produced before the signal still comes
6185 // back and still reaches the pipe.
6186 let result = match self.execute_stmt_flow(stmt).await? {
6187 ControlFlow::Normal(result)
6188 | ControlFlow::Break { result, .. }
6189 | ControlFlow::Continue { result, .. }
6190 | ControlFlow::Return { value: result } => result,
6191 ControlFlow::Exit { code, mut result } => {
6192 result.code = code;
6193 result
6194 }
6195 };
6196
6197 // 3. Sync self → ctx
6198 {
6199 let scope = self.scope.read().await;
6200 ctx.scope = scope.clone();
6201 }
6202 {
6203 let mut ec = self.exec_ctx.write().await;
6204 ctx.cwd = ec.cwd.clone();
6205 ctx.prev_cwd = ec.prev_cwd.clone();
6206 ctx.aliases = ec.aliases.clone();
6207 ctx.ignore_config = ec.ignore_config.clone();
6208 ctx.output_limit = ec.output_limit.clone();
6209 ctx.pipe_stdin = ec.pipe_stdin.take();
6210 ctx.stdin = ec.stdin.take();
6211 ctx.stdin_data = ec.stdin_data.take();
6212 ctx.stdin_data_rx = ec.stdin_data_rx.take();
6213 }
6214
6215 Ok(result)
6216 }
6217
6218 /// Dispatch a single command using the full resolution chain.
6219 ///
6220 /// This is the core of `CommandDispatcher` — it syncs state between the
6221 /// passed-in `ExecContext` and kernel-internal state (scope, exec_ctx),
6222 /// then delegates to `execute_command` for the actual dispatch.
6223 ///
6224 /// State flow:
6225 /// 1. ctx → self: sync scope, cwd, stdin so internal methods see current state
6226 /// 2. execute_command: full dispatch chain (user tools, builtins, scripts, external, backend)
6227 /// 3. self → ctx: sync scope, cwd changes back so the pipeline runner sees them
6228 async fn dispatch_command(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
6229 // Ensure nested dispatch (e.g. the `timeout` builtin re-dispatching
6230 // its inner command via ctx.dispatcher) routes through THIS kernel,
6231 // not a stale parent. Critical for forks: the fork's builtins must
6232 // use the fork's dispatcher, not the parent's.
6233 if let Some(d) = self.dispatcher() {
6234 ctx.dispatcher = Some(d);
6235 }
6236
6237 // 1. Sync ctx → self internals
6238 {
6239 let mut scope = self.scope.write().await;
6240 *scope = ctx.scope.clone();
6241 }
6242 {
6243 let mut ec = self.exec_ctx.write().await;
6244 ec.cwd = ctx.cwd.clone();
6245 ec.prev_cwd = ctx.prev_cwd.clone();
6246 ec.stdin = ctx.stdin.take();
6247 ec.stdin_data = ctx.stdin_data.take();
6248 // The structured-data sideband receiver (set by the concurrent
6249 // pipeline runner on the stage ctx) must reach the tool's snapshot
6250 // too — same reason as the pipe endpoints below. Without this a
6251 // pipeline consumer never sees the producer's `.data`.
6252 ec.stdin_data_rx = ctx.stdin_data_rx.take();
6253 // Streaming pipe endpoints and kernel stderr must flow to the
6254 // tool via self.exec_ctx — execute_command reads that, not the
6255 // passed-in ctx. Without moving these, concurrent pipeline
6256 // stages dispatched via a fork get pipe_stdin = None and
6257 // silently read nothing.
6258 ec.pipe_stdin = ctx.pipe_stdin.take();
6259 ec.pipe_stdout = ctx.pipe_stdout.take();
6260 if let Some(stderr) = ctx.stderr.clone() {
6261 ec.stderr = Some(stderr);
6262 }
6263 ec.aliases = ctx.aliases.clone();
6264 ec.ignore_config = ctx.ignore_config.clone();
6265 ec.output_limit = ctx.output_limit.clone();
6266 ec.pipeline_position = ctx.pipeline_position;
6267 // Sync the cancel token from ctx → ec. Builtins like `timeout`
6268 // swap ctx.cancel to a derived child token before re-dispatching;
6269 // execute_command's snapshot reads ec.cancel (kept aligned by
6270 // this sync), so try_execute_external sees the right token.
6271 ec.cancel = ctx.cancel.clone();
6272 // Same alignment for the watchdog: a fork dispatching through its
6273 // own kernel must hand the shared script clock to the snapshot so
6274 // patient holds in forked stages suspend the right timer.
6275 ec.watchdog = ctx.watchdog.clone();
6276 }
6277
6278 // 2. Execute via the full dispatch chain
6279 let result = self.execute_command(&cmd.name, &cmd.args).await?;
6280
6281 // 3. Sync self → ctx
6282 {
6283 let scope = self.scope.read().await;
6284 ctx.scope = scope.clone();
6285 }
6286 {
6287 let mut ec = self.exec_ctx.write().await;
6288 ctx.cwd = ec.cwd.clone();
6289 ctx.prev_cwd = ec.prev_cwd.clone();
6290 ctx.aliases = ec.aliases.clone();
6291 ctx.ignore_config = ec.ignore_config.clone();
6292 ctx.output_limit = ec.output_limit.clone();
6293 // Return any pipe endpoints that the tool didn't consume.
6294 // `take()` here keeps the fork's exec_ctx in a clean state for
6295 // the next dispatch — these are per-command and shouldn't leak
6296 // between calls.
6297 ctx.pipe_stdin = ec.pipe_stdin.take();
6298 ctx.pipe_stdout = ec.pipe_stdout.take();
6299 // Unconsumed buffered stdin comes back the same way, and for a
6300 // sharper reason than symmetry: a partial read (`read` takes one
6301 // line) leaves its remainder in `ec`, and the caller's own
6302 // end-of-statement sync writes `ctx.stdin` back over `ec.stdin`.
6303 // Without this the caller writes its stale `None` over the
6304 // remainder and the rest of the stream is gone.
6305 ctx.stdin = ec.stdin.take();
6306 // The sideband rides home with stdin, same rule.
6307 ctx.stdin_data = ec.stdin_data.take();
6308 ctx.stdin_data_rx = ec.stdin_data_rx.take();
6309 // Same take-don't-clone discipline as stdin, and for the same
6310 // reason: these belong to exactly one dispatch, and a copy left
6311 // behind would let the next command adopt it.
6312 }
6313
6314 Ok(result)
6315 }
6316}
6317
6318/// Evaluates a single AST expression on behalf of [`bind_tool_args`], the one
6319/// shared arg-binding core behind both `Kernel::build_args_async`
6320/// (production: full recursion through the async pipeline, command
6321/// substitution, real glob expansion) and the reduced sync evaluator behind
6322/// scatter/gather's own option parsing and the `#[cfg(test)]`
6323/// `BackendDispatcher` (`scheduler::pipeline::build_tool_args`'s
6324/// `SyncEvalSource`). GH #188 closes the drift class between those two
6325/// callers: the flag/positional-binding logic (this file's `bind_tool_args`)
6326/// is now the ONLY implementation; only expression evaluation, which is
6327/// capability-bound (recursing into command substitution needs a live async
6328/// pipeline the reduced context doesn't have), still has two providers.
6329#[async_trait]
6330pub(crate) trait ArgValueSource: Send + Sync {
6331 /// Evaluate `expr` to a `Value`. `Ok(None)` means "not representable by
6332 /// this evaluator" — the reduced sync evaluator's bash-compatible
6333 /// "coalesce" convention for an unset bare variable, or an expression
6334 /// form it doesn't support (a binary op) — and the caller drops the
6335 /// argument the same way an unset bare variable always has. The real
6336 /// (Kernel) evaluator never returns `Ok(None)`: it can always fully
6337 /// evaluate.
6338 async fn eval(&self, expr: &Expr) -> Result<Option<Value>>;
6339
6340 /// Expand a bare glob-pattern positional to display strings, or `None`
6341 /// if this evaluator doesn't expand globs here (disabled, or the reduced
6342 /// sync context, which never has — matching its documented "no
6343 /// filesystem walk before worker forks" limit). `bind_tool_args` falls
6344 /// back to `eval` (which hands back the pattern text as a literal
6345 /// string) when this returns `None`. An enabled expansion that matches
6346 /// nothing is a genuine error, not `Ok(None)`.
6347 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>>;
6348
6349 /// Session `HOME`, for tilde expansion. `None` disables tilde expansion
6350 /// — the reduced sync evaluator's existing behavior (it never expanded
6351 /// `~`).
6352 async fn home(&self) -> Option<String>;
6353}
6354
6355#[async_trait]
6356impl ArgValueSource for Kernel {
6357 async fn eval(&self, expr: &Expr) -> Result<Option<Value>> {
6358 Ok(Some(self.eval_expr_async(expr).await?))
6359 }
6360
6361 async fn expand_glob(&self, pattern: &str) -> Result<Option<Vec<String>>> {
6362 let glob_enabled = self.scope.read().await.glob_enabled();
6363 if !glob_enabled {
6364 return Ok(None);
6365 }
6366 let (paths, cwd) = {
6367 let ctx = self.exec_ctx.read().await;
6368 let paths = ctx
6369 .expand_glob(pattern)
6370 .await
6371 .map_err(|e| anyhow::anyhow!("glob: {}", e))?;
6372 let cwd = ctx.resolve_path(".");
6373 (paths, cwd)
6374 };
6375 if paths.is_empty() {
6376 anyhow::bail!("no matches: {}", pattern);
6377 }
6378 let display = paths
6379 .into_iter()
6380 .map(|path| {
6381 if !pattern.starts_with('/') {
6382 path.strip_prefix(&cwd)
6383 .unwrap_or(&path)
6384 .to_string_lossy()
6385 .into_owned()
6386 } else {
6387 path.to_string_lossy().into_owned()
6388 }
6389 })
6390 .collect();
6391 Ok(Some(display))
6392 }
6393
6394 async fn home(&self) -> Option<String> {
6395 self.scope_home().await
6396 }
6397}
6398
6399/// Pull `consumes` positional args after a non-bool flag and stash them on
6400/// `tool_args.named` under the canonical param name. Shared core behind
6401/// [`bind_tool_args`]'s `ShortFlag`/`LongFlag` value-flag arms — see that
6402/// function's doc comment for the unification story (GH #188).
6403///
6404/// - `consumes == 1` (non-repeatable) keeps the historical contract: a
6405/// single scalar value (last write wins on the rare duplicate).
6406/// - `consumes == 1` + `repeatable` accumulates each occurrence as a scalar
6407/// inside `named[canonical] = Value::Json(Array(...))`, preserving
6408/// invocation order. This is the shape sed's `-e EXPR -e EXPR` lands in —
6409/// a repeated single-value flag must keep every value, not silently drop
6410/// all but the last (a "no silent corruption" violation).
6411/// - `consumes > 1` accumulates each occurrence as an inner
6412/// `serde_json::Value::Array` inside `named[canonical] =
6413/// Value::Json(Array(...))`, preserving invocation order. This is the
6414/// shape jq's `--arg NAME VAL` / `--argjson NAME VAL` land in.
6415///
6416/// Errors loudly if the flag is missing required positionals — matches
6417/// kaish's "no silent fallback" posture and mirrors real jq, which errors on
6418/// `--arg NAME` with no value. A reduced evaluator's `Ok(None)` (a value it
6419/// can't represent — Kernel's evaluator never returns this) falls back to a
6420/// bare flag on the FIRST occurrence, matching the pre-#188 sync twin's
6421/// unset-bare-var "coalesce" convention; mid-accumulation it's a genuine
6422/// error rather than a silently-partial array.
6423#[allow(clippy::too_many_arguments)]
6424async fn consume_flag_positionals(
6425 source: &dyn ArgValueSource,
6426 home: Option<&str>,
6427 args: &[Arg],
6428 flag_name: &str,
6429 canonical: &str,
6430 consumes: usize,
6431 repeatable: bool,
6432 positional_indices: &[usize],
6433 consumed: &mut std::collections::HashSet<usize>,
6434 current_idx: usize,
6435 tool_args: &mut ToolArgs,
6436) -> Result<()> {
6437 let mut collected: Vec<Value> = Vec::with_capacity(consumes.max(1));
6438 for _ in 0..consumes.max(1) {
6439 // A `key=value` (WordAssign) token is consumable only by a
6440 // single-value flag (`-v a=1`). For a multi-value flag (`jq --arg
6441 // NAME VAL`, consumes>1) it is NOT eligible — otherwise `--arg x=1
6442 // filter` would reassemble `x=1` into the first slot and steal the
6443 // filter into the second. Multi-value flags take plain positionals.
6444 let allow_word_assign = consumes <= 1;
6445 let next_pos = positional_indices
6446 .iter()
6447 .find(|idx| {
6448 **idx > current_idx
6449 && !consumed.contains(idx)
6450 && (allow_word_assign || matches!(args[**idx], Arg::Positional(_)))
6451 })
6452 .copied();
6453 match next_pos {
6454 Some(pos_idx) => match &args[pos_idx] {
6455 Arg::Positional(expr) => match source.eval(expr).await? {
6456 Some(value) => {
6457 let value = apply_tilde_expansion(value, home);
6458 collected.push(value);
6459 consumed.insert(pos_idx);
6460 }
6461 None if collected.is_empty() => {
6462 tool_args.flags.insert(flag_name.to_string());
6463 return Ok(());
6464 }
6465 None => anyhow::bail!(
6466 "--{flag_name}: could not evaluate argument {} in this context",
6467 collected.len() + 1
6468 ),
6469 },
6470 // `-v a=1`: reassemble the `key=value` token as the flag's
6471 // scalar value (see `positional_indices` construction).
6472 Arg::WordAssign { key, value } => match source.eval(value).await? {
6473 Some(val) => {
6474 let val = apply_tilde_expansion(val, home);
6475 // Loud on binary (GH #116): `-v a=$BIN` must not silently
6476 // reassemble the `[binary: N bytes]` placeholder into the
6477 // flag's value — same text-sink boundary as the primary
6478 // sinks fixed in #93 item 1.
6479 let val_str = crate::interpreter::value_to_text_sink_named(
6480 &val,
6481 "a key=value argument",
6482 )
6483 .map_err(|e| anyhow::anyhow!("{e}"))?;
6484 collected.push(Value::String(format!("{key}={val_str}")));
6485 consumed.insert(pos_idx);
6486 }
6487 None if collected.is_empty() => {
6488 tool_args.flags.insert(flag_name.to_string());
6489 return Ok(());
6490 }
6491 None => anyhow::bail!(
6492 "--{flag_name}: could not evaluate argument {} in this context",
6493 collected.len() + 1
6494 ),
6495 },
6496 _ => {}
6497 },
6498 None => {
6499 if consumes <= 1 && collected.is_empty() {
6500 // Back-compat: a flag with no follow-up positional
6501 // becomes a bare flag. `--path` with nothing after
6502 // lands in `flags`, same as before this refactor.
6503 tool_args.flags.insert(flag_name.to_string());
6504 return Ok(());
6505 }
6506 anyhow::bail!(
6507 "--{flag_name} requires {consumes} argument{}, got {}",
6508 if consumes == 1 { "" } else { "s" },
6509 collected.len()
6510 );
6511 }
6512 }
6513 }
6514
6515 if consumes <= 1 {
6516 if let Some(v) = collected.pop() {
6517 if repeatable {
6518 push_repeatable_value(tool_args, flag_name, canonical, v)?;
6519 } else {
6520 tool_args.named.insert(canonical.to_string(), v);
6521 }
6522 }
6523 return Ok(());
6524 }
6525
6526 // Multi-consume: accumulate under named[canonical] as array-of-arrays.
6527 let occ: Vec<serde_json::Value> = collected
6528 .iter()
6529 .map(|v| flag_value_to_json(canonical, v))
6530 .collect::<Result<Vec<_>>>()?;
6531 let entry = tool_args
6532 .named
6533 .entry(canonical.to_string())
6534 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
6535 if let Value::Json(serde_json::Value::Array(outer)) = entry {
6536 outer.push(serde_json::Value::Array(occ));
6537 } else {
6538 anyhow::bail!(
6539 "--{flag_name}: named[{canonical}] already holds a non-array value"
6540 );
6541 }
6542 Ok(())
6543}
6544
6545/// Build `ToolArgs` from AST `Arg`s — the single arg-binding implementation
6546/// (GH #188) shared by `Kernel::build_args_async` (production) and the
6547/// reduced sync path (`scheduler::pipeline::build_tool_args`, used by
6548/// scatter/gather's own option parsing and the `#[cfg(test)]`
6549/// `BackendDispatcher`). The two differ only in the [`ArgValueSource`] they
6550/// pass: Kernel's evaluates full expressions (including `$(...)` command
6551/// substitution) and expands real globs/tilde; the reduced one can't recurse
6552/// into the async pipeline this early (scatter/gather's own flags bind
6553/// before any worker forks) so it evaluates a smaller expression subset and
6554/// never expands globs/tilde — see `SyncEvalSource` in `scheduler::pipeline`.
6555///
6556/// If a schema is provided, uses it to determine argument types:
6557/// - For `--flag` where schema says type is non-bool: consume next
6558/// positional(s) as value(s) (`consumes`/`repeatable`-aware).
6559/// - For `--flag` where schema says type is bool (or unknown): treat as a
6560/// boolean flag.
6561///
6562/// This enables natural shell syntax like `mcp_tool --query "test" --limit 10`.
6563pub(crate) async fn bind_tool_args(
6564 args: &[Arg],
6565 schema: Option<&crate::tools::ToolSchema>,
6566 source: &dyn ArgValueSource,
6567) -> Result<ToolArgs> {
6568 let mut tool_args = ToolArgs::new();
6569 let home = source.home().await;
6570
6571 // A glob-passthrough tool (`glob`) consumes patterns as data: skip
6572 // argv glob expansion so the pattern reaches the tool as written —
6573 // otherwise `glob **/*.rs` binds the first *matching path* as its
6574 // pattern. The eval fallback turns `Expr::GlobPattern` into its
6575 // literal string.
6576 let glob_passthrough = schema.is_some_and(|s| s.glob_passthrough);
6577
6578 // Verbatim: the tool owns its grammar, so it gets every word in source
6579 // order and nothing in `positional`/`named`. The typed split below is
6580 // set-shaped and drops the order and multiplicity a clap subcommand tree
6581 // needs, which no inversion can recover.
6582 //
6583 // `--json` is still the kernel's, so it is lifted into `flags` wherever it
6584 // sits and `apply_from_args` handles it as it does for a typed tool —
6585 // unless the tool owns its output, in which case the kernel renders
6586 // nothing and the flag has to reach the tool's own argv instead. Lifting it
6587 // there would strip it from the words AND skip rendering, so asking for
6588 // JSON would do nothing at all.
6589 if schema.is_some_and(|s| matches!(s.arg_binding, crate::tools::ArgBinding::Verbatim)) {
6590 let lift_global_flags = !schema.is_some_and(|s| s.owns_output);
6591 let mut words: Vec<Value> = Vec::new();
6592 let mut past_double_dash = false;
6593 for arg in args {
6594 match arg {
6595 Arg::Positional(expr) => {
6596 let glob = if let Expr::GlobPattern(p) = expr {
6597 (!glob_passthrough).then(|| p.clone())
6598 } else {
6599 None
6600 };
6601 let expanded = match &glob {
6602 Some(pattern) => source.expand_glob(pattern).await?,
6603 None => None,
6604 };
6605 match expanded {
6606 Some(paths) => {
6607 for path in paths {
6608 words.push(Value::String(path));
6609 }
6610 }
6611 None => {
6612 // Nothing evaluated means no word, matching the
6613 // typed path's `if let Some(value)`.
6614 if let Some(value) = source.eval(expr).await? {
6615 words.push(apply_tilde_expansion(value, home.as_deref()));
6616 }
6617 }
6618 }
6619 }
6620 Arg::ShortFlag(name) => words.push(Value::String(format!("-{name}"))),
6621 Arg::LongFlag(name) => {
6622 if lift_global_flags
6623 && !past_double_dash
6624 && crate::tools::is_global_output_flag(name)
6625 {
6626 tool_args.flags.insert(name.clone());
6627 continue;
6628 }
6629 words.push(Value::String(format!("--{name}")));
6630 }
6631 Arg::Named { key, value } => {
6632 let val = source.eval(value).await?.ok_or_else(|| {
6633 anyhow::anyhow!("verbatim --key=value could not be evaluated in this context")
6634 })?;
6635 let val = apply_tilde_expansion(val, home.as_deref());
6636 if lift_global_flags
6637 && !past_double_dash
6638 && crate::tools::is_global_output_flag(key)
6639 {
6640 // Removed from the words whether or not it is on: the
6641 // tool must not meet the kernel's flag in any form.
6642 if global_flag_value_is_truthy(&val) {
6643 tool_args.flags.insert(key.clone());
6644 }
6645 continue;
6646 }
6647 // Loud on binary (GH #116): reassembling `--k=$BIN` as text
6648 // hands the tool a placeholder that looks like data. A bare
6649 // binary word is fine — it stays typed.
6650 let val_str = crate::interpreter::value_to_text_sink_named(
6651 &val,
6652 "a --key=value argument",
6653 )
6654 .map_err(|e| anyhow::anyhow!("{e}"))?;
6655 words.push(Value::String(format!("--{key}={val_str}")));
6656 }
6657 Arg::WordAssign { key, value } => {
6658 let val = source.eval(value).await?.ok_or_else(|| {
6659 anyhow::anyhow!("verbatim key=value could not be evaluated in this context")
6660 })?;
6661 let val = apply_tilde_expansion(val, home.as_deref());
6662 let val_str = crate::interpreter::value_to_text_sink_named(
6663 &val,
6664 "a key=value argument",
6665 )
6666 .map_err(|e| anyhow::anyhow!("{e}"))?;
6667 words.push(Value::String(format!("{key}={val_str}")));
6668 }
6669 Arg::DoubleDash => {
6670 past_double_dash = true;
6671 words.push(Value::String("--".to_string()));
6672 }
6673 }
6674 }
6675 tool_args.words = Some(words);
6676 return Ok(tool_args);
6677 }
6678
6679 // Raw-argv fast path (POSIX `test`): bind every argument to `positional`
6680 // in source order with types preserved — operators (`-f`, `=`, `!`) as
6681 // strings, operands keeping their `Value` — leaving `flags`/`named`
6682 // empty. A position-sensitive command needs the *true* argv: an operand
6683 // that looks like a flag (`test $x = -n`, `test 0 -gt -5`) must not be
6684 // hoisted into the unordered flag set the normal binder splits into.
6685 // Globs still expand and `~` still resolves, matching normal positional
6686 // binding — so `test -f *.rs` errors on too many args, not a literal
6687 // pattern stat.
6688 if schema.is_some_and(|s| s.raw_argv) {
6689 for arg in args {
6690 match arg {
6691 Arg::Positional(expr) => {
6692 let glob = if let Expr::GlobPattern(p) = expr {
6693 (!glob_passthrough).then(|| p.clone())
6694 } else {
6695 None
6696 };
6697 if let Some(pattern) = glob {
6698 match source.expand_glob(&pattern).await? {
6699 Some(paths) => {
6700 for path in paths {
6701 tool_args.positional.push(Value::String(path));
6702 }
6703 }
6704 None => {
6705 let value = source.eval(expr).await?.ok_or_else(|| {
6706 anyhow::anyhow!(
6707 "raw-argv positional could not be evaluated in this context"
6708 )
6709 })?;
6710 let value = apply_tilde_expansion(value, home.as_deref());
6711 tool_args.positional.push(value);
6712 }
6713 }
6714 } else {
6715 let value = source.eval(expr).await?.ok_or_else(|| {
6716 anyhow::anyhow!(
6717 "raw-argv positional could not be evaluated in this context"
6718 )
6719 })?;
6720 let value = apply_tilde_expansion(value, home.as_deref());
6721 tool_args.positional.push(value);
6722 }
6723 }
6724 Arg::ShortFlag(name) => {
6725 tool_args.positional.push(Value::String(format!("-{name}")));
6726 }
6727 Arg::LongFlag(name) => {
6728 tool_args.positional.push(Value::String(format!("--{name}")));
6729 }
6730 Arg::Named { key, value } => {
6731 let val = source.eval(value).await?.ok_or_else(|| {
6732 anyhow::anyhow!("raw-argv --key=value could not be evaluated in this context")
6733 })?;
6734 let val = apply_tilde_expansion(val, home.as_deref());
6735 // Loud on binary (GH #116): `test --k=$BIN` must not
6736 // silently reassemble the placeholder into the raw-argv
6737 // positional stream `test` binds against.
6738 let val_str = crate::interpreter::value_to_text_sink_named(
6739 &val,
6740 "a --key=value argument",
6741 )
6742 .map_err(|e| anyhow::anyhow!("{e}"))?;
6743 tool_args
6744 .positional
6745 .push(Value::String(format!("--{key}={val_str}")));
6746 }
6747 Arg::WordAssign { key, value } => {
6748 let val = source.eval(value).await?.ok_or_else(|| {
6749 anyhow::anyhow!("raw-argv key=value could not be evaluated in this context")
6750 })?;
6751 let val = apply_tilde_expansion(val, home.as_deref());
6752 // Loud on binary (GH #116): same reasoning as the Named
6753 // arm above, for the bare `key=value` raw-argv form.
6754 let val_str = crate::interpreter::value_to_text_sink_named(
6755 &val,
6756 "a key=value argument",
6757 )
6758 .map_err(|e| anyhow::anyhow!("{e}"))?;
6759 tool_args
6760 .positional
6761 .push(Value::String(format!("{key}={val_str}")));
6762 }
6763 Arg::DoubleDash => {
6764 tool_args.positional.push(Value::String("--".to_string()));
6765 }
6766 }
6767 }
6768 return Ok(tool_args);
6769 }
6770
6771 // Subcommand-aware tools (e.g. `kj context list`) expose a tree of
6772 // schemas; pick the leaf the leading positionals route to and bind
6773 // flags against *its* params. Flat tools return the root. select_leaf
6774 // errors (fail loud) if a computed positional sits where a subcommand
6775 // selector is required.
6776 let leaf = match schema {
6777 Some(s) => Some(select_leaf(s, args)?),
6778 None => None,
6779 };
6780 // Bind against the leaf's params, but MERGE the root schema's params on
6781 // top as "global" flags: a value-flag declared at the tool's top level
6782 // (e.g. kj's `--confirm <token>`) must bind at every leaf, including when
6783 // it trails the subcommand path (`kj context retag a b --confirm <n>`).
6784 // The leaf wins on name conflicts. For a flat tool, leaf == root, so the
6785 // merge is a harmless no-op.
6786 let mut param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
6787 if let Some(l) = leaf {
6788 param_lookup.extend(schema_param_lookup(l));
6789 }
6790 // accepts_word_assign keys off the root tool name (the WORD_ASSIGN list),
6791 // not the leaf — it's a property of the command, not the subcommand.
6792 let accepts_word_assign = schema
6793 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
6794 .unwrap_or(false);
6795
6796 // Track which positional indices have been consumed as flag values
6797 let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
6798 let mut past_double_dash = false;
6799
6800 // Indices a value-flag may consume as its value. Positionals always
6801 // qualify. A `WordAssign` (`a=1`) also qualifies when the tool does not
6802 // itself treat `key=value` as an assignment (everything but
6803 // export/alias/unalias) — getopt semantics: `awk -v a=1` binds `a=1` to
6804 // `-v`, rather than skipping it and grabbing the next positional (the
6805 // program). Without this, the natural `-F`/`-v NAME=VALUE` form silently
6806 // mis-binds. The main-loop `WordAssign` arm skips consumed indices.
6807 let positional_indices: Vec<usize> = args
6808 .iter()
6809 .enumerate()
6810 .filter_map(|(i, a)| {
6811 let consumable = matches!(a, Arg::Positional(_))
6812 || (!accepts_word_assign && matches!(a, Arg::WordAssign { .. }));
6813 consumable.then_some(i)
6814 })
6815 .collect();
6816
6817 let mut i = 0;
6818 while i < args.len() {
6819 match &args[i] {
6820 Arg::DoubleDash => {
6821 past_double_dash = true;
6822 }
6823 Arg::Positional(expr) => {
6824 if !consumed.contains(&i) {
6825 // Glob expansion: bare glob patterns expand to matching files
6826 if let Expr::GlobPattern(pattern) = expr {
6827 if !glob_passthrough {
6828 if let Some(paths) = source.expand_glob(pattern).await? {
6829 for path in paths {
6830 tool_args.positional.push(Value::String(path));
6831 }
6832 i += 1;
6833 continue;
6834 }
6835 }
6836 }
6837 if let Some(value) = source.eval(expr).await? {
6838 let value = apply_tilde_expansion(value, home.as_deref());
6839 tool_args.positional.push(value);
6840 }
6841 }
6842 }
6843 Arg::Named { key, value } => {
6844 if let Some(val) = source.eval(value).await? {
6845 let val = apply_tilde_expansion(val, home.as_deref());
6846 // Past `--` this is data, not a flag: one operand spelled
6847 // `--key=value`, the same collapse the `WordAssign` arm
6848 // below does for `A=1` (GH #189). The value still expands.
6849 if past_double_dash {
6850 let val_str = crate::interpreter::value_to_text_sink_named(
6851 &val,
6852 "a --key=value operand after `--`",
6853 )
6854 .map_err(|e| anyhow::anyhow!("{e}"))?;
6855 tool_args
6856 .positional
6857 .push(Value::String(format!("--{key}={val_str}")));
6858 i += 1;
6859 continue;
6860 }
6861 // The kernel's own `--json=VALUE`, decided here and never
6862 // placed in `named`. Left there it reaches the builtin's
6863 // clap parser as a value on a `bool` field, whose `SetTrue`
6864 // action rejects every spelling but `true`/`false` — so
6865 // `seq --json=1` exited 2 while the raw-argv and verbatim
6866 // binders were quietly accepting the same word. One rule,
6867 // asked in one place, for all three.
6868 if !past_double_dash && crate::tools::is_global_output_flag(key) {
6869 if global_flag_value_is_truthy(&val) {
6870 tool_args.flags.insert(key.clone());
6871 }
6872 i += 1;
6873 continue;
6874 }
6875 // A repeatable flag in `--flag=value` form must accumulate too,
6876 // not overwrite — otherwise `--expression=A --expression=B`
6877 // would silently keep only B, and mixing with the `-e` space
6878 // form would clobber the array. Route it through the same
6879 // accumulator the space form uses.
6880 let is_declared_value_flag = param_lookup
6881 .get(key.as_str())
6882 .is_some_and(|(_, typ, ..)| !is_bool_type(typ));
6883 if let Some(&(canonical, _, _, true)) = param_lookup.get(key.as_str()) {
6884 push_repeatable_value(&mut tool_args, key, canonical, val)?;
6885 } else if matches!(val, Value::Bool(_)) && !is_declared_value_flag {
6886 // Flagify at bind time (GH #189): `--flag=true`/
6887 // `--flag=false` binds the same way the bare
6888 // `--flag`/its absence already do (true → flag
6889 // presence, false → dropped) instead of landing in
6890 // `named` as a literal `Value::Bool` that a clap
6891 // `bool` field's `SetTrue` action rejects
6892 // (`seq --json=true` used to exit 2 with a clap
6893 // parse error). Covers both a schema-declared bool
6894 // param AND an undeclared flag — `--json` itself is
6895 // deliberately excluded from every builtin's schema
6896 // (`clap_schema::is_skipped`), so this is what makes
6897 // `--json=true` work universally instead of only for
6898 // the builtins that happen to call
6899 // `ToolArgs::flagify_bool_named` themselves. A
6900 // declared VALUE-taking flag's own `=true` literal
6901 // (`spawn --command=true`) is excluded by
6902 // `is_declared_value_flag` and still falls to
6903 // `named` below.
6904 if let Value::Bool(true) = val {
6905 tool_args.flags.insert(key.clone());
6906 }
6907 // Value::Bool(false): absent == false, nothing to insert.
6908 } else {
6909 tool_args.named.insert(key.clone(), val);
6910 }
6911 }
6912 }
6913 Arg::WordAssign { key, value } => {
6914 // Already pulled in as a preceding value-flag's argument
6915 // (`awk -v a=1`); don't also emit it as a positional.
6916 if consumed.contains(&i) {
6917 i += 1;
6918 continue;
6919 }
6920 if let Some(val) = source.eval(value).await? {
6921 let val = apply_tilde_expansion(val, home.as_deref());
6922 // Past `--`, EVERY token is raw data — including for
6923 // export/alias, whose `key=value` is normally a shell
6924 // assignment (GH #189). `export -- A=1` must bind `A=1`
6925 // as a literal positional, not silently re-enter the
6926 // named-assignment path `past_double_dash` exists to
6927 // suppress for flags right above this arm.
6928 if accepts_word_assign && !past_double_dash {
6929 tool_args.named.insert(key.clone(), val);
6930 } else {
6931 // Stringify "key=value" and pass as a positional.
6932 // Matches bash: `cat foo=bar` opens a file named `foo=bar`.
6933 // Loud on binary (GH #116): `cat foo=$BIN`/`dd if=$BIN`
6934 // must not silently become a path/operand literally named
6935 // `foo=[binary: N bytes]`.
6936 let val_str = crate::interpreter::value_to_text_sink_named(
6937 &val,
6938 "a key=value argument",
6939 )
6940 .map_err(|e| anyhow::anyhow!("{e}"))?;
6941 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
6942 }
6943 }
6944 }
6945 Arg::ShortFlag(name) => {
6946 if past_double_dash {
6947 tool_args.positional.push(Value::String(format!("-{name}")));
6948 } else if name.len() == 1 {
6949 let flag_name = name.as_str();
6950 let lookup = param_lookup.get(flag_name);
6951
6952 // Same ambiguity guard as the `LongFlag` arm below (GH
6953 // #189 item 4): an undeclared short flag immediately
6954 // followed by an unconsumed positional under a
6955 // map_positionals (backend/MCP) schema is exactly as
6956 // ambiguous as the long-flag case — kaish can't tell a
6957 // space-form value (`-t explorer`) from a bool flag
6958 // sitting before a real positional (`-f file.txt`).
6959 // Unlike `--flag`, there is no `-f=value` escape hatch to
6960 // suggest: a glued `-f=val` is two tokens with a dangling
6961 // `=` that the parser's no-token-pasting guard already
6962 // rejects — the only fix is declaring the flag.
6963 let ambiguous_value = (lookup.is_none()
6964 && leaf.is_some_and(|s| s.map_positionals)
6965 && !consumed.contains(&(i + 1)))
6966 .then(|| match args.get(i + 1) {
6967 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
6968 Some(s.clone())
6969 }
6970 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
6971 _ => None,
6972 })
6973 .flatten();
6974 if let Some(val) = ambiguous_value {
6975 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
6976 anyhow::bail!(
6977 "{tool}: -{name} is not a declared flag, so the \
6978 space-separated value ({val:?}) would be silently \
6979 dropped. Have {tool} declare -{name} in its schema \
6980 (short flags have no -{name}=value form to fall \
6981 back on)."
6982 );
6983 }
6984
6985 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
6986
6987 if is_bool {
6988 tool_args.flags.insert(flag_name.to_string());
6989 } else {
6990 // Non-bool: consume `consumes` positionals as value(s)
6991 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
6992 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
6993 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
6994 consume_flag_positionals(
6995 source,
6996 home.as_deref(),
6997 args,
6998 name,
6999 canonical,
7000 consumes,
7001 repeatable,
7002 &positional_indices,
7003 &mut consumed,
7004 i,
7005 &mut tool_args,
7006 )
7007 .await?;
7008 }
7009 } else if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name.as_str()) {
7010 // Multi-char short flag matches a schema param (POSIX style: -name value)
7011 if is_bool_type(typ) {
7012 tool_args.flags.insert(canonical.to_string());
7013 } else {
7014 consume_flag_positionals(
7015 source,
7016 home.as_deref(),
7017 args,
7018 name,
7019 canonical,
7020 consumes,
7021 repeatable,
7022 &positional_indices,
7023 &mut consumed,
7024 i,
7025 &mut tool_args,
7026 )
7027 .await?;
7028 }
7029 } else if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
7030 .get(&name[..1])
7031 .filter(|(_, typ, ..)| !is_bool_type(typ))
7032 {
7033 // Glued short-flag value: `cut -f1`, `head -c5`, `cut -f1-3`,
7034 // `grep -A1`, `sed -e1d`. The first char is a declared
7035 // value-taking short flag, so the rest of the token is its
7036 // value — the coreutils idiom. The lexer's flag char class is
7037 // `[a-zA-Z][a-zA-Z0-9-]*`, so the first byte is always ASCII
7038 // (safe to slice) and the tail is a plain literal.
7039 bind_glued_short_value(
7040 &mut tool_args,
7041 &name[..1],
7042 canonical,
7043 consumes,
7044 repeatable,
7045 name[1..].to_string(),
7046 )?;
7047 } else {
7048 // Multi-char combined short flags. Bool flags stack
7049 // (`-la`), but the FIRST value-taking flag reached
7050 // consumes the rest of the token as its glued value
7051 // (`-ivC3` → C=3) or, if it is the last char, the next
7052 // positional (`grep -ivC 3` → C=3). Before this, a
7053 // trailing value-flag was silently treated as a bool,
7054 // stranding its argument as a stray positional (arity
7055 // error). Undeclared/bool chars stay bare flags, so a
7056 // schemaless tool keeps the old all-boolean behavior.
7057 // The first char being value-taking is handled by the
7058 // glued arm above, so it never reaches here. The flag
7059 // char class is ASCII, so byte indexing is char indexing
7060 // (no `Vec<char>` allocation needed).
7061 let bytes = name.as_bytes();
7062 let mut p = 0;
7063 while p < bytes.len() {
7064 let key = &name[p..p + 1];
7065 match param_lookup.get(key) {
7066 Some(&(canonical, typ, consumes, repeatable))
7067 if !is_bool_type(typ) =>
7068 {
7069 let glued = name[p + 1..].to_string();
7070 if glued.is_empty() {
7071 // Value flag is the last char: take the
7072 // next positional. `consume_flag_positionals`
7073 // respects `consumes`.
7074 consume_flag_positionals(
7075 source,
7076 home.as_deref(),
7077 args,
7078 key,
7079 canonical,
7080 consumes,
7081 repeatable,
7082 &positional_indices,
7083 &mut consumed,
7084 i,
7085 &mut tool_args,
7086 )
7087 .await?;
7088 } else {
7089 bind_glued_short_value(
7090 &mut tool_args,
7091 key,
7092 canonical,
7093 consumes,
7094 repeatable,
7095 glued,
7096 )?;
7097 }
7098 break;
7099 }
7100 _ => {
7101 tool_args.flags.insert(key.to_string());
7102 p += 1;
7103 }
7104 }
7105 }
7106 }
7107 }
7108 Arg::LongFlag(name) => {
7109 if past_double_dash {
7110 tool_args.positional.push(Value::String(format!("--{name}")));
7111 } else {
7112 let lookup = param_lookup.get(name.as_str());
7113 // An *undeclared* long flag under a `map_positionals`
7114 // (backend/MCP) schema that is immediately followed by an
7115 // unconsumed positional is ambiguous: kaish can't tell the
7116 // space-form value (`--type explorer`) from a bool flag
7117 // before a real positional (`--force file.txt`). Defaulting
7118 // to bool here silently divorces the value and misroutes it
7119 // — a privilege-escalation-by-typo against deny-by-default
7120 // embedders. Fail loud instead of guessing.
7121 let ambiguous_value = (lookup.is_none()
7122 && leaf.is_some_and(|s| s.map_positionals)
7123 && !consumed.contains(&(i + 1)))
7124 .then(|| match args.get(i + 1) {
7125 // Echo a concrete value for a copy-pasteable fix
7126 // when it's a plain literal; fall back to VALUE.
7127 Some(Arg::Positional(Expr::Literal(Value::String(s)))) => {
7128 Some(s.clone())
7129 }
7130 Some(Arg::Positional(_)) => Some("VALUE".to_string()),
7131 _ => None,
7132 })
7133 .flatten();
7134 if let Some(val) = ambiguous_value {
7135 let tool = leaf.map(|s| s.name.as_str()).unwrap_or("command");
7136 anyhow::bail!(
7137 "{tool}: --{name} is not a declared flag, so the \
7138 space-separated value would be silently dropped. \
7139 Use --{name}={val}, or have {tool} declare --{name} \
7140 in its schema."
7141 );
7142 }
7143 let is_bool = lookup.map(|(_, typ, ..)| is_bool_type(typ)).unwrap_or(true);
7144
7145 if is_bool {
7146 tool_args.flags.insert(name.clone());
7147 } else {
7148 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
7149 let consumes = lookup.map(|(_, _, c, _)| *c).unwrap_or(1);
7150 let repeatable = lookup.map(|(_, _, _, r)| *r).unwrap_or(false);
7151 consume_flag_positionals(
7152 source,
7153 home.as_deref(),
7154 args,
7155 name,
7156 canonical,
7157 consumes,
7158 repeatable,
7159 &positional_indices,
7160 &mut consumed,
7161 i,
7162 &mut tool_args,
7163 )
7164 .await?;
7165 }
7166 }
7167 }
7168 }
7169 i += 1;
7170 }
7171
7172 // Map remaining positionals to unfilled non-bool schema params (in order).
7173 // This enables `drift_push "abc" "hello"` → named["target_ctx"] = "abc", named["content"] = "hello"
7174 // Positionals that appeared after `--` are never mapped (they're raw data).
7175 // Only for backend/external tools (map_positionals=true). Builtins handle their own positionals.
7176 // Keyed off the routed leaf so a subcommand tool maps against the active
7177 // leaf's params (kj leaves keep map_positionals=false → block skipped).
7178 if let Some(schema) = leaf.filter(|s| s.map_positionals) {
7179 let pre_dash_count = if past_double_dash {
7180 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
7181 positional_indices.iter()
7182 .filter(|idx| **idx < dash_pos && !consumed.contains(idx))
7183 .count()
7184 } else {
7185 tool_args.positional.len()
7186 };
7187
7188 let mut remaining = Vec::new();
7189 let mut positional_iter = tool_args.positional.drain(..).enumerate();
7190
7191 for param in &schema.params {
7192 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
7193 continue;
7194 }
7195 if is_bool_type(¶m.param_type) {
7196 continue;
7197 }
7198 loop {
7199 match positional_iter.next() {
7200 Some((idx, val)) if idx < pre_dash_count => {
7201 tool_args.named.insert(param.name.clone(), val);
7202 break;
7203 }
7204 Some((_, val)) => {
7205 remaining.push(val);
7206 }
7207 None => break,
7208 }
7209 }
7210 }
7211
7212 remaining.extend(positional_iter.map(|(_, v)| v));
7213 tool_args.positional = remaining;
7214 }
7215
7216 Ok(tool_args)
7217}
7218
7219#[async_trait]
7220impl CommandDispatcher for Kernel {
7221 /// Dispatch a command through the Kernel's full resolution chain.
7222 ///
7223 /// This is the single path for all command execution when called from
7224 /// the pipeline runner. It provides the full dispatch chain:
7225 /// user tools → builtins → .kai scripts → external commands → backend tools.
7226 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
7227 self.dispatch_command(cmd, ctx).await
7228 }
7229
7230 /// Run a compound pipeline stage through the kernel's statement executor.
7231 async fn dispatch_stmt(&self, stmt: &Stmt, ctx: &mut ExecContext) -> Result<ExecResult> {
7232 self.dispatch_statement(stmt, ctx).await
7233 }
7234
7235 /// Evaluate an expression through the kernel's async chain, including
7236 /// command substitution. Delegates to `eval_expr_async`, which snapshots
7237 /// the kernel's scope/cwd and restores them after any `$(...)` runs, so
7238 /// only command output escapes. The `ctx` is unused here because the
7239 /// kernel evaluates against its own session state (a fork carries the
7240 /// pipeline stage's snapshot); var refs resolve against that scope.
7241 async fn eval_expr(&self, expr: &Expr, _ctx: &ExecContext) -> Result<Value> {
7242 self.eval_expr_async(expr).await
7243 }
7244
7245 /// Produce a forked dispatcher with independent mutable state (detached).
7246 ///
7247 /// Calls the inherent `Kernel::fork` method (note the UFCS to avoid
7248 /// recursing into the trait method we're defining) and coerces the
7249 /// returned `Arc<Kernel>` to `Arc<dyn CommandDispatcher>`.
7250 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
7251 let fork: Arc<Kernel> = Kernel::fork(self).await;
7252 fork
7253 }
7254
7255 /// Produce a forked dispatcher with cancellation cascading from this kernel.
7256 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
7257 let fork: Arc<Kernel> = Kernel::fork_attached(self).await;
7258 fork
7259 }
7260}
7261
7262/// Apply the requested output format to a builtin's result, unless the tool
7263/// owns its own output — and even then, only on success.
7264///
7265/// `format` is `ctx.output_format` (set from `--json`). `owns_output` means
7266/// "this tool renders its own bespoke SUCCESS envelope" (scatter/gather's
7267/// JSONL/array rendering), not "never touch this tool's bytes" — scatter and
7268/// gather never render a structured error themselves, so a failure
7269/// (`ExecResult::failure(code, msg)`, plain text, no `.data`/`.output`) was
7270/// never "already rendered" by the tool. Skipping `apply_output_format` on
7271/// that path just leaked the raw diagnostic under `--json` instead of the
7272/// uniform `{"error","code"}` envelope every other builtin's failure gets
7273/// (kaibo review finding on merged PR #215, confirmed pre-existing for the
7274/// whole owns_output error-path class). Gating the skip on `result.ok()`
7275/// keeps the intentional success-path opt-out while closing that gap.
7276fn finalize_output(
7277 result: ExecResult,
7278 format: Option<crate::interpreter::OutputFormat>,
7279 owns_output: bool,
7280) -> ExecResult {
7281 match format {
7282 Some(_) if owns_output && result.ok() => result,
7283 Some(format) => apply_output_format(result, format),
7284 None => result,
7285 }
7286}
7287
7288/// Accumulate output from one result into another.
7289///
7290/// Appends stdout and stderr verbatim and updates the exit code to match the
7291/// new result. Used to preserve output from multiple statements, loop
7292/// iterations, and command chains. No separator is inserted between outputs —
7293/// each command's output concatenates raw, matching bash (`printf a; printf b`
7294/// and `printf a && printf b` both yield `ab`; a trailing newline only appears
7295/// when a command emits its own, as `echo` does).
7296/// Append `new`'s stdout to `accumulated`'s, and nothing else.
7297///
7298/// Split out of [`accumulate_result`] because a condition carries only its
7299/// stdout up to the enclosing statement — its exit code is the `if`/`while`'s
7300/// answer, not the statement's status. Sharing the append keeps the two
7301/// callers from drifting on the byte handling below.
7302fn push_stdout_of(accumulated: &mut ExecResult, new: &ExecResult) {
7303 // Materialize lazy OutputData into .out before accumulating.
7304 // Without this, the first command's output stays in .output while
7305 // the second's text gets appended to .out, losing the first.
7306 accumulated.materialize();
7307 match new.out_bytes() {
7308 // A binary result must not be lossy-decoded by text_out(): concatenate
7309 // raw bytes so the combined output stays binary (this is the path every
7310 // top-level statement's result flows through). See docs/binary-data.md.
7311 Some(new_bytes) => {
7312 let mut combined: Vec<u8> = match accumulated.out_bytes() {
7313 Some(b) => b.to_vec(),
7314 None => accumulated.text_out().into_owned().into_bytes(),
7315 };
7316 combined.extend_from_slice(new_bytes);
7317 accumulated.set_out_bytes(combined);
7318 }
7319 None => accumulated.push_out(&new.text_out()),
7320 }
7321}
7322
7323fn accumulate_result(accumulated: &mut ExecResult, new: &ExecResult) {
7324 push_stdout_of(accumulated, new);
7325 accumulated.err.push_str(&new.err);
7326 accumulated.code = new.code;
7327 // The marker travels WITH the data, always. Copying one without the other
7328 // is wrong in both directions: a compound statement ending in `fromjson`
7329 // lost its value (`$(if true; then fromjson '[1,2]'; fi)` bound text), and
7330 // a chain whose LEFT side was typed kept that marker over the right side's
7331 // data (`$(fromjson '[1,2]' && cut -f2 f)` bound `["b"]` typed — the very
7332 // bug this mechanism exists to fix, re-entering through a side door).
7333 accumulated.data = new.data.clone();
7334 accumulated.data_is_value = new.data_is_value;
7335 // OR, not assign. `did_spill` is a fact about the OUTPUT — this block's
7336 // text was truncated — and an ordinary statement running afterwards does
7337 // not untruncate it. Assigning let `seq …; echo after` report
7338 // `did_spill: false` with output still missing, telling an embedder asking
7339 // "did I get everything" the wrong thing. The exit CODE is a separate
7340 // question and still belongs to the last statement, as in any shell.
7341 accumulated.did_spill |= new.did_spill;
7342 // `original_code` is only meaningful alongside a spill, so it follows the
7343 // same rule: keep the first one rather than letting a later clean
7344 // statement's `None` erase the code the spill replaced.
7345 if accumulated.original_code.is_none() {
7346 accumulated.original_code = new.original_code;
7347 }
7348 accumulated.content_type = new.content_type.clone();
7349 accumulated.baggage.clone_from(&new.baggage);
7350}
7351
7352/// Fold a block's accumulated output into a signal that is leaving the block.
7353///
7354/// Any block that builds up a result — a loop body, an `if`/`case` branch, the
7355/// left side of a `&&`/`||` chain — hands that result back when it finishes.
7356/// When `break`/`continue`/`return`/`exit` leaves early instead, the signal
7357/// replaces the result on the way up, so output printed before the signal
7358/// would otherwise be discarded. Leaving early stops the block; it does not
7359/// unprint what already ran. The block's output comes first (it ran before the
7360/// signal was raised), then the signal's already-carried output.
7361fn fold_block_output_into_flow(block_output: ExecResult, flow: &mut ControlFlow) {
7362 let carried = match flow {
7363 ControlFlow::Break { result, .. }
7364 | ControlFlow::Continue { result, .. }
7365 | ControlFlow::Exit { result, .. } => result,
7366 ControlFlow::Return { value } => value,
7367 ControlFlow::Normal(_) => return,
7368 };
7369 let mut merged = block_output;
7370 accumulate_result(&mut merged, carried);
7371 *carried = merged;
7372}
7373
7374/// Accumulate the output a break/continue signal carried (from inner loops it
7375/// propagated through) into the loop that finally handles it, so it survives
7376/// into that loop's result.
7377fn accumulate_flow_output(accumulated: &mut ExecResult, flow: &ControlFlow) {
7378 if let ControlFlow::Break { result, .. } | ControlFlow::Continue { result, .. } = flow {
7379 // `break`/`continue` carry no value of their own, and a signal that
7380 // produced nothing must not erase what the body already produced —
7381 // leaving a loop early stops it, it does not unmake its output. Same
7382 // reasoning as `fold_block_output_into_flow`'s, one step further:
7383 // `$(while true; do fromjson '[5]'; break; done)` bound text because
7384 // the empty `break` result overwrote the body's value.
7385 let carried = (accumulated.data.take(), accumulated.data_is_value);
7386 accumulate_result(accumulated, result);
7387 if result.data.is_none() {
7388 (accumulated.data, accumulated.data_is_value) = carried;
7389 }
7390 }
7391}
7392
7393/// Check if a value is truthy.
7394fn is_truthy(value: &Value) -> bool {
7395 match value {
7396 Value::Null => false,
7397 Value::Bool(b) => *b,
7398 Value::Int(i) => *i != 0,
7399 Value::Float(f) => *f != 0.0,
7400 Value::String(s) => !s.is_empty(),
7401 Value::Json(json) => match json {
7402 serde_json::Value::Null => false,
7403 serde_json::Value::Array(arr) => !arr.is_empty(),
7404 serde_json::Value::Object(obj) => !obj.is_empty(),
7405 serde_json::Value::Bool(b) => *b,
7406 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
7407 serde_json::Value::String(s) => !s.is_empty(),
7408 },
7409 Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
7410 }
7411}
7412
7413/// Apply tilde expansion to a value.
7414///
7415/// Only string values starting with `~` are expanded. `home` is the session
7416/// `HOME` from the kernel scope (the kernel is hermetic and never reads the
7417/// host env); `None` leaves `~`/`~/path` unexpanded. See [`expand_tilde`].
7418fn apply_tilde_expansion(value: Value, home: Option<&str>) -> Value {
7419 match value {
7420 Value::String(s) if s.starts_with('~') => Value::String(expand_tilde(&s, home)),
7421 _ => value,
7422 }
7423}
7424
7425/// Classify an already-tokenized argv (`&[Value]`) into AST [`Arg`]s, mirroring
7426/// how the lexer tokenizes the equivalent minimally-quoted command string —
7427/// the seam that lets [`Kernel::execute_argv`] reuse the string door's binder
7428/// (`build_args_async`) verbatim instead of carrying a parallel one that could
7429/// drift. Tokens are literal: every string becomes an `Expr::Literal`, never an
7430/// `Expr::GlobPattern` or `VarRef`, so no glob/`$VAR`/`$()`/split can occur.
7431///
7432/// Classification matches the lexer's word classes:
7433/// - `--` → [`Arg::DoubleDash`] (subsequent flags are demoted to positionals by
7434/// the binder's `past_double_dash` arms, exactly as for the string door).
7435/// - `--name=value` → [`Arg::Named`]; `--name` → [`Arg::LongFlag`].
7436/// - `-x…` where the first char after `-` is an ASCII letter → [`Arg::ShortFlag`]
7437/// (the lexer's flag char class begins `[a-zA-Z]`; `-1`/`-9` lex as numbers, so
7438/// they fall through to a positional, not a flag).
7439/// - `key=value` with an identifier LHS → [`Arg::WordAssign`] (the binder either
7440/// binds it to a preceding value-flag — `awk -v x=1` — or stringifies it to a
7441/// `key=value` positional, per the command's word-assign allowlist).
7442/// - everything else → a literal [`Arg::Positional`].
7443///
7444/// A **non-string** `Value` (`Bytes`/`Json`/`Int`/`Bool`) is always a literal
7445/// positional — it can never be a flag — and rides through as-is. That is the
7446/// typed passthrough the string-native door cannot offer.
7447pub(crate) fn argv_to_args(argv: &[Value]) -> Vec<Arg> {
7448 argv.iter().map(classify_argv_token).collect()
7449}
7450
7451fn classify_argv_token(token: &Value) -> Arg {
7452 let Value::String(s) = token else {
7453 return Arg::Positional(Expr::Literal(token.clone()));
7454 };
7455
7456 if s == "--" {
7457 return Arg::DoubleDash;
7458 }
7459
7460 // Long flag: the lexer requires `--[a-zA-Z]…`. `---`, `--=v`, `--1` are NOT
7461 // long-flag words — the lexer now tokenizes each as one `DoubleDashBare`
7462 // literal word (GH #137), matching this classifier's own literal
7463 // fallback — so they fall through to a literal positional rather than a
7464 // silently-misbound `LongFlag("-")` / empty-key `Named{ key: "" }`.
7465 if let Some(rest) = s.strip_prefix("--") {
7466 if rest.starts_with(|c: char| c.is_ascii_alphabetic()) {
7467 return match rest.split_once('=') {
7468 Some((key, val)) => Arg::Named {
7469 key: key.to_string(),
7470 value: Expr::Literal(Value::String(val.to_string())),
7471 },
7472 None => Arg::LongFlag(rest.to_string()),
7473 };
7474 }
7475 } else if let Some(rest) = s.strip_prefix('-') {
7476 // Short flag: the lexer's flag char class is `[a-zA-Z][a-zA-Z0-9-]*`. A
7477 // token carrying any other char — notably `=` (`-k=v` is a parse error in
7478 // the string door) — or a leading digit (`-1` lexes as a number) is not a
7479 // short-flag word, so it falls through to a literal positional instead of
7480 // a `ShortFlag("k=v")` the binder would mangle into a stray `=` flag.
7481 if is_short_flag_body(rest) {
7482 return Arg::ShortFlag(rest.to_string());
7483 }
7484 }
7485
7486 if let Some((key, val)) = s.split_once('=') {
7487 if is_shell_identifier(key) {
7488 return Arg::WordAssign {
7489 key: key.to_string(),
7490 value: Expr::Literal(Value::String(val.to_string())),
7491 };
7492 }
7493 }
7494
7495 Arg::Positional(Expr::Literal(Value::String(s.clone())))
7496}
7497
7498/// A short-flag word: a leading ASCII letter, then only ASCII
7499/// letters/digits/`-` (the lexer's base `-[a-zA-Z][a-zA-Z0-9-]*` regex) or `:`
7500/// (which `merge_flag_metachar_adjacent` glues onto a `ShortFlag` for the
7501/// `awk -F:` idiom). `-la`, `-A1`, `-a:` qualify; `-1` (a number), `-k=v`
7502/// (`=` is the assignment operator — a parse error in the string door), and
7503/// any non-ASCII tail (never produced by the lexer, and not safe for the
7504/// combined-short-flag binder's byte-index slicing) do not, so they fall
7505/// through to a literal positional instead of a malformed `ShortFlag`.
7506fn is_short_flag_body(s: &str) -> bool {
7507 s.starts_with(|c: char| c.is_ascii_alphabetic())
7508 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
7509}
7510
7511/// Bash-style assignment-LHS identifier: `[A-Za-z_][A-Za-z0-9_]*`.
7512fn is_shell_identifier(s: &str) -> bool {
7513 let mut chars = s.chars();
7514 match chars.next() {
7515 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
7516 _ => return false,
7517 }
7518 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
7519}
7520
7521/// Accumulate one occurrence of a repeatable value flag (e.g. sed `-e`) under
7522/// `named[canonical]` as a flat `Value::Json(Array(...))`, in invocation order.
7523/// Never overwrites — that's the whole point of `repeatable`: a repeated flag
7524/// must keep every value, not silently drop all but the last. Used by every flag
7525/// surface that can carry the same flag twice — the space form
7526/// (`consume_flag_positionals`) and the `--flag=value` form (`Arg::Named`) — so
7527/// `-e A -e B`, `--expression=A --expression=B`, and any mix all converge on one
7528/// ordered array.
7529/// Flatten a bound flag value to JSON, going LOUD on binary.
7530///
7531/// The accumulating flag forms (`jq --arg NAME VAL`, `sed -e EXPR -e EXPR`)
7532/// store their values as `serde_json::Value` rather than keeping the kaish
7533/// `Value`, and [`value_to_json`](crate::interpreter::value_to_json) renders a
7534/// `Value::Bytes` as the base64 envelope
7535/// (`{"_type":"bytes","encoding":"base64",…}`). That envelope is an internal
7536/// wire form, not the user's data: bound into `--arg x`, the tool sees the
7537/// envelope's literal JSON *text* where the bytes should be and reports
7538/// success, which is silent corruption (GH #223). Binary stops here instead,
7539/// with the same wording and exit 1 as every other text sink.
7540///
7541/// Valid-UTF-8 bytes coerce to their text, matching
7542/// [`value_to_text_sink_named`](crate::interpreter::value_to_text_sink_named);
7543/// in practice `Value::Bytes` only ever holds non-UTF-8, so this errors
7544/// whenever binary reaches a flag value. Gating on the kaish `Value` (not on
7545/// the envelope's JSON shape) is what keeps an envelope-shaped record the user
7546/// actually built — `fromjson '{"_type":"bytes",…}'` — a plain record: kaish
7547/// never sniffs JSON to decide a type.
7548fn flag_value_to_json(canonical: &str, v: &Value) -> Result<serde_json::Value> {
7549 match v {
7550 Value::Bytes(_) => crate::interpreter::value_to_text_sink_named(
7551 v,
7552 &format!("the value of the {canonical} flag"),
7553 )
7554 .map(serde_json::Value::String)
7555 .map_err(|e| anyhow::anyhow!("{e}")),
7556 other => Ok(crate::interpreter::value_to_json(other)),
7557 }
7558}
7559
7560pub(crate) fn push_repeatable_value(
7561 tool_args: &mut ToolArgs,
7562 flag_name: &str,
7563 canonical: &str,
7564 v: Value,
7565) -> anyhow::Result<()> {
7566 let occ = flag_value_to_json(canonical, &v)?;
7567 let entry = tool_args
7568 .named
7569 .entry(canonical.to_string())
7570 .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
7571 if let Value::Json(serde_json::Value::Array(items)) = entry {
7572 items.push(occ);
7573 Ok(())
7574 } else {
7575 anyhow::bail!("--{flag_name}: named[{canonical}] already holds a non-array value")
7576 }
7577}
7578
7579/// Bind a *glued* short-flag value (`-f1` → f=1, `-e1d`, `-A2`). The whole tail
7580/// is one token, so it carries a single value: a repeatable flag accumulates
7581/// (never clobbers — `-e1d -e2d` keeps both), a plain one inserts. Shared by the
7582/// first-char glued arm and the combined-bundle arm so the two can't drift on
7583/// `repeatable` handling. A `consumes > 1` flag can't be expressed glued — that
7584/// is a loud error, not a silent single-value bind.
7585pub(crate) fn bind_glued_short_value(
7586 tool_args: &mut ToolArgs,
7587 flag_name: &str,
7588 canonical: &str,
7589 consumes: usize,
7590 repeatable: bool,
7591 value: String,
7592) -> anyhow::Result<()> {
7593 if consumes > 1 {
7594 anyhow::bail!(
7595 "-{flag_name} takes {consumes} arguments; use the separated form, not a glued value"
7596 );
7597 }
7598 if repeatable {
7599 push_repeatable_value(tool_args, flag_name, canonical, Value::String(value))
7600 } else {
7601 tool_args
7602 .named
7603 .insert(canonical.to_string(), Value::String(value));
7604 Ok(())
7605 }
7606}
7607
7608/// Map a child's exit status to a shell-style exit code.
7609///
7610/// `ExitStatus::code()` is `None` when the process died from a signal rather
7611/// than exiting normally; in that case this maps to POSIX's `128 + signal`
7612/// convention (SIGKILL → 137, SIGTERM → 143, …) instead of losing the signal
7613/// number. Shared by both external-command spawn sites — production
7614/// (`try_execute_external`, below) and the test-only twin
7615/// (`dispatch.rs::BackendDispatcher::try_external`) — so they can't drift on
7616/// this mapping again (GH #133 item 1).
7617#[cfg(feature = "subprocess")]
7618pub(crate) fn exit_code_from_status(status: &std::process::ExitStatus) -> i64 {
7619 status.code().unwrap_or_else(|| {
7620 #[cfg(unix)]
7621 {
7622 use std::os::unix::process::ExitStatusExt;
7623 128 + status.signal().unwrap_or(0)
7624 }
7625 #[cfg(not(unix))]
7626 {
7627 -1
7628 }
7629 }) as i64
7630}
7631
7632/// Wait for a child to exit, killing it if `cancel` fires first.
7633///
7634/// `target` carries a Linux pidfd (when available) for race-free direct-child
7635/// kill; fall-through to PID-based kill otherwise. On non-unix targets the
7636/// parameter is ignored and we use tokio's cross-platform `start_kill`.
7637#[cfg(all(unix, feature = "subprocess"))]
7638pub(crate) async fn wait_or_kill(
7639 child: &mut tokio::process::Child,
7640 target: Option<&crate::pidfd::KillTarget>,
7641 cancel: &tokio_util::sync::CancellationToken,
7642 grace: Duration,
7643) -> std::io::Result<std::process::ExitStatus> {
7644 tokio::select! {
7645 biased;
7646 status = child.wait() => status,
7647 _ = cancel.cancelled() => kill_with_grace(child, target, grace).await,
7648 }
7649}
7650
7651#[cfg(all(not(unix), feature = "subprocess"))]
7652pub(crate) async fn wait_or_kill(
7653 child: &mut tokio::process::Child,
7654 _target: Option<&()>,
7655 cancel: &tokio_util::sync::CancellationToken,
7656 _grace: Duration,
7657) -> std::io::Result<std::process::ExitStatus> {
7658 tokio::select! {
7659 biased;
7660 status = child.wait() => status,
7661 _ = cancel.cancelled() => {
7662 let _ = child.start_kill();
7663 child.wait().await
7664 }
7665 }
7666}
7667
7668/// Send SIGTERM to the child and its process group; wait `grace`; then SIGKILL.
7669///
7670/// Direct-child kill goes through `target.signal()`, which on Linux uses a
7671/// pidfd (immune to PID reuse). Process-group kill uses `killpg` — there is
7672/// no PGID-equivalent of pidfd, so grandchildren retain a small reuse window.
7673#[cfg(all(unix, feature = "subprocess"))]
7674pub(crate) async fn kill_with_grace(
7675 child: &mut tokio::process::Child,
7676 target: Option<&crate::pidfd::KillTarget>,
7677 grace: Duration,
7678) -> std::io::Result<std::process::ExitStatus> {
7679 use nix::sys::signal::Signal;
7680
7681 if let Some(t) = target {
7682 t.signal(Signal::SIGTERM);
7683 t.signal_pg(Signal::SIGTERM);
7684 if grace > Duration::ZERO
7685 && let Ok(status) = tokio::time::timeout(grace, child.wait()).await
7686 {
7687 return status;
7688 }
7689 t.signal(Signal::SIGKILL);
7690 t.signal_pg(Signal::SIGKILL);
7691 }
7692 child.wait().await
7693}
7694
7695#[cfg(test)]
7696#[allow(clippy::unwrap_used, clippy::expect_used)]
7697mod argv_classify_tests {
7698 use super::*;
7699
7700 /// A normalized, comparable view of one `Arg` representing its *logical
7701 /// argument* (what the command observably receives), not its exact AST shape:
7702 ///
7703 /// - Value-bearing arms compare by *stringified* value, so the parser's
7704 /// number coercion (`-1`→`Int(-1)`) vs the classifier's literal
7705 /// (`String("-1")`) count as the same argument.
7706 /// - `WordAssign{k,v}` collapses to the *same* form as a `Positional("k=v")`.
7707 /// For every command except the `export`/`alias` allowlist, a bareword
7708 /// `key=value` is stringified straight back to a `"key=value"` positional
7709 /// (bash: `cat foo=bar` opens a file named `foo=bar`). So the two doors
7710 /// converge observably even when they disagree on the AST tag — e.g. the
7711 /// lexer colon-merges `:A` into one `Ident` and parses `:A=0` as a
7712 /// `WordAssign`, where the classifier (bash-correctly) makes a positional.
7713 /// The genuine `WordAssign` *detection* on a real identifier LHS is pinned
7714 /// separately by `classifies_each_word_class`.
7715 ///
7716 /// Returns `None` for shapes we deliberately don't compare:
7717 /// - a parsed glob/interp `Expr`, which argv-native semantics never produce;
7718 /// - a parsed literal the lexer **number-coerced** (`Int`/`Float`). `00`/`-1`
7719 /// lex to `Int`, dropping the literal text, where the classifier keeps the
7720 /// string. That divergence is *intentional* — `execute_argv` preserves a
7721 /// literal numeric string (pass `Value::Int` for a number), the string door
7722 /// can only guess — so the property skips it rather than demanding the
7723 /// classifier replicate a lossy coercion. Numeric edges are pinned exactly
7724 /// by `classifies_each_word_class`.
7725 fn canonical(arg: &Arg) -> Option<(&'static str, String, String)> {
7726 // Only a *string*-valued literal is comparable; a coerced number is not.
7727 let lit = |e: &Expr| match e {
7728 Expr::Literal(Value::String(s)) => Some(s.clone()),
7729 _ => None,
7730 };
7731 Some(match arg {
7732 Arg::DoubleDash => ("dash", String::new(), String::new()),
7733 Arg::ShortFlag(s) => ("short", s.clone(), String::new()),
7734 Arg::LongFlag(s) => ("long", s.clone(), String::new()),
7735 Arg::Positional(e) => ("pos", String::new(), lit(e)?),
7736 Arg::Named { key, value } => ("named", key.clone(), lit(value)?),
7737 Arg::WordAssign { key, value } => ("pos", String::new(), format!("{key}={}", lit(value)?)),
7738 })
7739 }
7740
7741 /// Classify a single string token the way `execute_argv` would.
7742 fn classify(token: &str) -> Arg {
7743 classify_argv_token(&Value::String(token.to_string()))
7744 }
7745
7746 #[test]
7747 fn classifies_each_word_class() {
7748 assert_eq!(classify("--"), Arg::DoubleDash);
7749 assert_eq!(classify("-l"), Arg::ShortFlag("l".into()));
7750 assert_eq!(classify("-la"), Arg::ShortFlag("la".into()));
7751 assert_eq!(classify("--force"), Arg::LongFlag("force".into()));
7752 assert_eq!(
7753 classify("--key=value"),
7754 Arg::Named { key: "key".into(), value: Expr::Literal(Value::String("value".into())) }
7755 );
7756 assert_eq!(
7757 classify("NAME=val"),
7758 Arg::WordAssign { key: "NAME".into(), value: Expr::Literal(Value::String("val".into())) }
7759 );
7760 // Digits after the first flag char are ordinary (kept verbatim).
7761 assert_eq!(classify("-A1"), Arg::ShortFlag("A1".into()));
7762 assert_eq!(classify("--type2"), Arg::LongFlag("type2".into()));
7763 // Leading-digit dash is a number to the lexer, not a flag → positional.
7764 assert_eq!(classify("-1"), Arg::Positional(Expr::Literal(Value::String("-1".into()))));
7765 // Numeric strings keep their literal text — `execute_argv` does NOT
7766 // coerce (the string door's lexer would: `00`→`Int(0)`→"0"). A caller
7767 // who wants a number passes `Value::Int`; a string stays the string.
7768 assert_eq!(classify("00"), Arg::Positional(Expr::Literal(Value::String("00".into()))));
7769 assert_eq!(classify("1.50"), Arg::Positional(Expr::Literal(Value::String("1.50".into()))));
7770 // A lone dash (stdin convention) is a positional, not a flag.
7771 assert_eq!(classify("-"), Arg::Positional(Expr::Literal(Value::String("-".into()))));
7772 // Non-identifier LHS is not an assignment.
7773 assert_eq!(classify("1=2"), Arg::Positional(Expr::Literal(Value::String("1=2".into()))));
7774 assert_eq!(classify("plain"), Arg::Positional(Expr::Literal(Value::String("plain".into()))));
7775 }
7776
7777 #[test]
7778 fn typed_values_pass_through_as_literal_positionals() {
7779 // The whole point of the `&[Value]` signature: a non-string value is a
7780 // literal positional carrying the *exact* value, never stringified and
7781 // never flag-interpreted.
7782 let bytes = Value::Bytes(vec![0u8, 159, 146, 150]); // invalid UTF-8 on purpose
7783 assert_eq!(
7784 classify_argv_token(&bytes),
7785 Arg::Positional(Expr::Literal(bytes.clone()))
7786 );
7787 let json = Value::Json(serde_json::json!({"a": 1, "b": [2, 3]}));
7788 assert_eq!(
7789 classify_argv_token(&json),
7790 Arg::Positional(Expr::Literal(json.clone()))
7791 );
7792 // An integer token that *looks* like a flag is still a positional value
7793 // (only strings are inspected for a leading dash).
7794 assert_eq!(
7795 classify_argv_token(&Value::Int(-9)),
7796 Arg::Positional(Expr::Literal(Value::Int(-9)))
7797 );
7798 }
7799
7800 #[test]
7801 fn double_dash_only_matches_exactly() {
7802 // `--` is the marker; `--x` is a long flag. `---` is not a flag word
7803 // (the lexer lexes it as one `DoubleDashBare` literal word, GH #137);
7804 // as a single argv token here it's likewise literal.
7805 assert_eq!(classify("--"), Arg::DoubleDash);
7806 assert_eq!(classify("--x"), Arg::LongFlag("x".into()));
7807 assert_eq!(classify("---"), Arg::Positional(Expr::Literal(Value::String("---".into()))));
7808 }
7809
7810 #[test]
7811 fn malformed_flag_words_fall_back_to_literal_positionals() {
7812 // A token that isn't a well-formed flag word must NOT be silently misbound
7813 // into the arg binder (house rule: loud/visible over silent-wrong). Each
7814 // of these is a parse error or different tokenization in the string door,
7815 // so the argv door keeps them as literal positionals.
7816 let pos = |t: &str| Arg::Positional(Expr::Literal(Value::String(t.into())));
7817 // `=` is not in the short-flag char class (`-k=v` parse-errors in the
7818 // string door); don't emit `ShortFlag("k=v")` for the binder to mangle.
7819 assert_eq!(classify("-k=v"), pos("-k=v"));
7820 assert_eq!(classify("-="), pos("-="));
7821 // Empty long-flag key.
7822 assert_eq!(classify("--=v"), pos("--=v"));
7823 // `--` followed by a non-letter is not a long flag.
7824 assert_eq!(classify("--1"), pos("--1"));
7825 // A bare dash and a number-dash are positionals (covered above too).
7826 assert_eq!(classify("-"), pos("-"));
7827 assert_eq!(classify("-9"), pos("-9"));
7828 // A non-ASCII tail is not part of the lexer's short-flag char class
7829 // (`-[a-zA-Z][a-zA-Z0-9-]*`, plus the `:` the metachar-merge pass
7830 // absorbs) — classifying it as `ShortFlag` would hand the combined
7831 // short-flag binder a byte string it (correctly, for real ASCII flag
7832 // words) slices by *byte* index, panicking on a multi-byte char
7833 // boundary. Fall back to a literal positional instead.
7834 assert_eq!(classify("-lé"), pos("-lé"));
7835 assert_eq!(classify("-é"), pos("-é"));
7836 }
7837
7838 #[tokio::test]
7839 async fn non_ascii_short_flag_bundle_does_not_panic() {
7840 // Regression: `execute_argv`'s combined-short-flag loop assumed the
7841 // flag body was ASCII (safe to byte-slice) because the lexer's
7842 // grammar guarantees that on the *string* door. The argv door's
7843 // classifier let a non-ASCII tail through as `ShortFlag`, so
7844 // `execute_argv("ls", &["-lé"])` sliced mid-codepoint and panicked.
7845 let kernel = Kernel::transient().expect("failed to create kernel");
7846 let result = kernel
7847 .execute_argv("ls", &[Value::String("-lé".into())])
7848 .await
7849 .expect("execute_argv must not panic on a non-ASCII short-flag token");
7850 // Not a well-formed flag word, so it's a literal positional — `ls`
7851 // then reports it as a missing path rather than mangling flags.
7852 assert_ne!(result.code, 0);
7853 }
7854
7855 proptest::proptest! {
7856 /// The core correctness claim: the classifier mirrors the lexer/parser
7857 /// on metacharacter-free tokens. For any such single token, the `Arg`
7858 /// the classifier produces matches the one the real parser produces for
7859 /// the equivalent one-word command — so `execute_argv` reusing the
7860 /// string door's binder is sound. (First proptest in the workspace.)
7861 #[test]
7862 fn classifier_matches_parser_on_clean_tokens(
7863 // No digits: this property tests the *classification* boundary
7864 // (dash → flag, `--` → marker, `=` → assignment, colon-merge → one
7865 // positional), not numeric coercion. The lexer coerces digit runs to
7866 // `Int`/`Float` and drops the literal text (even inside a colon-merged
7867 // word: `00:` → `0:`); the classifier intentionally preserves the raw
7868 // string. Those numeric edges are pinned exactly by the unit tests.
7869 // Non-ASCII is a word character now, so the generator has to
7870 // reach it — an ASCII-only strategy tests a shrinking slice of
7871 // what the classifier actually sees.
7872 token in "[a-zA-Z_=./@:+\\-\u{00e9}\u{540d}\u{1f600}]{1,8}"
7873 ) {
7874 let parsed = match parse(&format!("cmd {token}")) {
7875 Ok(p) => p,
7876 Err(_) => return Ok(()), // parser rejects (e.g. empty `a=`) — not our concern
7877 };
7878 let [Stmt::Command(cmd)] = parsed.statements.as_slice() else {
7879 return Ok(());
7880 };
7881 // Only compare when the token lexed as exactly one argument.
7882 let [arg] = cmd.args.as_slice() else { return Ok(()); };
7883
7884 let (Some(theirs), Some(ours)) = (canonical(arg), canonical(&classify(&token))) else {
7885 return Ok(()); // a non-literal parsed Expr we don't model — skip
7886 };
7887 proptest::prop_assert_eq!(
7888 ours, theirs,
7889 "classifier diverged from parser on token {:?}", token
7890 );
7891 }
7892 }
7893}
7894
7895#[cfg(all(test, feature = "subprocess"))]
7896#[allow(clippy::expect_used)]
7897mod tests {
7898 use super::*;
7899
7900 #[tokio::test]
7901 async fn test_kernel_transient() {
7902 let kernel = Kernel::transient().expect("failed to create kernel");
7903 assert_eq!(kernel.name(), "transient");
7904 }
7905
7906 #[tokio::test]
7907 async fn test_kernel_execute_echo() {
7908 let kernel = Kernel::transient().expect("failed to create kernel");
7909 let result = kernel.execute("echo hello").await.expect("execution failed");
7910 assert!(result.ok());
7911 assert_eq!(result.text_out().trim(), "hello");
7912 }
7913
7914 #[tokio::test]
7915 async fn test_multiple_statements_accumulate_output() {
7916 let kernel = Kernel::transient().expect("failed to create kernel");
7917 let result = kernel
7918 .execute("echo one\necho two\necho three")
7919 .await
7920 .expect("execution failed");
7921 assert!(result.ok());
7922 // Should have all three outputs separated by newlines
7923 assert!(result.text_out().contains("one"), "missing 'one': {}", result.text_out());
7924 assert!(result.text_out().contains("two"), "missing 'two': {}", result.text_out());
7925 assert!(result.text_out().contains("three"), "missing 'three': {}", result.text_out());
7926 }
7927
7928 #[tokio::test]
7929 async fn test_and_chain_accumulates_output() {
7930 let kernel = Kernel::transient().expect("failed to create kernel");
7931 let result = kernel
7932 .execute("echo first && echo second")
7933 .await
7934 .expect("execution failed");
7935 assert!(result.ok());
7936 assert!(result.text_out().contains("first"), "missing 'first': {}", result.text_out());
7937 assert!(result.text_out().contains("second"), "missing 'second': {}", result.text_out());
7938 }
7939
7940 #[tokio::test]
7941 async fn test_for_loop_accumulates_output() {
7942 let kernel = Kernel::transient().expect("failed to create kernel");
7943 let result = kernel
7944 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
7945 .await
7946 .expect("execution failed");
7947 assert!(result.ok());
7948 assert!(result.text_out().contains("item: a"), "missing 'item: a': {}", result.text_out());
7949 assert!(result.text_out().contains("item: b"), "missing 'item: b': {}", result.text_out());
7950 assert!(result.text_out().contains("item: c"), "missing 'item: c': {}", result.text_out());
7951 }
7952
7953 #[tokio::test]
7954 async fn test_while_loop_accumulates_output() {
7955 let kernel = Kernel::transient().expect("failed to create kernel");
7956 let result = kernel
7957 .execute(r#"
7958 N=3
7959 while [[ ${N} -gt 0 ]]; do
7960 echo "N=${N}"
7961 N=$((N - 1))
7962 done
7963 "#)
7964 .await
7965 .expect("execution failed");
7966 assert!(result.ok());
7967 assert!(result.text_out().contains("N=3"), "missing 'N=3': {}", result.text_out());
7968 assert!(result.text_out().contains("N=2"), "missing 'N=2': {}", result.text_out());
7969 assert!(result.text_out().contains("N=1"), "missing 'N=1': {}", result.text_out());
7970 }
7971
7972 #[tokio::test]
7973 async fn test_kernel_set_var() {
7974 let kernel = Kernel::transient().expect("failed to create kernel");
7975
7976 kernel.execute("X=42").await.expect("set failed");
7977
7978 let value = kernel.get_var("X").await;
7979 assert_eq!(value, Some(Value::Int(42)));
7980 }
7981
7982 #[tokio::test]
7983 async fn test_kernel_var_expansion() {
7984 let kernel = Kernel::transient().expect("failed to create kernel");
7985
7986 kernel.execute("NAME=\"world\"").await.expect("set failed");
7987 let result = kernel.execute("echo \"hello ${NAME}\"").await.expect("echo failed");
7988
7989 assert!(result.ok());
7990 assert_eq!(result.text_out().trim(), "hello world");
7991 }
7992
7993 #[tokio::test]
7994 async fn test_kernel_last_result() {
7995 let kernel = Kernel::transient().expect("failed to create kernel");
7996
7997 kernel.execute("echo test").await.expect("echo failed");
7998
7999 let last = kernel.last_result().await;
8000 assert!(last.ok());
8001 assert_eq!(last.text_out().trim(), "test");
8002 }
8003
8004 #[tokio::test]
8005 async fn test_kernel_tool_not_found() {
8006 let kernel = Kernel::transient().expect("failed to create kernel");
8007
8008 let result = kernel.execute("nonexistent_tool").await.expect("execution failed");
8009 assert!(!result.ok());
8010 assert_eq!(result.code, 127);
8011 assert!(result.err.contains("command not found"));
8012 }
8013
8014 #[tokio::test]
8015 async fn backend_tool_data_content_type_and_baggage_survive_into_exec_result() {
8016 // The embedder seam: a backend-registered tool (kaijutsu, an MCP
8017 // engine, …) returns a `ToolResult` with structured `data` — this
8018 // must reach the caller's `ExecResult` intact so `x=$(embedder_tool)`
8019 // and `for r in $(embedder_tool)` see the typed value, not just
8020 // stdout text.
8021 use crate::backend::testing::MockBackend;
8022 use crate::backend::ToolResult;
8023 let (mock, _calls) = MockBackend::new();
8024 let backend = mock.with_tool_result(|_name| {
8025 let mut baggage = std::collections::BTreeMap::new();
8026 baggage.insert("trace_id".to_string(), "abc123".to_string());
8027 // ToolResult is #[non_exhaustive] (GH #93 item 3/hygiene pass) —
8028 // construct via with_data + the with_* setters, not a struct literal.
8029 Ok(ToolResult::with_data("", serde_json::json!({"key": "value"}))
8030 .with_content_type("application/json")
8031 .with_baggage(baggage))
8032 });
8033 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8034 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8035 .expect("with_backend kernel");
8036
8037 let result = kernel
8038 .execute("embedder_tool")
8039 .await
8040 .expect("execution failed");
8041 assert!(result.ok(), "backend tool call should succeed: {result:?}");
8042 assert_eq!(
8043 result.data,
8044 Some(Value::Json(serde_json::json!({"key": "value"}))),
8045 "backend tool's structured data must survive into ExecResult, not be dropped"
8046 );
8047 assert_eq!(
8048 result.content_type.as_deref(),
8049 Some("application/json"),
8050 "backend tool's content_type must survive into ExecResult"
8051 );
8052 assert_eq!(
8053 result.baggage.get("trace_id").map(String::as_str),
8054 Some("abc123"),
8055 "backend tool's baggage must survive into ExecResult"
8056 );
8057 }
8058
8059 #[tokio::test]
8060 async fn backend_tool_execution_error_is_not_reported_as_command_not_found() {
8061 // A backend tool that IS found but fails during execution (`Io`,
8062 // `PermissionDenied`, …) must surface its real error, not get
8063 // misreported as exit-127 "command not found" — that masks a genuine
8064 // failure as a lookup miss.
8065 use crate::backend::testing::MockBackend;
8066 let (mock, _calls) = MockBackend::new();
8067 let backend = mock.with_tool_result(|_name| Err(BackendError::Io("disk exploded".to_string())));
8068 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
8069 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8070 .expect("with_backend kernel");
8071
8072 let result = kernel
8073 .execute("embedder_tool")
8074 .await
8075 .expect("execution failed");
8076 assert_ne!(result.code, 127, "a real execution error must not look like command-not-found: {result:?}");
8077 assert!(!result.ok());
8078 assert!(
8079 result.err.contains("disk exploded"),
8080 "the real backend error must be visible, not masked: {result:?}"
8081 );
8082 }
8083
8084 #[tokio::test]
8085 async fn disabled_external_commands_still_resolve_a_backend_tool() {
8086 // Regression guard for the kaijutsu shape: a read-only shell sets
8087 // `allow_external_commands: false` (no host subprocess exec) but
8088 // still registers its own backend tools — e.g. a sandboxed `curl`
8089 // that reads the network without touching a host binary or the VFS.
8090 // Refusing external commands must NOT short-circuit the
8091 // backend-tool lookup that runs after it. If it did, that `curl`
8092 // would stop resolving and fail with a message claiming it isn't
8093 // available — the exact wrong belief this whole fix exists to
8094 // prevent, just relocated one layer down. Nothing stops a future
8095 // "simplification" of the disabled bail into a terminal branch;
8096 // this test is what catches that.
8097 use crate::backend::testing::MockBackend;
8098 let (mock, calls) = MockBackend::new();
8099 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(mock);
8100 let kernel = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
8101 .expect("with_backend kernel");
8102 assert!(!kernel.allow_external_commands, "isolated() must keep external commands off for this guard to mean anything");
8103
8104 let result = kernel.execute("curl").await.expect("execution failed");
8105 assert!(
8106 result.ok(),
8107 "a backend-registered tool must still run with external commands disabled: {result:?}"
8108 );
8109 assert_eq!(
8110 calls.load(std::sync::atomic::Ordering::SeqCst),
8111 1,
8112 "the backend tool must actually have been invoked, not just assumed"
8113 );
8114 }
8115
8116 #[tokio::test]
8117 async fn test_external_command_true() {
8118 // Use REPL config for passthrough filesystem access
8119 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8120
8121 // /bin/true should be available on any Unix system
8122 let result = kernel.execute("true").await.expect("execution failed");
8123 // This should use the builtin true, which returns 0
8124 assert!(result.ok(), "true should succeed: {:?}", result);
8125 }
8126
8127 #[tokio::test]
8128 async fn test_external_command_basic() {
8129 // Use REPL config for passthrough filesystem access
8130 let kernel = Kernel::new(KernelConfig::repl()).expect("failed to create kernel");
8131
8132 // Test with /bin/echo which is external
8133 // Note: kaish has a builtin echo, so this will use the builtin
8134 // Let's test with a command that's not a builtin
8135 // Actually, let's just test that PATH resolution works by checking the PATH var
8136 let path_var = std::env::var("PATH").unwrap_or_default();
8137 eprintln!("System PATH: {}", path_var);
8138
8139 // Set PATH in kernel to ensure it's available
8140 kernel.execute(&format!(r#"PATH="{}""#, path_var)).await.expect("set PATH failed");
8141
8142 // Now try an external command like /usr/bin/env
8143 // But env is also a builtin... let's try uname
8144 let result = kernel.execute("uname").await.expect("execution failed");
8145 eprintln!("uname result: {:?}", result);
8146 // uname should succeed if external commands work
8147 assert!(result.ok() || result.code == 127, "uname: {:?}", result);
8148 }
8149
8150 #[tokio::test]
8151 async fn test_kernel_reset() {
8152 let kernel = Kernel::transient().expect("failed to create kernel");
8153
8154 kernel.execute("X=1").await.expect("set failed");
8155 assert!(kernel.get_var("X").await.is_some());
8156
8157 kernel.reset().await.expect("reset failed");
8158 assert!(kernel.get_var("X").await.is_none());
8159 }
8160
8161 #[tokio::test]
8162 async fn test_kernel_reset_preserves_pid_and_initial_vars() {
8163 let kernel = Kernel::new(KernelConfig::transient().with_var("HOME", Value::String("/home/probe".into())))
8164 .expect("failed to create kernel");
8165
8166 let pid_before = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8167 assert_eq!(kernel.get_var("HOME").await, Some(Value::String("/home/probe".into())));
8168
8169 kernel.reset().await.expect("reset failed");
8170
8171 let pid_after = kernel.execute("echo $$").await.expect("execute failed").text_out().trim().to_string();
8172 assert_eq!(pid_before, pid_after, "$$ must stay stable across reset(), not silently renumber");
8173 assert_eq!(
8174 kernel.get_var("HOME").await,
8175 Some(Value::String("/home/probe".into())),
8176 "frontend-seeded initial vars (HOME/PATH) must survive reset(), not silently vanish"
8177 );
8178 }
8179
8180 #[tokio::test]
8181 async fn test_kernel_cwd() {
8182 let kernel = Kernel::transient().expect("failed to create kernel");
8183
8184 // Transient kernel uses sandboxed mode with cwd=$HOME
8185 let cwd = kernel.cwd().await;
8186 let home = std::env::var("HOME")
8187 .map(PathBuf::from)
8188 .unwrap_or_else(|_| PathBuf::from("/"));
8189 assert_eq!(cwd, home);
8190
8191 kernel.set_cwd(PathBuf::from("/tmp")).await;
8192 assert_eq!(kernel.cwd().await, PathBuf::from("/tmp"));
8193 }
8194
8195 #[tokio::test]
8196 async fn test_kernel_list_vars() {
8197 let kernel = Kernel::transient().expect("failed to create kernel");
8198
8199 kernel.execute("A=1").await.ok();
8200 kernel.execute("B=2").await.ok();
8201
8202 let vars = kernel.list_vars().await;
8203 assert!(vars.iter().any(|(n, v)| n == "A" && *v == Value::Int(1)));
8204 assert!(vars.iter().any(|(n, v)| n == "B" && *v == Value::Int(2)));
8205 }
8206
8207 #[tokio::test]
8208 async fn test_is_truthy() {
8209 assert!(!is_truthy(&Value::Null));
8210 assert!(!is_truthy(&Value::Bool(false)));
8211 assert!(is_truthy(&Value::Bool(true)));
8212 assert!(!is_truthy(&Value::Int(0)));
8213 assert!(is_truthy(&Value::Int(1)));
8214 assert!(!is_truthy(&Value::String("".into())));
8215 assert!(is_truthy(&Value::String("x".into())));
8216 }
8217
8218 #[tokio::test]
8219 async fn test_jq_in_pipeline() {
8220 let kernel = Kernel::transient().expect("failed to create kernel");
8221 // kaish uses double quotes only; escape inner quotes
8222 let result = kernel
8223 .execute(r#"echo "{\"name\": \"Alice\"}" | jq ".name" -r"#)
8224 .await
8225 .expect("execution failed");
8226 assert!(result.ok(), "jq pipeline failed: {}", result.err);
8227 assert_eq!(result.text_out().trim(), "Alice");
8228 }
8229
8230 #[tokio::test]
8231 async fn test_user_defined_tool() {
8232 let kernel = Kernel::transient().expect("failed to create kernel");
8233
8234 // Define a function
8235 kernel
8236 .execute(r#"greet() { echo "Hello, $1!" }"#)
8237 .await
8238 .expect("function definition failed");
8239
8240 // Call the function
8241 let result = kernel
8242 .execute(r#"greet "World""#)
8243 .await
8244 .expect("function call failed");
8245
8246 assert!(result.ok(), "greet failed: {}", result.err);
8247 assert_eq!(result.text_out().trim(), "Hello, World!");
8248 }
8249
8250 #[tokio::test]
8251 async fn test_user_tool_positional_args() {
8252 let kernel = Kernel::transient().expect("failed to create kernel");
8253
8254 // Define a function with positional param
8255 kernel
8256 .execute(r#"greet() { echo "Hi $1" }"#)
8257 .await
8258 .expect("function definition failed");
8259
8260 // Call with positional argument
8261 let result = kernel
8262 .execute(r#"greet "Amy""#)
8263 .await
8264 .expect("function call failed");
8265
8266 assert!(result.ok(), "greet failed: {}", result.err);
8267 assert_eq!(result.text_out().trim(), "Hi Amy");
8268 }
8269
8270 #[tokio::test]
8271 async fn test_function_shared_scope() {
8272 let kernel = Kernel::transient().expect("failed to create kernel");
8273
8274 // Set a variable in parent scope
8275 kernel
8276 .execute(r#"SECRET="hidden""#)
8277 .await
8278 .expect("set failed");
8279
8280 // Define a function that accesses and modifies parent variable
8281 kernel
8282 .execute(r#"access_parent() {
8283 echo "${SECRET}"
8284 SECRET="modified"
8285 }"#)
8286 .await
8287 .expect("function definition failed");
8288
8289 // Call the function - it SHOULD see SECRET (shared scope like sh)
8290 let result = kernel.execute("access_parent").await.expect("function call failed");
8291
8292 // Function should have access to parent scope
8293 assert!(
8294 result.text_out().contains("hidden"),
8295 "Function should access parent scope, got: {}",
8296 result.text_out()
8297 );
8298
8299 // Function should have modified the parent variable
8300 let secret = kernel.get_var("SECRET").await;
8301 assert_eq!(
8302 secret,
8303 Some(Value::String("modified".into())),
8304 "Function should modify parent scope"
8305 );
8306 }
8307
8308 #[tokio::test]
8309 #[ignore = "exec replaces the test binary via CommandExt::exec, hangs libtest; cannot be run under cargo test"]
8310 async fn test_exec_builtin() {
8311 let kernel = Kernel::transient().expect("failed to create kernel");
8312 // argv is now a space-separated string or JSON array string
8313 let result = kernel
8314 .execute(r#"exec command="/bin/echo" argv="hello world""#)
8315 .await
8316 .expect("exec failed");
8317
8318 assert!(result.ok(), "exec failed: {}", result.err);
8319 assert_eq!(result.text_out().trim(), "hello world");
8320 }
8321
8322 #[tokio::test]
8323 async fn test_while_false_never_runs() {
8324 let kernel = Kernel::transient().expect("failed to create kernel");
8325
8326 // A while loop with false condition should never run
8327 let result = kernel
8328 .execute(r#"
8329 while false; do
8330 echo "should not run"
8331 done
8332 "#)
8333 .await
8334 .expect("while false failed");
8335
8336 assert!(result.ok());
8337 assert!(result.text_out().is_empty(), "while false should not execute body: {}", result.text_out());
8338 }
8339
8340 #[tokio::test]
8341 async fn test_while_string_comparison() {
8342 let kernel = Kernel::transient().expect("failed to create kernel");
8343
8344 // Set a flag
8345 kernel.execute(r#"FLAG="go""#).await.expect("set failed");
8346
8347 // Use string comparison as condition (shell-compatible [[ ]] syntax)
8348 // Note: Put echo last so we can check the output
8349 let result = kernel
8350 .execute(r#"
8351 while [[ ${FLAG} == "go" ]]; do
8352 FLAG="stop"
8353 echo "running"
8354 done
8355 "#)
8356 .await
8357 .expect("while with string cmp failed");
8358
8359 assert!(result.ok());
8360 assert!(result.text_out().contains("running"), "should have run once: {}", result.text_out());
8361
8362 // Verify flag was changed
8363 let flag = kernel.get_var("FLAG").await;
8364 assert_eq!(flag, Some(Value::String("stop".into())));
8365 }
8366
8367 #[tokio::test]
8368 async fn test_while_numeric_comparison() {
8369 let kernel = Kernel::transient().expect("failed to create kernel");
8370
8371 // Test > comparison (shell-compatible [[ ]] with -gt)
8372 kernel.execute("N=5").await.expect("set failed");
8373
8374 // Note: Put echo last so we can check the output
8375 let result = kernel
8376 .execute(r#"
8377 while [[ ${N} -gt 3 ]]; do
8378 N=3
8379 echo "N was greater"
8380 done
8381 "#)
8382 .await
8383 .expect("while with > failed");
8384
8385 assert!(result.ok());
8386 assert!(result.text_out().contains("N was greater"), "should have run once: {}", result.text_out());
8387 }
8388
8389 #[tokio::test]
8390 async fn test_break_in_while_loop() {
8391 let kernel = Kernel::transient().expect("failed to create kernel");
8392
8393 let result = kernel
8394 .execute(r#"
8395 I=0
8396 while true; do
8397 I=1
8398 echo "before break"
8399 break
8400 echo "after break"
8401 done
8402 "#)
8403 .await
8404 .expect("while with break failed");
8405
8406 assert!(result.ok());
8407 assert!(result.text_out().contains("before break"), "should see before break: {}", result.text_out());
8408 assert!(!result.text_out().contains("after break"), "should not see after break: {}", result.text_out());
8409
8410 // Verify we exited the loop
8411 let i = kernel.get_var("I").await;
8412 assert_eq!(i, Some(Value::Int(1)));
8413 }
8414
8415 #[tokio::test]
8416 async fn test_continue_in_while_loop() {
8417 let kernel = Kernel::transient().expect("failed to create kernel");
8418
8419 // Test continue in a while loop where variables persist
8420 // We use string state transition: "start" -> "middle" -> "end"
8421 // continue on "middle" should skip to next iteration
8422 // Shell-compatible: use [[ ]] for comparisons
8423 let result = kernel
8424 .execute(r#"
8425 STATE="start"
8426 AFTER_CONTINUE="no"
8427 while [[ ${STATE} != "done" ]]; do
8428 if [[ ${STATE} == "start" ]]; then
8429 STATE="middle"
8430 continue
8431 AFTER_CONTINUE="yes"
8432 fi
8433 if [[ ${STATE} == "middle" ]]; then
8434 STATE="done"
8435 fi
8436 done
8437 "#)
8438 .await
8439 .expect("while with continue failed");
8440
8441 assert!(result.ok());
8442
8443 // STATE should be "done" (we completed the loop)
8444 let state = kernel.get_var("STATE").await;
8445 assert_eq!(state, Some(Value::String("done".into())));
8446
8447 // AFTER_CONTINUE should still be "no" (continue skipped the assignment)
8448 let after = kernel.get_var("AFTER_CONTINUE").await;
8449 assert_eq!(after, Some(Value::String("no".into())));
8450 }
8451
8452 #[tokio::test]
8453 async fn test_break_with_level() {
8454 let kernel = Kernel::transient().expect("failed to create kernel");
8455
8456 // Nested loop with break 2 to exit both loops
8457 // We verify by checking OUTER value:
8458 // - If break 2 works, OUTER stays at 1 (set before for loop)
8459 // - If break 2 fails, OUTER becomes 2 (set after for loop)
8460 let result = kernel
8461 .execute(r#"
8462 OUTER=0
8463 while true; do
8464 OUTER=1
8465 for X in "1 2"; do
8466 break 2
8467 done
8468 OUTER=2
8469 done
8470 "#)
8471 .await
8472 .expect("nested break failed");
8473
8474 assert!(result.ok());
8475
8476 // OUTER should be 1 (set before for loop), not 2 (would be set after for loop)
8477 let outer = kernel.get_var("OUTER").await;
8478 assert_eq!(outer, Some(Value::Int(1)), "break 2 should have skipped OUTER=2");
8479 }
8480
8481 #[tokio::test]
8482 async fn test_return_from_tool() {
8483 let kernel = Kernel::transient().expect("failed to create kernel");
8484
8485 // Define a function that returns early
8486 kernel
8487 .execute(r#"early_return() {
8488 if [[ $1 == 1 ]]; then
8489 return 42
8490 fi
8491 echo "not returned"
8492 }"#)
8493 .await
8494 .expect("function definition failed");
8495
8496 // Call with arg=1 should return with exit code 42
8497 // (POSIX shell behavior: return N sets exit code, doesn't output N)
8498 let result = kernel
8499 .execute("early_return 1")
8500 .await
8501 .expect("function call failed");
8502
8503 // Exit code should be 42 (non-zero, so not ok())
8504 assert_eq!(result.code, 42);
8505 // Output should be empty (we returned before echo)
8506 assert!(result.text_out().is_empty());
8507 }
8508
8509 #[tokio::test]
8510 async fn test_return_without_value() {
8511 let kernel = Kernel::transient().expect("failed to create kernel");
8512
8513 // Define a function that returns without a value
8514 kernel
8515 .execute(r#"early_exit() {
8516 if [[ $1 == "stop" ]]; then
8517 return
8518 fi
8519 echo "continued"
8520 }"#)
8521 .await
8522 .expect("function definition failed");
8523
8524 // Call with arg="stop" should return early
8525 let result = kernel
8526 .execute(r#"early_exit "stop""#)
8527 .await
8528 .expect("function call failed");
8529
8530 assert!(result.ok());
8531 assert!(result.text_out().is_empty() || result.text_out().trim().is_empty());
8532 }
8533
8534 #[tokio::test]
8535 async fn test_exit_stops_execution() {
8536 let kernel = Kernel::transient().expect("failed to create kernel");
8537
8538 // exit should stop further execution
8539 kernel
8540 .execute(r#"
8541 BEFORE="yes"
8542 exit 0
8543 AFTER="yes"
8544 "#)
8545 .await
8546 .expect("execution failed");
8547
8548 // BEFORE should be set, AFTER should not
8549 let before = kernel.get_var("BEFORE").await;
8550 assert_eq!(before, Some(Value::String("yes".into())));
8551
8552 let after = kernel.get_var("AFTER").await;
8553 assert!(after.is_none(), "AFTER should not be set after exit");
8554 }
8555
8556 #[tokio::test]
8557 async fn test_exit_with_code() {
8558 let kernel = Kernel::transient().expect("failed to create kernel");
8559
8560 // exit with code should propagate the exit code
8561 let result = kernel
8562 .execute("exit 42")
8563 .await
8564 .expect("exit failed");
8565
8566 assert_eq!(result.code, 42);
8567 assert!(result.text_out().is_empty(), "exit should not produce stdout");
8568 }
8569
8570 #[tokio::test]
8571 async fn test_set_e_stops_on_failure() {
8572 let kernel = Kernel::transient().expect("failed to create kernel");
8573
8574 // Enable error-exit mode
8575 kernel.execute("set -e").await.expect("set -e failed");
8576
8577 // Run a sequence where the middle command fails
8578 kernel
8579 .execute(r#"
8580 STEP1="done"
8581 false
8582 STEP2="done"
8583 "#)
8584 .await
8585 .expect("execution failed");
8586
8587 // STEP1 should be set, but STEP2 should NOT be set (exit on false)
8588 let step1 = kernel.get_var("STEP1").await;
8589 assert_eq!(step1, Some(Value::String("done".into())));
8590
8591 let step2 = kernel.get_var("STEP2").await;
8592 assert!(step2.is_none(), "STEP2 should not be set after false with set -e");
8593 }
8594
8595 #[tokio::test]
8596 async fn test_set_plus_e_disables_error_exit() {
8597 let kernel = Kernel::transient().expect("failed to create kernel");
8598
8599 // Enable then disable error-exit mode
8600 kernel.execute("set -e").await.expect("set -e failed");
8601 kernel.execute("set +e").await.expect("set +e failed");
8602
8603 // Now failure should NOT stop execution
8604 kernel
8605 .execute(r#"
8606 STEP1="done"
8607 false
8608 STEP2="done"
8609 "#)
8610 .await
8611 .expect("execution failed");
8612
8613 // Both should be set since +e disables error exit
8614 let step1 = kernel.get_var("STEP1").await;
8615 assert_eq!(step1, Some(Value::String("done".into())));
8616
8617 let step2 = kernel.get_var("STEP2").await;
8618 assert_eq!(step2, Some(Value::String("done".into())));
8619 }
8620
8621 #[tokio::test]
8622 async fn test_set_euo_pipefail_is_a_working_prelude() {
8623 let kernel = Kernel::transient().expect("failed to create kernel");
8624
8625 // `set -euo pipefail` is muscle memory for a lot of script authors.
8626 // kaish implements -e and pipefail, and silently ignores the bare -u
8627 // (no fixed set to check it against). It used to FAIL on -o pipefail,
8628 // which mattered well beyond tidiness: an embedder whose exit status
8629 // is a policy decision — kaijutsu gates a tool call on it — read a
8630 // habitual first line as a deny.
8631 let result = kernel
8632 .execute("set -euo pipefail")
8633 .await
8634 .expect("set -euo pipefail failed");
8635 assert!(result.ok(), "the prelude must succeed, err={}", result.err);
8636
8637 // Both options really took effect, not just parsed.
8638 let result = kernel.execute("set -o").await.expect("set -o failed");
8639 let text = result.text_out();
8640 for row in text.lines() {
8641 if row.contains("pipefail") {
8642 assert!(row.contains("on"), "pipefail should read on: {row}");
8643 }
8644 }
8645
8646 // -e is live: the statement after a failure never runs.
8647 kernel
8648 .execute(r#"
8649 BEFORE="yes"
8650 false
8651 AFTER="yes"
8652 "#)
8653 .await
8654 .ok();
8655
8656 let after = kernel.get_var("AFTER").await;
8657 assert!(after.is_none(), "-e should be enabled by the prelude");
8658 }
8659
8660 #[tokio::test]
8661 async fn test_set_no_args_shows_settings() {
8662 let kernel = Kernel::transient().expect("failed to create kernel");
8663
8664 // Enable -e
8665 kernel.execute("set -e").await.expect("set -e failed");
8666
8667 // Call set with no args to see settings
8668 let result = kernel.execute("set").await.expect("set failed");
8669
8670 assert!(result.ok());
8671 assert!(result.text_out().contains("set -e"), "should show -e is enabled: {}", result.text_out());
8672 }
8673
8674 #[tokio::test]
8675 async fn test_set_e_in_pipeline() {
8676 let kernel = Kernel::transient().expect("failed to create kernel");
8677
8678 kernel.execute("set -e").await.expect("set -e failed");
8679
8680 // Pipeline failure should trigger exit
8681 kernel
8682 .execute(r#"
8683 BEFORE="yes"
8684 false | cat
8685 AFTER="yes"
8686 "#)
8687 .await
8688 .ok();
8689
8690 let before = kernel.get_var("BEFORE").await;
8691 assert_eq!(before, Some(Value::String("yes".into())));
8692
8693 // AFTER should not be set if pipeline failure triggers exit
8694 // Note: The exit code of a pipeline is the exit code of the last command
8695 // So `false | cat` returns 0 (cat succeeds). This is bash-compatible behavior.
8696 // To test pipeline failure, we need the last command to fail.
8697 }
8698
8699 #[tokio::test]
8700 async fn test_set_e_with_and_chain() {
8701 let kernel = Kernel::transient().expect("failed to create kernel");
8702
8703 kernel.execute("set -e").await.expect("set -e failed");
8704
8705 // Commands in && chain should not trigger -e on the first failure
8706 // because && explicitly handles the error
8707 kernel
8708 .execute(r#"
8709 RESULT="initial"
8710 false && RESULT="chained"
8711 RESULT="continued"
8712 "#)
8713 .await
8714 .ok();
8715
8716 // In bash, commands in && don't trigger -e. The chain handles the failure.
8717 // Our implementation may differ - let's verify current behavior.
8718 let result = kernel.get_var("RESULT").await;
8719 // If we follow bash semantics, RESULT should be "continued"
8720 // If we trigger -e on the false, RESULT stays "initial"
8721 assert!(result.is_some(), "RESULT should be set");
8722 }
8723
8724 #[tokio::test]
8725 async fn test_set_e_exits_in_for_loop() {
8726 let kernel = Kernel::transient().expect("failed to create kernel");
8727
8728 kernel.execute("set -e").await.expect("set -e failed");
8729
8730 kernel
8731 .execute(r#"
8732 REACHED="no"
8733 for x in 1 2 3; do
8734 false
8735 REACHED="yes"
8736 done
8737 "#)
8738 .await
8739 .ok();
8740
8741 // With set -e, false should trigger exit; REACHED should remain "no"
8742 let reached = kernel.get_var("REACHED").await;
8743 assert_eq!(reached, Some(Value::String("no".into())),
8744 "set -e should exit on failure in for loop body");
8745 }
8746
8747 #[tokio::test]
8748 async fn test_for_loop_continues_without_set_e() {
8749 let kernel = Kernel::transient().expect("failed to create kernel");
8750
8751 // Without set -e, for loop should continue normally
8752 kernel
8753 .execute(r#"
8754 COUNT=0
8755 for x in 1 2 3; do
8756 false
8757 COUNT=$((COUNT + 1))
8758 done
8759 "#)
8760 .await
8761 .ok();
8762
8763 let count = kernel.get_var("COUNT").await;
8764 // Arithmetic produces Int values; accept either Int or String representation
8765 let count_val = match &count {
8766 Some(Value::Int(n)) => *n,
8767 Some(Value::String(s)) => s.parse().unwrap_or(-1),
8768 _ => -1,
8769 };
8770 assert_eq!(count_val, 3,
8771 "without set -e, loop should complete all iterations (got {:?})", count);
8772 }
8773
8774 // ═══════════════════════════════════════════════════════════════════════════
8775 // Source Tests
8776 // ═══════════════════════════════════════════════════════════════════════════
8777
8778 #[tokio::test]
8779 async fn test_source_sets_variables() {
8780 let kernel = Kernel::transient().expect("failed to create kernel");
8781
8782 // Write a script to the VFS
8783 kernel
8784 .execute(r#"write "/test.kai" 'FOO="bar"'"#)
8785 .await
8786 .expect("write failed");
8787
8788 // Source the script
8789 let result = kernel
8790 .execute(r#"source "/test.kai""#)
8791 .await
8792 .expect("source failed");
8793
8794 assert!(result.ok(), "source should succeed");
8795
8796 // Variable should be set in current scope
8797 let foo = kernel.get_var("FOO").await;
8798 assert_eq!(foo, Some(Value::String("bar".into())));
8799 }
8800
8801 #[tokio::test]
8802 async fn test_source_with_dot_alias() {
8803 let kernel = Kernel::transient().expect("failed to create kernel");
8804
8805 // Write a script to the VFS
8806 kernel
8807 .execute(r#"write "/vars.kai" 'X=42'"#)
8808 .await
8809 .expect("write failed");
8810
8811 // Source using . alias
8812 let result = kernel
8813 .execute(r#". "/vars.kai""#)
8814 .await
8815 .expect(". failed");
8816
8817 assert!(result.ok(), ". should succeed");
8818
8819 // Variable should be set in current scope
8820 let x = kernel.get_var("X").await;
8821 assert_eq!(x, Some(Value::Int(42)));
8822 }
8823
8824 #[tokio::test]
8825 async fn test_source_not_found() {
8826 let kernel = Kernel::transient().expect("failed to create kernel");
8827
8828 // Try to source a non-existent file
8829 let result = kernel
8830 .execute(r#"source "/nonexistent.kai""#)
8831 .await
8832 .expect("source should not fail with error");
8833
8834 assert!(!result.ok(), "source of non-existent file should fail");
8835 assert!(result.err.contains("nonexistent.kai"), "error should mention filename");
8836 }
8837
8838 #[tokio::test]
8839 async fn test_source_missing_filename() {
8840 let kernel = Kernel::transient().expect("failed to create kernel");
8841
8842 // Call source with no arguments
8843 let result = kernel
8844 .execute("source")
8845 .await
8846 .expect("source should not fail with error");
8847
8848 assert!(!result.ok(), "source without filename should fail");
8849 assert!(result.err.contains("missing filename"), "error should mention missing filename");
8850 }
8851
8852 #[tokio::test]
8853 async fn test_source_executes_multiple_statements() {
8854 let kernel = Kernel::transient().expect("failed to create kernel");
8855
8856 // Write a script with multiple statements
8857 kernel
8858 .execute(r#"write "/multi.kai" 'A=1
8859B=2
8860C=3'"#)
8861 .await
8862 .expect("write failed");
8863
8864 // Source it
8865 kernel
8866 .execute(r#"source "/multi.kai""#)
8867 .await
8868 .expect("source failed");
8869
8870 // All variables should be set
8871 assert_eq!(kernel.get_var("A").await, Some(Value::Int(1)));
8872 assert_eq!(kernel.get_var("B").await, Some(Value::Int(2)));
8873 assert_eq!(kernel.get_var("C").await, Some(Value::Int(3)));
8874 }
8875
8876 #[tokio::test]
8877 async fn test_source_can_define_functions() {
8878 let kernel = Kernel::transient().expect("failed to create kernel");
8879
8880 // Write a script that defines a function
8881 kernel
8882 .execute(r#"write "/functions.kai" 'greet() {
8883 echo "Hello, $1!"
8884}'"#)
8885 .await
8886 .expect("write failed");
8887
8888 // Source it
8889 kernel
8890 .execute(r#"source "/functions.kai""#)
8891 .await
8892 .expect("source failed");
8893
8894 // Use the defined function
8895 let result = kernel
8896 .execute(r#"greet "World""#)
8897 .await
8898 .expect("greet failed");
8899
8900 assert!(result.ok());
8901 assert!(result.text_out().contains("Hello, World!"));
8902 }
8903
8904 #[tokio::test]
8905 async fn test_source_inherits_error_exit() {
8906 let kernel = Kernel::transient().expect("failed to create kernel");
8907
8908 // Enable error exit
8909 kernel.execute("set -e").await.expect("set -e failed");
8910
8911 // Write a script that has a failure
8912 kernel
8913 .execute(r#"write "/fail.kai" 'BEFORE="yes"
8914false
8915AFTER="yes"'"#)
8916 .await
8917 .expect("write failed");
8918
8919 // Source it (should exit on false due to set -e)
8920 kernel
8921 .execute(r#"source "/fail.kai""#)
8922 .await
8923 .ok();
8924
8925 // BEFORE should be set, AFTER should NOT be set due to error exit
8926 let before = kernel.get_var("BEFORE").await;
8927 assert_eq!(before, Some(Value::String("yes".into())));
8928
8929 // Note: This test depends on whether error exit is checked within source
8930 // Currently our implementation checks per-statement in the main kernel
8931 }
8932
8933 // ═══════════════════════════════════════════════════════════════════════════
8934 // set -e with && / || chains
8935 // ═══════════════════════════════════════════════════════════════════════════
8936
8937 #[tokio::test]
8938 async fn test_set_e_and_chain_left_fails() {
8939 // set -e; false && echo hi; REACHED=1 → REACHED should be set
8940 let kernel = Kernel::transient().expect("failed to create kernel");
8941 kernel.execute("set -e").await.expect("set -e failed");
8942
8943 kernel
8944 .execute("false && echo hi; REACHED=1")
8945 .await
8946 .expect("execution failed");
8947
8948 let reached = kernel.get_var("REACHED").await;
8949 assert_eq!(
8950 reached,
8951 Some(Value::Int(1)),
8952 "set -e should not trigger on left side of &&"
8953 );
8954 }
8955
8956 #[tokio::test]
8957 async fn test_set_e_and_chain_right_fails() {
8958 // set -e; true && false; REACHED=1 → REACHED should NOT be set
8959 let kernel = Kernel::transient().expect("failed to create kernel");
8960 kernel.execute("set -e").await.expect("set -e failed");
8961
8962 kernel
8963 .execute("true && false; REACHED=1")
8964 .await
8965 .expect("execution failed");
8966
8967 let reached = kernel.get_var("REACHED").await;
8968 assert!(
8969 reached.is_none(),
8970 "set -e should trigger when right side of && fails"
8971 );
8972 }
8973
8974 #[tokio::test]
8975 async fn test_set_e_or_chain_recovers() {
8976 // set -e; false || echo recovered; REACHED=1 → REACHED should be set
8977 let kernel = Kernel::transient().expect("failed to create kernel");
8978 kernel.execute("set -e").await.expect("set -e failed");
8979
8980 kernel
8981 .execute("false || echo recovered; REACHED=1")
8982 .await
8983 .expect("execution failed");
8984
8985 let reached = kernel.get_var("REACHED").await;
8986 assert_eq!(
8987 reached,
8988 Some(Value::Int(1)),
8989 "set -e should not trigger when || recovers the failure"
8990 );
8991 }
8992
8993 #[tokio::test]
8994 async fn test_set_e_or_chain_both_fail() {
8995 // set -e; false || false; REACHED=1 → REACHED should NOT be set
8996 let kernel = Kernel::transient().expect("failed to create kernel");
8997 kernel.execute("set -e").await.expect("set -e failed");
8998
8999 kernel
9000 .execute("false || false; REACHED=1")
9001 .await
9002 .expect("execution failed");
9003
9004 let reached = kernel.get_var("REACHED").await;
9005 assert!(
9006 reached.is_none(),
9007 "set -e should trigger when || chain ultimately fails"
9008 );
9009 }
9010
9011 // ═══════════════════════════════════════════════════════════════════════════
9012 // Cancellation Tests
9013 // ═══════════════════════════════════════════════════════════════════════════
9014
9015 /// Helper: schedule a cancel after a delay from a background thread.
9016 /// Uses std::thread because cancel() is sync and Kernel is not Send.
9017 fn schedule_cancel(kernel: &Arc<Kernel>, delay: std::time::Duration) {
9018 let k = Arc::clone(kernel);
9019 std::thread::spawn(move || {
9020 std::thread::sleep(delay);
9021 k.cancel();
9022 });
9023 }
9024
9025 #[tokio::test]
9026 async fn test_cancel_interrupts_for_loop() {
9027 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9028
9029 // Schedule cancel after a short delay from a background OS thread
9030 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
9031
9032 // #149: a bare `X=$i` body has no await point, so the for-loop's
9033 // cancellation checkpoint (checked once per iteration, see the
9034 // `Stmt::For` arm above) never gets a chance to run mid-body — under
9035 // host load, 100_000 trivial iterations could complete and return
9036 // before the background thread's 10ms sleep ever elapsed, racing a
9037 // natural exit-0 completion against the scheduled cancel. Rather than
9038 // widen the margin (there's no bound on how slow "under load" can be),
9039 // make completion deterministically impossible inside the test
9040 // window: `sleep` is a real interruptible await point (it races
9041 // `tokio::time::sleep` against the same cancellation token — see
9042 // `tools/builtin/sleep.rs`), so a per-iteration sleep both gives
9043 // cancellation somewhere to land almost immediately AND, at enough
9044 // iterations, makes natural completion take far longer than the
9045 // bounded wait below. The outer timeout is the "must not hang CI if
9046 // cancellation is broken" backstop: it fails loudly well before the
9047 // loop could ever finish on its own.
9048 const ITERATIONS: u32 = 2000;
9049 const PER_ITERATION_SLEEP_SECS: f64 = 0.05;
9050 let bound = std::time::Duration::from_secs(10);
9051 let script = format!("for i in $(seq 1 {ITERATIONS}); do X=$i; sleep {PER_ITERATION_SLEEP_SECS}; done");
9052
9053 let result = tokio::time::timeout(bound, kernel.execute(&script))
9054 .await
9055 .unwrap_or_else(|_| {
9056 panic!(
9057 "for-loop did not return within {bound:?} — cancellation support looks \
9058 broken (an uncancelled loop needs ~{:.0}s to finish on its own, far \
9059 longer than this bound)",
9060 ITERATIONS as f64 * PER_ITERATION_SLEEP_SECS
9061 )
9062 })
9063 .expect("execute failed");
9064
9065 assert_eq!(result.code, 130, "cancelled execution should exit with code 130");
9066
9067 // The loop variable should be set to something well short of the full
9068 // iteration count — i.e. cancellation landed long before the loop
9069 // could complete on its own.
9070 let x = kernel.get_var("X").await;
9071 if let Some(Value::Int(n)) = x {
9072 assert!(
9073 n < i64::from(ITERATIONS),
9074 "loop should have been interrupted before finishing, got X={n}"
9075 );
9076 }
9077 }
9078
9079 #[tokio::test]
9080 async fn test_cancel_interrupts_while_loop() {
9081 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9082 kernel.execute("COUNT=0").await.expect("init failed");
9083
9084 schedule_cancel(&kernel, std::time::Duration::from_millis(10));
9085
9086 let result = kernel
9087 .execute("while true; do COUNT=$((COUNT + 1)); done")
9088 .await
9089 .expect("execute failed");
9090
9091 assert_eq!(result.code, 130);
9092
9093 let count = kernel.get_var("COUNT").await;
9094 if let Some(Value::Int(n)) = count {
9095 assert!(n > 0, "loop should have run at least once");
9096 }
9097 }
9098
9099 #[tokio::test]
9100 async fn test_reset_after_cancel() {
9101 // After cancellation, the next execute() should work normally
9102 let kernel = Kernel::transient().expect("failed to create kernel");
9103 kernel.cancel(); // cancel with nothing running
9104
9105 let result = kernel.execute("echo hello").await.expect("execute failed");
9106 assert!(result.ok(), "execute after cancel should succeed");
9107 assert_eq!(result.text_out().trim(), "hello");
9108 }
9109
9110 #[tokio::test]
9111 async fn test_cancel_interrupts_statement_sequence() {
9112 let kernel = Arc::new(Kernel::transient().expect("failed to create kernel"));
9113
9114 // Schedule cancel after the first statement runs but before sleep finishes
9115 schedule_cancel(&kernel, std::time::Duration::from_millis(50));
9116
9117 let result = kernel
9118 .execute("STEP=1; sleep 5; STEP=2; sleep 5; STEP=3")
9119 .await
9120 .expect("execute failed");
9121
9122 assert_eq!(result.code, 130);
9123
9124 // STEP should be 1 (set before sleep), not 2 or 3
9125 let step = kernel.get_var("STEP").await;
9126 assert_eq!(step, Some(Value::Int(1)), "cancel should stop before STEP=2");
9127 }
9128
9129 // ═══════════════════════════════════════════════════════════════════════════
9130 // Case Statement Tests
9131 // ═══════════════════════════════════════════════════════════════════════════
9132
9133 #[tokio::test]
9134 async fn test_case_simple_match() {
9135 let kernel = Kernel::transient().expect("failed to create kernel");
9136
9137 let result = kernel
9138 .execute(r#"
9139 case "hello" in
9140 hello) echo "matched hello" ;;
9141 world) echo "matched world" ;;
9142 esac
9143 "#)
9144 .await
9145 .expect("case failed");
9146
9147 assert!(result.ok());
9148 assert_eq!(result.text_out().trim(), "matched hello");
9149 }
9150
9151 #[tokio::test]
9152 async fn test_case_wildcard_match() {
9153 let kernel = Kernel::transient().expect("failed to create kernel");
9154
9155 let result = kernel
9156 .execute(r#"
9157 case "main.rs" in
9158 *.py) echo "Python" ;;
9159 *.rs) echo "Rust" ;;
9160 *) echo "Unknown" ;;
9161 esac
9162 "#)
9163 .await
9164 .expect("case failed");
9165
9166 assert!(result.ok());
9167 assert_eq!(result.text_out().trim(), "Rust");
9168 }
9169
9170 #[tokio::test]
9171 async fn test_case_default_match() {
9172 let kernel = Kernel::transient().expect("failed to create kernel");
9173
9174 let result = kernel
9175 .execute(r#"
9176 case "unknown.xyz" in
9177 *.py) echo "Python" ;;
9178 *.rs) echo "Rust" ;;
9179 *) echo "Default" ;;
9180 esac
9181 "#)
9182 .await
9183 .expect("case failed");
9184
9185 assert!(result.ok());
9186 assert_eq!(result.text_out().trim(), "Default");
9187 }
9188
9189 #[tokio::test]
9190 async fn test_case_no_match() {
9191 let kernel = Kernel::transient().expect("failed to create kernel");
9192
9193 // Case with no default branch and no match
9194 let result = kernel
9195 .execute(r#"
9196 case "nope" in
9197 "yes") echo "yes" ;;
9198 "no") echo "no" ;;
9199 esac
9200 "#)
9201 .await
9202 .expect("case failed");
9203
9204 assert!(result.ok());
9205 assert!(result.text_out().is_empty(), "no match should produce empty output");
9206 }
9207
9208 #[tokio::test]
9209 async fn test_case_with_variable() {
9210 let kernel = Kernel::transient().expect("failed to create kernel");
9211
9212 kernel.execute(r#"LANG="rust""#).await.expect("set failed");
9213
9214 let result = kernel
9215 .execute(r#"
9216 case ${LANG} in
9217 python) echo "snake" ;;
9218 rust) echo "crab" ;;
9219 go) echo "gopher" ;;
9220 esac
9221 "#)
9222 .await
9223 .expect("case failed");
9224
9225 assert!(result.ok());
9226 assert_eq!(result.text_out().trim(), "crab");
9227 }
9228
9229 #[tokio::test]
9230 async fn test_case_multiple_patterns() {
9231 let kernel = Kernel::transient().expect("failed to create kernel");
9232
9233 let result = kernel
9234 .execute(r#"
9235 case "yes" in
9236 "y"|"yes"|"Y"|"YES") echo "affirmative" ;;
9237 "n"|"no"|"N"|"NO") echo "negative" ;;
9238 esac
9239 "#)
9240 .await
9241 .expect("case failed");
9242
9243 assert!(result.ok());
9244 assert_eq!(result.text_out().trim(), "affirmative");
9245 }
9246
9247 #[tokio::test]
9248 async fn test_case_glob_question_mark() {
9249 let kernel = Kernel::transient().expect("failed to create kernel");
9250
9251 let result = kernel
9252 .execute(r#"
9253 case "test1" in
9254 test?) echo "matched test?" ;;
9255 *) echo "default" ;;
9256 esac
9257 "#)
9258 .await
9259 .expect("case failed");
9260
9261 assert!(result.ok());
9262 assert_eq!(result.text_out().trim(), "matched test?");
9263 }
9264
9265 #[tokio::test]
9266 async fn test_case_char_class() {
9267 let kernel = Kernel::transient().expect("failed to create kernel");
9268
9269 let result = kernel
9270 .execute(r#"
9271 case "Yes" in
9272 [Yy]*) echo "yes-like" ;;
9273 [Nn]*) echo "no-like" ;;
9274 esac
9275 "#)
9276 .await
9277 .expect("case failed");
9278
9279 assert!(result.ok());
9280 assert_eq!(result.text_out().trim(), "yes-like");
9281 }
9282
9283 // ═══════════════════════════════════════════════════════════════════════════
9284 // Cat Stdin Tests
9285 // ═══════════════════════════════════════════════════════════════════════════
9286
9287 #[tokio::test]
9288 async fn test_cat_from_pipeline() {
9289 let kernel = Kernel::transient().expect("failed to create kernel");
9290
9291 let result = kernel
9292 .execute(r#"echo "piped text" | cat"#)
9293 .await
9294 .expect("cat pipeline failed");
9295
9296 assert!(result.ok(), "cat failed: {}", result.err);
9297 assert_eq!(result.text_out().trim(), "piped text");
9298 }
9299
9300 #[tokio::test]
9301 async fn test_cat_from_pipeline_multiline() {
9302 let kernel = Kernel::transient().expect("failed to create kernel");
9303
9304 let result = kernel
9305 .execute(r#"echo "line1\nline2" | cat -n"#)
9306 .await
9307 .expect("cat pipeline failed");
9308
9309 assert!(result.ok(), "cat failed: {}", result.err);
9310 assert!(result.text_out().contains("1\t"), "output: {}", result.text_out());
9311 }
9312
9313 // ═══════════════════════════════════════════════════════════════════════════
9314 // Heredoc Tests
9315 // ═══════════════════════════════════════════════════════════════════════════
9316
9317 #[tokio::test]
9318 async fn test_heredoc_basic() {
9319 let kernel = Kernel::transient().expect("failed to create kernel");
9320
9321 let result = kernel
9322 .execute("cat <<EOF\nhello\nEOF")
9323 .await
9324 .expect("heredoc failed");
9325
9326 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9327 assert_eq!(result.text_out().trim(), "hello");
9328 }
9329
9330 #[tokio::test]
9331 async fn test_arithmetic_in_string() {
9332 let kernel = Kernel::transient().expect("failed to create kernel");
9333
9334 let result = kernel
9335 .execute(r#"echo "result: $((1 + 2))""#)
9336 .await
9337 .expect("arithmetic in string failed");
9338
9339 assert!(result.ok(), "echo failed: {}", result.err);
9340 assert_eq!(result.text_out().trim(), "result: 3");
9341 }
9342
9343 #[tokio::test]
9344 async fn test_heredoc_multiline() {
9345 let kernel = Kernel::transient().expect("failed to create kernel");
9346
9347 let result = kernel
9348 .execute("cat <<EOF\nline1\nline2\nline3\nEOF")
9349 .await
9350 .expect("heredoc failed");
9351
9352 assert!(result.ok(), "cat with heredoc failed: {}", result.err);
9353 assert!(result.text_out().contains("line1"), "output: {}", result.text_out());
9354 assert!(result.text_out().contains("line2"), "output: {}", result.text_out());
9355 assert!(result.text_out().contains("line3"), "output: {}", result.text_out());
9356 }
9357
9358 #[tokio::test]
9359 async fn test_heredoc_variable_expansion() {
9360 // Bug N: unquoted heredoc should expand variables
9361 let kernel = Kernel::transient().expect("failed to create kernel");
9362
9363 kernel.execute("GREETING=hello").await.expect("set var");
9364
9365 let result = kernel
9366 .execute("cat <<EOF\n$GREETING world\nEOF")
9367 .await
9368 .expect("heredoc expansion failed");
9369
9370 assert!(result.ok(), "heredoc expansion failed: {}", result.err);
9371 assert_eq!(result.text_out().trim(), "hello world");
9372 }
9373
9374 #[tokio::test]
9375 async fn test_heredoc_quoted_no_expansion() {
9376 // Bug N: quoted heredoc (<<'EOF') should NOT expand variables
9377 let kernel = Kernel::transient().expect("failed to create kernel");
9378
9379 kernel.execute("GREETING=hello").await.expect("set var");
9380
9381 let result = kernel
9382 .execute("cat <<'EOF'\n$GREETING world\nEOF")
9383 .await
9384 .expect("quoted heredoc failed");
9385
9386 assert!(result.ok(), "quoted heredoc failed: {}", result.err);
9387 assert_eq!(result.text_out().trim(), "$GREETING world");
9388 }
9389
9390 #[tokio::test]
9391 async fn test_heredoc_default_value_expansion() {
9392 // Bug N: ${VAR:-default} should expand in unquoted heredocs
9393 let kernel = Kernel::transient().expect("failed to create kernel");
9394
9395 let result = kernel
9396 .execute("cat <<EOF\n${UNSET:-fallback}\nEOF")
9397 .await
9398 .expect("heredoc default expansion failed");
9399
9400 assert!(result.ok(), "heredoc default expansion failed: {}", result.err);
9401 assert_eq!(result.text_out().trim(), "fallback");
9402 }
9403
9404 // ═══════════════════════════════════════════════════════════════════════════
9405 // Read Builtin Tests
9406 // ═══════════════════════════════════════════════════════════════════════════
9407
9408 #[tokio::test]
9409 async fn test_read_from_pipeline() {
9410 let kernel = Kernel::transient().expect("failed to create kernel");
9411
9412 // Pipe input to read
9413 let result = kernel
9414 .execute(r#"echo "Alice" | read NAME; echo "Hello, ${NAME}""#)
9415 .await
9416 .expect("read pipeline failed");
9417
9418 assert!(result.ok(), "read failed: {}", result.err);
9419 assert!(result.text_out().contains("Hello, Alice"), "output: {}", result.text_out());
9420 }
9421
9422 #[tokio::test]
9423 async fn test_read_multiple_vars_from_pipeline() {
9424 let kernel = Kernel::transient().expect("failed to create kernel");
9425
9426 let result = kernel
9427 .execute(r#"echo "John Doe 42" | read FIRST LAST AGE; echo "${FIRST} is ${AGE}""#)
9428 .await
9429 .expect("read pipeline failed");
9430
9431 assert!(result.ok(), "read failed: {}", result.err);
9432 assert!(result.text_out().contains("John is 42"), "output: {}", result.text_out());
9433 }
9434
9435 // ═══════════════════════════════════════════════════════════════════════════
9436 // Shell-Style Function Tests
9437 // ═══════════════════════════════════════════════════════════════════════════
9438
9439 #[tokio::test]
9440 async fn test_posix_function_with_positional_params() {
9441 let kernel = Kernel::transient().expect("failed to create kernel");
9442
9443 // Define POSIX-style function
9444 kernel
9445 .execute(r#"greet() { echo "Hello, $1!" }"#)
9446 .await
9447 .expect("function definition failed");
9448
9449 // Call the function
9450 let result = kernel
9451 .execute(r#"greet "Amy""#)
9452 .await
9453 .expect("function call failed");
9454
9455 assert!(result.ok(), "greet failed: {}", result.err);
9456 assert_eq!(result.text_out().trim(), "Hello, Amy!");
9457 }
9458
9459 #[tokio::test]
9460 async fn test_posix_function_multiple_args() {
9461 let kernel = Kernel::transient().expect("failed to create kernel");
9462
9463 // Define function using $1 and $2
9464 kernel
9465 .execute(r#"add_greeting() { echo "$1 $2!" }"#)
9466 .await
9467 .expect("function definition failed");
9468
9469 // Call the function
9470 let result = kernel
9471 .execute(r#"add_greeting "Hello" "World""#)
9472 .await
9473 .expect("function call failed");
9474
9475 assert!(result.ok(), "function failed: {}", result.err);
9476 assert_eq!(result.text_out().trim(), "Hello World!");
9477 }
9478
9479 #[tokio::test]
9480 async fn test_bash_function_with_positional_params() {
9481 let kernel = Kernel::transient().expect("failed to create kernel");
9482
9483 // Define bash-style function (function keyword, no parens)
9484 kernel
9485 .execute(r#"function greet { echo "Hi $1" }"#)
9486 .await
9487 .expect("function definition failed");
9488
9489 // Call the function
9490 let result = kernel
9491 .execute(r#"greet "Bob""#)
9492 .await
9493 .expect("function call failed");
9494
9495 assert!(result.ok(), "greet failed: {}", result.err);
9496 assert_eq!(result.text_out().trim(), "Hi Bob");
9497 }
9498
9499 #[tokio::test]
9500 async fn test_shell_function_with_all_args() {
9501 let kernel = Kernel::transient().expect("failed to create kernel");
9502
9503 // Define function using $@ (all args)
9504 kernel
9505 .execute(r#"echo_all() { echo "args: $@" }"#)
9506 .await
9507 .expect("function definition failed");
9508
9509 // Call with multiple args
9510 let result = kernel
9511 .execute(r#"echo_all "a" "b" "c""#)
9512 .await
9513 .expect("function call failed");
9514
9515 assert!(result.ok(), "function failed: {}", result.err);
9516 assert_eq!(result.text_out().trim(), "args: a b c");
9517 }
9518
9519 #[tokio::test]
9520 async fn test_shell_function_with_arg_count() {
9521 let kernel = Kernel::transient().expect("failed to create kernel");
9522
9523 // Define function using $# (arg count)
9524 kernel
9525 .execute(r#"count_args() { echo "count: $#" }"#)
9526 .await
9527 .expect("function definition failed");
9528
9529 // Call with three args
9530 let result = kernel
9531 .execute(r#"count_args "x" "y" "z""#)
9532 .await
9533 .expect("function call failed");
9534
9535 assert!(result.ok(), "function failed: {}", result.err);
9536 assert_eq!(result.text_out().trim(), "count: 3");
9537 }
9538
9539 #[tokio::test]
9540 async fn test_shell_function_shared_scope() {
9541 let kernel = Kernel::transient().expect("failed to create kernel");
9542
9543 // Set a variable in parent scope
9544 kernel
9545 .execute(r#"PARENT_VAR="visible""#)
9546 .await
9547 .expect("set failed");
9548
9549 // Define shell function that reads and writes parent variable
9550 kernel
9551 .execute(r#"modify_parent() {
9552 echo "saw: ${PARENT_VAR}"
9553 PARENT_VAR="changed by function"
9554 }"#)
9555 .await
9556 .expect("function definition failed");
9557
9558 // Call the function - it SHOULD see PARENT_VAR (bash-compatible shared scope)
9559 let result = kernel.execute("modify_parent").await.expect("function failed");
9560
9561 assert!(
9562 result.text_out().contains("visible"),
9563 "Shell function should access parent scope, got: {}",
9564 result.text_out()
9565 );
9566
9567 // Parent variable should be modified
9568 let var = kernel.get_var("PARENT_VAR").await;
9569 assert_eq!(
9570 var,
9571 Some(Value::String("changed by function".into())),
9572 "Shell function should modify parent scope"
9573 );
9574 }
9575
9576 // ═══════════════════════════════════════════════════════════════════════════
9577 // Script Execution via PATH Tests
9578 // ═══════════════════════════════════════════════════════════════════════════
9579
9580 #[tokio::test]
9581 async fn test_script_execution_from_path() {
9582 let kernel = Kernel::transient().expect("failed to create kernel");
9583
9584 // Create /bin directory and script
9585 kernel.execute(r#"mkdir "/bin""#).await.ok();
9586 kernel
9587 .execute(r#"write "/bin/hello.kai" 'echo "Hello from script!"'"#)
9588 .await
9589 .expect("write script failed");
9590
9591 // Set PATH to /bin
9592 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9593
9594 // Call script by name (without .kai extension)
9595 let result = kernel
9596 .execute("hello")
9597 .await
9598 .expect("script execution failed");
9599
9600 assert!(result.ok(), "script failed: {}", result.err);
9601 assert_eq!(result.text_out().trim(), "Hello from script!");
9602 }
9603
9604 #[tokio::test]
9605 async fn test_script_with_args() {
9606 let kernel = Kernel::transient().expect("failed to create kernel");
9607
9608 // Create script that uses positional params
9609 kernel.execute(r#"mkdir "/bin""#).await.ok();
9610 kernel
9611 .execute(r#"write "/bin/greet.kai" 'echo "Hello, $1!"'"#)
9612 .await
9613 .expect("write script failed");
9614
9615 // Set PATH
9616 kernel.execute(r#"PATH="/bin""#).await.expect("set PATH failed");
9617
9618 // Call script with arg
9619 let result = kernel
9620 .execute(r#"greet "World""#)
9621 .await
9622 .expect("script execution failed");
9623
9624 assert!(result.ok(), "script failed: {}", result.err);
9625 assert_eq!(result.text_out().trim(), "Hello, World!");
9626 }
9627
9628 #[tokio::test]
9629 async fn test_script_not_found() {
9630 let kernel = Kernel::transient().expect("failed to create kernel");
9631
9632 // Set empty PATH
9633 kernel.execute(r#"PATH="/nonexistent""#).await.expect("set PATH failed");
9634
9635 // Call non-existent script
9636 let result = kernel
9637 .execute("noscript")
9638 .await
9639 .expect("execution failed");
9640
9641 assert!(!result.ok(), "should fail with command not found");
9642 assert_eq!(result.code, 127);
9643 assert!(result.err.contains("command not found"));
9644 }
9645
9646 #[tokio::test]
9647 async fn test_script_path_search_order() {
9648 let kernel = Kernel::transient().expect("failed to create kernel");
9649
9650 // Create two directories with same-named script
9651 // Note: using "myscript" not "test" to avoid conflict with test builtin
9652 kernel.execute(r#"mkdir "/first""#).await.ok();
9653 kernel.execute(r#"mkdir "/second""#).await.ok();
9654 kernel
9655 .execute(r#"write "/first/myscript.kai" 'echo "from first"'"#)
9656 .await
9657 .expect("write failed");
9658 kernel
9659 .execute(r#"write "/second/myscript.kai" 'echo "from second"'"#)
9660 .await
9661 .expect("write failed");
9662
9663 // Set PATH with first before second
9664 kernel.execute(r#"PATH="/first:/second""#).await.expect("set PATH failed");
9665
9666 // Should find first one
9667 let result = kernel
9668 .execute("myscript")
9669 .await
9670 .expect("script execution failed");
9671
9672 assert!(result.ok(), "script failed: {}", result.err);
9673 assert_eq!(result.text_out().trim(), "from first");
9674 }
9675
9676 // ═══════════════════════════════════════════════════════════════════════════
9677 // Special Variable Tests ($?, $$, unset vars)
9678 // ═══════════════════════════════════════════════════════════════════════════
9679
9680 #[tokio::test]
9681 async fn test_last_exit_code_success() {
9682 let kernel = Kernel::transient().expect("failed to create kernel");
9683
9684 // true exits with 0
9685 let result = kernel.execute("true; echo $?").await.expect("execution failed");
9686 assert!(result.text_out().contains("0"), "expected 0, got: {}", result.text_out());
9687 }
9688
9689 #[tokio::test]
9690 async fn test_last_exit_code_failure() {
9691 let kernel = Kernel::transient().expect("failed to create kernel");
9692
9693 // false exits with 1
9694 let result = kernel.execute("false; echo $?").await.expect("execution failed");
9695 assert!(result.text_out().contains("1"), "expected 1, got: {}", result.text_out());
9696 }
9697
9698 #[tokio::test]
9699 async fn test_current_pid() {
9700 let kernel = Kernel::transient().expect("failed to create kernel");
9701
9702 let result = kernel.execute("echo $$").await.expect("execution failed");
9703 // PID should be a positive number
9704 let pid: u32 = result.text_out().trim().parse().expect("PID should be a number");
9705 assert!(pid > 0, "PID should be positive");
9706 }
9707
9708 #[tokio::test]
9709 async fn test_unset_variable_expands_to_empty() {
9710 let kernel = Kernel::transient().expect("failed to create kernel");
9711
9712 // Unset variable in interpolation should be empty
9713 let result = kernel.execute(r#"echo "prefix:${UNSET_VAR}:suffix""#).await.expect("execution failed");
9714 assert_eq!(result.text_out().trim(), "prefix::suffix");
9715 }
9716
9717 #[tokio::test]
9718 async fn test_eq_ne_operators() {
9719 let kernel = Kernel::transient().expect("failed to create kernel");
9720
9721 // Test -eq operator
9722 let result = kernel.execute(r#"if [[ 5 -eq 5 ]]; then echo "eq works"; fi"#).await.expect("execution failed");
9723 assert_eq!(result.text_out().trim(), "eq works");
9724
9725 // Test -ne operator
9726 let result = kernel.execute(r#"if [[ 5 -ne 3 ]]; then echo "ne works"; fi"#).await.expect("execution failed");
9727 assert_eq!(result.text_out().trim(), "ne works");
9728
9729 // Test -eq with different values
9730 let result = kernel.execute(r#"if [[ 5 -eq 3 ]]; then echo "wrong"; else echo "correct"; fi"#).await.expect("execution failed");
9731 assert_eq!(result.text_out().trim(), "correct");
9732 }
9733
9734 #[tokio::test]
9735 async fn test_escaped_dollar_in_string() {
9736 let kernel = Kernel::transient().expect("failed to create kernel");
9737
9738 // \$ should produce literal $
9739 let result = kernel.execute(r#"echo "\$100""#).await.expect("execution failed");
9740 assert_eq!(result.text_out().trim(), "$100");
9741 }
9742
9743 #[tokio::test]
9744 async fn test_special_vars_in_interpolation() {
9745 let kernel = Kernel::transient().expect("failed to create kernel");
9746
9747 // Test $? in string interpolation
9748 let result = kernel.execute(r#"true; echo "exit: $?""#).await.expect("execution failed");
9749 assert_eq!(result.text_out().trim(), "exit: 0");
9750
9751 // Test $$ in string interpolation
9752 let result = kernel.execute(r#"echo "pid: $$""#).await.expect("execution failed");
9753 assert!(result.text_out().starts_with("pid: "), "unexpected output: {}", result.text_out());
9754 let text = result.text_out();
9755 let pid_part = text.trim().strip_prefix("pid: ").unwrap();
9756 let _pid: u32 = pid_part.parse().expect("PID in string should be a number");
9757 }
9758
9759 // ═══════════════════════════════════════════════════════════════════════════
9760 // Command Substitution Tests
9761 // ═══════════════════════════════════════════════════════════════════════════
9762
9763 #[tokio::test]
9764 async fn test_command_subst_assignment() {
9765 let kernel = Kernel::transient().expect("failed to create kernel");
9766
9767 // Command substitution in assignment
9768 let result = kernel.execute(r#"X=$(echo hello); echo "$X""#).await.expect("execution failed");
9769 assert_eq!(result.text_out().trim(), "hello");
9770 }
9771
9772 #[tokio::test]
9773 async fn test_command_subst_with_args() {
9774 let kernel = Kernel::transient().expect("failed to create kernel");
9775
9776 // Command substitution with string argument
9777 let result = kernel.execute(r#"X=$(echo "a b c"); echo "$X""#).await.expect("execution failed");
9778 assert_eq!(result.text_out().trim(), "a b c");
9779 }
9780
9781 #[tokio::test]
9782 async fn test_command_subst_nested_vars() {
9783 let kernel = Kernel::transient().expect("failed to create kernel");
9784
9785 // Variables inside command substitution
9786 let result = kernel.execute(r#"Y=world; X=$(echo "hello $Y"); echo "$X""#).await.expect("execution failed");
9787 assert_eq!(result.text_out().trim(), "hello world");
9788 }
9789
9790 #[tokio::test]
9791 async fn test_background_job_basic() {
9792 use std::time::Duration;
9793
9794 let kernel = Kernel::new(KernelConfig::isolated()).expect("failed to create kernel");
9795
9796 // Run a simple background command, redirecting its output to a
9797 // memory-backed file. `/v/jobs/{id}/stdout` would work too (and is
9798 // live); the redirect is what this test asserts on.
9799 let result = kernel.execute("echo hello > /tmp/basic_out.txt &").await.expect("execution failed");
9800 assert!(result.ok(), "background command should succeed: {}", result.err);
9801 assert!(result.err.contains("[1]"), "announcement rides stderr: {:?}", result.err);
9802
9803 // Give the job time to complete
9804 tokio::time::sleep(Duration::from_millis(100)).await;
9805
9806 // Check job status
9807 let status = kernel.execute("cat /v/jobs/1/status").await.expect("status check failed");
9808 assert!(status.ok(), "status should succeed: {}", status.err);
9809 assert!(
9810 status.text_out().contains("done:") || status.text_out().contains("running"),
9811 "should have valid status: {}",
9812 status.text_out()
9813 );
9814
9815 // Check the redirected output
9816 let stdout = kernel.execute("cat /tmp/basic_out.txt").await.expect("output check failed");
9817 assert!(stdout.ok());
9818 assert!(stdout.text_out().contains("hello"));
9819 }
9820
9821 #[tokio::test]
9822 async fn test_heredoc_piped_to_command() {
9823 // Bug 4: heredoc content should pipe through to next command
9824 let kernel = Kernel::transient().expect("kernel");
9825 let result = kernel.execute("cat <<EOF | cat\nhello world\nEOF").await.expect("exec");
9826 assert!(result.ok(), "heredoc | cat failed: {}", result.err);
9827 assert_eq!(result.text_out().trim(), "hello world");
9828 }
9829
9830 /// A transient kernel paired with a real, auto-cleaning tempdir. The
9831 /// transient (Sandboxed) kernel mounts `/tmp` as a real `LocalFs`, so glob
9832 /// tests need actual files on disk. Hold the returned `TempDir` for the
9833 /// test's lifetime: it removes the directory tree on drop — including on
9834 /// panic — so no test scratch leaks into `/tmp` (the project's tmp-builder
9835 /// convention; never hardcode `/tmp/...` paths). Returns the absolute path
9836 /// as a string for interpolation into scripts.
9837 fn transient_with_tempdir() -> (Kernel, tempfile::TempDir, String) {
9838 let kernel = Kernel::transient().expect("kernel");
9839 let tmp = tempfile::tempdir().expect("tempdir");
9840 let dir = tmp.path().display().to_string();
9841 (kernel, tmp, dir)
9842 }
9843
9844 #[tokio::test]
9845 async fn test_for_loop_glob_iterates() {
9846 // Bug 1: for F in $(glob ...) should iterate per file, not once
9847 let (kernel, _tmp, dir) = transient_with_tempdir();
9848 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9849 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9850 let result = kernel.execute(&format!(r#"
9851 N=0
9852 for F in $(glob "{dir}/*.txt"); do
9853 N=$((N + 1))
9854 done
9855 echo $N
9856 "#)).await.unwrap();
9857 assert!(result.ok(), "for glob failed: {}", result.err);
9858 assert_eq!(result.text_out().trim(), "2", "Should iterate 2 files, got: {}", result.text_out());
9859 }
9860
9861 #[tokio::test]
9862 async fn test_bare_glob_expansion_echo() {
9863 let (kernel, _tmp, dir) = transient_with_tempdir();
9864 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9865 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9866 kernel.execute(&format!("echo c > {dir}/c.rs")).await.unwrap();
9867 kernel.execute(&format!("cd {dir}")).await.unwrap();
9868 let result = kernel.execute("echo *.txt").await.unwrap();
9869 assert!(result.ok(), "echo *.txt failed: {}", result.err);
9870 let out = result.text_out();
9871 let out = out.trim();
9872 // Should contain both .txt files (order may vary)
9873 assert!(out.contains("a.txt"), "missing a.txt in: {}", out);
9874 assert!(out.contains("b.txt"), "missing b.txt in: {}", out);
9875 assert!(!out.contains("c.rs"), "should not contain c.rs in: {}", out);
9876 }
9877
9878 #[tokio::test]
9879 async fn test_bare_glob_no_matches_errors() {
9880 let (kernel, _tmp, dir) = transient_with_tempdir();
9881 kernel.execute(&format!("cd {dir}")).await.unwrap();
9882 let result = kernel.execute("echo *.nonexistent").await;
9883 match &result {
9884 Ok(exec) => {
9885 // No-match glob should produce a non-zero exit code
9886 assert!(!exec.ok(), "expected failure, got success: out={}, err={}", exec.text_out(), exec.err);
9887 assert!(exec.err.contains("no matches"), "error should say no matches: {}", exec.err);
9888 }
9889 Err(e) => {
9890 assert!(e.to_string().contains("no matches"), "error should say no matches: {}", e);
9891 }
9892 }
9893 }
9894
9895 #[tokio::test]
9896 async fn test_bare_glob_disabled_with_set() {
9897 let (kernel, _tmp, dir) = transient_with_tempdir();
9898 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9899 kernel.execute(&format!("cd {dir}")).await.unwrap();
9900 // Disable glob expansion
9901 kernel.execute("set +o glob").await.unwrap();
9902 let result = kernel.execute("echo *.txt").await.unwrap();
9903 // With glob disabled, *.txt should be passed as literal string
9904 assert!(result.ok(), "echo should succeed: {}", result.err);
9905 assert_eq!(result.text_out().trim(), "*.txt", "should be literal: {}", result.text_out());
9906 }
9907
9908 #[tokio::test]
9909 async fn test_bare_glob_quoted_not_expanded() {
9910 let (kernel, _tmp, dir) = transient_with_tempdir();
9911 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9912 kernel.execute(&format!("cd {dir}")).await.unwrap();
9913 // Quoted globs should NOT expand
9914 let result = kernel.execute("echo \"*.txt\"").await.unwrap();
9915 assert!(result.ok(), "echo should succeed: {}", result.err);
9916 assert_eq!(result.text_out().trim(), "*.txt", "quoted should be literal: {}", result.text_out());
9917 }
9918
9919 #[tokio::test]
9920 async fn test_bare_glob_for_loop() {
9921 let (kernel, _tmp, dir) = transient_with_tempdir();
9922 kernel.execute(&format!("echo a > {dir}/a.txt")).await.unwrap();
9923 kernel.execute(&format!("echo b > {dir}/b.txt")).await.unwrap();
9924 kernel.execute(&format!("cd {dir}")).await.unwrap();
9925 let result = kernel.execute(r#"
9926 N=0
9927 for f in *.txt; do
9928 N=$((N + 1))
9929 done
9930 echo $N
9931 "#).await.unwrap();
9932 assert!(result.ok(), "for loop failed: {}", result.err);
9933 assert_eq!(result.text_out().trim(), "2", "should iterate 2 files: {}", result.text_out());
9934 }
9935
9936 #[tokio::test]
9937 async fn test_glob_in_assignment_is_literal() {
9938 let kernel = Kernel::transient().expect("kernel");
9939 let result = kernel.execute("X=*.txt; echo $X").await.unwrap();
9940 assert!(result.ok());
9941 assert_eq!(result.text_out().trim(), "*.txt", "glob in assignment should be literal");
9942 }
9943
9944 #[tokio::test]
9945 async fn test_glob_in_test_expr_is_literal() {
9946 let kernel = Kernel::transient().expect("kernel");
9947 let result = kernel.execute(r#"
9948 if [[ *.txt == "*.txt" ]]; then
9949 echo "match"
9950 else
9951 echo "no"
9952 fi
9953 "#).await.unwrap();
9954 assert!(result.ok());
9955 assert_eq!(result.text_out().trim(), "match", "glob in test expr should be literal");
9956 }
9957
9958 #[tokio::test]
9959 async fn test_command_subst_echo_not_iterable() {
9960 // Regression guard: $(echo "a b c") must remain a single string
9961 let kernel = Kernel::transient().expect("kernel");
9962 let result = kernel.execute(r#"
9963 N=0
9964 for X in $(echo "a b c"); do N=$((N + 1)); done
9965 echo $N
9966 "#).await.unwrap();
9967 assert!(result.ok());
9968 assert_eq!(result.text_out().trim(), "1", "echo should be one item: {}", result.text_out());
9969 }
9970
9971 // -- accumulate_result / newline tests --
9972
9973 #[test]
9974 fn test_accumulate_preserves_own_newlines() {
9975 // Outputs concatenate verbatim — a command's own trailing newline is
9976 // kept, none is invented.
9977 let mut acc = ExecResult::success("line1\n");
9978 let new = ExecResult::success("line2\n");
9979 accumulate_result(&mut acc, &new);
9980 assert_eq!(&*acc.text_out(), "line1\nline2\n");
9981 assert!(!acc.text_out().contains("\n\n"), "should not have double newlines: {:?}", acc.text_out());
9982 }
9983
9984 #[test]
9985 fn test_accumulate_inserts_no_separator() {
9986 // No artificial separator: `printf a; printf b` style concatenates to
9987 // `ab`, matching bash (regression for the 2026-06-09 finding).
9988 let mut acc = ExecResult::success("line1");
9989 let new = ExecResult::success("line2");
9990 accumulate_result(&mut acc, &new);
9991 assert_eq!(&*acc.text_out(), "line1line2");
9992 }
9993
9994 #[test]
9995 fn test_accumulate_empty_into_nonempty() {
9996 let mut acc = ExecResult::success("");
9997 let new = ExecResult::success("hello\n");
9998 accumulate_result(&mut acc, &new);
9999 assert_eq!(&*acc.text_out(), "hello\n");
10000 }
10001
10002 #[test]
10003 fn test_accumulate_nonempty_into_empty() {
10004 let mut acc = ExecResult::success("hello\n");
10005 let new = ExecResult::success("");
10006 accumulate_result(&mut acc, &new);
10007 assert_eq!(&*acc.text_out(), "hello\n");
10008 }
10009
10010 #[test]
10011 fn test_accumulate_stderr_no_double_newlines() {
10012 let mut acc = ExecResult::failure(1, "err1\n");
10013 let new = ExecResult::failure(1, "err2\n");
10014 accumulate_result(&mut acc, &new);
10015 assert!(!acc.err.contains("\n\n"), "stderr should not have double newlines: {:?}", acc.err);
10016 }
10017
10018 #[tokio::test]
10019 async fn test_multiple_echo_no_blank_lines() {
10020 let kernel = Kernel::transient().expect("kernel");
10021 let result = kernel
10022 .execute("echo one\necho two\necho three")
10023 .await
10024 .expect("execution failed");
10025 assert!(result.ok());
10026 assert_eq!(&*result.text_out(), "one\ntwo\nthree\n");
10027 }
10028
10029 #[tokio::test]
10030 async fn test_for_loop_no_blank_lines() {
10031 let kernel = Kernel::transient().expect("kernel");
10032 let result = kernel
10033 .execute(r#"for X in a b c; do echo "item: ${X}"; done"#)
10034 .await
10035 .expect("execution failed");
10036 assert!(result.ok());
10037 assert_eq!(&*result.text_out(), "item: a\nitem: b\nitem: c\n");
10038 }
10039
10040 #[tokio::test]
10041 async fn test_for_command_subst_no_blank_lines() {
10042 let kernel = Kernel::transient().expect("kernel");
10043 let result = kernel
10044 .execute(r#"for N in $(seq 1 3); do echo "n=${N}"; done"#)
10045 .await
10046 .expect("execution failed");
10047 assert!(result.ok());
10048 assert_eq!(&*result.text_out(), "n=1\nn=2\nn=3\n");
10049 }
10050
10051 // ------------------------------------------------------------------
10052 // build_args_async: multi-consume flags (jq --arg NAME VALUE pattern)
10053 // ------------------------------------------------------------------
10054
10055 /// Helper: a throwaway schema with one `--pair` param declared as
10056 /// consuming two positionals per occurrence. Modelled after what
10057 /// jq_native will declare for `--arg` / `--argjson`.
10058 fn multi_consume_schema() -> crate::tools::ToolSchema {
10059 use crate::tools::{ParamSchema, ToolSchema};
10060 ToolSchema::new("test", "multi-consume smoke")
10061 .param(
10062 ParamSchema::optional("pair", "array", Value::Null, "name+value pair")
10063 .consumes(2),
10064 )
10065 }
10066
10067 fn pos(s: &str) -> Arg {
10068 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
10069 }
10070
10071 #[tokio::test]
10072 async fn build_args_multi_consume_single_occurrence() {
10073 let kernel = Kernel::transient().expect("kernel");
10074 let schema = multi_consume_schema();
10075 // Simulates: test --pair NAME VALUE filter
10076 let args = vec![
10077 Arg::LongFlag("pair".into()),
10078 pos("NAME"),
10079 pos("VALUE"),
10080 pos("filter"),
10081 ];
10082 let built = kernel
10083 .build_args_async(&args, Some(&schema))
10084 .await
10085 .expect("build_args should succeed");
10086
10087 // `--pair` + its two positionals are consumed into named["pair"],
10088 // which becomes an outer array of one inner 2-element array.
10089 let pair = built.named.get("pair").expect("named[pair] missing");
10090 match pair {
10091 Value::Json(serde_json::Value::Array(occurrences)) => {
10092 assert_eq!(occurrences.len(), 1, "expected one occurrence");
10093 match &occurrences[0] {
10094 serde_json::Value::Array(values) => {
10095 assert_eq!(values.len(), 2, "pair must have 2 values");
10096 assert_eq!(values[0], serde_json::Value::String("NAME".into()));
10097 assert_eq!(values[1], serde_json::Value::String("VALUE".into()));
10098 }
10099 other => panic!("expected inner array, got {other:?}"),
10100 }
10101 }
10102 other => panic!("expected Json(Array(...)) for named[pair], got {other:?}"),
10103 }
10104
10105 // The un-consumed positional ("filter") remains in `positional`.
10106 assert_eq!(built.positional.len(), 1);
10107 assert_eq!(built.positional[0], Value::String("filter".into()));
10108 }
10109 #[tokio::test]
10110 async fn build_args_multi_consume_two_occurrences_accumulate() {
10111 let kernel = Kernel::transient().expect("kernel");
10112 let schema = multi_consume_schema();
10113 // Simulates: test --pair A 1 --pair B 2 filter
10114 let args = vec![
10115 Arg::LongFlag("pair".into()),
10116 pos("A"),
10117 pos("1"),
10118 Arg::LongFlag("pair".into()),
10119 pos("B"),
10120 pos("2"),
10121 pos("filter"),
10122 ];
10123 let built = kernel
10124 .build_args_async(&args, Some(&schema))
10125 .await
10126 .expect("build_args should succeed");
10127
10128 let pair = built.named.get("pair").expect("named[pair] missing");
10129 match pair {
10130 Value::Json(serde_json::Value::Array(occurrences)) => {
10131 assert_eq!(occurrences.len(), 2, "expected two occurrences");
10132 // Preserved in invocation order.
10133 match &occurrences[0] {
10134 serde_json::Value::Array(values) => {
10135 assert_eq!(values[0], serde_json::Value::String("A".into()));
10136 assert_eq!(values[1], serde_json::Value::String("1".into()));
10137 }
10138 other => panic!("expected inner array, got {other:?}"),
10139 }
10140 match &occurrences[1] {
10141 serde_json::Value::Array(values) => {
10142 assert_eq!(values[0], serde_json::Value::String("B".into()));
10143 assert_eq!(values[1], serde_json::Value::String("2".into()));
10144 }
10145 other => panic!("expected inner array, got {other:?}"),
10146 }
10147 }
10148 other => panic!("expected Json(Array(...)), got {other:?}"),
10149 }
10150 }
10151
10152 // ── undeclared space-form flag under map_positionals (kj --type val) ──
10153 //
10154 // A backend/MCP tool whose schema does NOT declare a flag must not let
10155 // `--flag value` (space form) silently divorce the value: that was a
10156 // privilege-escalation-by-typo against kaijutsu.
10157 // kaish fails loud rather than guessing.
10158
10159 use crate::tools::{ParamSchema, ToolSchema};
10160
10161 /// Backend-style schema (map_positionals) declaring only a `name`
10162 /// positional — `--type` is intentionally undeclared.
10163 fn kj_like_schema() -> ToolSchema {
10164 ToolSchema::new("kj", "incomplete backend schema")
10165 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10166 .with_positional_mapping()
10167 }
10168
10169 #[tokio::test]
10170 async fn build_args_undeclared_space_flag_errors_under_map_positionals() {
10171 let kernel = Kernel::transient().expect("kernel");
10172 let schema = kj_like_schema();
10173 // kj context create exp --type explorer
10174 let args = vec![
10175 pos("context"),
10176 pos("create"),
10177 pos("exp"),
10178 Arg::LongFlag("type".into()),
10179 pos("explorer"),
10180 ];
10181 let err = kernel
10182 .build_args_async(&args, Some(&schema))
10183 .await
10184 .expect_err("undeclared --type with a space value must fail loud");
10185 let msg = err.to_string();
10186 assert!(msg.contains("--type"), "message should name the flag: {msg}");
10187 assert!(msg.contains("--type=explorer"), "message should suggest the = form: {msg}");
10188 assert!(msg.contains("kj"), "message should name the tool: {msg}");
10189 }
10190
10191 #[tokio::test]
10192 async fn build_args_declared_space_flag_still_binds() {
10193 let kernel = Kernel::transient().expect("kernel");
10194 // Same tool, but now the schema DECLARES --type as a string param.
10195 let schema = ToolSchema::new("kj", "complete schema")
10196 .param(ParamSchema::optional("name", "string", Value::Null, "context name"))
10197 .param(ParamSchema::optional("type", "string", Value::Null, "role type"))
10198 .with_positional_mapping();
10199 let args = vec![
10200 pos("exp"),
10201 Arg::LongFlag("type".into()),
10202 pos("explorer"),
10203 ];
10204 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10205 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10206 }
10207
10208 #[tokio::test]
10209 async fn build_args_equals_form_binds_for_undeclared_flag() {
10210 let kernel = Kernel::transient().expect("kernel");
10211 let schema = kj_like_schema();
10212 // The unambiguous `=` form must keep working even when undeclared.
10213 let args = vec![
10214 pos("exp"),
10215 Arg::Named { key: "type".into(), value: Expr::Literal(Value::String("explorer".into())) },
10216 ];
10217 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10218 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10219 }
10220
10221 #[tokio::test]
10222 async fn build_args_undeclared_bool_flag_at_end_is_ok() {
10223 let kernel = Kernel::transient().expect("kernel");
10224 let schema = kj_like_schema();
10225 // No positional follows --force → unambiguously a bare flag.
10226 let args = vec![pos("exp"), Arg::LongFlag("force".into())];
10227 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10228 assert!(built.flags.contains("force"));
10229 }
10230
10231 #[tokio::test]
10232 async fn build_args_undeclared_flag_before_another_flag_is_ok() {
10233 let kernel = Kernel::transient().expect("kernel");
10234 let schema = kj_like_schema();
10235 // --verbose is followed by a flag, not a positional → not ambiguous.
10236 let args = vec![
10237 Arg::LongFlag("verbose".into()),
10238 Arg::Named { key: "name".into(), value: Expr::Literal(Value::String("x".into())) },
10239 ];
10240 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10241 assert!(built.flags.contains("verbose"));
10242 }
10243
10244 #[tokio::test]
10245 async fn build_args_undeclared_space_flag_ok_for_builtin_schema() {
10246 let kernel = Kernel::transient().expect("kernel");
10247 // Builtins set map_positionals=false; the ambiguity guard must not
10248 // fire there (clap validates their flags separately).
10249 let schema = ToolSchema::new("frobnicate", "builtin-style")
10250 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10251 let args = vec![Arg::LongFlag("frob".into()), pos("value")];
10252 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10253 assert!(built.flags.contains("frob"));
10254 }
10255
10256 // ── GH #189 item 4: the short-flag half of the same ambiguity guard ──
10257 //
10258 // The long-flag guard above was closed by GH #188; an undeclared SHORT
10259 // flag under a map_positionals schema was still silently defaulting to
10260 // bare bool, divorcing a space-form value (`kj -t explorer`) exactly the
10261 // same way the long-flag case used to.
10262
10263 #[tokio::test]
10264 async fn build_args_undeclared_short_space_flag_errors_under_map_positionals() {
10265 let kernel = Kernel::transient().expect("kernel");
10266 let schema = kj_like_schema();
10267 // kj exp -t explorer
10268 let args = vec![pos("exp"), Arg::ShortFlag("t".into()), pos("explorer")];
10269 let err = kernel
10270 .build_args_async(&args, Some(&schema))
10271 .await
10272 .expect_err("undeclared -t with a space value must fail loud");
10273 let msg = err.to_string();
10274 assert!(msg.contains("-t"), "message should name the flag: {msg}");
10275 assert!(msg.contains("kj"), "message should name the tool: {msg}");
10276 }
10277
10278 #[tokio::test]
10279 async fn build_args_undeclared_short_space_flag_ok_for_builtin_schema() {
10280 let kernel = Kernel::transient().expect("kernel");
10281 // Builtins set map_positionals=false; the ambiguity guard must not
10282 // fire there.
10283 let schema = ToolSchema::new("frobnicate", "builtin-style")
10284 .param(ParamSchema::optional("name", "string", Value::Null, "name"));
10285 let args = vec![Arg::ShortFlag("t".into()), pos("value")];
10286 let built = kernel.build_args_async(&args, Some(&schema)).await.unwrap();
10287 assert!(built.flags.contains("t"));
10288 }
10289
10290 // ── subcommand-aware binding (select_leaf wired into build_args_async) ──
10291 //
10292 // A tool exposing a subcommand tree binds flags against the *routed leaf's*
10293 // params, not the root's. The subcommand-path positionals stay positional
10294 // (kj re-parses them with its own clap), and a value flag declared only on
10295 // a deep leaf still binds in space form.
10296
10297 /// kj → context (alias ctx) → create{--type value, --force bool}.
10298 /// map_positionals defaults false on every node (builtin/kj style).
10299 fn kj_tree_schema() -> ToolSchema {
10300 ToolSchema::new("kj", "subcommand tool").subcommand(
10301 ToolSchema::new("context", "context ops")
10302 .with_command_aliases(["ctx"])
10303 .subcommand(
10304 ToolSchema::new("create", "create context")
10305 .param(ParamSchema::new("type", "string").with_aliases(["t"]))
10306 .param(ParamSchema::new("force", "bool")),
10307 ),
10308 )
10309 }
10310
10311 #[tokio::test]
10312 async fn build_args_binds_deep_leaf_value_flag_space_form() {
10313 let kernel = Kernel::transient().expect("kernel");
10314 let schema = kj_tree_schema();
10315 // kj context create --type explorer
10316 let args = vec![
10317 pos("context"),
10318 pos("create"),
10319 Arg::LongFlag("type".into()),
10320 pos("explorer"),
10321 ];
10322 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10323 // --type (declared only on the create leaf) binds in space form.
10324 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10325 // The subcommand path survives as positionals for kj to re-parse.
10326 let positionals: Vec<&str> = built
10327 .positional
10328 .iter()
10329 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10330 .collect();
10331 assert_eq!(positionals, vec!["context", "create"]);
10332 }
10333
10334 #[tokio::test]
10335 async fn build_args_leaf_bool_flag_does_not_swallow_positional() {
10336 let kernel = Kernel::transient().expect("kernel");
10337 let schema = kj_tree_schema();
10338 // kj context create --force somearg → --force is a leaf bool flag,
10339 // it must NOT consume `somearg`.
10340 let args = vec![
10341 pos("context"),
10342 pos("create"),
10343 Arg::LongFlag("force".into()),
10344 pos("somearg"),
10345 ];
10346 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10347 assert!(built.flags.contains("force"), "force should be a bare flag");
10348 let positionals: Vec<&str> = built
10349 .positional
10350 .iter()
10351 .filter_map(|v| if let Value::String(s) = v { Some(s.as_str()) } else { None })
10352 .collect();
10353 assert_eq!(positionals, vec!["context", "create", "somearg"]);
10354 }
10355
10356 #[tokio::test]
10357 async fn build_args_alias_routed_leaf_binds_value_flag() {
10358 let kernel = Kernel::transient().expect("kernel");
10359 let schema = kj_tree_schema();
10360 // kj ctx create -t explorer → command alias + short flag alias.
10361 let args = vec![
10362 pos("ctx"),
10363 pos("create"),
10364 Arg::ShortFlag("t".into()),
10365 pos("explorer"),
10366 ];
10367 let built = kernel.build_args_async(&args, Some(&schema)).await.expect("build_args");
10368 assert_eq!(built.named.get("type"), Some(&Value::String("explorer".into())));
10369 }
10370
10371 #[tokio::test]
10372 async fn build_args_computed_subcommand_selector_fails_loud() {
10373 let kernel = Kernel::transient().expect("kernel");
10374 let schema = kj_tree_schema();
10375 // kj $(echo context) — routing can't see the value; fail loud.
10376 let args = vec![Arg::Positional(Expr::CommandSubst(vec![Stmt::Command(
10377 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
10378 )]))];
10379 let err = kernel
10380 .build_args_async(&args, Some(&schema))
10381 .await
10382 .expect_err("computed subcommand selector must error");
10383 assert!(
10384 err.to_string().contains("subcommand name is required"),
10385 "got: {err}"
10386 );
10387 }
10388
10389 // ── finalize_output: --json rendering vs. owns_output opt-out ───────────
10390
10391 #[test]
10392 fn finalize_output_renders_when_kernel_owns_it() {
10393 use crate::interpreter::{OutputData, OutputFormat};
10394 let r = ExecResult::with_output(OutputData::text("RAW"));
10395 let out = finalize_output(r, Some(OutputFormat::Json), false);
10396 // Kernel renders the typed OutputData → JSON; text is no longer bare.
10397 assert_ne!(out.text_out(), "RAW", "kernel should reformat to JSON");
10398 }
10399
10400 #[test]
10401 fn finalize_output_skips_when_tool_owns_output_and_succeeds() {
10402 use crate::interpreter::{OutputData, OutputFormat};
10403 let r = ExecResult::with_output(OutputData::text("RAW"));
10404 let out = finalize_output(r, Some(OutputFormat::Json), true);
10405 // owns_output + success: the tool already rendered; kernel leaves bytes
10406 // untouched.
10407 assert_eq!(out.text_out(), "RAW", "owned output must be left as-is");
10408 }
10409
10410 #[test]
10411 fn finalize_output_renders_owns_output_failure() {
10412 // scatter/gather (the only owns_output tools) never render their own
10413 // JSONL/array on a FAILURE path — their error returns are plain-text
10414 // `ExecResult::failure(code, msg)`, identical in shape to any other
10415 // builtin's. owns_output means "the tool already rendered its own
10416 // SUCCESS output", not "never touch this tool's bytes" — a failure
10417 // must still get the uniform --json error envelope like every other
10418 // builtin (kaibo review finding on merged PR #215; confirmed
10419 // pre-existing for scatter/gather's whole error-path class, including
10420 // the clap-parse-failure path).
10421 use crate::interpreter::OutputFormat;
10422 let r = ExecResult::failure(2, "scatter: unexpected argument '--nope'");
10423 let out = finalize_output(r, Some(OutputFormat::Json), true);
10424 let parsed: serde_json::Value =
10425 serde_json::from_str(&out.text_out()).expect("--json must always parse as JSON");
10426 assert_eq!(parsed["error"], "scatter: unexpected argument '--nope'");
10427 assert_eq!(parsed["code"], 2);
10428 }
10429
10430 #[test]
10431 fn finalize_output_no_format_is_noop() {
10432 use crate::interpreter::OutputData;
10433 let r = ExecResult::with_output(OutputData::text("RAW"));
10434 let out = finalize_output(r, None, false);
10435 assert_eq!(out.text_out(), "RAW");
10436 }
10437
10438 // ── initial_vars + execute_with_vars + hermetic env ───────────────────
10439
10440 #[tokio::test]
10441 async fn test_initial_vars_set_and_exported() {
10442 let config = KernelConfig::transient()
10443 .with_var("INIT_FOO", Value::String("bar".into()));
10444 let kernel = Kernel::new(config).expect("failed to create kernel");
10445
10446 assert_eq!(
10447 kernel.get_var("INIT_FOO").await,
10448 Some(Value::String("bar".into()))
10449 );
10450 assert!(
10451 kernel.scope.read().await.is_exported("INIT_FOO"),
10452 "initial_vars entries must be marked exported"
10453 );
10454 }
10455
10456 #[tokio::test]
10457 async fn test_execute_with_vars_overlay_visible() {
10458 let kernel = Kernel::transient().expect("failed to create kernel");
10459 let mut overlay = HashMap::new();
10460 overlay.insert("OVERLAY_X".to_string(), Value::String("yes".into()));
10461
10462 let result = kernel
10463 .execute_with_options(r#"echo "${OVERLAY_X}""#, ExecuteOptions::new().with_vars(overlay))
10464 .await
10465 .expect("execute failed");
10466
10467 assert!(result.ok());
10468 assert_eq!(result.text_out().trim(), "yes");
10469 }
10470
10471 #[tokio::test]
10472 async fn test_execute_with_vars_overlay_cleanup() {
10473 let kernel = Kernel::transient().expect("failed to create kernel");
10474 let mut overlay = HashMap::new();
10475 overlay.insert("EPHEMERAL".to_string(), Value::String("transient".into()));
10476
10477 kernel
10478 .execute_with_options("echo ignored", ExecuteOptions::new().with_vars(overlay))
10479 .await
10480 .expect("execute failed");
10481
10482 assert_eq!(kernel.get_var("EPHEMERAL").await, None);
10483 assert!(
10484 !kernel.scope.read().await.is_exported("EPHEMERAL"),
10485 "overlay-only export must be cleared on return"
10486 );
10487 }
10488
10489 #[tokio::test]
10490 async fn test_execute_with_vars_does_not_clobber_existing_export() {
10491 let kernel = Kernel::transient().expect("failed to create kernel");
10492 kernel
10493 .execute("export OUTER=outer")
10494 .await
10495 .expect("export failed");
10496
10497 let mut overlay = HashMap::new();
10498 overlay.insert("OUTER".to_string(), Value::String("inner".into()));
10499 let result = kernel
10500 .execute_with_options(r#"echo "${OUTER}""#, ExecuteOptions::new().with_vars(overlay))
10501 .await
10502 .expect("execute failed");
10503 assert_eq!(result.text_out().trim(), "inner");
10504
10505 assert_eq!(
10506 kernel.get_var("OUTER").await,
10507 Some(Value::String("outer".into())),
10508 "outer value must reappear after pop"
10509 );
10510 assert!(
10511 kernel.scope.read().await.is_exported("OUTER"),
10512 "outer export must survive overlay"
10513 );
10514 }
10515
10516 #[tokio::test]
10517 async fn test_execute_with_vars_inner_assignment_is_local() {
10518 let kernel = Kernel::transient().expect("failed to create kernel");
10519 let mut overlay = HashMap::new();
10520 overlay.insert("LOCAL_FOO".to_string(), Value::String("from-overlay".into()));
10521
10522 // Variable assignment inside a single statement uses set() (innermost
10523 // frame), not set_global() — this matches bash function-local semantics.
10524 // We explicitly use `local FOO=...` style by relying on the pushed
10525 // frame; the assignment in the script body modifies the same frame.
10526 let result = kernel
10527 .execute_with_options(
10528 r#"LOCAL_FOO="reassigned"; echo "${LOCAL_FOO}""#,
10529 ExecuteOptions::new().with_vars(overlay),
10530 )
10531 .await
10532 .expect("execute failed");
10533 assert!(result.ok());
10534
10535 // After the call the frame is popped, so LOCAL_FOO is gone regardless
10536 // of how the script reassigned it.
10537 assert_eq!(kernel.get_var("LOCAL_FOO").await, None);
10538 }
10539
10540 #[tokio::test]
10541 async fn test_external_command_sees_exported_var() {
10542 let kernel = Kernel::transient().expect("failed to create kernel");
10543 // PATH must be in scope to resolve the external `printenv` — the kernel
10544 // never falls back to OS PATH. Seeding it via a scope assignment mirrors
10545 // what a frontend does through initial_vars.
10546 let path = std::env::var("PATH").unwrap_or_default();
10547 let result = kernel
10548 .execute(&format!(
10549 "PATH=\"{path}\"; export EXT_FOO=bar; printenv EXT_FOO"
10550 ))
10551 .await
10552 .expect("execute failed");
10553
10554 assert!(result.ok(), "printenv should succeed: stderr={}", result.err);
10555 assert_eq!(result.text_out().trim(), "bar");
10556 }
10557
10558 #[tokio::test]
10559 async fn test_external_command_does_not_see_unexported_var() {
10560 let kernel = Kernel::transient().expect("failed to create kernel");
10561
10562 // Set without exporting; printenv must not see it (exit code != 0,
10563 // empty stdout per printenv semantics).
10564 let result = kernel
10565 .execute("EXT_BAR=hidden; printenv EXT_BAR")
10566 .await
10567 .expect("execute failed");
10568
10569 assert!(!result.ok(), "printenv should fail when var is unexported");
10570 assert!(
10571 result.text_out().trim().is_empty(),
10572 "no stdout when var is missing, got: {}",
10573 result.text_out()
10574 );
10575 }
10576
10577 #[tokio::test]
10578 async fn test_external_command_does_not_see_os_env() {
10579 // The kernel is hermetic: it never reads std::env::vars() and only
10580 // exports what it has been told to export. Cargo always sets PATH for
10581 // tests, so PATH is reliably present in the OS env — but a transient
10582 // kernel doesn't seed it into initial_vars, so `printenv PATH` from
10583 // inside the kernel must fail.
10584 assert!(
10585 std::env::var_os("PATH").is_some(),
10586 "test precondition: cargo should set PATH"
10587 );
10588
10589 let kernel = Kernel::transient().expect("failed to create kernel");
10590 let result = kernel
10591 .execute("printenv PATH")
10592 .await
10593 .expect("execute failed");
10594
10595 assert!(
10596 !result.ok(),
10597 "printenv PATH must fail in hermetic kernel, got stdout={:?}",
10598 result.text_out()
10599 );
10600 assert!(
10601 result.text_out().trim().is_empty(),
10602 "no PATH in subprocess env, got stdout={:?}",
10603 result.text_out()
10604 );
10605 }
10606
10607 #[tokio::test]
10608 async fn test_execute_with_vars_overlay_reaches_subprocess() {
10609 let kernel = Kernel::transient().expect("failed to create kernel");
10610 let mut overlay = HashMap::new();
10611 overlay.insert("SUB_FOO".to_string(), Value::String("subproc".into()));
10612 // PATH in the overlay so the external `printenv` resolves (no OS fallback).
10613 overlay.insert(
10614 "PATH".to_string(),
10615 Value::String(std::env::var("PATH").unwrap_or_default()),
10616 );
10617
10618 let result = kernel
10619 .execute_with_options("printenv SUB_FOO", ExecuteOptions::new().with_vars(overlay))
10620 .await
10621 .expect("execute failed");
10622
10623 assert!(
10624 result.ok(),
10625 "printenv should succeed: code={} stdout={:?} stderr={:?}",
10626 result.code,
10627 result.text_out(),
10628 result.err
10629 );
10630 assert_eq!(result.text_out().trim(), "subproc");
10631 }
10632
10633 #[tokio::test]
10634 async fn test_classify_command_builtin() {
10635 let kernel = Kernel::transient().expect("failed to create kernel");
10636 assert_eq!(kernel.classify_command("cat").await, CommandKind::Builtin);
10637 assert_eq!(kernel.classify_command("grep").await, CommandKind::Builtin);
10638 }
10639
10640 #[tokio::test]
10641 async fn test_classify_command_special_forms() {
10642 let kernel = Kernel::transient().expect("failed to create kernel");
10643 for name in ["true", "false", "source", "."] {
10644 assert_eq!(
10645 kernel.classify_command(name).await,
10646 CommandKind::Special,
10647 "{name} should be a special-form",
10648 );
10649 }
10650 }
10651
10652 #[tokio::test]
10653 async fn test_classify_command_dynamic() {
10654 let kernel = Kernel::transient().expect("failed to create kernel");
10655 assert_eq!(kernel.classify_command("$cmd").await, CommandKind::Dynamic);
10656 assert_eq!(
10657 kernel.classify_command("$(pick)").await,
10658 CommandKind::Dynamic
10659 );
10660 }
10661
10662 #[tokio::test]
10663 async fn test_classify_command_external() {
10664 let kernel = Kernel::transient().expect("failed to create kernel");
10665 // Not a builtin, user function, or special-form → escapes to PATH.
10666 assert_eq!(
10667 kernel.classify_command("definitely_not_a_kaish_builtin").await,
10668 CommandKind::External
10669 );
10670 // `readonly` is *not* a kaish special-form despite the validator's
10671 // warning heuristic — at runtime it resolves to an external command, so
10672 // a consent gate must see it as External (regression guard against the
10673 // validator/runtime divergence).
10674 assert_eq!(
10675 kernel.classify_command("readonly").await,
10676 CommandKind::External
10677 );
10678 assert!(kernel.classify_command("readonly").await.escapes_kernel());
10679 }
10680
10681 #[tokio::test]
10682 async fn test_classify_command_user_tool_shadows_builtin() {
10683 let kernel = Kernel::transient().expect("failed to create kernel");
10684 kernel
10685 .execute(r#"greet() { echo "hi" }"#)
10686 .await
10687 .expect("function definition failed");
10688 assert_eq!(
10689 kernel.classify_command("greet").await,
10690 CommandKind::UserTool
10691 );
10692
10693 // A user function named after a builtin classifies as UserTool, matching
10694 // the interpreter's user-tools-first resolution.
10695 kernel
10696 .execute(r#"cat() { echo "shadowed" }"#)
10697 .await
10698 .expect("function definition failed");
10699 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10700 }
10701
10702 #[tokio::test]
10703 async fn test_classify_command_alias_to_external_is_external() {
10704 let kernel = Kernel::transient().expect("failed to create kernel");
10705 // An alias whose head is an external binary must NOT report as the
10706 // builtin it shadows — execution expands the alias, so a consent gate
10707 // would otherwise be told an external command is internal.
10708 kernel
10709 .execute("alias cat='/usr/bin/whatever'")
10710 .await
10711 .expect("alias failed");
10712 assert_eq!(kernel.classify_command("cat").await, CommandKind::External);
10713 assert!(kernel.classify_command("cat").await.escapes_kernel());
10714 }
10715
10716 #[tokio::test]
10717 async fn test_classify_command_alias_to_builtin() {
10718 let kernel = Kernel::transient().expect("failed to create kernel");
10719 kernel.execute("alias g=grep").await.expect("alias failed");
10720 assert_eq!(kernel.classify_command("g").await, CommandKind::Builtin);
10721 }
10722
10723 #[tokio::test]
10724 async fn test_classify_command_alias_to_special_form() {
10725 let kernel = Kernel::transient().expect("failed to create kernel");
10726 kernel.execute("alias t=true").await.expect("alias failed");
10727 assert_eq!(kernel.classify_command("t").await, CommandKind::Special);
10728 }
10729
10730 #[tokio::test]
10731 async fn test_classify_command_braced_var_is_dynamic() {
10732 let kernel = Kernel::transient().expect("failed to create kernel");
10733 // The string API can be handed a `${VAR}` head; it must not be mistaken
10734 // for an external named literally "${VAR}".
10735 assert_eq!(
10736 kernel.classify_command("${CMD}").await,
10737 CommandKind::Dynamic
10738 );
10739 }
10740
10741 /// Drift guard: `classify_command` must agree with what the executor
10742 /// (`execute_command_depth`) actually resolves. The classifier duplicates the
10743 /// interpreter's resolution rules (special-form set, user-tools-before-builtins
10744 /// precedence, alias expansion); without this test those copies could diverge
10745 /// silently — the exact failure class `classify_command` exists to prevent,
10746 /// just moved inside the kernel. Each case asserts the classification AND
10747 /// observes the real resolution, so a future change to one side without the
10748 /// other fails here.
10749 #[tokio::test]
10750 async fn classify_command_matches_executor() {
10751 let kernel = Kernel::transient().expect("failed to create kernel");
10752
10753 // (1) Special-forms. `SpecialForm::from_name` is the single source of
10754 // truth: classify reports Special via it, and the executor matches the
10755 // enum exhaustively, so const↔behavior parity is compile-enforced (a new
10756 // form won't build until both sides handle it). This test pins the other
10757 // half — that each form classifies Special AND actually short-circuits at
10758 // runtime rather than escaping to `PATH`. Every form is executed (not just
10759 // `true`/`false`): an external miss in this PATH-less kernel would be exit
10760 // 127, so a non-127 result that matches the form's own behavior proves the
10761 // short-circuit fired.
10762 for name in ["true", "false", "source", "."] {
10763 assert_eq!(
10764 kernel.classify_command(name).await,
10765 CommandKind::Special,
10766 "{name} should classify Special",
10767 );
10768 }
10769 assert_eq!(kernel.execute("true").await.expect("run true").code, 0);
10770 assert_eq!(kernel.execute("false").await.expect("run false").code, 1);
10771 // `source`/`.` short-circuit to execute_source, which (no filename) fails
10772 // with its own message — exit 1, never the 127 of an unresolved external.
10773 for name in ["source", "."] {
10774 let r = kernel.execute(name).await.expect("run source form");
10775 assert_ne!(r.code, 127, "{name} fell through to PATH instead of source");
10776 assert!(
10777 r.err.contains("source: missing filename"),
10778 "{name} did not route to execute_source: {:?}",
10779 r.err,
10780 );
10781 }
10782
10783 // (2) Builtin: classify Builtin AND the executor runs the builtin.
10784 assert_eq!(kernel.classify_command("echo").await, CommandKind::Builtin);
10785 let r = kernel.execute("echo hi").await.expect("run echo");
10786 assert!(r.ok() && r.text_out().trim() == "hi", "echo builtin didn't run");
10787
10788 // (3) User function shadows a builtin: classify UserTool AND the executor
10789 // runs the function body, not the `cat` builtin.
10790 kernel
10791 .execute(r#"cat() { echo SHADOWED }"#)
10792 .await
10793 .expect("define cat()");
10794 assert_eq!(kernel.classify_command("cat").await, CommandKind::UserTool);
10795 let r = kernel.execute("cat").await.expect("run shadowed cat");
10796 assert_eq!(
10797 r.text_out().trim(),
10798 "SHADOWED",
10799 "executor ran the builtin instead of the shadowing function",
10800 );
10801
10802 // (4) Alias whose head is external: classify External AND the executor
10803 // resolves through the alias to a missing external (not a builtin).
10804 kernel
10805 .execute("alias x='/nonexistent/binary'")
10806 .await
10807 .expect("define alias x");
10808 assert_eq!(kernel.classify_command("x").await, CommandKind::External);
10809 let r = kernel.execute("x").await.expect("run alias x");
10810 assert!(
10811 !r.ok(),
10812 "alias to a missing external should fail, not resolve internally",
10813 );
10814 }
10815}