kaish_kernel/tools/context.rs
1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, PathBuf};
5use std::sync::Arc;
6
7use crate::ast::Value;
8use crate::backend::{KernelBackend, LocalBackend};
9use crate::dispatch::PipelinePosition;
10use crate::ignore_config::IgnoreConfig;
11use crate::interpreter::{ExecResult, Scope};
12use crate::nonce::NonceStore;
13use crate::output_limit::OutputLimitConfig;
14use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
15use crate::tools::ToolRegistry;
16use crate::trash::TrashBackend;
17use crate::vfs::VfsRouter;
18use kaish_vfs::ByteBudget;
19use tokio_util::sync::CancellationToken;
20
21use crate::interpreter::OutputFormat;
22
23use super::traits::ToolSchema;
24
25/// Output context determines how command output should be formatted.
26///
27/// Different contexts prefer different output formats:
28/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
29/// - **Piped** — Raw output for pipeline processing
30/// - **Model** — Token-efficient compact formats (MCP server / agent context)
31/// - **Script** — Non-interactive script execution
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum OutputContext {
34 /// Interactive TTY/REPL - use human-friendly format with colors.
35 #[default]
36 Interactive,
37 /// Output to another command - use raw output for pipes.
38 Piped,
39 /// MCP server / agent context - use token-efficient model format.
40 Model,
41 /// Non-interactive script - use raw output.
42 Script,
43}
44
45/// Execution context passed to tools.
46///
47/// Provides access to the backend (for file operations and tool dispatch),
48/// scope, and other kernel state.
49pub struct ExecContext {
50 /// Kernel backend for I/O operations.
51 ///
52 /// This is the preferred way to access filesystem operations.
53 /// Use `backend.read()`, `backend.write()`, etc.
54 pub backend: Arc<dyn KernelBackend>,
55 /// Variable scope.
56 pub scope: Scope,
57 /// Current working directory (VFS path).
58 pub cwd: PathBuf,
59 /// Previous working directory (for `cd -`).
60 pub prev_cwd: Option<PathBuf>,
61 /// Standard input for the tool (from pipeline).
62 pub stdin: Option<String>,
63 /// Structured data from pipeline (pre-parsed JSON from previous command).
64 /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
65 pub stdin_data: Option<Value>,
66 /// Streaming pipe input (set when this command is in a concurrent pipeline).
67 pub pipe_stdin: Option<PipeReader>,
68 /// Streaming pipe output (set when this command is in a concurrent pipeline).
69 pub pipe_stdout: Option<PipeWriter>,
70 /// Tool schemas for help command.
71 pub tool_schemas: Vec<ToolSchema>,
72 /// Tool registry reference (for tools that need to inspect available tools).
73 pub tools: Option<Arc<ToolRegistry>>,
74 /// Job manager for background jobs (optional).
75 pub job_manager: Option<Arc<JobManager>>,
76 /// Kernel stderr stream for real-time error output from pipeline stages.
77 ///
78 /// When set, pipeline stages write stderr here instead of buffering in
79 /// `ExecResult.err`. This allows stderr from all stages to stream to
80 /// the terminal (or other sink) concurrently, matching bash behavior.
81 pub stderr: Option<StderrStream>,
82 /// Position of this command within a pipeline (for stdio decisions).
83 pub pipeline_position: PipelinePosition,
84 /// Whether we're running in interactive (REPL) mode.
85 pub interactive: bool,
86 /// Command aliases (name → expansion string).
87 pub aliases: HashMap<String, String>,
88 /// Ignore file configuration for file-walking tools.
89 pub ignore_config: IgnoreConfig,
90 /// Output size limit configuration for agent safety.
91 pub output_limit: OutputLimitConfig,
92 /// Whether external command execution is allowed.
93 ///
94 /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
95 /// Only kaish builtins and backend-registered tools (MCP) are available.
96 pub allow_external_commands: bool,
97 /// Confirmation nonce store for latch-gated operations.
98 ///
99 /// Arc-shared across pipeline stages so nonces issued in one stage
100 /// can be validated in another.
101 pub nonce_store: NonceStore,
102 /// Trash backend for safe file deletion.
103 ///
104 /// Always present when the kernel creates the context (even if `set -o trash`
105 /// is off — the backend exists so `kaish-trash list/restore/empty` work
106 /// regardless of the trash flag).
107 pub trash_backend: Option<Arc<dyn TrashBackend>>,
108 /// Terminal state for job control (interactive mode, Unix only).
109 #[cfg(all(unix, feature = "subprocess"))]
110 pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
111 /// Command dispatcher for re-dispatching through the full resolution chain.
112 ///
113 /// When set (via `Kernel::into_arc()`), builtins like `timeout` can dispatch
114 /// inner commands through the full chain (user tools → builtins → .kai scripts
115 /// → external commands) instead of being limited to `backend.call_tool()`.
116 ///
117 /// `None` when the Kernel was not wrapped via `into_arc()`.
118 pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
119 /// Cancellation token for this execution path.
120 ///
121 /// Populated by the kernel at execute entry, then propagated through pipeline
122 /// stages, foreground forks (scatter workers, concurrent pipeline stages,
123 /// `$(...)` cmdsubs), and into spawned external children. When the token
124 /// fires, externals receive SIGTERM/SIGKILL via the `wait_or_kill` helper.
125 ///
126 /// Default for stand-alone `ExecContext` constructors is a fresh, never-fired
127 /// token so non-kernel test contexts behave as before.
128 pub cancel: CancellationToken,
129 /// Per-execution output format override set by a builtin's GlobalFlags
130 /// flatten (e.g. `--json`). The dispatcher reads this after `tool.execute()`
131 /// returns and applies the format via `apply_output_format`.
132 ///
133 /// Builtins set this via `GlobalFlags::apply(ctx)`; external commands
134 /// don't touch it.
135 pub output_format: Option<OutputFormat>,
136
137 /// Shared VFS memory budget for this kernel's `MemoryFs` mounts.
138 ///
139 /// `Arc`-cloned from the owning `Kernel` (or its fork parent) so all
140 /// concurrent execution paths draw from the same pool. `None` means
141 /// unbounded. Populated by `Kernel::assemble` and forwarded through
142 /// `child_for_pipeline` / `fork_inner` so background jobs and scatter
143 /// workers see the same cap as foreground execution.
144 pub vfs_budget: Option<Arc<ByteBudget>>,
145
146 /// The per-execute timeout watchdog, when a script timeout is in effect.
147 ///
148 /// Populated by the kernel at execute entry (alongside `cancel`) and
149 /// shared through `child_for_pipeline` so forks and pipeline stages can
150 /// acquire patient holds against the same script clock. `None` when no
151 /// timeout is configured — `ToolCtx::patient` then returns an inert guard.
152 pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
153
154 /// Active overlay handle when the kernel was constructed with `overlay: true`.
155 ///
156 /// `Arc`-cloned so forks and pipeline stages share the same transaction.
157 /// `None` when no overlay is active (most kernels).
158 #[cfg(all(feature = "localfs", feature = "overlay"))]
159 pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
160}
161
162impl ExecContext {
163 /// Create a new execution context with a VFS (uses LocalBackend without tools).
164 ///
165 /// This constructor is for backward compatibility and tests that don't need tool dispatch.
166 /// For full tool support, use `with_vfs_and_tools`.
167 pub fn new(vfs: Arc<VfsRouter>) -> Self {
168 Self {
169 backend: Arc::new(LocalBackend::new(vfs)),
170 scope: Scope::new(),
171 cwd: PathBuf::from("/"),
172 prev_cwd: None,
173 stdin: None,
174 stdin_data: None,
175 pipe_stdin: None,
176 pipe_stdout: None,
177 stderr: None,
178 tool_schemas: Vec::new(),
179 tools: None,
180 job_manager: None,
181 pipeline_position: PipelinePosition::Only,
182 interactive: false,
183 aliases: HashMap::new(),
184 ignore_config: IgnoreConfig::none(),
185 output_limit: OutputLimitConfig::none(),
186 allow_external_commands: true,
187 nonce_store: NonceStore::new(),
188 trash_backend: None,
189 #[cfg(all(unix, feature = "subprocess"))]
190 terminal_state: None,
191 dispatcher: None,
192 cancel: CancellationToken::new(),
193 output_format: None,
194 vfs_budget: None,
195 watchdog: None,
196 #[cfg(all(feature = "localfs", feature = "overlay"))]
197 overlay_handle: None,
198 }
199 }
200
201 /// Create a new execution context with VFS and tool registry.
202 ///
203 /// This is the preferred constructor for full kaish operation where
204 /// tools need to be dispatched through the backend.
205 pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
206 Self {
207 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
208 scope: Scope::new(),
209 cwd: PathBuf::from("/"),
210 prev_cwd: None,
211 stdin: None,
212 stdin_data: None,
213 pipe_stdin: None,
214 pipe_stdout: None,
215 stderr: None,
216 tool_schemas: Vec::new(),
217 tools: Some(tools),
218 job_manager: None,
219 pipeline_position: PipelinePosition::Only,
220 interactive: false,
221 aliases: HashMap::new(),
222 ignore_config: IgnoreConfig::none(),
223 output_limit: OutputLimitConfig::none(),
224 allow_external_commands: true,
225 nonce_store: NonceStore::new(),
226 trash_backend: None,
227 #[cfg(all(unix, feature = "subprocess"))]
228 terminal_state: None,
229 dispatcher: None,
230 cancel: CancellationToken::new(),
231 output_format: None,
232 vfs_budget: None,
233 watchdog: None,
234 #[cfg(all(feature = "localfs", feature = "overlay"))]
235 overlay_handle: None,
236 }
237 }
238
239 /// Create a new execution context with a custom backend.
240 pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
241 Self {
242 backend,
243 scope: Scope::new(),
244 cwd: PathBuf::from("/"),
245 prev_cwd: None,
246 stdin: None,
247 stdin_data: None,
248 pipe_stdin: None,
249 pipe_stdout: None,
250 stderr: None,
251 tool_schemas: Vec::new(),
252 tools: None,
253 job_manager: None,
254 pipeline_position: PipelinePosition::Only,
255 interactive: false,
256 aliases: HashMap::new(),
257 ignore_config: IgnoreConfig::none(),
258 output_limit: OutputLimitConfig::none(),
259 allow_external_commands: true,
260 nonce_store: NonceStore::new(),
261 trash_backend: None,
262 #[cfg(all(unix, feature = "subprocess"))]
263 terminal_state: None,
264 dispatcher: None,
265 cancel: CancellationToken::new(),
266 output_format: None,
267 vfs_budget: None,
268 watchdog: None,
269 #[cfg(all(feature = "localfs", feature = "overlay"))]
270 overlay_handle: None,
271 }
272 }
273
274 /// Create a context with VFS, tools, and a specific scope.
275 pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
276 Self {
277 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
278 scope,
279 cwd: PathBuf::from("/"),
280 prev_cwd: None,
281 stdin: None,
282 stdin_data: None,
283 pipe_stdin: None,
284 pipe_stdout: None,
285 stderr: None,
286 tool_schemas: Vec::new(),
287 tools: Some(tools),
288 job_manager: None,
289 pipeline_position: PipelinePosition::Only,
290 interactive: false,
291 aliases: HashMap::new(),
292 ignore_config: IgnoreConfig::none(),
293 output_limit: OutputLimitConfig::none(),
294 allow_external_commands: true,
295 nonce_store: NonceStore::new(),
296 trash_backend: None,
297 #[cfg(all(unix, feature = "subprocess"))]
298 terminal_state: None,
299 dispatcher: None,
300 cancel: CancellationToken::new(),
301 output_format: None,
302 vfs_budget: None,
303 watchdog: None,
304 #[cfg(all(feature = "localfs", feature = "overlay"))]
305 overlay_handle: None,
306 }
307 }
308
309 /// Create a context with a specific scope (uses LocalBackend without tools).
310 ///
311 /// For tests that don't need tool dispatch. For full tool support,
312 /// use `with_vfs_tools_and_scope`.
313 pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
314 Self {
315 backend: Arc::new(LocalBackend::new(vfs)),
316 scope,
317 cwd: PathBuf::from("/"),
318 prev_cwd: None,
319 stdin: None,
320 stdin_data: None,
321 pipe_stdin: None,
322 pipe_stdout: None,
323 stderr: None,
324 tool_schemas: Vec::new(),
325 tools: None,
326 job_manager: None,
327 pipeline_position: PipelinePosition::Only,
328 interactive: false,
329 aliases: HashMap::new(),
330 ignore_config: IgnoreConfig::none(),
331 output_limit: OutputLimitConfig::none(),
332 allow_external_commands: true,
333 nonce_store: NonceStore::new(),
334 trash_backend: None,
335 #[cfg(all(unix, feature = "subprocess"))]
336 terminal_state: None,
337 dispatcher: None,
338 cancel: CancellationToken::new(),
339 output_format: None,
340 vfs_budget: None,
341 watchdog: None,
342 #[cfg(all(feature = "localfs", feature = "overlay"))]
343 overlay_handle: None,
344 }
345 }
346
347 /// Create a context with a custom backend and scope.
348 pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
349 Self {
350 backend,
351 scope,
352 cwd: PathBuf::from("/"),
353 prev_cwd: None,
354 stdin: None,
355 stdin_data: None,
356 pipe_stdin: None,
357 pipe_stdout: None,
358 stderr: None,
359 tool_schemas: Vec::new(),
360 tools: None,
361 job_manager: None,
362 pipeline_position: PipelinePosition::Only,
363 interactive: false,
364 aliases: HashMap::new(),
365 ignore_config: IgnoreConfig::none(),
366 output_limit: OutputLimitConfig::none(),
367 allow_external_commands: true,
368 nonce_store: NonceStore::new(),
369 trash_backend: None,
370 #[cfg(all(unix, feature = "subprocess"))]
371 terminal_state: None,
372 dispatcher: None,
373 cancel: CancellationToken::new(),
374 output_format: None,
375 vfs_budget: None,
376 watchdog: None,
377 #[cfg(all(feature = "localfs", feature = "overlay"))]
378 overlay_handle: None,
379 }
380 }
381
382 /// Set the available tool schemas (for help command).
383 pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
384 self.tool_schemas = schemas;
385 }
386
387 /// Set the tool registry reference.
388 pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
389 self.tools = Some(tools);
390 }
391
392 /// Set the job manager for background job tracking.
393 pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
394 self.job_manager = Some(manager);
395 }
396
397 /// Set the trash backend.
398 pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
399 self.trash_backend = Some(backend);
400 }
401
402 /// Set stdin for this execution.
403 ///
404 /// An explicit stdin string (`< file`, heredoc, here-string, or a pipeline
405 /// hand-off) supersedes any inherited lazy `pipe_stdin`. Since `read_stdin_*`
406 /// prefers `pipe_stdin`, clear it here so redirect precedence holds — a
407 /// `< file` must beat a frontend-seeded piped stdin.
408 pub fn set_stdin(&mut self, stdin: String) {
409 self.stdin = Some(stdin);
410 self.pipe_stdin = None;
411 }
412
413 /// Get stdin, consuming it.
414 pub fn take_stdin(&mut self) -> Option<String> {
415 self.stdin.take()
416 }
417
418 /// Set both text stdin and structured data.
419 ///
420 /// Use this when passing output through a pipeline where the previous
421 /// command produced structured data (e.g., JSON from MCP tools).
422 pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
423 self.stdin = Some(text);
424 self.stdin_data = data;
425 }
426
427 /// Take structured data if available, consuming it.
428 ///
429 /// Tools can use this to avoid re-parsing JSON that was already parsed
430 /// by a previous command in the pipeline.
431 pub fn take_stdin_data(&mut self) -> Option<Value> {
432 self.stdin_data.take()
433 }
434
435 /// Resolve a path relative to cwd, normalizing `.` and `..` components.
436 pub fn resolve_path(&self, path: &str) -> PathBuf {
437 let raw = if path.starts_with('/') {
438 PathBuf::from(path)
439 } else {
440 self.cwd.join(path)
441 };
442 normalize_path(&raw)
443 }
444
445 /// Change the current working directory.
446 ///
447 /// Saves the old directory for `cd -` support.
448 pub fn set_cwd(&mut self, path: PathBuf) {
449 self.prev_cwd = Some(self.cwd.clone());
450 self.cwd = path;
451 }
452
453 /// Get the previous working directory (for `cd -`).
454 pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
455 self.prev_cwd.as_ref()
456 }
457
458 /// Read all stdin (pipe or buffered string) into a String.
459 ///
460 /// Prefers pipe_stdin if set (streaming pipeline), otherwise falls back
461 /// to the buffered stdin string. Consumes the source.
462 pub async fn read_stdin_to_string(&mut self) -> Option<String> {
463 if let Some(mut reader) = self.pipe_stdin.take() {
464 use tokio::io::AsyncReadExt;
465 let mut buf = Vec::new();
466 reader.read_to_end(&mut buf).await.ok()?;
467 Some(String::from_utf8_lossy(&buf).into_owned())
468 } else {
469 self.stdin.take()
470 }
471 }
472
473 /// Read stdin as text, erroring on non-UTF-8 instead of silently
474 /// lossy-decoding it (which corrupts binary with `U+FFFD`).
475 ///
476 /// The strict counterpart to [`Self::read_stdin_to_string`], for text-only
477 /// builtins (`grep`, `sed`, `awk`, `cut`, `sort`, `jq`, …): a binary stream
478 /// is a loud error, not a mangle. Returns `Ok(None)` when there is no stdin
479 /// at all. The `Err` is a ready-to-use message; callers prefix their name.
480 /// See `docs/binary-data.md` and `docs/issues.md`.
481 pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
482 match self.read_stdin_to_bytes().await {
483 None => Ok(None),
484 Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
485 "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
486 or use a binary-aware tool (cat, dd, cmp, wc -c)"
487 .to_string()
488 }),
489 }
490 }
491
492 /// Read all of stdin as raw bytes, preserving binary intact.
493 ///
494 /// The byte-clean counterpart to [`Self::read_stdin_to_string`], for
495 /// binary-aware builtins (`base64`, `xxd`, `checksum`, `wc -c`, `cmp`, …).
496 /// Returns `None` when there is no stdin at all (no pipe and no buffer);
497 /// an empty pipe yields `Some(vec![])`. A buffered text stdin is returned
498 /// as its UTF-8 bytes. See `docs/binary-data.md`.
499 pub async fn read_stdin_to_bytes(&mut self) -> Option<Vec<u8>> {
500 if let Some(mut reader) = self.pipe_stdin.take() {
501 use tokio::io::AsyncReadExt;
502 let mut buf = Vec::new();
503 reader.read_to_end(&mut buf).await.ok()?;
504 Some(buf)
505 } else {
506 self.stdin.take().map(String::into_bytes)
507 }
508 }
509
510 /// Create a child context for a pipeline stage.
511 ///
512 /// Shares backend, tools, job_manager, aliases, cwd, and scope
513 /// but has independent stdin/stdout pipes.
514 pub fn child_for_pipeline(&self) -> Self {
515 Self {
516 backend: self.backend.clone(),
517 scope: self.scope.clone(),
518 cwd: self.cwd.clone(),
519 prev_cwd: self.prev_cwd.clone(),
520 stdin: None,
521 stdin_data: None,
522 pipe_stdin: None,
523 pipe_stdout: None,
524 stderr: self.stderr.clone(),
525 tool_schemas: self.tool_schemas.clone(),
526 tools: self.tools.clone(),
527 job_manager: self.job_manager.clone(),
528 pipeline_position: PipelinePosition::Only,
529 interactive: self.interactive,
530 aliases: self.aliases.clone(),
531 ignore_config: self.ignore_config.clone(),
532 output_limit: self.output_limit.clone(),
533 allow_external_commands: self.allow_external_commands,
534 nonce_store: self.nonce_store.clone(),
535 trash_backend: self.trash_backend.clone(),
536 #[cfg(all(unix, feature = "subprocess"))]
537 terminal_state: self.terminal_state.clone(),
538 dispatcher: self.dispatcher.clone(),
539 cancel: self.cancel.clone(),
540 // Output format is per-execution; child pipeline stages start fresh.
541 output_format: None,
542 // Budget is shared: the child draws from the same pool as the parent.
543 vfs_budget: self.vfs_budget.clone(),
544 // Watchdog is shared: a patient hold in a pipeline stage or fork
545 // suspends the same script clock as foreground execution.
546 watchdog: self.watchdog.clone(),
547 // Overlay handle is shared: pipeline stages share the same transaction.
548 #[cfg(all(feature = "localfs", feature = "overlay"))]
549 overlay_handle: self.overlay_handle.clone(),
550 }
551 }
552
553 /// Build an `IgnoreFilter` from the current ignore configuration.
554 ///
555 /// Returns `None` if no filtering is configured.
556 pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
557 use crate::backend_walker_fs::BackendWalkerFs;
558 let fs = BackendWalkerFs(self.backend.as_ref());
559 self.ignore_config.build_filter(root, &fs).await
560 }
561
562 /// Validate a confirmation nonce against a command and paths.
563 ///
564 /// Thin wrapper on `NonceStore::validate` for ergonomic use from builtins.
565 pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
566 self.nonce_store.validate(nonce, command, paths)
567 }
568
569 /// Issue a nonce and build the standard exit-2 latch result.
570 ///
571 /// `reason` explains why confirmation is needed (e.g., `"latch enabled"`,
572 /// `"emptying trash is destructive"`). The `confirm_hint` closure receives
573 /// the nonce string so each tool can format its own re-run command.
574 ///
575 /// The result includes structured data in `.data` for programmatic access:
576 /// ```json
577 /// {"nonce": "a3f7b2c1", "command": "rm", "paths": [...], "hint": "rm --confirm=a3f7b2c1 file", "ttl": 60}
578 /// ```
579 pub fn latch_result(
580 &self,
581 command: &str,
582 paths: &[&str],
583 reason: &str,
584 confirm_hint: impl FnOnce(&str) -> String,
585 ) -> ExecResult {
586 let nonce = self.nonce_store.issue(command, paths);
587 let ttl = self.nonce_store.ttl().as_secs();
588 let authorized = if paths.is_empty() {
589 String::new()
590 } else {
591 format!("\nAuthorized: {}", paths.join(", "))
592 };
593 let hint = confirm_hint(&nonce);
594
595 let mut result = ExecResult::failure(2, format!(
596 "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
597 ));
598 result.data = Some(Value::Json(serde_json::json!({
599 "nonce": nonce,
600 "command": command,
601 "paths": paths,
602 "hint": hint,
603 "ttl": ttl,
604 })));
605 result
606 }
607
608 /// Expand a glob pattern to matching file paths.
609 ///
610 /// Returns the matched paths (absolute). Used by builtins that accept glob
611 /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
612 pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
613 use crate::backend_walker_fs::BackendWalkerFs;
614 use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
615
616 let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
617
618 let root = if glob.is_anchored() {
619 self.resolve_path("/")
620 } else {
621 self.resolve_path(".")
622 };
623
624 let options = WalkOptions {
625 entry_types: EntryTypes::all(),
626 respect_gitignore: self.ignore_config.auto_gitignore(),
627 ..WalkOptions::default()
628 };
629
630 let fs = BackendWalkerFs(self.backend.as_ref());
631 let mut walker = FileWalker::new(&fs, &root)
632 .with_pattern(glob)
633 .with_options(options);
634
635 // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
636 // the root .gitignore is loaded twice (once here, once by the walker).
637 // This is harmless — merge is additive and rules are idempotent.
638 if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
639 walker = walker.with_ignore(filter);
640 }
641
642 walker.collect().await.map_err(|e| e.to_string())
643 }
644
645 /// Expand positional arguments, resolving glob patterns to relative paths.
646 ///
647 /// Used by file-processing builtins (cat, head, tail, wc) that accept
648 /// glob patterns in their path arguments. Non-string values are converted
649 /// to strings (matching shell conventions).
650 pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
651 let mut paths = Vec::new();
652 for arg in positional {
653 let s = match arg {
654 Value::String(s) => s.clone(),
655 Value::Int(n) => n.to_string(),
656 Value::Float(f) => f.to_string(),
657 _ => continue,
658 };
659 if crate::glob::contains_glob(&s) {
660 let expanded = self.expand_glob(&s).await?;
661 let root = self.resolve_path(".");
662 for p in expanded {
663 let rel = p.strip_prefix(&root).unwrap_or(&p);
664 paths.push(rel.to_string_lossy().to_string());
665 }
666 } else {
667 paths.push(s);
668 }
669 }
670 Ok(paths)
671 }
672
673 /// Default chunk size for forward file scans. Bounds the memory a
674 /// scan-oriented builtin holds at once, independent of file size.
675 pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
676
677 /// Stream a file's bytes forward in `chunk_size` slices, handing each
678 /// non-empty chunk to `f`.
679 ///
680 /// Reads are issued as positional `read_range` requests, so backends slice
681 /// without materialising the whole file (LocalFs seeks; MemoryFs/OverlayFs
682 /// slice their stored bytes). The loop terminates on the first empty chunk,
683 /// which every backend returns once the offset reaches EOF. `f` returns a
684 /// [`ControlFlow`](std::ops::ControlFlow): `Break` stops the loop early
685 /// (e.g. a consumer that has detected binary content and will discard the
686 /// rest), so we don't keep reading a file the caller is done with. This is
687 /// the shared engine for scan-oriented builtins (`wc`, `checksum`, `grep`)
688 /// that walk a file front-to-back and must not hold it all in memory.
689 pub async fn read_file_chunked<F>(
690 &self,
691 path: &std::path::Path,
692 chunk_size: u64,
693 mut f: F,
694 ) -> kaish_types::backend::BackendResult<()>
695 where
696 F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
697 {
698 use kaish_types::ReadRange;
699 let mut offset = 0u64;
700 loop {
701 let chunk = self
702 .backend
703 .read(path, Some(ReadRange::bytes(offset, chunk_size)))
704 .await?;
705 if chunk.is_empty() {
706 break;
707 }
708 offset += chunk.len() as u64;
709 if f(&chunk).is_break() {
710 break;
711 }
712 }
713 Ok(())
714 }
715}
716
717/// The kernel's full execution context satisfies the trimmed portable
718/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
719///
720/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
721/// pipes, dispatcher) through [`ToolCtx::as_any_mut`].
722impl kaish_tool_api::ToolCtx for ExecContext {
723 fn backend(&self) -> &Arc<dyn KernelBackend> {
724 &self.backend
725 }
726
727 fn cwd(&self) -> &std::path::Path {
728 self.cwd.as_path()
729 }
730
731 fn resolve_path(&self, path: &str) -> PathBuf {
732 // Inherent methods shadow trait methods in call syntax, so the
733 // fully-qualified inherent call here is not recursive.
734 ExecContext::resolve_path(self, path)
735 }
736
737 fn var(&self, name: &str) -> Option<Value> {
738 self.scope.get(name).cloned()
739 }
740
741 fn set_var(&mut self, name: &str, value: Value) {
742 self.scope.set(name, value);
743 }
744
745 fn set_output_format(&mut self, format: OutputFormat) {
746 self.output_format = Some(format);
747 }
748
749 fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
750 match &self.watchdog {
751 Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
752 None => kaish_tool_api::PatientGuard::inert(),
753 }
754 }
755
756 fn as_any(&self) -> &dyn std::any::Any {
757 self
758 }
759
760 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
761 self
762 }
763}
764
765/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
766fn normalize_path(path: &std::path::Path) -> PathBuf {
767 let mut parts: Vec<Component> = Vec::new();
768 for component in path.components() {
769 match component {
770 Component::CurDir => {} // skip `.`
771 Component::ParentDir => {
772 // Pop the last normal component, but don't pop past root
773 if let Some(Component::Normal(_)) = parts.last() {
774 parts.pop();
775 } else {
776 parts.push(component);
777 }
778 }
779 _ => parts.push(component),
780 }
781 }
782 if parts.is_empty() {
783 PathBuf::from("/")
784 } else {
785 parts.iter().collect()
786 }
787}