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 tokio_util::sync::CancellationToken;
19
20use crate::interpreter::OutputFormat;
21
22use super::traits::ToolSchema;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum OutputContext {
33 #[default]
35 Interactive,
36 Piped,
38 Model,
40 Script,
42}
43
44pub struct ExecContext {
49 pub backend: Arc<dyn KernelBackend>,
54 pub scope: Scope,
56 pub cwd: PathBuf,
58 pub prev_cwd: Option<PathBuf>,
60 pub stdin: Option<String>,
62 pub stdin_data: Option<Value>,
65 pub pipe_stdin: Option<PipeReader>,
67 pub pipe_stdout: Option<PipeWriter>,
69 pub tool_schemas: Vec<ToolSchema>,
71 pub tools: Option<Arc<ToolRegistry>>,
73 pub job_manager: Option<Arc<JobManager>>,
75 pub stderr: Option<StderrStream>,
81 pub pipeline_position: PipelinePosition,
83 pub interactive: bool,
85 pub aliases: HashMap<String, String>,
87 pub ignore_config: IgnoreConfig,
89 pub output_limit: OutputLimitConfig,
91 pub allow_external_commands: bool,
96 pub nonce_store: NonceStore,
101 pub trash_backend: Option<Arc<dyn TrashBackend>>,
107 #[cfg(all(unix, feature = "subprocess"))]
109 pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
110 pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
118 pub cancel: CancellationToken,
128 pub output_format: Option<OutputFormat>,
135}
136
137impl ExecContext {
138 pub fn new(vfs: Arc<VfsRouter>) -> Self {
143 Self {
144 backend: Arc::new(LocalBackend::new(vfs)),
145 scope: Scope::new(),
146 cwd: PathBuf::from("/"),
147 prev_cwd: None,
148 stdin: None,
149 stdin_data: None,
150 pipe_stdin: None,
151 pipe_stdout: None,
152 stderr: None,
153 tool_schemas: Vec::new(),
154 tools: None,
155 job_manager: None,
156 pipeline_position: PipelinePosition::Only,
157 interactive: false,
158 aliases: HashMap::new(),
159 ignore_config: IgnoreConfig::none(),
160 output_limit: OutputLimitConfig::none(),
161 allow_external_commands: true,
162 nonce_store: NonceStore::new(),
163 trash_backend: None,
164 #[cfg(all(unix, feature = "subprocess"))]
165 terminal_state: None,
166 dispatcher: None,
167 cancel: CancellationToken::new(),
168 output_format: None,
169 }
170 }
171
172 pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
177 Self {
178 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
179 scope: Scope::new(),
180 cwd: PathBuf::from("/"),
181 prev_cwd: None,
182 stdin: None,
183 stdin_data: None,
184 pipe_stdin: None,
185 pipe_stdout: None,
186 stderr: None,
187 tool_schemas: Vec::new(),
188 tools: Some(tools),
189 job_manager: None,
190 pipeline_position: PipelinePosition::Only,
191 interactive: false,
192 aliases: HashMap::new(),
193 ignore_config: IgnoreConfig::none(),
194 output_limit: OutputLimitConfig::none(),
195 allow_external_commands: true,
196 nonce_store: NonceStore::new(),
197 trash_backend: None,
198 #[cfg(all(unix, feature = "subprocess"))]
199 terminal_state: None,
200 dispatcher: None,
201 cancel: CancellationToken::new(),
202 output_format: None,
203 }
204 }
205
206 pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
208 Self {
209 backend,
210 scope: Scope::new(),
211 cwd: PathBuf::from("/"),
212 prev_cwd: None,
213 stdin: None,
214 stdin_data: None,
215 pipe_stdin: None,
216 pipe_stdout: None,
217 stderr: None,
218 tool_schemas: Vec::new(),
219 tools: None,
220 job_manager: None,
221 pipeline_position: PipelinePosition::Only,
222 interactive: false,
223 aliases: HashMap::new(),
224 ignore_config: IgnoreConfig::none(),
225 output_limit: OutputLimitConfig::none(),
226 allow_external_commands: true,
227 nonce_store: NonceStore::new(),
228 trash_backend: None,
229 #[cfg(all(unix, feature = "subprocess"))]
230 terminal_state: None,
231 dispatcher: None,
232 cancel: CancellationToken::new(),
233 output_format: None,
234 }
235 }
236
237 pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
239 Self {
240 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
241 scope,
242 cwd: PathBuf::from("/"),
243 prev_cwd: None,
244 stdin: None,
245 stdin_data: None,
246 pipe_stdin: None,
247 pipe_stdout: None,
248 stderr: None,
249 tool_schemas: Vec::new(),
250 tools: Some(tools),
251 job_manager: None,
252 pipeline_position: PipelinePosition::Only,
253 interactive: false,
254 aliases: HashMap::new(),
255 ignore_config: IgnoreConfig::none(),
256 output_limit: OutputLimitConfig::none(),
257 allow_external_commands: true,
258 nonce_store: NonceStore::new(),
259 trash_backend: None,
260 #[cfg(all(unix, feature = "subprocess"))]
261 terminal_state: None,
262 dispatcher: None,
263 cancel: CancellationToken::new(),
264 output_format: None,
265 }
266 }
267
268 pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
273 Self {
274 backend: Arc::new(LocalBackend::new(vfs)),
275 scope,
276 cwd: PathBuf::from("/"),
277 prev_cwd: None,
278 stdin: None,
279 stdin_data: None,
280 pipe_stdin: None,
281 pipe_stdout: None,
282 stderr: None,
283 tool_schemas: Vec::new(),
284 tools: None,
285 job_manager: None,
286 pipeline_position: PipelinePosition::Only,
287 interactive: false,
288 aliases: HashMap::new(),
289 ignore_config: IgnoreConfig::none(),
290 output_limit: OutputLimitConfig::none(),
291 allow_external_commands: true,
292 nonce_store: NonceStore::new(),
293 trash_backend: None,
294 #[cfg(all(unix, feature = "subprocess"))]
295 terminal_state: None,
296 dispatcher: None,
297 cancel: CancellationToken::new(),
298 output_format: None,
299 }
300 }
301
302 pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
304 Self {
305 backend,
306 scope,
307 cwd: PathBuf::from("/"),
308 prev_cwd: None,
309 stdin: None,
310 stdin_data: None,
311 pipe_stdin: None,
312 pipe_stdout: None,
313 stderr: None,
314 tool_schemas: Vec::new(),
315 tools: None,
316 job_manager: None,
317 pipeline_position: PipelinePosition::Only,
318 interactive: false,
319 aliases: HashMap::new(),
320 ignore_config: IgnoreConfig::none(),
321 output_limit: OutputLimitConfig::none(),
322 allow_external_commands: true,
323 nonce_store: NonceStore::new(),
324 trash_backend: None,
325 #[cfg(all(unix, feature = "subprocess"))]
326 terminal_state: None,
327 dispatcher: None,
328 cancel: CancellationToken::new(),
329 output_format: None,
330 }
331 }
332
333 pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
335 self.tool_schemas = schemas;
336 }
337
338 pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
340 self.tools = Some(tools);
341 }
342
343 pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
345 self.job_manager = Some(manager);
346 }
347
348 pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
350 self.trash_backend = Some(backend);
351 }
352
353 pub fn set_stdin(&mut self, stdin: String) {
355 self.stdin = Some(stdin);
356 }
357
358 pub fn take_stdin(&mut self) -> Option<String> {
360 self.stdin.take()
361 }
362
363 pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
368 self.stdin = Some(text);
369 self.stdin_data = data;
370 }
371
372 pub fn take_stdin_data(&mut self) -> Option<Value> {
377 self.stdin_data.take()
378 }
379
380 pub fn resolve_path(&self, path: &str) -> PathBuf {
382 let raw = if path.starts_with('/') {
383 PathBuf::from(path)
384 } else {
385 self.cwd.join(path)
386 };
387 normalize_path(&raw)
388 }
389
390 pub fn set_cwd(&mut self, path: PathBuf) {
394 self.prev_cwd = Some(self.cwd.clone());
395 self.cwd = path;
396 }
397
398 pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
400 self.prev_cwd.as_ref()
401 }
402
403 pub async fn read_stdin_to_string(&mut self) -> Option<String> {
408 if let Some(mut reader) = self.pipe_stdin.take() {
409 use tokio::io::AsyncReadExt;
410 let mut buf = Vec::new();
411 reader.read_to_end(&mut buf).await.ok()?;
412 Some(String::from_utf8_lossy(&buf).into_owned())
413 } else {
414 self.stdin.take()
415 }
416 }
417
418 pub fn child_for_pipeline(&self) -> Self {
423 Self {
424 backend: self.backend.clone(),
425 scope: self.scope.clone(),
426 cwd: self.cwd.clone(),
427 prev_cwd: self.prev_cwd.clone(),
428 stdin: None,
429 stdin_data: None,
430 pipe_stdin: None,
431 pipe_stdout: None,
432 stderr: self.stderr.clone(),
433 tool_schemas: self.tool_schemas.clone(),
434 tools: self.tools.clone(),
435 job_manager: self.job_manager.clone(),
436 pipeline_position: PipelinePosition::Only,
437 interactive: self.interactive,
438 aliases: self.aliases.clone(),
439 ignore_config: self.ignore_config.clone(),
440 output_limit: self.output_limit.clone(),
441 allow_external_commands: self.allow_external_commands,
442 nonce_store: self.nonce_store.clone(),
443 trash_backend: self.trash_backend.clone(),
444 #[cfg(all(unix, feature = "subprocess"))]
445 terminal_state: self.terminal_state.clone(),
446 dispatcher: self.dispatcher.clone(),
447 cancel: self.cancel.clone(),
448 output_format: None,
450 }
451 }
452
453 pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
457 use crate::backend_walker_fs::BackendWalkerFs;
458 let fs = BackendWalkerFs(self.backend.as_ref());
459 self.ignore_config.build_filter(root, &fs).await
460 }
461
462 pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
466 self.nonce_store.validate(nonce, command, paths)
467 }
468
469 pub fn latch_result(
480 &self,
481 command: &str,
482 paths: &[&str],
483 reason: &str,
484 confirm_hint: impl FnOnce(&str) -> String,
485 ) -> ExecResult {
486 let nonce = self.nonce_store.issue(command, paths);
487 let ttl = self.nonce_store.ttl().as_secs();
488 let authorized = if paths.is_empty() {
489 String::new()
490 } else {
491 format!("\nAuthorized: {}", paths.join(", "))
492 };
493 let hint = confirm_hint(&nonce);
494
495 let mut result = ExecResult::failure(2, format!(
496 "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
497 ));
498 result.data = Some(Value::Json(serde_json::json!({
499 "nonce": nonce,
500 "command": command,
501 "paths": paths,
502 "hint": hint,
503 "ttl": ttl,
504 })));
505 result
506 }
507
508 pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
513 use crate::backend_walker_fs::BackendWalkerFs;
514 use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
515
516 let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
517
518 let root = if glob.is_anchored() {
519 self.resolve_path("/")
520 } else {
521 self.resolve_path(".")
522 };
523
524 let options = WalkOptions {
525 entry_types: EntryTypes::all(),
526 respect_gitignore: self.ignore_config.auto_gitignore(),
527 ..WalkOptions::default()
528 };
529
530 let fs = BackendWalkerFs(self.backend.as_ref());
531 let mut walker = FileWalker::new(&fs, &root)
532 .with_pattern(glob)
533 .with_options(options);
534
535 if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
539 walker = walker.with_ignore(filter);
540 }
541
542 walker.collect().await.map_err(|e| e.to_string())
543 }
544
545 pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
551 let mut paths = Vec::new();
552 for arg in positional {
553 let s = match arg {
554 Value::String(s) => s.clone(),
555 Value::Int(n) => n.to_string(),
556 Value::Float(f) => f.to_string(),
557 _ => continue,
558 };
559 if crate::glob::contains_glob(&s) {
560 let expanded = self.expand_glob(&s).await?;
561 let root = self.resolve_path(".");
562 for p in expanded {
563 let rel = p.strip_prefix(&root).unwrap_or(&p);
564 paths.push(rel.to_string_lossy().to_string());
565 }
566 } else {
567 paths.push(s);
568 }
569 }
570 Ok(paths)
571 }
572}
573
574impl kaish_tool_api::ToolCtx for ExecContext {
580 fn backend(&self) -> &Arc<dyn KernelBackend> {
581 &self.backend
582 }
583
584 fn cwd(&self) -> &std::path::Path {
585 self.cwd.as_path()
586 }
587
588 fn resolve_path(&self, path: &str) -> PathBuf {
589 ExecContext::resolve_path(self, path)
592 }
593
594 fn var(&self, name: &str) -> Option<Value> {
595 self.scope.get(name).cloned()
596 }
597
598 fn set_var(&mut self, name: &str, value: Value) {
599 self.scope.set(name, value);
600 }
601
602 fn set_output_format(&mut self, format: OutputFormat) {
603 self.output_format = Some(format);
604 }
605
606 fn as_any(&self) -> &dyn std::any::Any {
607 self
608 }
609
610 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
611 self
612 }
613}
614
615fn normalize_path(path: &std::path::Path) -> PathBuf {
617 let mut parts: Vec<Component> = Vec::new();
618 for component in path.components() {
619 match component {
620 Component::CurDir => {} Component::ParentDir => {
622 if let Some(Component::Normal(_)) = parts.last() {
624 parts.pop();
625 } else {
626 parts.push(component);
627 }
628 }
629 _ => parts.push(component),
630 }
631 }
632 if parts.is_empty() {
633 PathBuf::from("/")
634 } else {
635 parts.iter().collect()
636 }
637}