Skip to main content

kaish_kernel/tools/
context.rs

1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8
9use crate::ast::Value;
10use crate::backend::{KernelBackend, LocalBackend};
11use crate::dispatch::PipelinePosition;
12use crate::ignore_config::IgnoreConfig;
13use crate::interpreter::{ExecResult, Scope};
14use crate::output_limit::OutputLimitConfig;
15use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
16use crate::tools::ToolRegistry;
17use crate::trash::TrashBackend;
18use crate::vfs::VfsRouter;
19use kaish_vfs::ByteBudget;
20use tokio::sync::oneshot;
21use tokio_util::sync::CancellationToken;
22
23use crate::interpreter::OutputFormat;
24
25use super::traits::ToolSchema;
26
27/// Grace between SIGTERM and SIGKILL for a cancelled child, when nothing sets
28/// one: 2 seconds. `KernelConfig::kill_grace` overrides it for a kernel-built
29/// context.
30pub const DEFAULT_KILL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
31
32/// Output context determines how command output should be formatted.
33///
34/// Different contexts prefer different output formats:
35/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
36/// - **Piped** — Raw output for pipeline processing
37/// - **Model** — Token-efficient compact formats (MCP server / agent context)
38/// - **Script** — Non-interactive script execution
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40#[non_exhaustive]
41pub enum OutputContext {
42    /// Interactive TTY/REPL - use human-friendly format with colors.
43    #[default]
44    Interactive,
45    /// Output to another command - use raw output for pipes.
46    Piped,
47    /// MCP server / agent context - use token-efficient model format.
48    Model,
49    /// Non-interactive script - use raw output.
50    Script,
51}
52
53/// Why kaish will not run an external command for this dispatch — distinct
54/// from "not found in PATH", which is a different condition entirely (see
55/// `crate::tools::builtin::spawn::virtual_cwd_error` for that one).
56///
57/// The two variants name different audiences, not just different remedies:
58/// [`Self::NotCompiled`] is a build-time fact only whoever built this binary
59/// can change; [`Self::ConfiguredOff`] is a runtime policy an embedder set
60/// and can change. A caller must not collapse them into one message.
61///
62/// Lives here (not behind `#[cfg(feature = "subprocess")]`) because both
63/// `Kernel::try_execute_external` and `dispatch.rs`'s test-only
64/// `BackendDispatcher::try_external` need it from their `not(subprocess)`
65/// arm too, where the `spawn` module — gated on that same feature — is not
66/// compiled in.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ExternalCommandsUnavailable {
69    /// This binary was built without the `subprocess` capability.
70    NotCompiled,
71    /// This binary has the capability, but this kernel's configuration
72    /// turned it off.
73    ConfiguredOff,
74}
75
76impl ExternalCommandsUnavailable {
77    /// The condition, named precisely and completely — no remedy. A remedy
78    /// depends on the embedder's own tool surface, which kaish cannot see
79    /// (one caller's fix is "use the other tool"; another's is "reconfigure
80    /// the kernel"), so the message stops at the fact.
81    pub fn condition(self) -> &'static str {
82        match self {
83            Self::NotCompiled => "external commands are not available in this build of the shell",
84            Self::ConfiguredOff => "external commands are disabled on this shell",
85        }
86    }
87}
88
89/// Refuse to run `name` as an external command, exit 127 — the same code
90/// POSIX gives "command not found", since from here it's indistinguishable
91/// to a caller checking only the exit code. The message is what carries the
92/// distinction; see [`ExternalCommandsUnavailable::condition`].
93pub fn external_commands_unavailable_error(name: &str, reason: ExternalCommandsUnavailable) -> ExecResult {
94    ExecResult::failure(127, format!("{name}: {}", reason.condition()))
95}
96
97/// Outcome of attempting to run a command as an external process — what
98/// `Kernel::try_execute_external` and `dispatch.rs`'s test-only
99/// `BackendDispatcher::try_external` return instead of `Option<ExecResult>`.
100///
101/// The distinction matters past this call: only [`Self::NotFound`] means
102/// "keep looking" in the ordinary sense. [`Self::Unavailable`] also means
103/// "keep looking" — a backend-registered tool of the same name is a separate
104/// capability kaish still tries — but the reason must survive that lookup so
105/// the final "nothing claimed this name" message can name it, instead of the
106/// fallthrough re-deriving the wrong "command not found".
107// Without `subprocess`, both spawn sites' stubs only ever produce
108// `Unavailable(NotCompiled)` — `Ran`/`NotFound` are real variants, just
109// unreachable in that build, not leftover code.
110#[cfg_attr(not(feature = "subprocess"), allow(dead_code))]
111pub(crate) enum ExternalCommandOutcome {
112    /// A command was resolved and run; this IS the final result.
113    /// Boxed: `ExecResult` dwarfs the other two variants, and clippy's
114    /// `large_enum_variant` fires on the difference.
115    Ran(Box<ExecResult>),
116    /// Nothing on PATH matches `name`, and no explicit-path form resolved
117    /// either — a genuine "not found" specific to external resolution.
118    NotFound,
119    /// kaish did not attempt a PATH lookup or spawn at all. See
120    /// [`ExternalCommandsUnavailable`] for which of the two reasons.
121    Unavailable(ExternalCommandsUnavailable),
122}
123
124/// Execution context passed to tools.
125///
126/// Provides access to the backend (for file operations and tool dispatch),
127/// scope, and other kernel state.
128///
129/// Build one with [`ExecContext::new`] or one of the `with_*` constructors and
130/// set the fields you need. The struct is `#[non_exhaustive]`, so the kernel can
131/// add a field without breaking an embedder that never asked for it.
132#[non_exhaustive]
133pub struct ExecContext {
134    /// Kernel backend for I/O operations.
135    ///
136    /// This is the preferred way to access filesystem operations.
137    /// Use `backend.read()`, `backend.write()`, etc.
138    pub backend: Arc<dyn KernelBackend>,
139    /// Variable scope.
140    pub scope: Scope,
141    /// Current working directory (VFS path).
142    pub cwd: PathBuf,
143    /// Previous working directory (for `cd -`).
144    pub prev_cwd: Option<PathBuf>,
145    /// Standard input for the tool (from a redirect, heredoc, here-string, or
146    /// `ExecuteOptions::stdin`). Bytes-typed (GH #176) so a `< binfile`
147    /// redirect over non-UTF-8 content reaches a byte-aware builtin intact
148    /// instead of erroring at redirect setup; a text-only builtin still
149    /// refuses it loudly when it calls `read_stdin_to_text`.
150    pub stdin: Option<Vec<u8>>,
151    /// Structured data from pipeline (pre-parsed JSON from previous command).
152    /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
153    pub stdin_data: Option<Value>,
154    /// Sideband receiver for the previous stage's structured `.data`, set by the
155    /// concurrent pipeline runner. Resolved lazily via [`Self::resolve_stdin`]
156    /// AFTER the pipe is drained — never pre-read — so a streaming upstream that
157    /// only sends its data after writing the pipe can't deadlock a consumer that
158    /// awaits it. Non-`Clone`, so it's moved on resolve.
159    pub stdin_data_rx: Option<oneshot::Receiver<Option<Value>>>,
160    /// Streaming pipe input (set when this command is in a concurrent pipeline).
161    pub pipe_stdin: Option<PipeReader>,
162    /// Streaming pipe output (set when this command is in a concurrent pipeline).
163    pub pipe_stdout: Option<PipeWriter>,
164    /// Tool schemas for help command.
165    ///
166    /// `Arc<[…]>` rather than `Vec`: the full builtin schema catalog (~70
167    /// entries, each with its own `Vec`s and `String`s) is snapshotted into a
168    /// fresh `ExecContext` at every command dispatch and pipeline/fork child. As
169    /// a `Vec` that was a deep clone of the whole catalog per command; as an
170    /// `Arc<[…]>` it's a refcount bump (GH #48, item 8). Immutable after the
171    /// kernel seeds it, so a shared slice is the right shape.
172    pub tool_schemas: Arc<[ToolSchema]>,
173    /// Tool registry reference (for tools that need to inspect available tools).
174    pub tools: Option<Arc<ToolRegistry>>,
175    /// Job manager for background jobs (optional).
176    pub job_manager: Option<Arc<JobManager>>,
177    /// Kernel stderr stream for real-time error output from pipeline stages.
178    ///
179    /// When set, pipeline stages write stderr here instead of buffering in
180    /// `ExecResult.err`. This allows stderr from all stages to stream to
181    /// the terminal (or other sink) concurrently, matching bash behavior.
182    pub stderr: Option<StderrStream>,
183    /// Position of this command within a pipeline (for stdio decisions).
184    pub pipeline_position: PipelinePosition,
185    /// Whether we're running in interactive (REPL) mode.
186    pub interactive: bool,
187    /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands spawned from this
188    /// context, so a hard-killed kaish process cannot orphan them.
189    ///
190    /// Seeded from `KernelConfig::kill_children_on_parent_death` — read that
191    /// field for the tradeoff and the macOS gap. It lives here, not on the
192    /// `Kernel`, because both external-command spawn sites (`Kernel::
193    /// try_execute_external` and `dispatch.rs`'s `BackendDispatcher`) reach an
194    /// `ExecContext` and only one of them reaches a `Kernel`; one home keeps
195    /// the two `pre_exec` blocks from drifting.
196    ///
197    /// `false` for a stand-alone `ExecContext` built outside a kernel, which
198    /// is the pre-existing behavior.
199    pub kill_children_on_parent_death: bool,
200    /// SIGTERM-to-SIGKILL grace period for a child killed by cancellation.
201    ///
202    /// Seeded from `KernelConfig::kill_grace`. It lives here, not only on the
203    /// `Kernel`, for the same reason as `kill_children_on_parent_death`: a
204    /// tool that spawns a child holds an `ExecContext` and never a `Kernel`,
205    /// and one home keeps the grace from drifting between spawn sites.
206    ///
207    /// [`DEFAULT_KILL_GRACE`] for a stand-alone `ExecContext` built outside a
208    /// kernel.
209    pub kill_grace: std::time::Duration,
210    /// The background job this context executes on behalf of, if any.
211    ///
212    /// Stamped by `Kernel::fork_for_background` and inherited by every
213    /// sub-fork (pipeline stages, scatter workers), so a child spawned
214    /// anywhere beneath a background job records its process group on that
215    /// job for `kill -<sig> %N` and tees its output into the job's streams.
216    /// `None` for foreground execution.
217    pub background_job: Option<crate::scheduler::JobId>,
218    /// Command aliases (name → expansion string).
219    pub aliases: HashMap<String, String>,
220    /// Ignore file configuration for file-walking tools.
221    pub ignore_config: IgnoreConfig,
222    /// Output size limit configuration for agent safety.
223    pub output_limit: OutputLimitConfig,
224    /// Whether external command execution is allowed.
225    ///
226    /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
227    /// Only kaish builtins and backend-registered tools (MCP) are available.
228    /// A blocked attempt reports [`ExternalCommandsUnavailable::ConfiguredOff`],
229    /// not "command not found".
230    pub allow_external_commands: bool,
231    /// Trash backend for safe file deletion.
232    ///
233    /// Always present when the kernel creates the context (even if `set -o trash`
234    /// is off — the backend exists so `kaish-trash list/restore/empty` work
235    /// regardless of the trash flag).
236    pub trash_backend: Option<Arc<dyn TrashBackend>>,
237    /// Terminal state for job control (interactive mode, Unix only).
238    #[cfg(all(unix, feature = "subprocess"))]
239    pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
240    /// Command dispatcher for re-dispatching through the full resolution chain.
241    ///
242    /// When set (via `Kernel::into_arc()`), builtins like `timeout` can dispatch
243    /// inner commands through the full chain (user tools → builtins → .kai scripts
244    /// → external commands) instead of being limited to `backend.call_tool()`.
245    ///
246    /// `None` when the Kernel was not wrapped via `into_arc()`.
247    pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
248    /// Cancellation token for this execution path.
249    ///
250    /// Populated by the kernel at execute entry, then propagated through pipeline
251    /// stages, foreground forks (scatter workers, concurrent pipeline stages,
252    /// `$(...)` cmdsubs), and into spawned external children. When the token
253    /// fires, externals receive SIGTERM/SIGKILL via the `wait_or_kill` helper.
254    ///
255    /// Default for stand-alone `ExecContext` constructors is a fresh, never-fired
256    /// token so non-kernel test contexts behave as before.
257    pub cancel: CancellationToken,
258    /// Per-execution output format override set by a builtin's GlobalFlags
259    /// flatten (e.g. `--json`). The dispatcher reads this after `tool.execute()`
260    /// returns and applies the format via `apply_output_format`.
261    ///
262    /// Builtins set this via `GlobalFlags::apply(ctx)`; external commands
263    /// don't touch it.
264    pub output_format: Option<OutputFormat>,
265
266    /// Shared VFS memory budget for this kernel's `MemoryFs` mounts.
267    ///
268    /// `Arc`-cloned from the owning `Kernel` (or its fork parent) so all
269    /// concurrent execution paths draw from the same pool. `None` means
270    /// unbounded. Populated by `Kernel::assemble` and forwarded through
271    /// `child_for_pipeline` / `fork_inner` so background jobs and scatter
272    /// workers see the same cap as foreground execution.
273    pub vfs_budget: Option<Arc<ByteBudget>>,
274
275    /// The per-execute timeout watchdog, when a script timeout is in effect.
276    ///
277    /// Populated by the kernel at execute entry (alongside `cancel`) and
278    /// shared through `child_for_pipeline` so forks and pipeline stages can
279    /// acquire patient holds against the same script clock. `None` when no
280    /// timeout is configured — `ToolCtx::patient` then returns an inert guard.
281    pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
282
283    /// Active overlay handle when the kernel was constructed with `overlay: true`.
284    ///
285    /// `Arc`-cloned so forks and pipeline stages share the same transaction.
286    /// `None` when no overlay is active (most kernels).
287    #[cfg(all(feature = "localfs", feature = "overlay"))]
288    pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
289
290}
291
292/// What the write-model gate chose for a single truncating overwrite.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub(crate) enum MutationAction {
295    /// Write now — new file, append, excluded path, or trash off.
296    Proceed,
297    /// Snapshot the prior content to trash, then write.
298    TrashFirst,
299}
300
301/// What a snapshotted overwrite must still find at the target before it
302/// writes — the compare-and-swap expectation `overwrite_checked` enforces.
303///
304/// The trash path already holds the prior bytes (it had to copy them to the
305/// trash), so the expectation compares bytes.
306#[derive(Debug, Clone)]
307#[non_exhaustive]
308pub enum OverwriteExpectation {
309    /// The exact prior bytes, from the trash snapshot.
310    Bytes(Vec<u8>),
311}
312
313/// What each snapshotted target must still look like when the caller writes
314/// it, keyed by resolved path (see `overwrite_checked`).
315///
316/// Every existing target the trash snapshotted appears. A new file, an
317/// append, and an excluded or unsnapshotted path are absent, because none of
318/// them has prior content to lose.
319pub type GateExpectations = std::collections::HashMap<PathBuf, OverwriteExpectation>;
320
321/// Real paths the trash gate skips: host scratch under `/tmp`, where
322/// snapshotting prior content to trash is pointless. Shared by `rm`'s delete
323/// gate (`decide_rm_action`) and the overwrite gate (`decide_mutation_action`)
324/// so the exclusion can't drift between them. `Path::starts_with` is
325/// component-aware, so `/tmp_file` does not match `/tmp`.
326///
327/// Note: kaish's own in-memory VFS mounts (e.g. `/v/blobs`) have `real_path ==
328/// None`, so they are handled by the no-real-path gating path, not here — there
329/// is deliberately no lexical `/v` exclusion. Mount-coverage routing delegates
330/// unclaimed `/v/*` to the embedder's backend, whose *real* content under `/v`
331/// (a real path like `/v/cas/blob.bin`) must keep the trash safety net; a
332/// `/v` prefix exclusion here would silently strip it.
333pub(crate) fn is_trash_excluded(real_path: Option<&Path>) -> bool {
334    matches!(real_path, Some(rp) if rp.starts_with("/tmp"))
335}
336
337/// Decide whether a truncating overwrite snapshots to trash first, mirroring
338/// `rm`'s trash priority. Pure so the decision table is unit-testable in
339/// isolation.
340///
341/// - A non-existent target or an append has nothing to lose → `Proceed`.
342/// - A real path under `/tmp` (host scratch) is excluded (matches `rm`) → `Proceed`.
343/// - `TrashFirst` when trash is on **and** the prior content fits under
344///   `trash_max_size` (a file too big to snapshot can't be backed up, so it
345///   falls through, exactly like `rm`); else `Proceed`.
346///
347/// An overlay/in-memory target has `real_path == None`, so it is *not*
348/// excluded and still snapshots — the protection is about agent-operation
349/// safety, not just real-FS data (Amy, 2026-06-17).
350pub(crate) fn decide_mutation_action(
351    trash_enabled: bool,
352    real_path: Option<&Path>,
353    target_exists: bool,
354    is_append: bool,
355    file_size: u64,
356    trash_max_size: u64,
357) -> MutationAction {
358    if !target_exists || is_append {
359        return MutationAction::Proceed;
360    }
361    if is_trash_excluded(real_path) {
362        return MutationAction::Proceed;
363    }
364    if trash_enabled && file_size <= trash_max_size {
365        return MutationAction::TrashFirst;
366    }
367    // A target too large for the trash is written directly. kaish does not
368    // hold it back: nothing in the kernel decides whether an overwrite is
369    // allowed — an embedder that wants to refuse one reads the plan first.
370    MutationAction::Proceed
371}
372
373/// Overwrite `resolved` with `content`, compare-and-swapping against
374/// `expected` first when there is one. The target's current state is
375/// re-derived and must match, else a concurrent change is a loud conflict —
376/// never a silent clobber. Binary-safe (raw bytes, unlike the `String`-based
377/// `PatchOp` CAS). Shared by the byte-oriented gated builtins via
378/// `ExecContext::overwrite_checked` (`tee`/`write`/`dd`) and directly by
379/// `cp`'s free copy path.
380///
381/// This catches a change between the snapshot and the write. It does not
382/// make the write OS-atomic — a crash mid-write can still truncate (the
383/// atomic write-temp-then-rename primitive is a tracked write-model
384/// residual).
385pub(crate) async fn cas_overwrite(
386    backend: &dyn KernelBackend,
387    resolved: &Path,
388    content: &[u8],
389    expected: Option<&OverwriteExpectation>,
390) -> Result<(), crate::backend::BackendError> {
391    // A re-read or re-digest failure propagates loudly — never
392    // `unwrap_or_default()` to empty bytes, which would false-match an empty
393    // snapshot (silent overwrite) or report a bogus "file changed" for a real
394    // I/O error. A target that vanished since the gate is a change → abort.
395    match expected {
396        Some(OverwriteExpectation::Bytes(exp)) => {
397            let current = backend.read(resolved, None).await?;
398            if current != *exp {
399                return Err(concurrent_change_error(resolved));
400            }
401        }
402        None => {}
403    }
404    backend
405        .write(resolved, content, crate::backend::WriteMode::Overwrite)
406        .await
407}
408
409/// One wording for "somebody else wrote this while the write-model gate was
410/// deciding".
411fn concurrent_change_error(resolved: &Path) -> crate::backend::BackendError {
412    crate::backend::BackendError::InvalidOperation(format!(
413        "{}: changed since the write-model gate checked it (concurrent write); \
414         aborting overwrite",
415        resolved.display()
416    ))
417}
418
419impl ExecContext {
420    /// Create a new execution context with a VFS (uses LocalBackend without tools).
421    ///
422    /// This constructor is for backward compatibility and tests that don't need tool dispatch.
423    /// For full tool support, use `with_vfs_and_tools`.
424    pub fn new(vfs: Arc<VfsRouter>) -> Self {
425        Self {
426            backend: Arc::new(LocalBackend::new(vfs)),
427            scope: Scope::new(),
428            cwd: PathBuf::from("/"),
429            prev_cwd: None,
430            stdin: None,
431            stdin_data: None,
432            stdin_data_rx: None,
433            pipe_stdin: None,
434            pipe_stdout: None,
435            stderr: None,
436            tool_schemas: Vec::new().into(),
437            tools: None,
438            job_manager: None,
439            pipeline_position: PipelinePosition::Only,
440            interactive: false,
441            kill_children_on_parent_death: false,
442            kill_grace: DEFAULT_KILL_GRACE,
443            background_job: None,
444            aliases: HashMap::new(),
445            ignore_config: IgnoreConfig::none(),
446            output_limit: OutputLimitConfig::none(),
447            allow_external_commands: true,
448            trash_backend: None,
449            #[cfg(all(unix, feature = "subprocess"))]
450            terminal_state: None,
451            dispatcher: None,
452            cancel: CancellationToken::new(),
453            output_format: None,
454            vfs_budget: None,
455            watchdog: None,
456            #[cfg(all(feature = "localfs", feature = "overlay"))]
457            overlay_handle: None,
458        }
459    }
460
461    /// Create a new execution context with VFS and tool registry.
462    ///
463    /// This is the preferred constructor for full kaish operation where
464    /// tools need to be dispatched through the backend.
465    pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
466        Self {
467            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
468            scope: Scope::new(),
469            cwd: PathBuf::from("/"),
470            prev_cwd: None,
471            stdin: None,
472            stdin_data: None,
473            stdin_data_rx: None,
474            pipe_stdin: None,
475            pipe_stdout: None,
476            stderr: None,
477            tool_schemas: Vec::new().into(),
478            tools: Some(tools),
479            job_manager: None,
480            pipeline_position: PipelinePosition::Only,
481            interactive: false,
482            kill_children_on_parent_death: false,
483            kill_grace: DEFAULT_KILL_GRACE,
484            background_job: None,
485            aliases: HashMap::new(),
486            ignore_config: IgnoreConfig::none(),
487            output_limit: OutputLimitConfig::none(),
488            allow_external_commands: true,
489            trash_backend: None,
490            #[cfg(all(unix, feature = "subprocess"))]
491            terminal_state: None,
492            dispatcher: None,
493            cancel: CancellationToken::new(),
494            output_format: None,
495            vfs_budget: None,
496            watchdog: None,
497            #[cfg(all(feature = "localfs", feature = "overlay"))]
498            overlay_handle: None,
499        }
500    }
501
502    /// Create a new execution context with a custom backend.
503    pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
504        Self {
505            backend,
506            scope: Scope::new(),
507            cwd: PathBuf::from("/"),
508            prev_cwd: None,
509            stdin: None,
510            stdin_data: None,
511            stdin_data_rx: None,
512            pipe_stdin: None,
513            pipe_stdout: None,
514            stderr: None,
515            tool_schemas: Vec::new().into(),
516            tools: None,
517            job_manager: None,
518            pipeline_position: PipelinePosition::Only,
519            interactive: false,
520            kill_children_on_parent_death: false,
521            kill_grace: DEFAULT_KILL_GRACE,
522            background_job: None,
523            aliases: HashMap::new(),
524            ignore_config: IgnoreConfig::none(),
525            output_limit: OutputLimitConfig::none(),
526            allow_external_commands: true,
527            trash_backend: None,
528            #[cfg(all(unix, feature = "subprocess"))]
529            terminal_state: None,
530            dispatcher: None,
531            cancel: CancellationToken::new(),
532            output_format: None,
533            vfs_budget: None,
534            watchdog: None,
535            #[cfg(all(feature = "localfs", feature = "overlay"))]
536            overlay_handle: None,
537        }
538    }
539
540    /// Create a context with VFS, tools, and a specific scope.
541    pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
542        Self {
543            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
544            scope,
545            cwd: PathBuf::from("/"),
546            prev_cwd: None,
547            stdin: None,
548            stdin_data: None,
549            stdin_data_rx: None,
550            pipe_stdin: None,
551            pipe_stdout: None,
552            stderr: None,
553            tool_schemas: Vec::new().into(),
554            tools: Some(tools),
555            job_manager: None,
556            pipeline_position: PipelinePosition::Only,
557            interactive: false,
558            kill_children_on_parent_death: false,
559            kill_grace: DEFAULT_KILL_GRACE,
560            background_job: None,
561            aliases: HashMap::new(),
562            ignore_config: IgnoreConfig::none(),
563            output_limit: OutputLimitConfig::none(),
564            allow_external_commands: true,
565            trash_backend: None,
566            #[cfg(all(unix, feature = "subprocess"))]
567            terminal_state: None,
568            dispatcher: None,
569            cancel: CancellationToken::new(),
570            output_format: None,
571            vfs_budget: None,
572            watchdog: None,
573            #[cfg(all(feature = "localfs", feature = "overlay"))]
574            overlay_handle: None,
575        }
576    }
577
578    /// Create a context with a specific scope (uses LocalBackend without tools).
579    ///
580    /// For tests that don't need tool dispatch. For full tool support,
581    /// use `with_vfs_tools_and_scope`.
582    pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
583        Self {
584            backend: Arc::new(LocalBackend::new(vfs)),
585            scope,
586            cwd: PathBuf::from("/"),
587            prev_cwd: None,
588            stdin: None,
589            stdin_data: None,
590            stdin_data_rx: None,
591            pipe_stdin: None,
592            pipe_stdout: None,
593            stderr: None,
594            tool_schemas: Vec::new().into(),
595            tools: None,
596            job_manager: None,
597            pipeline_position: PipelinePosition::Only,
598            interactive: false,
599            kill_children_on_parent_death: false,
600            kill_grace: DEFAULT_KILL_GRACE,
601            background_job: None,
602            aliases: HashMap::new(),
603            ignore_config: IgnoreConfig::none(),
604            output_limit: OutputLimitConfig::none(),
605            allow_external_commands: true,
606            trash_backend: None,
607            #[cfg(all(unix, feature = "subprocess"))]
608            terminal_state: None,
609            dispatcher: None,
610            cancel: CancellationToken::new(),
611            output_format: None,
612            vfs_budget: None,
613            watchdog: None,
614            #[cfg(all(feature = "localfs", feature = "overlay"))]
615            overlay_handle: None,
616        }
617    }
618
619    /// Create a context with a custom backend and scope.
620    pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
621        Self {
622            backend,
623            scope,
624            cwd: PathBuf::from("/"),
625            prev_cwd: None,
626            stdin: None,
627            stdin_data: None,
628            stdin_data_rx: None,
629            pipe_stdin: None,
630            pipe_stdout: None,
631            stderr: None,
632            tool_schemas: Vec::new().into(),
633            tools: None,
634            job_manager: None,
635            pipeline_position: PipelinePosition::Only,
636            interactive: false,
637            kill_children_on_parent_death: false,
638            kill_grace: DEFAULT_KILL_GRACE,
639            background_job: None,
640            aliases: HashMap::new(),
641            ignore_config: IgnoreConfig::none(),
642            output_limit: OutputLimitConfig::none(),
643            allow_external_commands: true,
644            trash_backend: None,
645            #[cfg(all(unix, feature = "subprocess"))]
646            terminal_state: None,
647            dispatcher: None,
648            cancel: CancellationToken::new(),
649            output_format: None,
650            vfs_budget: None,
651            watchdog: None,
652            #[cfg(all(feature = "localfs", feature = "overlay"))]
653            overlay_handle: None,
654        }
655    }
656
657    /// Set the available tool schemas (for help command).
658    ///
659    /// Takes a `Vec` for caller convenience and converts to the shared
660    /// `Arc<[…]>` the field stores (see the field docs; GH #48).
661    pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
662        self.tool_schemas = schemas.into();
663    }
664
665    /// Set the tool registry reference.
666    pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
667        self.tools = Some(tools);
668    }
669
670    /// Set the job manager for background job tracking.
671    pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
672        self.job_manager = Some(manager);
673    }
674
675    /// Set the trash backend.
676    pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
677        self.trash_backend = Some(backend);
678    }
679
680    /// Set stdin for this execution.
681    ///
682    /// An explicit stdin buffer (`< file`, heredoc, here-string, or a pipeline
683    /// hand-off) supersedes any inherited lazy `pipe_stdin`. Since `read_stdin_*`
684    /// prefers `pipe_stdin`, clear it here so redirect precedence holds — a
685    /// `< file` must beat a frontend-seeded piped stdin. Accepts anything
686    /// `Into<Vec<u8>>` — a `String`/`&str` (heredocs, here-strings, most
687    /// callers) or a raw `Vec<u8>` (a `< binfile` redirect, GH #176) both work.
688    pub fn set_stdin(&mut self, stdin: impl Into<Vec<u8>>) {
689        self.stdin = Some(stdin.into());
690        self.pipe_stdin = None;
691    }
692
693    /// Get stdin, consuming it.
694    pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
695        self.stdin.take()
696    }
697
698    /// Set both text stdin and structured data.
699    ///
700    /// Use this when passing output through a pipeline where the previous
701    /// command produced structured data (e.g., JSON from MCP tools). The text
702    /// side is always a genuine `String` here (structured-data hand-off is a
703    /// JSON-producing pipeline stage, never binary).
704    pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
705        self.stdin = Some(text.into_bytes());
706        self.stdin_data = data;
707    }
708
709    /// Take structured data if available, consuming it.
710    ///
711    /// Tools can use this to avoid re-parsing JSON that was already parsed
712    /// by a previous command in the pipeline.
713    pub fn take_stdin_data(&mut self) -> Option<Value> {
714        self.stdin_data.take()
715    }
716
717    /// Resolve stdin for a builtin that can consume *either* structured `.data`
718    /// or raw text from the previous pipeline stage (jq, scatter, …). Returns
719    /// `(Some(data), _)` when the upstream produced structured data, else
720    /// `(None, text)`.
721    ///
722    /// Ordering matters and is the whole point: the pipe is drained to text
723    /// FIRST, which runs the upstream producer to completion (it can't be parked
724    /// on pipe backpressure), and only THEN is the structured-data sideband
725    /// awaited — by which point the producer has definitely sent it (it sends
726    /// before writing/closing its pipe). A streaming upstream that emits a lot
727    /// of text before sending its (absent) data therefore can't deadlock us, and
728    /// a fast structured producer (`seq`) is no longer lost to a startup race
729    /// that a one-shot `try_recv` used to drop on the floor.
730    pub async fn resolve_stdin(&mut self) -> Result<(Option<Value>, String), String> {
731        // Data set directly on the context (not via the pipeline sideband) wins
732        // and needs no pipe — e.g. a non-pipeline caller seeded `stdin_data`.
733        if let Some(data) = self.stdin_data.take() {
734            return Ok((Some(data), String::new()));
735        }
736        // Drain the pipe (and/or buffered stdin) to text — unblocks the upstream.
737        let text = self.read_stdin_to_text().await?.unwrap_or_default();
738        // Upstream has now finished; its structured data (if any) is waiting.
739        if let Some(rx) = self.stdin_data_rx.take()
740            && let Ok(Some(data)) = rx.await
741        {
742            return Ok((Some(data), text));
743        }
744        Ok((None, text))
745    }
746
747    /// Resolve a path relative to cwd, normalizing `.` and `..` components.
748    pub fn resolve_path(&self, path: &str) -> PathBuf {
749        let raw = if path.starts_with('/') {
750            PathBuf::from(path)
751        } else {
752            self.cwd.join(path)
753        };
754        normalize_path(&raw)
755    }
756
757    /// Change the current working directory.
758    ///
759    /// Saves the old directory for `cd -` support.
760    pub fn set_cwd(&mut self, path: PathBuf) {
761        let previous = self.cwd.clone();
762        self.prev_cwd = Some(previous.clone());
763        self.cwd = path;
764        // `$PWD`/`$OLDPWD` follow the working directory from HERE, the one
765        // place it changes. They used to be whatever the process inherited and
766        // nothing ever wrote them, so `cd /tmp; echo $PWD` reported the
767        // directory the shell started in while `pwd` reported `/tmp` — a wrong
768        // value with nothing to say so, and the validator vouched for the name
769        // because `scope_tracker` lists both as known. `$OLDPWD` was worse than
770        // stale: it held a directory from the INVOKING shell's history.
771        //
772        // Here rather than in the `cd` builtin so a second writer cannot drift
773        // from the first.
774        self.sync_cwd_vars(&previous);
775    }
776
777    /// Write `$PWD`/`$OLDPWD` from the context's own directories.
778    ///
779    /// Kept next to [`Self::set_cwd`] because the kernel also calls it once at
780    /// startup: a script that reads `$PWD` before its first `cd` must not see
781    /// the inherited environment's value either.
782    pub(crate) fn sync_cwd_vars(&mut self, previous: &Path) {
783        self.scope.set_global("PWD", Value::String(self.cwd.to_string_lossy().into_owned()));
784        self.scope.set_global(
785            "OLDPWD",
786            Value::String(previous.to_string_lossy().into_owned()),
787        );
788    }
789
790    /// Get the previous working directory (for `cd -`).
791    pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
792        self.prev_cwd.as_ref()
793    }
794
795    /// Read stdin as text, erroring on non-UTF-8 instead of silently
796    /// lossy-decoding it (which corrupts binary with `U+FFFD`).
797    ///
798    /// The strict counterpart to [`Self::read_stdin_to_bytes`], for text-only
799    /// builtins (`grep`, `sed`, `awk`, `cut`, `sort`, `jq`, …): a binary stream
800    /// is a loud error, not a mangle. Returns `Ok(None)` when there is no stdin
801    /// at all. The `Err` is a ready-to-use message; callers prefix their name.
802    /// See `docs/binary-data.md`.
803    pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
804        match self.read_stdin_to_bytes().await? {
805            None => Ok(None),
806            Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
807                "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
808                 or use a binary-aware tool (cat, dd, cmp, wc -c)"
809                    .to_string()
810            }),
811        }
812    }
813
814    /// Read all of stdin as raw bytes, preserving binary intact.
815    ///
816    /// The byte-clean counterpart to [`Self::read_stdin_to_text`], for
817    /// binary-aware builtins (`base64`, `xxd`, `checksum`, `wc -c`, `cmp`, …).
818    /// Returns `Ok(None)` when there is no stdin at all (no pipe and no
819    /// buffer); an empty pipe yields `Ok(Some(vec![]))`. The buffered source is
820    /// already bytes-typed (GH #176), so this is a plain move, never a
821    /// re-encode. See `docs/binary-data.md`.
822    ///
823    /// Anything an earlier [`Self::read_stdin_line`] left behind comes first,
824    /// then the rest of the pipe — `read x; cat` gives `cat` everything after
825    /// the line `read` took, in order, and nothing twice.
826    ///
827    /// A failed pipe read is `Err`, never `Ok(None)`. "The pipe broke" and
828    /// "there was no stdin" are different facts, and collapsing them hands the
829    /// builtin a short read dressed up as empty input — `wc` would report 0
830    /// lines and exit 0 on a stream that died halfway, and the bytes already
831    /// read would go with it. [`Self::read_stdin_line`] propagates this same
832    /// error from this same reader; the two now agree. The `Err` is a
833    /// ready-to-use message; callers prefix their name.
834    pub async fn read_stdin_to_bytes(&mut self) -> Result<Option<Vec<u8>>, String> {
835        let leftover = self.stdin.take();
836        match self.pipe_stdin.take() {
837            Some(mut reader) => {
838                use tokio::io::AsyncReadExt;
839                let mut buf = leftover.unwrap_or_default();
840                reader
841                    .read_to_end(&mut buf)
842                    .await
843                    .map_err(|e| format!("reading stdin: {e}"))?;
844                Ok(Some(buf))
845            }
846            None => Ok(leftover),
847        }
848    }
849
850    /// Read one line from stdin, leaving the rest for the next reader.
851    ///
852    /// This is the stream-shaped counterpart to [`Self::read_stdin_to_bytes`]:
853    /// it takes a single line and keeps everything after it, so `read x; read y`
854    /// binds two lines and `read x; cat` hands `cat` the remainder. Draining to
855    /// EOF for one line would discard the rest of the stream — there is no way
856    /// to put it back once a pipe has been read.
857    ///
858    /// The trailing newline is stripped, and a final line without one is still
859    /// a line. Returns `Ok(None)` at end of input — no line left, which is a
860    /// fact the caller reports, not an empty binding. `Err` on non-UTF-8, with
861    /// the same message shape as [`Self::read_stdin_to_text`].
862    pub async fn read_stdin_line(&mut self) -> Result<Option<String>, String> {
863        loop {
864            // A complete line already buffered? Take it and keep the rest.
865            if let Some(buf) = self.stdin.as_mut()
866                && let Some(nl) = buf.iter().position(|b| *b == b'\n')
867            {
868                let rest = buf.split_off(nl + 1);
869                let mut line = std::mem::replace(buf, rest);
870                line.pop(); // the '\n' itself
871                if line.last() == Some(&b'\r') {
872                    line.pop();
873                }
874                // Drop an emptied buffer only when nothing can refill it, so
875                // `read_stdin_to_bytes` can still tell "no stdin" (None) from
876                // "stdin that is now empty" (Some(vec![])).
877                if self.pipe_stdin.is_none()
878                    && self.stdin.as_ref().is_some_and(|b| b.is_empty())
879                {
880                    self.stdin = None;
881                }
882                return decode_stdin_line(line).map(Some);
883            }
884
885            // No newline buffered — pull another chunk from the pipe. The
886            // reader stays in place: taking it would strand the remainder.
887            if let Some(reader) = self.pipe_stdin.as_mut() {
888                use tokio::io::AsyncReadExt;
889                let mut chunk = [0u8; 8192];
890                match reader.read(&mut chunk).await {
891                    Ok(0) => {
892                        self.pipe_stdin = None; // EOF; fall through to the tail
893                    }
894                    Ok(n) => {
895                        self.stdin
896                            .get_or_insert_with(Vec::new)
897                            .extend_from_slice(&chunk[..n]);
898                    }
899                    Err(e) => return Err(format!("reading stdin: {e}")),
900                }
901                continue;
902            }
903
904            // Nothing left to read: whatever is buffered is the last line.
905            return match self.stdin.take() {
906                Some(buf) if !buf.is_empty() => decode_stdin_line(buf).map(Some),
907                _ => Ok(None),
908            };
909        }
910    }
911
912    /// Create a child context for a pipeline stage.
913    ///
914    /// Shares backend, tools, job_manager, aliases, cwd, and scope
915    /// but has independent stdin/stdout pipes.
916    pub fn child_for_pipeline(&self) -> Self {
917        Self {
918            backend: self.backend.clone(),
919            scope: self.scope.clone(),
920            cwd: self.cwd.clone(),
921            prev_cwd: self.prev_cwd.clone(),
922            stdin: None,
923            stdin_data: None,
924            stdin_data_rx: None,
925            pipe_stdin: None,
926            pipe_stdout: None,
927            stderr: self.stderr.clone(),
928            tool_schemas: self.tool_schemas.clone(),
929            tools: self.tools.clone(),
930            job_manager: self.job_manager.clone(),
931            pipeline_position: PipelinePosition::Only,
932            interactive: self.interactive,
933            kill_children_on_parent_death: self.kill_children_on_parent_death,
934            kill_grace: self.kill_grace,
935            background_job: self.background_job,
936            aliases: self.aliases.clone(),
937            ignore_config: self.ignore_config.clone(),
938            output_limit: self.output_limit.clone(),
939            allow_external_commands: self.allow_external_commands,
940            trash_backend: self.trash_backend.clone(),
941            #[cfg(all(unix, feature = "subprocess"))]
942            terminal_state: self.terminal_state.clone(),
943            dispatcher: self.dispatcher.clone(),
944            cancel: self.cancel.clone(),
945            // Output format is per-execution; child pipeline stages start fresh.
946            output_format: None,
947            // Budget is shared: the child draws from the same pool as the parent.
948            vfs_budget: self.vfs_budget.clone(),
949            // Watchdog is shared: a patient hold in a pipeline stage or fork
950            // suspends the same script clock as foreground execution.
951            watchdog: self.watchdog.clone(),
952            // Overlay handle is shared: pipeline stages share the same transaction.
953            #[cfg(all(feature = "localfs", feature = "overlay"))]
954            overlay_handle: self.overlay_handle.clone(),
955        }
956    }
957
958    /// Build an `IgnoreFilter` from the current ignore configuration.
959    ///
960    /// Returns `None` if no filtering is configured.
961    pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
962        use crate::backend_walker_fs::BackendWalkerFs;
963        let fs = BackendWalkerFs(self.backend.as_ref());
964        self.ignore_config.build_filter(root, &fs).await
965    }
966
967    /// Snapshot a batch of truncating overwrites into the trash, the way `rm`
968    /// snapshots deletes — so `tee`/`patch`/`sed -i` can't clobber a file
969    /// under `set -o trash` without leaving a recoverable prior copy.
970    ///
971    /// Each target is `(display_path, is_append)`. A path that doesn't exist
972    /// yet or is an append has nothing to lose and passes. For an existing
973    /// file under `set -o trash`, the prior content is copied to trash first
974    /// (via `trash_bytes`) so it's recoverable; the file is left in place for
975    /// the caller to overwrite. With trash off, every target passes: the
976    /// kernel does not decide whether an overwrite is allowed.
977    ///
978    /// `Ok(snapshots)` means every snapshot is done and the caller may write
979    /// all targets; `snapshots` maps each trash-snapshotted target's resolved
980    /// path to its prior bytes, so a byte-oriented caller can pass them as the
981    /// `expected` to `overwrite_checked` for a binary-safe compare-and-swap.
982    /// `Err(result)` is what the caller must return verbatim — a trash failure
983    /// is an error, never a fall-through to a destructive overwrite.
984    // `ExecResult` IS the error here — `Err(result)` is what the caller
985    // returns verbatim, which is the point of the signature. Boxing it to
986    // satisfy `result_large_err` would put a `*deref` at every call site to
987    // save an allocation on a path that already reads and copies file bytes.
988    #[allow(clippy::result_large_err)]
989    pub async fn snapshot_overwrites(
990        &mut self,
991        command: &str,
992        targets: &[(String, bool)],
993    ) -> Result<GateExpectations, ExecResult> {
994        let mut expectations = GateExpectations::new();
995        let trash_enabled = self.scope.trash_enabled();
996        // Fast path: nothing is trashed, so this costs one branch and
997        // allocates nothing.
998        if !trash_enabled {
999            return Ok(expectations);
1000        }
1001        let trash_max_size = self.scope.trash_max_size();
1002
1003        struct Decided {
1004            display: String,
1005            resolved: PathBuf,
1006            action: MutationAction,
1007        }
1008        // Dedup by resolved path (keep first): a multi-file patch with an
1009        // explicit target lists the same file once per hunk-group, and we must
1010        // not snapshot it N times or list it N times in the request.
1011        let mut seen = std::collections::HashSet::new();
1012        let mut decided = Vec::with_capacity(targets.len());
1013        for (display, is_append) in targets {
1014            let resolved = self.resolve_path(display);
1015            if !seen.insert(resolved.clone()) {
1016                continue;
1017            }
1018            // `real` is used only for the exclusion decision (/tmp, /v); the
1019            // snapshot reads bytes through the backend, not the real path.
1020            let real = self.backend.resolve_real_path(Path::new(&resolved));
1021            let exists = self.backend.exists(Path::new(&resolved)).await;
1022            // Prior size decides trash eligibility (a file too big to snapshot
1023            // can't be backed up). Only stat an existing target.
1024            let size = if exists {
1025                self.backend
1026                    .stat(Path::new(&resolved))
1027                    .await
1028                    .map(|e| e.size)
1029                    .unwrap_or(0)
1030            } else {
1031                0
1032            };
1033            let action = decide_mutation_action(
1034                trash_enabled,
1035                real.as_deref(),
1036                exists,
1037                *is_append,
1038                size,
1039                trash_max_size,
1040            );
1041            decided.push(Decided {
1042                display: display.clone(),
1043                resolved,
1044                action,
1045            });
1046        }
1047
1048        // Snapshot prior content for every trash-first target before any write,
1049        // keeping the bytes so a byte-oriented caller can CAS against them.
1050        for d in &decided {
1051            if matches!(d.action, MutationAction::TrashFirst) {
1052                match self.snapshot_for_overwrite(&d.display, &d.resolved).await {
1053                    Ok(bytes) => {
1054                        expectations.insert(d.resolved.clone(), OverwriteExpectation::Bytes(bytes));
1055                    }
1056                    Err(e) => return Err(ExecResult::failure(1, format!("{command}: {e}"))),
1057                }
1058            }
1059        }
1060        Ok(expectations)
1061    }
1062
1063    /// Copy the prior content of `resolved` into the trash before it's
1064    /// overwritten, returning those bytes for the caller's compare-and-swap.
1065    ///
1066    /// We **copy** (not move): the builtin overwrites the file in place next,
1067    /// and read-modify-write callers (`patch`, `sed -i`) still need to read it —
1068    /// the file keeps its identity, only its content changes. (`rm` *moves*
1069    /// because removal is the op; an overwrite backs up the prior bytes.) Reads
1070    /// through the backend so a real, overlay, or in-memory file is handled the
1071    /// same way. A missing trash backend or a trash failure is an error — never
1072    /// a silent fall-through to a destructive overwrite.
1073    async fn snapshot_for_overwrite(
1074        &self,
1075        display: &str,
1076        resolved: &Path,
1077    ) -> Result<Vec<u8>, String> {
1078        let trash = self
1079            .trash_backend
1080            .as_ref()
1081            .ok_or_else(|| "trash backend not available".to_string())?;
1082        let bytes = self
1083            .backend
1084            .read(resolved, None)
1085            .await
1086            .map_err(|e| format!("{display}: {e}"))?;
1087        trash
1088            .trash_bytes(Path::new(display), &bytes)
1089            .await
1090            .map_err(|e| format!("{display}: trash failed: {e}"))?;
1091        Ok(bytes)
1092    }
1093
1094    /// Overwrite `resolved` with `content`. When `expected` is `Some`, this is a
1095    /// binary-safe compare-and-swap: the current bytes are re-read and must
1096    /// equal `expected` (the gate's snapshot), else it errors — a concurrent
1097    /// change since the gate is a loud conflict, never a silent clobber. Unlike
1098    /// the `String`-based `PatchOp::Replace` CAS used by `patch`/`sed -i`, this
1099    /// operates on raw bytes, so binary overwrites (`tee`, `write`, `dd`, `cp`,
1100    /// `mv`) keep the same protection. It is *not* OS-atomic — a crash mid-write
1101    /// can still truncate; the atomic write-temp-then-rename primitive remains a
1102    /// tracked write-model residual.
1103    pub(crate) async fn overwrite_checked(
1104        &self,
1105        resolved: &Path,
1106        content: &[u8],
1107        expected: Option<&OverwriteExpectation>,
1108    ) -> Result<(), String> {
1109        cas_overwrite(&*self.backend, resolved, content, expected)
1110            .await
1111            .map_err(|e| e.to_string())
1112    }
1113
1114    /// Expand a glob pattern to matching file paths.
1115    ///
1116    /// Returns the matched paths (absolute). Used by builtins that accept glob
1117    /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
1118    pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
1119        use crate::backend_walker_fs::BackendWalkerFs;
1120        use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
1121
1122        let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
1123
1124        let root = if glob.is_anchored() {
1125            self.resolve_path("/")
1126        } else {
1127            self.resolve_path(".")
1128        };
1129
1130        let options = WalkOptions {
1131            entry_types: EntryTypes::all(),
1132            respect_gitignore: self.ignore_config.auto_gitignore(),
1133            ..WalkOptions::default()
1134        };
1135
1136        let fs = BackendWalkerFs(self.backend.as_ref());
1137        let mut walker = FileWalker::new(&fs, &root)
1138            .with_pattern(glob)
1139            .with_options(options);
1140
1141        // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
1142        // the root .gitignore is loaded twice (once here, once by the walker).
1143        // This is harmless — merge is additive and rules are idempotent.
1144        if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
1145            walker = walker.with_ignore(filter);
1146        }
1147
1148        walker.collect().await.map_err(|e| e.to_string())
1149    }
1150
1151    /// Expand positional arguments, resolving glob patterns to relative paths.
1152    ///
1153    /// Used by file-processing builtins (cat, head, tail, wc) that accept
1154    /// glob patterns in their path arguments. Non-string values are converted
1155    /// to strings (matching shell conventions).
1156    ///
1157    /// A `Value::Bytes` operand goes LOUD (GH #93 item 1), and `Value::Json`
1158    /// (list/record), `Value::Bool`, and `Value::Null` operands go LOUD too
1159    /// (GH #121) — none is silently dropped by a catch-all anymore. Every
1160    /// caller here falls back to reading stdin (or a generic "missing path"
1161    /// error) when the path list comes back empty, so a structured, bool, or
1162    /// null path used to vanish into a wrong data source instead of erroring.
1163    /// The match is exhaustive over all 7 `Value` variants on purpose: a
1164    /// future new variant fails to compile here until handled, rather than
1165    /// silently falling through a wildcard arm.
1166    pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
1167        let mut paths = Vec::new();
1168        for arg in positional {
1169            let s = match arg {
1170                Value::String(s) => s.clone(),
1171                Value::Int(n) => n.to_string(),
1172                Value::Float(f) => f.to_string(),
1173                Value::Bytes(_) => {
1174                    crate::interpreter::value_to_text_sink_named(arg, "a path").map_err(|e| e.to_string())?
1175                }
1176                Value::Json(_) => {
1177                    return Err(crate::interpreter::structured_boundary_error("a path", arg)
1178                        .unwrap_or_else(|| "cannot use this value as a path".to_string()));
1179                }
1180                Value::Bool(b) => return Err(format!("cannot use a bool ({b}) as a path")),
1181                Value::Null => return Err("cannot use null as a path".to_string()),
1182            };
1183            if crate::glob::contains_glob(&s) {
1184                let expanded = self.expand_glob(&s).await?;
1185                // An absolute pattern reports absolute paths. Stripping the
1186                // cwd unconditionally is a no-op only while cwd is a real
1187                // prefix; when cwd is `/` the strip removes the leading
1188                // separator itself and turns `/tmp/a.txt` into `tmp/a.txt`.
1189                // `/` is the default cwd for an isolated kernel, so that is
1190                // the common case for an embedder, not a corner. Same guard
1191                // the kernel's own argv expansion uses.
1192                let absolute = s.starts_with('/');
1193                let root = self.resolve_path(".");
1194                for p in expanded {
1195                    if absolute {
1196                        paths.push(p.to_string_lossy().to_string());
1197                    } else {
1198                        let rel = p.strip_prefix(&root).unwrap_or(&p);
1199                        paths.push(rel.to_string_lossy().to_string());
1200                    }
1201                }
1202            } else {
1203                paths.push(s);
1204            }
1205        }
1206        Ok(paths)
1207    }
1208
1209    /// Default chunk size for forward file scans. Bounds the memory a
1210    /// scan-oriented builtin holds at once, independent of file size.
1211    pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
1212
1213    /// Stream a file's bytes forward in `chunk_size` slices, handing each
1214    /// non-empty chunk to `f`.
1215    ///
1216    /// Reads are issued as positional `read_range` requests, so backends slice
1217    /// without materialising the whole file (LocalFs seeks; MemoryFs/OverlayFs
1218    /// slice their stored bytes). The loop terminates on the first empty chunk,
1219    /// which every backend returns once the offset reaches EOF. `f` returns a
1220    /// [`ControlFlow`](std::ops::ControlFlow): `Break` stops the loop early
1221    /// (e.g. a consumer that has detected binary content and will discard the
1222    /// rest), so we don't keep reading a file the caller is done with. This is
1223    /// the shared engine for scan-oriented builtins (`wc`, `checksum`, `grep`)
1224    /// that walk a file front-to-back and must not hold it all in memory.
1225    pub async fn read_file_chunked<F>(
1226        &self,
1227        path: &std::path::Path,
1228        chunk_size: u64,
1229        mut f: F,
1230    ) -> kaish_types::backend::BackendResult<()>
1231    where
1232        F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
1233    {
1234        use kaish_types::ReadRange;
1235        let mut offset = 0u64;
1236        loop {
1237            let chunk = self
1238                .backend
1239                .read(path, Some(ReadRange::bytes(offset, chunk_size)))
1240                .await?;
1241            if chunk.is_empty() {
1242                break;
1243            }
1244            offset += chunk.len() as u64;
1245            if f(&chunk).is_break() {
1246                break;
1247            }
1248        }
1249        Ok(())
1250    }
1251}
1252
1253/// The kernel's full execution context satisfies the trimmed portable
1254/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
1255///
1256/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
1257/// pipes, dispatcher) through
1258/// [`ToolCtx::as_any_mut`](kaish_tool_api::ToolCtx::as_any_mut).
1259#[async_trait]
1260impl kaish_tool_api::ToolCtx for ExecContext {
1261    fn backend(&self) -> &Arc<dyn KernelBackend> {
1262        &self.backend
1263    }
1264
1265    fn cwd(&self) -> &std::path::Path {
1266        self.cwd.as_path()
1267    }
1268
1269    fn resolve_path(&self, path: &str) -> PathBuf {
1270        // Inherent methods shadow trait methods in call syntax, so the
1271        // fully-qualified inherent call here is not recursive.
1272        ExecContext::resolve_path(self, path)
1273    }
1274
1275    fn var(&self, name: &str) -> Option<Value> {
1276        self.scope.get(name).cloned()
1277    }
1278
1279    fn set_var(&mut self, name: &str, value: Value) {
1280        self.scope.set(name, value);
1281    }
1282
1283    fn set_output_format(&mut self, format: OutputFormat) {
1284        self.output_format = Some(format);
1285    }
1286
1287    fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
1288        match &self.watchdog {
1289            Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
1290            None => kaish_tool_api::PatientGuard::inert(),
1291        }
1292    }
1293
1294    fn as_any(&self) -> &dyn std::any::Any {
1295        self
1296    }
1297
1298    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1299        self
1300    }
1301}
1302
1303/// Decode one line of stdin as UTF-8, refusing binary rather than mangling it.
1304///
1305/// Same rule and same wording as [`ExecContext::read_stdin_to_text`] — a
1306/// line-at-a-time reader must not be the one place where `U+FFFD` creeps in.
1307fn decode_stdin_line(bytes: Vec<u8>) -> Result<String, String> {
1308    String::from_utf8(bytes).map_err(|_| {
1309        "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
1310         or use a binary-aware tool (cat, dd, cmp, wc -c)"
1311            .to_string()
1312    })
1313}
1314
1315/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
1316fn normalize_path(path: &std::path::Path) -> PathBuf {
1317    let mut parts: Vec<Component> = Vec::new();
1318    for component in path.components() {
1319        match component {
1320            Component::CurDir => {} // skip `.`
1321            Component::ParentDir => {
1322                // Pop the last normal component, but don't pop past root
1323                if let Some(Component::Normal(_)) = parts.last() {
1324                    parts.pop();
1325                } else {
1326                    parts.push(component);
1327                }
1328            }
1329            _ => parts.push(component),
1330        }
1331    }
1332    if parts.is_empty() {
1333        PathBuf::from("/")
1334    } else {
1335        parts.iter().collect()
1336    }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341    use super::{decide_mutation_action, MutationAction};
1342    use std::path::Path;
1343
1344    fn decide(
1345        trash: bool,
1346        real: Option<&str>,
1347        exists: bool,
1348        append: bool,
1349    ) -> MutationAction {
1350        // Default to a small file well under the cap; the size-cap behavior
1351        // has its own dedicated test below.
1352        decide_mutation_action(trash, real.map(Path::new), exists, append, 1, 10_000_000)
1353    }
1354
1355    #[test]
1356    fn new_file_and_append_always_proceed() {
1357        // Non-existent target: nothing to lose.
1358        assert_eq!(decide(true, Some("/work/new"), false, false), MutationAction::Proceed);
1359        // Append to an existing file doesn't destroy prior content.
1360        assert_eq!(decide(true, Some("/work/log"), true, true), MutationAction::Proceed);
1361    }
1362
1363    #[test]
1364    fn an_existing_file_is_snapshotted_before_it_is_overwritten() {
1365        assert_eq!(decide(true, Some("/work/f"), true, false), MutationAction::TrashFirst);
1366    }
1367
1368    #[test]
1369    fn trash_off_proceeds() {
1370        assert_eq!(decide(false, Some("/work/f"), true, false), MutationAction::Proceed);
1371    }
1372
1373    #[test]
1374    fn tmp_is_excluded_but_a_real_v_path_is_still_trashed() {
1375        // /tmp scratch proceeds even with trash on (matches rm).
1376        assert_eq!(decide(true, Some("/tmp/scratch"), true, false), MutationAction::Proceed);
1377        // A *real* path under /v is NOT excluded: mount-coverage routing
1378        // delegates unclaimed /v/* to the embedder's backend, so its real
1379        // content under /v keeps the trash safety net.
1380        assert_eq!(decide(true, Some("/v/cas/blob.bin"), true, false), MutationAction::TrashFirst);
1381    }
1382
1383    #[test]
1384    fn file_too_big_to_trash_is_written_directly_like_rm() {
1385        // Prior content larger than the cap can't be snapshotted, so trash is
1386        // skipped and the overwrite proceeds unbacked. Nothing holds it back.
1387        let big = 100u64;
1388        let cap = 10u64;
1389        assert_eq!(
1390            decide_mutation_action(true, Some(Path::new("/work/f")), true, false, big, cap),
1391            MutationAction::Proceed
1392        );
1393        // Exactly at the cap still trashes (inclusive bound, matches rm).
1394        assert_eq!(
1395            decide_mutation_action(true, Some(Path::new("/work/f")), true, false, cap, cap),
1396            MutationAction::TrashFirst
1397        );
1398    }
1399}