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