1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum OutputContext {
34 #[default]
36 Interactive,
37 Piped,
39 Model,
41 Script,
43}
44
45pub struct ExecContext {
50 pub backend: Arc<dyn KernelBackend>,
55 pub scope: Scope,
57 pub cwd: PathBuf,
59 pub prev_cwd: Option<PathBuf>,
61 pub stdin: Option<String>,
63 pub stdin_data: Option<Value>,
66 pub pipe_stdin: Option<PipeReader>,
68 pub pipe_stdout: Option<PipeWriter>,
70 pub tool_schemas: Vec<ToolSchema>,
72 pub tools: Option<Arc<ToolRegistry>>,
74 pub job_manager: Option<Arc<JobManager>>,
76 pub stderr: Option<StderrStream>,
82 pub pipeline_position: PipelinePosition,
84 pub interactive: bool,
86 pub aliases: HashMap<String, String>,
88 pub ignore_config: IgnoreConfig,
90 pub output_limit: OutputLimitConfig,
92 pub allow_external_commands: bool,
97 pub nonce_store: NonceStore,
102 pub trash_backend: Option<Arc<dyn TrashBackend>>,
108 #[cfg(all(unix, feature = "subprocess"))]
110 pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
111 pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
119 pub cancel: CancellationToken,
129 pub output_format: Option<OutputFormat>,
136
137 pub vfs_budget: Option<Arc<ByteBudget>>,
145
146 pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
153
154 #[cfg(all(feature = "localfs", feature = "overlay"))]
159 pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
160}
161
162impl ExecContext {
163 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 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 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 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 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 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 pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
384 self.tool_schemas = schemas;
385 }
386
387 pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
389 self.tools = Some(tools);
390 }
391
392 pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
394 self.job_manager = Some(manager);
395 }
396
397 pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
399 self.trash_backend = Some(backend);
400 }
401
402 pub fn set_stdin(&mut self, stdin: String) {
404 self.stdin = Some(stdin);
405 }
406
407 pub fn take_stdin(&mut self) -> Option<String> {
409 self.stdin.take()
410 }
411
412 pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
417 self.stdin = Some(text);
418 self.stdin_data = data;
419 }
420
421 pub fn take_stdin_data(&mut self) -> Option<Value> {
426 self.stdin_data.take()
427 }
428
429 pub fn resolve_path(&self, path: &str) -> PathBuf {
431 let raw = if path.starts_with('/') {
432 PathBuf::from(path)
433 } else {
434 self.cwd.join(path)
435 };
436 normalize_path(&raw)
437 }
438
439 pub fn set_cwd(&mut self, path: PathBuf) {
443 self.prev_cwd = Some(self.cwd.clone());
444 self.cwd = path;
445 }
446
447 pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
449 self.prev_cwd.as_ref()
450 }
451
452 pub async fn read_stdin_to_string(&mut self) -> Option<String> {
457 if let Some(mut reader) = self.pipe_stdin.take() {
458 use tokio::io::AsyncReadExt;
459 let mut buf = Vec::new();
460 reader.read_to_end(&mut buf).await.ok()?;
461 Some(String::from_utf8_lossy(&buf).into_owned())
462 } else {
463 self.stdin.take()
464 }
465 }
466
467 pub fn child_for_pipeline(&self) -> Self {
472 Self {
473 backend: self.backend.clone(),
474 scope: self.scope.clone(),
475 cwd: self.cwd.clone(),
476 prev_cwd: self.prev_cwd.clone(),
477 stdin: None,
478 stdin_data: None,
479 pipe_stdin: None,
480 pipe_stdout: None,
481 stderr: self.stderr.clone(),
482 tool_schemas: self.tool_schemas.clone(),
483 tools: self.tools.clone(),
484 job_manager: self.job_manager.clone(),
485 pipeline_position: PipelinePosition::Only,
486 interactive: self.interactive,
487 aliases: self.aliases.clone(),
488 ignore_config: self.ignore_config.clone(),
489 output_limit: self.output_limit.clone(),
490 allow_external_commands: self.allow_external_commands,
491 nonce_store: self.nonce_store.clone(),
492 trash_backend: self.trash_backend.clone(),
493 #[cfg(all(unix, feature = "subprocess"))]
494 terminal_state: self.terminal_state.clone(),
495 dispatcher: self.dispatcher.clone(),
496 cancel: self.cancel.clone(),
497 output_format: None,
499 vfs_budget: self.vfs_budget.clone(),
501 watchdog: self.watchdog.clone(),
504 #[cfg(all(feature = "localfs", feature = "overlay"))]
506 overlay_handle: self.overlay_handle.clone(),
507 }
508 }
509
510 pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
514 use crate::backend_walker_fs::BackendWalkerFs;
515 let fs = BackendWalkerFs(self.backend.as_ref());
516 self.ignore_config.build_filter(root, &fs).await
517 }
518
519 pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
523 self.nonce_store.validate(nonce, command, paths)
524 }
525
526 pub fn latch_result(
537 &self,
538 command: &str,
539 paths: &[&str],
540 reason: &str,
541 confirm_hint: impl FnOnce(&str) -> String,
542 ) -> ExecResult {
543 let nonce = self.nonce_store.issue(command, paths);
544 let ttl = self.nonce_store.ttl().as_secs();
545 let authorized = if paths.is_empty() {
546 String::new()
547 } else {
548 format!("\nAuthorized: {}", paths.join(", "))
549 };
550 let hint = confirm_hint(&nonce);
551
552 let mut result = ExecResult::failure(2, format!(
553 "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
554 ));
555 result.data = Some(Value::Json(serde_json::json!({
556 "nonce": nonce,
557 "command": command,
558 "paths": paths,
559 "hint": hint,
560 "ttl": ttl,
561 })));
562 result
563 }
564
565 pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
570 use crate::backend_walker_fs::BackendWalkerFs;
571 use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
572
573 let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
574
575 let root = if glob.is_anchored() {
576 self.resolve_path("/")
577 } else {
578 self.resolve_path(".")
579 };
580
581 let options = WalkOptions {
582 entry_types: EntryTypes::all(),
583 respect_gitignore: self.ignore_config.auto_gitignore(),
584 ..WalkOptions::default()
585 };
586
587 let fs = BackendWalkerFs(self.backend.as_ref());
588 let mut walker = FileWalker::new(&fs, &root)
589 .with_pattern(glob)
590 .with_options(options);
591
592 if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
596 walker = walker.with_ignore(filter);
597 }
598
599 walker.collect().await.map_err(|e| e.to_string())
600 }
601
602 pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
608 let mut paths = Vec::new();
609 for arg in positional {
610 let s = match arg {
611 Value::String(s) => s.clone(),
612 Value::Int(n) => n.to_string(),
613 Value::Float(f) => f.to_string(),
614 _ => continue,
615 };
616 if crate::glob::contains_glob(&s) {
617 let expanded = self.expand_glob(&s).await?;
618 let root = self.resolve_path(".");
619 for p in expanded {
620 let rel = p.strip_prefix(&root).unwrap_or(&p);
621 paths.push(rel.to_string_lossy().to_string());
622 }
623 } else {
624 paths.push(s);
625 }
626 }
627 Ok(paths)
628 }
629}
630
631impl kaish_tool_api::ToolCtx for ExecContext {
637 fn backend(&self) -> &Arc<dyn KernelBackend> {
638 &self.backend
639 }
640
641 fn cwd(&self) -> &std::path::Path {
642 self.cwd.as_path()
643 }
644
645 fn resolve_path(&self, path: &str) -> PathBuf {
646 ExecContext::resolve_path(self, path)
649 }
650
651 fn var(&self, name: &str) -> Option<Value> {
652 self.scope.get(name).cloned()
653 }
654
655 fn set_var(&mut self, name: &str, value: Value) {
656 self.scope.set(name, value);
657 }
658
659 fn set_output_format(&mut self, format: OutputFormat) {
660 self.output_format = Some(format);
661 }
662
663 fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
664 match &self.watchdog {
665 Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
666 None => kaish_tool_api::PatientGuard::inert(),
667 }
668 }
669
670 fn as_any(&self) -> &dyn std::any::Any {
671 self
672 }
673
674 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
675 self
676 }
677}
678
679fn normalize_path(path: &std::path::Path) -> PathBuf {
681 let mut parts: Vec<Component> = Vec::new();
682 for component in path.components() {
683 match component {
684 Component::CurDir => {} Component::ParentDir => {
686 if let Some(Component::Normal(_)) = parts.last() {
688 parts.pop();
689 } else {
690 parts.push(component);
691 }
692 }
693 _ => parts.push(component),
694 }
695 }
696 if parts.is_empty() {
697 PathBuf::from("/")
698 } else {
699 parts.iter().collect()
700 }
701}