1use super::{Capability, CapabilityStatus, RiskLevel};
36use crate::background::{
37 BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
38};
39use crate::exec_tool_result::ExecToolResultPayload;
40use crate::session_file::SessionFile;
41use crate::tool_types::ToolHints;
42use crate::tools::{Tool, ToolExecutionResult};
43use crate::traits::{SessionFileSystem, ToolContext};
44use crate::typed_id::SessionId;
45use async_trait::async_trait;
46use bashkit::{
47 Bash, BashBuilder, BashTool as BashkitTool, DirEntry, ExecutionLimits, FileSystem,
48 FileSystemExt, FileType, Metadata, OutputCallback, SearchCapabilities, SearchCapable,
49 SearchMatch as BashkitSearchMatch, SearchProvider, SearchQuery, SearchResults,
50 Tool as BashkitToolTrait, TraceEventKind, TraceMode,
51};
52use serde_json::{Value, json};
53use std::path::{Path, PathBuf};
54use std::sync::atomic::{AtomicUsize, Ordering};
55use std::sync::{Arc, LazyLock};
56use std::time::SystemTime;
57
58fn execution_limits() -> ExecutionLimits {
64 ExecutionLimits::new()
65 .max_commands(1000)
66 .max_loop_iterations(10000)
67 .max_function_depth(100)
68 .max_input_bytes(1_000_000) .max_ast_depth(100)
70 .parser_timeout(std::time::Duration::from_secs(5))
71}
72
73static BASHKIT_TOOL: LazyLock<BashkitTool> = LazyLock::new(|| {
75 BashkitTool::builder()
76 .username("everruns")
77 .hostname("everruns")
78 .limits(execution_limits())
79 .env("HOME", "/home/agent")
80 .env("SHELL", "/bin/bash")
81 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
82 .env("WORKSPACE", "/workspace")
83 .build()
84});
85
86static TOOL_DESCRIPTION: LazyLock<String> =
88 LazyLock::new(|| BASHKIT_TOOL.description().to_string());
89
90static TOOL_SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
92 let mut prompt = BASHKIT_TOOL.system_prompt().to_string();
93 prompt.push_str(crate::tool_output_sanitizer::EXEC_OUTPUT_HINT);
94 prompt
95});
96
97static TOOL_INPUT_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
100 let mut schema = BASHKIT_TOOL.input_schema();
101 if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
103 if !props.contains_key("working_dir") {
104 props.insert(
105 "working_dir".to_string(),
106 json!({
107 "type": "string",
108 "default": "/workspace",
109 "description": "Working directory for command execution"
110 }),
111 );
112 }
113 if !props.contains_key("output") {
114 props.insert(
115 "output".to_string(),
116 crate::tool_output_sanitizer::output_verbosity_schema(),
117 );
118 }
119 }
120 schema
121});
122
123pub struct VirtualBashCapability;
125
126impl Capability for VirtualBashCapability {
127 fn id(&self) -> &str {
128 "virtual_bash"
129 }
130
131 fn name(&self) -> &str {
132 "Virtual Bash"
133 }
134
135 fn description(&self) -> &str {
136 r#"Execute bash commands in an isolated, sandboxed environment.
137
138> [!NOTE]
139> Commands run in a virtual environment with no access to the host system.
140> The session filesystem is mounted at root, so you can read and write session files.
141
142> [!TIP]
143> Use standard Unix commands like `ls`, `cat`, `grep`, `echo`, and shell features
144> like pipes, redirections, and command substitution. Built-in commands support
145> `<command> --help`, and many also support `<command> --version`."#
146 }
147
148 fn status(&self) -> CapabilityStatus {
149 CapabilityStatus::Available
150 }
151
152 fn risk_level(&self) -> RiskLevel {
153 RiskLevel::High
154 }
155
156 fn icon(&self) -> Option<&str> {
157 Some("terminal")
158 }
159
160 fn category(&self) -> Option<&str> {
161 Some("Execution")
162 }
163
164 fn system_prompt_addition(&self) -> Option<&str> {
165 Some(&TOOL_SYSTEM_PROMPT)
166 }
167
168 fn tools(&self) -> Vec<Box<dyn Tool>> {
169 vec![Box::new(BashTool)]
170 }
171
172 fn dependencies(&self) -> Vec<&'static str> {
173 vec!["session_file_system"]
175 }
176
177 fn features(&self) -> Vec<&'static str> {
178 vec!["file_system"]
179 }
180}
181
182pub struct BashTool;
188
189#[async_trait]
190impl Tool for BashTool {
191 fn name(&self) -> &str {
192 "bash"
193 }
194
195 fn display_name(&self) -> Option<&str> {
196 Some("Bash")
197 }
198
199 fn description(&self) -> &str {
200 &TOOL_DESCRIPTION
201 }
202
203 fn parameters_schema(&self) -> Value {
204 TOOL_INPUT_SCHEMA.clone()
205 }
206
207 fn hints(&self) -> ToolHints {
208 ToolHints::default()
209 .with_long_running(true)
210 .with_open_world(true)
211 .with_persist_output(true)
212 .with_supports_background(true)
213 .with_concurrency_class("session_workspace")
218 .with_cpu_bound(true)
219 }
220
221 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
222 ToolExecutionResult::tool_error(
223 "bash requires context. This tool must be executed with session context.",
224 )
225 }
226
227 async fn execute_with_context(
228 &self,
229 arguments: Value,
230 context: &ToolContext,
231 ) -> ToolExecutionResult {
232 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
233 Some(c) => c,
234 None => {
235 return ToolExecutionResult::tool_error("Missing required parameter: commands");
236 }
237 };
238
239 let working_dir = arguments
240 .get("working_dir")
241 .and_then(|v| v.as_str())
242 .unwrap_or("/workspace");
243
244 let timeout_ms = arguments
245 .get("timeout_ms")
246 .and_then(|v| v.as_u64())
247 .unwrap_or(30000)
248 .min(60000);
249
250 let output_mode = arguments
253 .get("output")
254 .and_then(|v| v.as_str())
255 .unwrap_or("auto");
256
257 let file_store = match &context.file_store {
258 Some(store) => store.clone(),
259 None => {
260 return ToolExecutionResult::tool_error(
261 "File system not available in this context",
262 );
263 }
264 };
265
266 let session_fs = Arc::new(SessionFileSystemAdapter::new(
268 context.session_id,
269 file_store,
270 ));
271
272 let locale = context.locale.as_deref().unwrap_or("en-US");
274
275 let builder = Bash::builder()
279 .fs(session_fs)
280 .cwd(working_dir)
281 .username("everruns")
282 .hostname("everruns")
283 .env("HOME", "/home/agent")
284 .env("SHELL", "/bin/bash")
285 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
286 .env("WORKSPACE", "/workspace")
287 .env("LANG", locale)
288 .limits(execution_limits())
289 .max_memory(10 * 1024 * 1024) .trace_mode(TraceMode::Redacted);
291 let mut bash = install_observability_hooks(builder, context.session_id).build();
292
293 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
299 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
300
301 let output_callback: OutputCallback =
302 Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
303 let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
305 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
307 });
308
309 let emit_context = context.clone();
311 let emit_task = tokio::spawn(async move {
312 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
313 if !stdout_chunk.is_empty() {
314 emit_context
315 .emit_tool_output("bash", &stdout_chunk, "stdout")
316 .await;
317 }
318 if !stderr_chunk.is_empty() {
319 emit_context
320 .emit_tool_output("bash", &stderr_chunk, "stderr")
321 .await;
322 }
323 }
324 });
325
326 let cancel_token = bash.cancellation_token();
328
329 let exec_start = std::time::Instant::now();
333 let result = tokio::time::timeout(
334 std::time::Duration::from_millis(timeout_ms),
335 bash.exec_streaming(command, output_callback),
336 )
337 .await;
338 let exec_duration = exec_start.elapsed();
339
340 let _ = emit_task.await;
342
343 match result {
344 Ok(Ok(output)) => {
345 let commands_executed = output
347 .events
348 .iter()
349 .filter(|e| e.kind == TraceEventKind::CommandExit)
350 .count();
351 let fs_reads = output
352 .events
353 .iter()
354 .filter(|e| e.kind == TraceEventKind::FileAccess)
355 .count();
356 let fs_writes = output
357 .events
358 .iter()
359 .filter(|e| e.kind == TraceEventKind::FileMutation)
360 .count();
361
362 tracing::info!(
363 tool = "bash",
364 duration_ms = exec_duration.as_millis() as u64,
365 exit_code = output.exit_code,
366 commands_executed,
367 fs_reads,
368 fs_writes,
369 stdout_bytes = output.stdout.len(),
370 stderr_bytes = output.stderr.len(),
371 "bashkit execution completed"
372 );
373
374 let payload = ExecToolResultPayload::new(
375 &output.stdout,
376 &output.stderr,
377 output.exit_code,
378 output_mode,
379 );
380 let ExecToolResultPayload {
381 stdout,
382 stderr,
383 exit_code,
384 success,
385 truncated,
386 total_lines,
387 raw_output,
388 } = payload;
389 ToolExecutionResult::success_with_raw_output(
390 json!({
391 "stdout": stdout,
392 "stderr": stderr,
393 "exit_code": exit_code,
394 "success": success,
395 "truncated": truncated,
396 "total_lines": total_lines,
397 }),
398 raw_output,
399 )
400 }
401 Ok(Err(e)) => {
402 ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
404 }
405 Err(_) => {
406 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
410
411 let partial = collect_partial_output(partial_rx);
412 if partial.is_empty() {
413 ToolExecutionResult::tool_error(format!(
414 "Command timed out after {}ms",
415 timeout_ms
416 ))
417 } else {
418 use crate::tool_output_sanitizer::{
419 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
420 resolve_auto_mode,
421 };
422 let effective = resolve_auto_mode(output_mode, 1);
425 let clean = clean_exec_output(&partial);
426 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
427 priority_aware_truncate(&clean, budget)
428 } else {
429 clean.clone()
430 };
431 ToolExecutionResult::tool_error(format!(
432 "Command timed out after {}ms. Partial output:\n{}",
433 timeout_ms, truncated
434 ))
435 }
436 }
437 }
438 }
439
440 fn requires_context(&self) -> bool {
441 true
442 }
443
444 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
445 Some(self)
446 }
447}
448
449#[async_trait]
450impl BackgroundExecutableTool for BashTool {
451 async fn execute_background(
452 &self,
453 arguments: Value,
454 context: ToolContext,
455 sink: Arc<dyn BackgroundEventSink>,
456 ) -> Result<BackgroundOutcome, ToolExecutionResult> {
457 let command = match arguments.get("commands").and_then(|v| v.as_str()) {
458 Some(c) => c,
459 None => {
460 return Err(ToolExecutionResult::tool_error(
461 "Missing required parameter: commands",
462 ));
463 }
464 };
465
466 let working_dir = arguments
467 .get("working_dir")
468 .and_then(|v| v.as_str())
469 .unwrap_or("/workspace");
470
471 let timeout_ms = arguments
472 .get("timeout_ms")
473 .and_then(|v| v.as_u64())
474 .unwrap_or(30000)
475 .min(60000);
476
477 let output_mode = arguments
479 .get("output")
480 .and_then(|v| v.as_str())
481 .unwrap_or("auto");
482
483 let file_store = match &context.file_store {
484 Some(store) => store.clone(),
485 None => {
486 return Err(ToolExecutionResult::tool_error(
487 "File system not available in this context",
488 ));
489 }
490 };
491
492 let session_fs = Arc::new(SessionFileSystemAdapter::new(
493 context.session_id,
494 file_store,
495 ));
496 let locale = context.locale.as_deref().unwrap_or("en-US");
497
498 let builder = Bash::builder()
499 .fs(session_fs)
500 .cwd(working_dir)
501 .username("everruns")
502 .hostname("everruns")
503 .env("HOME", "/home/agent")
504 .env("SHELL", "/bin/bash")
505 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
506 .env("WORKSPACE", "/workspace")
507 .env("LANG", locale)
508 .limits(execution_limits())
509 .max_memory(10 * 1024 * 1024)
510 .trace_mode(TraceMode::Redacted);
511 let mut bash = install_observability_hooks(builder, context.session_id).build();
512
513 let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
514 let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
515 let sink_for_output = sink.clone();
516 let dropped_chunks = Arc::new(AtomicUsize::new(0));
517 let dropped_chunks_for_callback = dropped_chunks.clone();
518 let output_callback: OutputCallback =
519 Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
520 if tx
521 .try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
522 .is_err()
523 {
524 dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
525 }
526 let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
527 });
528
529 let emit_task = tokio::spawn(async move {
530 while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
531 if !stdout_chunk.is_empty() {
532 let _ = sink_for_output.output("stdout", &stdout_chunk).await;
533 }
534 if !stderr_chunk.is_empty() {
535 let _ = sink_for_output.output("stderr", &stderr_chunk).await;
536 }
537 }
538 });
539
540 let _ = sink.status("Running bash command").await;
541 let cancel_token = bash.cancellation_token();
542 let exec_start = std::time::Instant::now();
543 let result = tokio::time::timeout(
544 std::time::Duration::from_millis(timeout_ms),
545 bash.exec_streaming(command, output_callback),
546 )
547 .await;
548 let exec_duration = exec_start.elapsed();
549 let _ = emit_task.await;
550 let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
551 if dropped_chunks > 0 {
552 let _ = sink
553 .output(
554 "stderr",
555 &format!(
556 "[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
557 ),
558 )
559 .await;
560 }
561
562 match result {
563 Ok(Ok(output)) => {
564 let payload = ExecToolResultPayload::new(
565 &output.stdout,
566 &output.stderr,
567 output.exit_code,
568 output_mode,
569 );
570 let ExecToolResultPayload {
571 stdout,
572 stderr,
573 exit_code,
574 success,
575 truncated,
576 total_lines,
577 raw_output,
578 } = payload;
579 let _ = sink
580 .progress(BackgroundProgress {
581 current: Some(exec_duration.as_millis() as u64),
582 total: None,
583 unit: Some("ms".to_string()),
584 label: Some("runtime".to_string()),
585 })
586 .await;
587 Ok(BackgroundOutcome {
588 summary: format!(
589 "Bash command exited with code {} after {} ms",
590 exit_code,
591 exec_duration.as_millis()
592 ),
593 result: json!({
594 "stdout": stdout,
595 "stderr": stderr,
596 "exit_code": exit_code,
597 "success": success,
598 "truncated": truncated,
599 "total_lines": total_lines,
600 }),
601 raw_output: Some(raw_output),
602 })
603 }
604 Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
605 "Bash execution error: {}",
606 e
607 ))),
608 Err(_) => {
609 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
610
611 let partial = collect_partial_output(partial_rx);
612 if partial.is_empty() {
613 Err(ToolExecutionResult::tool_error(format!(
614 "Command timed out after {}ms",
615 timeout_ms
616 )))
617 } else {
618 use crate::tool_output_sanitizer::{
619 clean_exec_output, output_verbosity_budget, priority_aware_truncate,
620 resolve_auto_mode,
621 };
622 let effective = resolve_auto_mode(output_mode, 1);
625 let clean = clean_exec_output(&partial);
626 let truncated = if let Some(budget) = output_verbosity_budget(effective) {
627 priority_aware_truncate(&clean, budget)
628 } else {
629 clean.clone()
630 };
631 Err(ToolExecutionResult::tool_error(format!(
632 "Command timed out after {}ms. Partial output:\n{}",
633 timeout_ms, truncated
634 )))
635 }
636 }
637 }
638 }
639}
640
641fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
651 use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
652 builder
653 .before_tool(Box::new(move |ev: ToolEvent| {
654 tracing::debug!(
655 target: "bashkit.hook",
656 capability = "virtual_bash",
657 session_id = %session_id,
658 event = "before_tool",
659 tool = %ev.name,
660 arg_count = ev.args.len(),
661 "builtin invoked"
662 );
663 HookAction::Continue(ev)
664 }))
665 .after_tool(Box::new(move |res: ToolResult| {
666 tracing::debug!(
667 target: "bashkit.hook",
668 capability = "virtual_bash",
669 session_id = %session_id,
670 event = "after_tool",
671 tool = %res.name,
672 exit_code = res.exit_code,
673 stdout_bytes = res.stdout.len(),
674 "builtin completed"
675 );
676 HookAction::Continue(res)
677 }))
678 .on_error(Box::new(move |ev: ErrorEvent| {
679 let preview = truncate_for_log(&ev.message, 256);
680 tracing::warn!(
681 target: "bashkit.hook",
682 capability = "virtual_bash",
683 session_id = %session_id,
684 event = "on_error",
685 message = %preview,
686 "interpreter error"
687 );
688 HookAction::Continue(ev)
689 }))
690}
691
692fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
697 const MARKER: &str = "…[truncated]";
698 if msg.len() <= max_bytes {
699 return msg.to_string();
700 }
701 let budget = max_bytes.saturating_sub(MARKER.len());
702 let mut cut = budget.min(msg.len());
703 while cut > 0 && !msg.is_char_boundary(cut) {
704 cut -= 1;
705 }
706 if max_bytes > MARKER.len() {
707 format!("{}{}", &msg[..cut], MARKER)
708 } else {
709 let mut cut = max_bytes.min(msg.len());
711 while cut > 0 && !msg.is_char_boundary(cut) {
712 cut -= 1;
713 }
714 msg[..cut].to_string()
715 }
716}
717
718fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
721 let mut stdout_buf = String::new();
722 let mut stderr_buf = String::new();
723 while let Ok((stdout, stderr)) = rx.try_recv() {
724 stdout_buf.push_str(&stdout);
725 stderr_buf.push_str(&stderr);
726 }
727 let mut partial = stdout_buf;
728 if !stderr_buf.is_empty() {
729 if !partial.is_empty() && !partial.ends_with('\n') {
730 partial.push('\n');
731 }
732 partial.push_str("--- stderr ---\n");
733 partial.push_str(&stderr_buf);
734 }
735 partial
736}
737
738pub struct SessionFileSystemAdapter {
747 session_id: SessionId,
748 store: Arc<dyn SessionFileSystem>,
749}
750
751impl SessionFileSystemAdapter {
752 pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
753 Self { session_id, store }
754 }
755
756 const WORKSPACE_PREFIX: &'static str = "/workspace";
758
759 fn to_session_path(path: &Path) -> Option<String> {
763 let path_str = path.to_string_lossy();
764
765 let abs_path = if path_str.starts_with('/') {
767 path_str.to_string()
768 } else {
769 format!("/{}", path_str)
770 };
771
772 if abs_path == Self::WORKSPACE_PREFIX {
774 Some("/".to_string())
776 } else if let Some(stripped) = abs_path.strip_prefix(Self::WORKSPACE_PREFIX) {
777 if stripped.starts_with('/') {
779 Some(stripped.to_string())
780 } else {
781 None
783 }
784 } else {
785 None
787 }
788 }
789}
790
791#[async_trait]
792impl FileSystemExt for SessionFileSystemAdapter {}
793
794#[async_trait]
795impl FileSystem for SessionFileSystemAdapter {
796 async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
797 let session_path = Self::to_session_path(path).ok_or_else(|| {
798 bashkit::Error::Io(std::io::Error::new(
799 std::io::ErrorKind::NotFound,
800 format!("Path not in workspace: {}", path.display()),
801 ))
802 })?;
803
804 match self.store.read_file(self.session_id, &session_path).await {
805 Ok(Some(file)) => {
806 let content = file.content.unwrap_or_default();
807 SessionFile::decode_content(&content, &file.encoding)
808 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
809 }
810 Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
811 std::io::ErrorKind::NotFound,
812 format!("File not found: {}", path.display()),
813 ))),
814 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
815 }
816 }
817
818 async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
819 let session_path = Self::to_session_path(path).ok_or_else(|| {
820 bashkit::Error::Io(std::io::Error::new(
821 std::io::ErrorKind::PermissionDenied,
822 format!("Cannot write outside workspace: {}", path.display()),
823 ))
824 })?;
825
826 let (encoded, encoding) = SessionFile::encode_content(content);
827
828 self.store
829 .write_file(self.session_id, &session_path, &encoded, &encoding)
830 .await
831 .map(|_| ())
832 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
833 }
834
835 async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
836 let session_path = Self::to_session_path(path).ok_or_else(|| {
837 bashkit::Error::Io(std::io::Error::new(
838 std::io::ErrorKind::PermissionDenied,
839 format!("Cannot write outside workspace: {}", path.display()),
840 ))
841 })?;
842
843 let mut existing = match self.store.read_file(self.session_id, &session_path).await {
845 Ok(Some(file)) => {
846 let content = file.content.unwrap_or_default();
847 SessionFile::decode_content(&content, &file.encoding)
848 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
849 }
850 Ok(None) => Vec::new(),
851 Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
852 };
853
854 existing.extend_from_slice(content);
856
857 let (encoded, encoding) = SessionFile::encode_content(&existing);
859 self.store
860 .write_file(self.session_id, &session_path, &encoded, &encoding)
861 .await
862 .map(|_| ())
863 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
864 }
865
866 async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
867 let session_path = Self::to_session_path(path).ok_or_else(|| {
868 bashkit::Error::Io(std::io::Error::new(
869 std::io::ErrorKind::PermissionDenied,
870 format!(
871 "Cannot create directory outside workspace: {}",
872 path.display()
873 ),
874 ))
875 })?;
876
877 self.store
878 .create_directory(self.session_id, &session_path)
879 .await
880 .map(|_| ())
881 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
882 }
883
884 async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
885 let session_path = Self::to_session_path(path).ok_or_else(|| {
886 bashkit::Error::Io(std::io::Error::new(
887 std::io::ErrorKind::PermissionDenied,
888 format!("Cannot delete outside workspace: {}", path.display()),
889 ))
890 })?;
891
892 self.store
893 .delete_file(self.session_id, &session_path, recursive)
894 .await
895 .map(|_| ())
896 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
897 }
898
899 async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
900 if path.to_string_lossy() == "/workspace" {
902 let now = SystemTime::now();
903 return Ok(Metadata {
904 file_type: FileType::Directory,
905 size: 0,
906 mode: 0o755,
907 modified: now,
908 created: now,
909 });
910 }
911
912 let session_path = Self::to_session_path(path).ok_or_else(|| {
913 bashkit::Error::Io(std::io::Error::new(
914 std::io::ErrorKind::NotFound,
915 format!("Path not in workspace: {}", path.display()),
916 ))
917 })?;
918
919 match self.store.read_file(self.session_id, &session_path).await {
921 Ok(Some(file)) => {
922 let now = SystemTime::now();
923
924 let file_type = if file.is_directory {
925 FileType::Directory
926 } else {
927 FileType::File
928 };
929
930 Ok(Metadata {
934 file_type,
935 size: file.size_bytes as u64,
936 mode: 0o755,
937 modified: now,
938 created: now,
939 })
940 }
941 Ok(None) => {
942 match self
944 .store
945 .list_directory(self.session_id, &session_path)
946 .await
947 {
948 Ok(_entries) => {
949 let now = SystemTime::now();
950 Ok(Metadata {
951 file_type: FileType::Directory,
952 size: 0,
953 mode: 0o755,
954 modified: now,
955 created: now,
956 })
957 }
958 Err(_) => Err(bashkit::Error::Io(std::io::Error::new(
959 std::io::ErrorKind::NotFound,
960 format!("Path not found: {}", path.display()),
961 ))),
962 }
963 }
964 Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
965 }
966 }
967
968 async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
969 let session_path = Self::to_session_path(path).ok_or_else(|| {
970 bashkit::Error::Io(std::io::Error::new(
971 std::io::ErrorKind::NotFound,
972 format!("Path not in workspace: {}", path.display()),
973 ))
974 })?;
975
976 let entries = self
977 .store
978 .list_directory(self.session_id, &session_path)
979 .await
980 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
981
982 let now = SystemTime::now();
983
984 Ok(entries
985 .into_iter()
986 .map(|e| {
987 let file_type = if e.is_directory {
988 FileType::Directory
989 } else {
990 FileType::File
991 };
992
993 DirEntry {
994 name: e.name,
995 metadata: Metadata {
996 file_type,
997 size: e.size_bytes as u64,
998 mode: 0o755,
999 modified: now,
1000 created: now,
1001 },
1002 }
1003 })
1004 .collect())
1005 }
1006
1007 async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
1008 if path.to_string_lossy() == "/workspace" {
1010 return Ok(true);
1011 }
1012
1013 let session_path = match Self::to_session_path(path) {
1014 Some(p) => p,
1015 None => return Ok(false), };
1017
1018 if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
1020 return Ok(true);
1021 }
1022
1023 if self
1025 .store
1026 .list_directory(self.session_id, &session_path)
1027 .await
1028 .is_ok()
1029 {
1030 return Ok(true);
1031 }
1032
1033 Ok(false)
1034 }
1035
1036 async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1037 let from_session = Self::to_session_path(from).ok_or_else(|| {
1038 bashkit::Error::Io(std::io::Error::new(
1039 std::io::ErrorKind::NotFound,
1040 format!("Source not in workspace: {}", from.display()),
1041 ))
1042 })?;
1043
1044 let content = self.read_file(from).await?;
1046
1047 self.write_file(to, &content).await?;
1049
1050 self.store
1052 .delete_file(self.session_id, &from_session, false)
1053 .await
1054 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1055
1056 Ok(())
1057 }
1058
1059 async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1060 let content = self.read_file(from).await?;
1061 self.write_file(to, &content).await
1062 }
1063
1064 async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
1065 Err(bashkit::Error::Io(std::io::Error::new(
1067 std::io::ErrorKind::Unsupported,
1068 "Symlinks not supported in session filesystem",
1069 )))
1070 }
1071
1072 async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
1073 Err(bashkit::Error::Io(std::io::Error::new(
1075 std::io::ErrorKind::Unsupported,
1076 format!("Symlinks not supported: {}", path.display()),
1077 )))
1078 }
1079
1080 async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
1081 Ok(())
1083 }
1084
1085 fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
1086 Some(self)
1087 }
1088}
1089
1090impl SearchCapable for SessionFileSystemAdapter {
1095 fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>> {
1096 Self::to_session_path(path)?;
1098 Some(Box::new(SessionSearchProvider {
1099 session_id: self.session_id,
1100 store: self.store.clone(),
1101 }))
1102 }
1103}
1104
1105struct SessionSearchProvider {
1110 session_id: SessionId,
1111 store: Arc<dyn SessionFileSystem>,
1112}
1113
1114impl SearchProvider for SessionSearchProvider {
1115 fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
1116 let session_id = self.session_id;
1117 let store = self.store.clone();
1118 let root = query.root.to_string_lossy().into_owned();
1119 let max_results = query.max_results;
1120
1121 let pattern = if query.case_insensitive {
1123 format!("(?i){}", query.pattern)
1124 } else {
1125 query.pattern.clone()
1126 };
1127
1128 let session_root =
1132 SessionFileSystemAdapter::to_session_path(Path::new(&root)).ok_or_else(|| {
1133 bashkit::Error::Io(std::io::Error::new(
1134 std::io::ErrorKind::NotFound,
1135 format!("Path not in workspace: {}", root),
1136 ))
1137 })?;
1138 let path_pattern = if session_root == "/" {
1139 None
1140 } else {
1141 Some(session_root)
1142 };
1143
1144 let matches = std::thread::scope(|s| {
1148 s.spawn(|| {
1149 let rt = tokio::runtime::Builder::new_current_thread()
1150 .enable_all()
1151 .build()
1152 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1153 rt.block_on(async {
1154 store
1155 .grep_files(session_id, &pattern, path_pattern.as_deref())
1156 .await
1157 })
1158 .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1159 })
1160 .join()
1161 .unwrap_or_else(|_| {
1162 Err(bashkit::Error::Io(std::io::Error::other(
1163 "search thread panicked",
1164 )))
1165 })
1166 })?;
1167
1168 let truncated = max_results.is_some_and(|max| matches.len() > max);
1169 let matches: Vec<BashkitSearchMatch> = matches
1170 .into_iter()
1171 .take(max_results.unwrap_or(usize::MAX))
1172 .map(|m| {
1173 let vfs_path = format!("{}{}", SessionFileSystemAdapter::WORKSPACE_PREFIX, m.path);
1175 BashkitSearchMatch {
1176 path: PathBuf::from(vfs_path),
1177 line_number: m.line_number,
1178 line_content: m.line,
1179 }
1180 })
1181 .collect();
1182
1183 Ok(SearchResults { matches, truncated })
1184 }
1185
1186 fn capabilities(&self) -> SearchCapabilities {
1187 SearchCapabilities {
1188 regex: true,
1189 glob_filter: false,
1190 content_search: true,
1191 filename_search: false,
1192 }
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199 use crate::session_file::FileInfo;
1200 use crate::traits::SessionFileSystem;
1201 use crate::typed_id::SessionId;
1202 use crate::{FileStat, GrepMatch, Result};
1203 use std::collections::HashMap;
1204 use std::sync::Mutex;
1205
1206 struct MockFileStore {
1212 files: Mutex<HashMap<(SessionId, String), (String, String)>>, directories: Mutex<HashMap<(SessionId, String), bool>>,
1214 }
1215
1216 impl MockFileStore {
1217 fn new() -> Self {
1218 Self {
1219 files: Mutex::new(HashMap::new()),
1220 directories: Mutex::new(HashMap::new()),
1221 }
1222 }
1223
1224 fn normalize_path(path: &str) -> String {
1225 let mut normalized = path.trim().to_string();
1226 if !normalized.starts_with('/') {
1227 normalized = format!("/{}", normalized);
1228 }
1229 if normalized.len() > 1 && normalized.ends_with('/') {
1230 normalized.pop();
1231 }
1232 normalized
1233 }
1234 }
1235
1236 #[async_trait]
1237 impl SessionFileSystem for MockFileStore {
1238 async fn read_file(
1239 &self,
1240 session_id: SessionId,
1241 path: &str,
1242 ) -> Result<Option<SessionFile>> {
1243 let path = Self::normalize_path(path);
1244 let files = self.files.lock().unwrap();
1245 if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
1246 Ok(Some(SessionFile {
1247 id: uuid::Uuid::new_v4(),
1248 session_id: session_id.into(),
1249 path: path.clone(),
1250 name: path.split('/').next_back().unwrap_or("").to_string(),
1251 is_directory: false,
1252 is_readonly: false,
1253 content: Some(content.clone()),
1254 encoding: encoding.clone(),
1255 size_bytes: content.len() as i64,
1256 created_at: chrono::Utc::now(),
1257 updated_at: chrono::Utc::now(),
1258 }))
1259 } else {
1260 Ok(None)
1261 }
1262 }
1263
1264 async fn write_file(
1265 &self,
1266 session_id: SessionId,
1267 path: &str,
1268 content: &str,
1269 encoding: &str,
1270 ) -> Result<SessionFile> {
1271 let path = Self::normalize_path(path);
1272 let mut files = self.files.lock().unwrap();
1273 files.insert(
1274 (session_id, path.clone()),
1275 (content.to_string(), encoding.to_string()),
1276 );
1277 Ok(SessionFile {
1278 id: uuid::Uuid::new_v4(),
1279 session_id: session_id.into(),
1280 path: path.clone(),
1281 name: path.split('/').next_back().unwrap_or("").to_string(),
1282 is_directory: false,
1283 is_readonly: false,
1284 content: Some(content.to_string()),
1285 encoding: encoding.to_string(),
1286 size_bytes: content.len() as i64,
1287 created_at: chrono::Utc::now(),
1288 updated_at: chrono::Utc::now(),
1289 })
1290 }
1291
1292 async fn delete_file(
1293 &self,
1294 session_id: SessionId,
1295 path: &str,
1296 _recursive: bool,
1297 ) -> Result<bool> {
1298 let path = Self::normalize_path(path);
1299 let mut files = self.files.lock().unwrap();
1300 Ok(files.remove(&(session_id, path)).is_some())
1301 }
1302
1303 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
1304 let path = Self::normalize_path(path);
1305 let files = self.files.lock().unwrap();
1306 let dirs = self.directories.lock().unwrap();
1307 let mut entries = Vec::new();
1308
1309 let is_root = path == "/";
1311
1312 for ((sid, file_path), (content, _)) in files.iter() {
1313 if *sid != session_id {
1314 continue;
1315 }
1316
1317 let parent = if let Some(idx) = file_path.rfind('/') {
1319 if idx == 0 {
1320 "/".to_string()
1321 } else {
1322 file_path[..idx].to_string()
1323 }
1324 } else {
1325 "/".to_string()
1326 };
1327
1328 if parent == path {
1329 entries.push(FileInfo {
1330 id: uuid::Uuid::new_v4(),
1331 session_id: session_id.into(),
1332 path: file_path.clone(),
1333 name: file_path.split('/').next_back().unwrap_or("").to_string(),
1334 is_directory: false,
1335 is_readonly: false,
1336 size_bytes: content.len() as i64,
1337 created_at: chrono::Utc::now(),
1338 updated_at: chrono::Utc::now(),
1339 });
1340 }
1341 }
1342
1343 if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
1346 let has_children = files
1348 .keys()
1349 .any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
1350 if !has_children {
1351 return Err(anyhow::anyhow!("Directory not found: {}", path).into());
1352 }
1353 }
1354
1355 Ok(entries)
1356 }
1357
1358 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1359 let path = Self::normalize_path(path);
1360 let files = self.files.lock().unwrap();
1361 if let Some((content, _)) = files.get(&(session_id, path.clone())) {
1362 Ok(Some(FileStat {
1363 path: path.clone(),
1364 name: path.split('/').next_back().unwrap_or("").to_string(),
1365 is_directory: false,
1366 is_readonly: false,
1367 size_bytes: content.len() as i64,
1368 created_at: chrono::Utc::now(),
1369 updated_at: chrono::Utc::now(),
1370 }))
1371 } else {
1372 Ok(None)
1373 }
1374 }
1375
1376 async fn grep_files(
1377 &self,
1378 session_id: SessionId,
1379 pattern: &str,
1380 path_pattern: Option<&str>,
1381 ) -> Result<Vec<GrepMatch>> {
1382 let regex = regex::Regex::new(pattern)
1383 .map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
1384 let files = self.files.lock().unwrap();
1385 let mut matches = Vec::new();
1386 for ((sid, file_path), (content, _)) in files.iter() {
1387 if *sid != session_id {
1388 continue;
1389 }
1390 if let Some(pp) = path_pattern
1391 && !file_path.starts_with(pp)
1392 {
1393 continue;
1394 }
1395 let decoded = SessionFile::decode_content(content, "utf-8")
1396 .unwrap_or_else(|_| content.as_bytes().to_vec());
1397 let text = String::from_utf8_lossy(&decoded);
1398 for (i, line) in text.lines().enumerate() {
1399 if regex.is_match(line) {
1400 matches.push(GrepMatch {
1401 path: file_path.clone(),
1402 line_number: i + 1,
1403 line: line.to_string(),
1404 });
1405 }
1406 }
1407 }
1408 matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
1409 Ok(matches)
1410 }
1411
1412 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1413 let path = Self::normalize_path(path);
1414 let mut dirs = self.directories.lock().unwrap();
1415 dirs.insert((session_id, path.clone()), true);
1416 Ok(FileInfo {
1417 id: uuid::Uuid::new_v4(),
1418 session_id: session_id.into(),
1419 path: path.clone(),
1420 name: path.split('/').next_back().unwrap_or("").to_string(),
1421 is_directory: true,
1422 is_readonly: false,
1423 size_bytes: 0,
1424 created_at: chrono::Utc::now(),
1425 updated_at: chrono::Utc::now(),
1426 })
1427 }
1428 }
1429
1430 #[test]
1435 fn test_capability_metadata() {
1436 let cap = VirtualBashCapability;
1437 assert_eq!(cap.id(), "virtual_bash");
1438 assert_eq!(cap.name(), "Virtual Bash");
1439 assert_eq!(cap.status(), CapabilityStatus::Available);
1440 assert_eq!(cap.risk_level(), RiskLevel::High);
1441 assert_eq!(cap.icon(), Some("terminal"));
1442 assert_eq!(cap.category(), Some("Execution"));
1443 let description = cap.description();
1444 assert!(
1445 description.contains("`<command> --help`"),
1446 "description should advertise built-in help, got: {}",
1447 description
1448 );
1449 assert!(
1450 description.contains("`<command> --version`"),
1451 "description should advertise built-in version support, got: {}",
1452 description
1453 );
1454 }
1455
1456 #[test]
1457 fn test_capability_has_tools() {
1458 let cap = VirtualBashCapability;
1459 let tools = cap.tools();
1460
1461 assert_eq!(tools.len(), 1);
1462 assert_eq!(tools[0].name(), "bash");
1463 }
1464
1465 #[test]
1466 fn test_capability_has_system_prompt() {
1467 let cap = VirtualBashCapability;
1468 let prompt = cap.system_prompt_addition().unwrap();
1469 assert!(!prompt.is_empty(), "System prompt should not be empty");
1471 assert!(
1473 prompt.contains("everruns"),
1474 "System prompt should contain configured identity"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_capability_has_dependencies() {
1480 let cap = VirtualBashCapability;
1481 let deps = cap.dependencies();
1482 assert_eq!(deps.len(), 1);
1483 assert_eq!(deps[0], "session_file_system");
1484 }
1485
1486 #[test]
1487 fn test_tool_requires_context() {
1488 assert!(BashTool.requires_context());
1489 }
1490
1491 #[test]
1496 fn test_to_session_path_workspace_root() {
1497 let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace"));
1498 assert_eq!(result, Some("/".to_string()));
1499 }
1500
1501 #[test]
1502 fn test_to_session_path_workspace_file() {
1503 let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace/file.txt"));
1504 assert_eq!(result, Some("/file.txt".to_string()));
1505 }
1506
1507 #[test]
1508 fn test_to_session_path_workspace_nested() {
1509 let result =
1510 SessionFileSystemAdapter::to_session_path(Path::new("/workspace/dir/subdir/file.txt"));
1511 assert_eq!(result, Some("/dir/subdir/file.txt".to_string()));
1512 }
1513
1514 #[test]
1515 fn test_to_session_path_outside_workspace() {
1516 let result = SessionFileSystemAdapter::to_session_path(Path::new("/tmp/file.txt"));
1517 assert_eq!(result, None);
1518 }
1519
1520 #[test]
1521 fn test_to_session_path_home_outside_workspace() {
1522 let result = SessionFileSystemAdapter::to_session_path(Path::new("/home/agent/file.txt"));
1523 assert_eq!(result, None);
1524 }
1525
1526 #[test]
1527 fn test_to_session_path_workspacefoo_invalid() {
1528 let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspacefoo"));
1530 assert_eq!(result, None);
1531 }
1532
1533 #[test]
1534 fn test_to_session_path_relative_path() {
1535 let result = SessionFileSystemAdapter::to_session_path(Path::new("workspace/file.txt"));
1537 assert_eq!(result, Some("/file.txt".to_string()));
1543 }
1544
1545 #[tokio::test]
1550 async fn test_bash_without_context() {
1551 let tool = BashTool;
1552 let result = tool.execute(json!({"commands": "echo hello"})).await;
1553
1554 if let ToolExecutionResult::ToolError(msg) = result {
1555 assert!(msg.contains("requires context"));
1556 } else {
1557 panic!("Expected tool error");
1558 }
1559 }
1560
1561 #[tokio::test]
1562 async fn test_bash_missing_command() {
1563 let tool = BashTool;
1564 let context = ToolContext::new(SessionId::new());
1565
1566 let result = tool.execute_with_context(json!({}), &context).await;
1567
1568 if let ToolExecutionResult::ToolError(msg) = result {
1569 assert!(msg.contains("Missing required parameter"));
1570 } else {
1571 panic!("Expected tool error for missing command");
1572 }
1573 }
1574
1575 #[tokio::test]
1576 async fn test_bash_no_file_store() {
1577 let tool = BashTool;
1578 let context = ToolContext::new(SessionId::new());
1579
1580 let result = tool
1581 .execute_with_context(json!({"commands": "echo hello"}), &context)
1582 .await;
1583
1584 if let ToolExecutionResult::ToolError(msg) = result {
1585 assert!(msg.contains("not available"));
1586 } else {
1587 panic!("Expected tool error for missing file store");
1588 }
1589 }
1590
1591 fn create_context_with_mock_store() -> (ToolContext, SessionId) {
1596 let session_id = SessionId::new();
1597 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1598 let mut context = ToolContext::new(session_id);
1599 context.file_store = Some(store);
1600 (context, session_id)
1601 }
1602
1603 #[tokio::test]
1604 async fn test_bash_echo_command() {
1605 let (context, _) = create_context_with_mock_store();
1606 let tool = BashTool;
1607
1608 let result = tool
1609 .execute_with_context(json!({"commands": "echo hello world"}), &context)
1610 .await;
1611
1612 if let ToolExecutionResult::Success(output) = result {
1613 assert_eq!(output["stdout"], "hello world\n");
1614 assert_eq!(output["exit_code"], 0);
1615 assert_eq!(output["success"], true);
1616 } else {
1617 panic!("Expected success result, got: {:?}", result);
1618 }
1619 }
1620
1621 #[tokio::test]
1622 async fn test_bash_pwd_default_workspace() {
1623 let (context, _) = create_context_with_mock_store();
1624 let tool = BashTool;
1625
1626 let result = tool
1627 .execute_with_context(json!({"commands": "pwd"}), &context)
1628 .await;
1629
1630 if let ToolExecutionResult::Success(output) = result {
1631 assert_eq!(output["stdout"], "/workspace\n");
1632 assert_eq!(output["exit_code"], 0);
1633 } else {
1634 panic!("Expected success result, got: {:?}", result);
1635 }
1636 }
1637
1638 #[tokio::test]
1639 async fn test_bash_env_variables() {
1640 let (context, _) = create_context_with_mock_store();
1641 let tool = BashTool;
1642
1643 let result = tool
1645 .execute_with_context(json!({"commands": "echo $HOME"}), &context)
1646 .await;
1647 if let ToolExecutionResult::Success(output) = result {
1648 assert_eq!(output["stdout"], "/home/agent\n");
1649 } else {
1650 panic!("Expected success");
1651 }
1652
1653 let result = tool
1655 .execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
1656 .await;
1657 if let ToolExecutionResult::Success(output) = result {
1658 assert_eq!(output["stdout"], "/workspace\n");
1659 } else {
1660 panic!("Expected success");
1661 }
1662
1663 let result = tool
1665 .execute_with_context(json!({"commands": "echo $USER"}), &context)
1666 .await;
1667 if let ToolExecutionResult::Success(output) = result {
1668 assert_eq!(output["stdout"], "everruns\n");
1669 } else {
1670 panic!("Expected success");
1671 }
1672 }
1673
1674 #[tokio::test]
1675 async fn test_bash_lang_env_default() {
1676 let (context, _) = create_context_with_mock_store();
1677 let tool = BashTool;
1678
1679 let result = tool
1681 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1682 .await;
1683 if let ToolExecutionResult::Success(output) = result {
1684 assert_eq!(output["stdout"], "en-US\n");
1685 } else {
1686 panic!("Expected success");
1687 }
1688 }
1689
1690 #[tokio::test]
1691 async fn test_bash_lang_env_from_context_locale() {
1692 let (mut context, _) = create_context_with_mock_store();
1693 context.locale = Some("uk-UA".to_string());
1694 let tool = BashTool;
1695
1696 let result = tool
1697 .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1698 .await;
1699 if let ToolExecutionResult::Success(output) = result {
1700 assert_eq!(output["stdout"], "uk-UA\n");
1701 } else {
1702 panic!("Expected success");
1703 }
1704 }
1705
1706 #[tokio::test]
1707 async fn test_bash_write_and_read_file() {
1708 let (context, _) = create_context_with_mock_store();
1709 let tool = BashTool;
1710
1711 let result = tool
1713 .execute_with_context(
1714 json!({"commands": "echo 'test content' > /workspace/test.txt"}),
1715 &context,
1716 )
1717 .await;
1718 assert!(matches!(result, ToolExecutionResult::Success(_)));
1719
1720 let result = tool
1722 .execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
1723 .await;
1724 if let ToolExecutionResult::Success(output) = result {
1725 assert_eq!(output["stdout"], "test content\n");
1726 } else {
1727 panic!("Expected success result");
1728 }
1729 }
1730
1731 #[tokio::test]
1732 async fn test_bash_pipe_command() {
1733 let (context, _) = create_context_with_mock_store();
1734 let tool = BashTool;
1735
1736 let result = tool
1737 .execute_with_context(json!({"commands": "echo hello | cat"}), &context)
1738 .await;
1739
1740 if let ToolExecutionResult::Success(output) = result {
1741 assert_eq!(output["stdout"], "hello\n");
1742 assert_eq!(output["exit_code"], 0);
1743 } else {
1744 panic!("Expected success result");
1745 }
1746 }
1747
1748 #[tokio::test]
1749 async fn test_bash_arithmetic() {
1750 let (context, _) = create_context_with_mock_store();
1751 let tool = BashTool;
1752
1753 let result = tool
1754 .execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
1755 .await;
1756
1757 if let ToolExecutionResult::Success(output) = result {
1758 assert_eq!(output["stdout"], "14\n");
1759 } else {
1760 panic!("Expected success result");
1761 }
1762 }
1763
1764 #[tokio::test]
1765 async fn test_bash_command_substitution() {
1766 let (context, _) = create_context_with_mock_store();
1767 let tool = BashTool;
1768
1769 let result = tool
1770 .execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
1771 .await;
1772
1773 if let ToolExecutionResult::Success(output) = result {
1774 assert_eq!(output["stdout"], "nested\n");
1775 } else {
1776 panic!("Expected success result");
1777 }
1778 }
1779
1780 #[tokio::test]
1785 async fn test_bash_write_outside_workspace_fails() {
1786 let (context, _) = create_context_with_mock_store();
1787 let tool = BashTool;
1788
1789 let result = tool
1791 .execute_with_context(json!({"commands": "echo 'hack' > /tmp/evil.txt"}), &context)
1792 .await;
1793
1794 if let ToolExecutionResult::ToolError(msg) = result {
1796 assert!(
1797 msg.contains("outside workspace") || msg.contains("Permission"),
1798 "Expected workspace error, got: {}",
1799 msg
1800 );
1801 } else if let ToolExecutionResult::Success(output) = result {
1802 let read_result = tool
1805 .execute_with_context(json!({"commands": "cat /tmp/evil.txt"}), &context)
1806 .await;
1807 assert!(
1809 matches!(read_result, ToolExecutionResult::ToolError(_))
1810 || matches!(&read_result, ToolExecutionResult::Success(o) if o["exit_code"] != 0),
1811 "File should not exist outside workspace"
1812 );
1813 assert!(
1815 output["stderr"]
1816 .as_str()
1817 .unwrap_or("")
1818 .contains("Permission")
1819 || output["stderr"]
1820 .as_str()
1821 .unwrap_or("")
1822 .contains("workspace")
1823 || output["exit_code"] != 0,
1824 "Write outside workspace should fail or be blocked"
1825 );
1826 } else {
1827 }
1829 }
1830
1831 #[tokio::test]
1832 async fn test_bash_read_outside_workspace_fails() {
1833 let (context, _) = create_context_with_mock_store();
1834 let tool = BashTool;
1835
1836 let result = tool
1838 .execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
1839 .await;
1840
1841 match result {
1843 ToolExecutionResult::ToolError(msg) => {
1844 assert!(
1845 msg.contains("workspace") || msg.contains("not found"),
1846 "Expected workspace error, got: {}",
1847 msg
1848 );
1849 }
1850 ToolExecutionResult::Success(output) => {
1851 assert_ne!(
1853 output["exit_code"], 0,
1854 "Reading /etc/passwd should fail with non-zero exit"
1855 );
1856 }
1857 _ => panic!("Unexpected result type"),
1858 }
1859 }
1860
1861 #[tokio::test]
1862 async fn test_bash_mkdir_outside_workspace_fails() {
1863 let (context, _) = create_context_with_mock_store();
1864 let tool = BashTool;
1865
1866 let result = tool
1868 .execute_with_context(json!({"commands": "mkdir /tmp/evil_dir"}), &context)
1869 .await;
1870
1871 match result {
1873 ToolExecutionResult::ToolError(msg) => {
1874 assert!(
1875 msg.contains("workspace") || msg.contains("Permission"),
1876 "Got: {}",
1877 msg
1878 );
1879 }
1880 ToolExecutionResult::Success(output) => {
1881 assert!(
1884 output["exit_code"] != 0
1885 || output["stderr"]
1886 .as_str()
1887 .unwrap_or("")
1888 .contains("Permission"),
1889 "mkdir outside workspace should fail"
1890 );
1891 }
1892 _ => {}
1893 }
1894 }
1895
1896 #[tokio::test]
1901 async fn test_bash_custom_working_dir() {
1902 let (context, _) = create_context_with_mock_store();
1903 let tool = BashTool;
1904
1905 let result = tool
1907 .execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
1908 .await;
1909 assert!(matches!(result, ToolExecutionResult::Success(_)));
1910
1911 let result = tool
1913 .execute_with_context(
1914 json!({
1915 "commands": "pwd",
1916 "working_dir": "/workspace/mydir"
1917 }),
1918 &context,
1919 )
1920 .await;
1921
1922 if let ToolExecutionResult::Success(output) = result {
1923 assert_eq!(output["stdout"], "/workspace/mydir\n");
1924 } else {
1925 panic!("Expected success result");
1926 }
1927 }
1928
1929 #[tokio::test]
1934 async fn test_bash_false_command_exit_code() {
1935 let (context, _) = create_context_with_mock_store();
1936 let tool = BashTool;
1937
1938 let result = tool
1939 .execute_with_context(json!({"commands": "false"}), &context)
1940 .await;
1941
1942 if let ToolExecutionResult::Success(output) = result {
1943 assert_eq!(output["exit_code"], 1);
1944 assert_eq!(output["success"], false);
1945 } else {
1946 panic!("Expected success result with non-zero exit code");
1947 }
1948 }
1949
1950 #[tokio::test]
1951 async fn test_bash_true_command_exit_code() {
1952 let (context, _) = create_context_with_mock_store();
1953 let tool = BashTool;
1954
1955 let result = tool
1956 .execute_with_context(json!({"commands": "true"}), &context)
1957 .await;
1958
1959 if let ToolExecutionResult::Success(output) = result {
1960 assert_eq!(output["exit_code"], 0);
1961 assert_eq!(output["success"], true);
1962 } else {
1963 panic!("Expected success result");
1964 }
1965 }
1966
1967 #[tokio::test]
1972 async fn test_adapter_read_write_workspace_file() {
1973 let session_id = SessionId::new();
1974 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1975 let adapter = SessionFileSystemAdapter::new(session_id, store);
1976
1977 adapter
1979 .write_file(Path::new("/workspace/test.txt"), b"hello")
1980 .await
1981 .unwrap();
1982
1983 let content = adapter
1985 .read_file(Path::new("/workspace/test.txt"))
1986 .await
1987 .unwrap();
1988 assert_eq!(content, b"hello");
1989 }
1990
1991 #[tokio::test]
1992 async fn test_adapter_read_outside_workspace_fails() {
1993 let session_id = SessionId::new();
1994 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1995 let adapter = SessionFileSystemAdapter::new(session_id, store);
1996
1997 let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
1998 assert!(result.is_err());
1999
2000 let err = result.unwrap_err();
2001 assert!(err.to_string().contains("workspace"));
2002 }
2003
2004 #[tokio::test]
2005 async fn test_adapter_write_outside_workspace_fails() {
2006 let session_id = SessionId::new();
2007 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2008 let adapter = SessionFileSystemAdapter::new(session_id, store);
2009
2010 let result = adapter
2011 .write_file(Path::new("/tmp/file.txt"), b"data")
2012 .await;
2013 assert!(result.is_err());
2014
2015 let err = result.unwrap_err();
2016 assert!(err.to_string().contains("workspace"));
2017 }
2018
2019 #[tokio::test]
2020 async fn test_adapter_stat_workspace_root() {
2021 let session_id = SessionId::new();
2022 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2023 let adapter = SessionFileSystemAdapter::new(session_id, store);
2024
2025 let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
2026 assert!(stat.file_type.is_dir());
2027 }
2028
2029 #[tokio::test]
2030 async fn test_adapter_stat_directory_returns_dir_type() {
2031 let session_id = SessionId::new();
2032 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2033 let adapter = SessionFileSystemAdapter::new(session_id, store);
2034
2035 adapter
2037 .mkdir(Path::new("/workspace/mydir"), false)
2038 .await
2039 .unwrap();
2040
2041 let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
2043 assert!(
2044 stat.file_type.is_dir(),
2045 "Expected directory but got file type for /workspace/mydir"
2046 );
2047 }
2048
2049 #[tokio::test]
2050 async fn test_adapter_stat_file_returns_file_type() {
2051 let session_id = SessionId::new();
2052 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2053 let adapter = SessionFileSystemAdapter::new(session_id, store);
2054
2055 adapter
2057 .write_file(Path::new("/workspace/test.txt"), b"hello")
2058 .await
2059 .unwrap();
2060
2061 let stat = adapter
2063 .stat(Path::new("/workspace/test.txt"))
2064 .await
2065 .unwrap();
2066 assert!(
2067 stat.file_type.is_file(),
2068 "Expected file but got directory type for /workspace/test.txt"
2069 );
2070 assert_eq!(stat.size, 5);
2071 }
2072
2073 #[tokio::test]
2074 async fn test_adapter_exists_workspace() {
2075 let session_id = SessionId::new();
2076 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2077 let adapter = SessionFileSystemAdapter::new(session_id, store);
2078
2079 assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
2081
2082 assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
2084 }
2085
2086 #[tokio::test]
2087 async fn test_adapter_mkdir_and_list() {
2088 let session_id = SessionId::new();
2089 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2090 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2091
2092 adapter
2094 .mkdir(Path::new("/workspace/mydir"), false)
2095 .await
2096 .unwrap();
2097
2098 adapter
2100 .write_file(Path::new("/workspace/mydir/file.txt"), b"content")
2101 .await
2102 .unwrap();
2103
2104 let entries = adapter
2106 .read_dir(Path::new("/workspace/mydir"))
2107 .await
2108 .unwrap();
2109 assert_eq!(entries.len(), 1);
2110 assert_eq!(entries[0].name, "file.txt");
2111 }
2112
2113 #[tokio::test]
2114 async fn test_adapter_rename_file() {
2115 let session_id = SessionId::new();
2116 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2117 let adapter = SessionFileSystemAdapter::new(session_id, store);
2118
2119 adapter
2121 .write_file(Path::new("/workspace/old.txt"), b"data")
2122 .await
2123 .unwrap();
2124
2125 adapter
2127 .rename(
2128 Path::new("/workspace/old.txt"),
2129 Path::new("/workspace/new.txt"),
2130 )
2131 .await
2132 .unwrap();
2133
2134 let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
2136 assert!(old_result.is_err());
2137
2138 let new_content = adapter
2140 .read_file(Path::new("/workspace/new.txt"))
2141 .await
2142 .unwrap();
2143 assert_eq!(new_content, b"data");
2144 }
2145
2146 #[tokio::test]
2147 async fn test_adapter_copy_file() {
2148 let session_id = SessionId::new();
2149 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2150 let adapter = SessionFileSystemAdapter::new(session_id, store);
2151
2152 adapter
2154 .write_file(Path::new("/workspace/source.txt"), b"copy me")
2155 .await
2156 .unwrap();
2157
2158 adapter
2160 .copy(
2161 Path::new("/workspace/source.txt"),
2162 Path::new("/workspace/dest.txt"),
2163 )
2164 .await
2165 .unwrap();
2166
2167 let source = adapter
2169 .read_file(Path::new("/workspace/source.txt"))
2170 .await
2171 .unwrap();
2172 let dest = adapter
2173 .read_file(Path::new("/workspace/dest.txt"))
2174 .await
2175 .unwrap();
2176 assert_eq!(source, dest);
2177 assert_eq!(source, b"copy me");
2178 }
2179
2180 #[tokio::test]
2181 async fn test_adapter_append_file() {
2182 let session_id = SessionId::new();
2183 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2184 let adapter = SessionFileSystemAdapter::new(session_id, store);
2185
2186 adapter
2188 .write_file(Path::new("/workspace/log.txt"), b"line1\n")
2189 .await
2190 .unwrap();
2191
2192 adapter
2194 .append_file(Path::new("/workspace/log.txt"), b"line2\n")
2195 .await
2196 .unwrap();
2197
2198 let content = adapter
2200 .read_file(Path::new("/workspace/log.txt"))
2201 .await
2202 .unwrap();
2203 assert_eq!(content, b"line1\nline2\n");
2204 }
2205
2206 #[tokio::test]
2207 async fn test_adapter_symlink_not_supported() {
2208 let session_id = SessionId::new();
2209 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2210 let adapter = SessionFileSystemAdapter::new(session_id, store);
2211
2212 let result = adapter
2213 .symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
2214 .await;
2215 assert!(result.is_err());
2216 assert!(result.unwrap_err().to_string().contains("not supported"));
2217 }
2218
2219 #[tokio::test]
2220 async fn test_adapter_chmod_is_noop() {
2221 let session_id = SessionId::new();
2222 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2223 let adapter = SessionFileSystemAdapter::new(session_id, store);
2224
2225 let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
2227 assert!(result.is_ok());
2228 }
2229
2230 #[tokio::test]
2235 async fn test_bash_max_input_bytes_limit() {
2236 let (context, _) = create_context_with_mock_store();
2237 let tool = BashTool;
2238
2239 let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
2241
2242 let result = tool
2243 .execute_with_context(json!({"commands": large_script}), &context)
2244 .await;
2245
2246 match result {
2248 ToolExecutionResult::ToolError(msg) => {
2249 assert!(
2250 msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
2251 "Expected input size error, got: {}",
2252 msg
2253 );
2254 }
2255 ToolExecutionResult::Success(output) => {
2256 panic!(
2257 "Expected error for oversized script, got success: {:?}",
2258 output
2259 );
2260 }
2261 _ => panic!("Unexpected result type"),
2262 }
2263 }
2264
2265 #[tokio::test]
2266 async fn test_bash_loop_within_limit() {
2267 let (context, _) = create_context_with_mock_store();
2268 let tool = BashTool;
2269
2270 let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
2272
2273 let result = tool
2274 .execute_with_context(json!({"commands": command}), &context)
2275 .await;
2276
2277 if let ToolExecutionResult::Success(output) = result {
2279 assert_eq!(output["exit_code"], 0);
2280 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
2281 } else {
2282 panic!("Expected success for loop within limit: {:?}", result);
2283 }
2284 }
2285
2286 #[tokio::test]
2287 async fn test_bash_function_calls() {
2288 let (context, _) = create_context_with_mock_store();
2289 let tool = BashTool;
2290
2291 let command = r#"
2293 greet() {
2294 echo "Hello, $1!"
2295 }
2296 greet world
2297 "#;
2298
2299 let result = tool
2300 .execute_with_context(json!({"commands": command}), &context)
2301 .await;
2302
2303 if let ToolExecutionResult::Success(output) = result {
2305 assert_eq!(output["exit_code"], 0);
2306 assert!(
2307 output["stdout"]
2308 .as_str()
2309 .unwrap_or("")
2310 .contains("Hello, world!")
2311 );
2312 } else {
2313 panic!("Expected success for function call: {:?}", result);
2314 }
2315 }
2316
2317 #[tokio::test]
2318 async fn test_bash_arithmetic_expressions() {
2319 let (context, _) = create_context_with_mock_store();
2320 let tool = BashTool;
2321
2322 let command = "echo $((1 + 2 * 3))";
2324
2325 let result = tool
2326 .execute_with_context(json!({"commands": command}), &context)
2327 .await;
2328
2329 if let ToolExecutionResult::Success(output) = result {
2331 assert_eq!(output["exit_code"], 0);
2332 assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
2333 } else {
2334 panic!("Expected success for arithmetic expression: {:?}", result);
2335 }
2336 }
2337
2338 #[tokio::test]
2339 async fn test_bash_commands_within_limit() {
2340 let (context, _) = create_context_with_mock_store();
2341 let tool = BashTool;
2342
2343 let command = "for i in $(seq 1 100); do true; done; echo done";
2345
2346 let result = tool
2347 .execute_with_context(json!({"commands": command}), &context)
2348 .await;
2349
2350 if let ToolExecutionResult::Success(output) = result {
2352 assert_eq!(output["exit_code"], 0);
2353 assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
2354 } else {
2355 panic!("Expected success for commands within limit: {:?}", result);
2356 }
2357 }
2358
2359 #[tokio::test]
2364 async fn test_bash_execute_script_by_absolute_path() {
2365 let (context, _) = create_context_with_mock_store();
2366 let tool = BashTool;
2367
2368 let result = tool
2370 .execute_with_context(
2371 json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
2372 &context,
2373 )
2374 .await;
2375 assert!(
2376 matches!(result, ToolExecutionResult::Success(_)),
2377 "Failed to create script: {:?}",
2378 result
2379 );
2380
2381 let result = tool
2383 .execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
2384 .await;
2385
2386 if let ToolExecutionResult::Success(output) = result {
2387 assert_eq!(output["exit_code"], 0);
2388 assert_eq!(output["stdout"], "hello\n");
2389 } else {
2390 panic!("Expected success, got: {:?}", result);
2391 }
2392 }
2393
2394 #[tokio::test]
2395 async fn test_bash_execute_script_with_args() {
2396 let (context, _) = create_context_with_mock_store();
2397 let tool = BashTool;
2398
2399 let result = tool
2401 .execute_with_context(
2402 json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
2403 &context,
2404 )
2405 .await;
2406 assert!(matches!(result, ToolExecutionResult::Success(_)));
2407
2408 let result = tool
2410 .execute_with_context(
2411 json!({"commands": "/workspace/greet.sh world awesome"}),
2412 &context,
2413 )
2414 .await;
2415
2416 if let ToolExecutionResult::Success(output) = result {
2417 assert_eq!(output["exit_code"], 0);
2418 assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
2419 } else {
2420 panic!("Expected success, got: {:?}", result);
2421 }
2422 }
2423
2424 #[tokio::test]
2425 async fn test_bash_execute_script_without_shebang() {
2426 let (context, _) = create_context_with_mock_store();
2427 let tool = BashTool;
2428
2429 let result = tool
2431 .execute_with_context(
2432 json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
2433 &context,
2434 )
2435 .await;
2436 assert!(matches!(result, ToolExecutionResult::Success(_)));
2437
2438 let result = tool
2440 .execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
2441 .await;
2442
2443 if let ToolExecutionResult::Success(output) = result {
2444 assert_eq!(output["exit_code"], 0);
2445 assert_eq!(output["stdout"], "simple\n");
2446 } else {
2447 panic!("Expected success, got: {:?}", result);
2448 }
2449 }
2450
2451 #[tokio::test]
2452 async fn test_bash_execute_nonexistent_script() {
2453 let (context, _) = create_context_with_mock_store();
2454 let tool = BashTool;
2455
2456 let result = tool
2458 .execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
2459 .await;
2460
2461 if let ToolExecutionResult::Success(output) = result {
2462 assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
2463 let stderr = output["stderr"].as_str().unwrap_or("");
2464 assert!(
2465 stderr.contains("No such file") || stderr.contains("not found"),
2466 "Expected file not found error, got stderr: {}",
2467 stderr
2468 );
2469 } else {
2470 panic!(
2471 "Expected success result with error output, got: {:?}",
2472 result
2473 );
2474 }
2475 }
2476
2477 #[tokio::test]
2478 async fn test_bash_execute_script_in_nested_dir() {
2479 let (context, _) = create_context_with_mock_store();
2480 let tool = BashTool;
2481
2482 let setup = tool
2484 .execute_with_context(
2485 json!({"commands": "mkdir -p /workspace/.agents/skills/nav/scripts && cat > /workspace/.agents/skills/nav/scripts/nav.sh << 'EOF'\n#!/bin/bash\necho \"navigating $1\"\nEOF"}),
2486 &context,
2487 )
2488 .await;
2489 assert!(matches!(setup, ToolExecutionResult::Success(_)));
2490
2491 let result = tool
2493 .execute_with_context(
2494 json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
2495 &context,
2496 )
2497 .await;
2498
2499 if let ToolExecutionResult::Success(output) = result {
2500 assert_eq!(output["exit_code"], 0);
2501 assert_eq!(output["stdout"], "navigating dist\n");
2502 } else {
2503 panic!("Expected success, got: {:?}", result);
2504 }
2505 }
2506
2507 #[tokio::test]
2508 async fn test_bash_file_mode_is_executable() {
2509 let (context, _) = create_context_with_mock_store();
2510 let tool = BashTool;
2511
2512 let result = tool
2514 .execute_with_context(
2515 json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
2516 &context,
2517 )
2518 .await;
2519
2520 if let ToolExecutionResult::Success(output) = result {
2521 assert_eq!(output["exit_code"], 0);
2522 assert!(
2523 output["stdout"]
2524 .as_str()
2525 .unwrap_or("")
2526 .contains("executable"),
2527 "File should be reported as executable, got: {}",
2528 output["stdout"]
2529 );
2530 } else {
2531 panic!("Expected success, got: {:?}", result);
2532 }
2533 }
2534
2535 #[tokio::test]
2536 async fn test_bash_execute_script_with_exit_code() {
2537 let (context, _) = create_context_with_mock_store();
2538 let tool = BashTool;
2539
2540 let result = tool
2542 .execute_with_context(
2543 json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
2544 &context,
2545 )
2546 .await;
2547 assert!(matches!(result, ToolExecutionResult::Success(_)));
2548
2549 let result = tool
2551 .execute_with_context(
2552 json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
2553 &context,
2554 )
2555 .await;
2556
2557 if let ToolExecutionResult::Success(output) = result {
2558 let stdout = output["stdout"].as_str().unwrap_or("");
2559 assert!(stdout.contains("failing"), "Script should have run");
2560 assert!(
2561 stdout.contains("code: 42"),
2562 "Exit code should propagate, got: {}",
2563 stdout
2564 );
2565 } else {
2566 panic!("Expected success, got: {:?}", result);
2567 }
2568 }
2569
2570 #[tokio::test]
2575 async fn test_bash_overwrite_existing_file() {
2576 let (context, _) = create_context_with_mock_store();
2577 let tool = BashTool;
2578
2579 let result = tool
2581 .execute_with_context(
2582 json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
2583 &context,
2584 )
2585 .await;
2586 assert!(matches!(result, ToolExecutionResult::Success(_)));
2587
2588 let result = tool
2590 .execute_with_context(
2591 json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
2592 &context,
2593 )
2594 .await;
2595 if let ToolExecutionResult::Success(output) = &result {
2596 assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
2597 } else {
2598 panic!("Expected success on overwrite, got: {:?}", result);
2599 }
2600
2601 let result = tool
2603 .execute_with_context(
2604 json!({"commands": "cat /workspace/overwrite.txt"}),
2605 &context,
2606 )
2607 .await;
2608 if let ToolExecutionResult::Success(output) = result {
2609 assert_eq!(output["stdout"], "second\n");
2610 } else {
2611 panic!("Expected success on read, got: {:?}", result);
2612 }
2613 }
2614
2615 #[tokio::test]
2616 async fn test_bash_append_to_existing_file() {
2617 let (context, _) = create_context_with_mock_store();
2618 let tool = BashTool;
2619
2620 let result = tool
2622 .execute_with_context(
2623 json!({"commands": "echo 'line1' > /workspace/append.txt"}),
2624 &context,
2625 )
2626 .await;
2627 assert!(matches!(result, ToolExecutionResult::Success(_)));
2628
2629 let result = tool
2631 .execute_with_context(
2632 json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
2633 &context,
2634 )
2635 .await;
2636 if let ToolExecutionResult::Success(output) = &result {
2637 assert_eq!(output["exit_code"], 0, "Append should succeed");
2638 } else {
2639 panic!("Expected success on append, got: {:?}", result);
2640 }
2641
2642 let result = tool
2644 .execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
2645 .await;
2646 if let ToolExecutionResult::Success(output) = result {
2647 assert_eq!(output["stdout"], "line1\nline2\n");
2648 } else {
2649 panic!("Expected success on read");
2650 }
2651 }
2652
2653 #[tokio::test]
2654 async fn test_adapter_overwrite_existing_file() {
2655 let session_id = SessionId::new();
2656 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2657 let adapter = SessionFileSystemAdapter::new(session_id, store);
2658
2659 adapter
2661 .write_file(Path::new("/workspace/ow.txt"), b"original")
2662 .await
2663 .unwrap();
2664
2665 adapter
2667 .write_file(Path::new("/workspace/ow.txt"), b"updated")
2668 .await
2669 .unwrap();
2670
2671 let content = adapter
2673 .read_file(Path::new("/workspace/ow.txt"))
2674 .await
2675 .unwrap();
2676 assert_eq!(content, b"updated");
2677 }
2678
2679 #[tokio::test]
2680 async fn test_adapter_append_to_existing_file() {
2681 let session_id = SessionId::new();
2682 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2683 let adapter = SessionFileSystemAdapter::new(session_id, store);
2684
2685 adapter
2687 .write_file(Path::new("/workspace/ap.txt"), b"AAA")
2688 .await
2689 .unwrap();
2690
2691 adapter
2693 .append_file(Path::new("/workspace/ap.txt"), b"BBB")
2694 .await
2695 .unwrap();
2696
2697 let content = adapter
2699 .read_file(Path::new("/workspace/ap.txt"))
2700 .await
2701 .unwrap();
2702 assert_eq!(content, b"AAABBB");
2703 }
2704
2705 #[tokio::test]
2706 async fn test_bash_redirect_creates_parent_dirs() {
2707 let (context, _) = create_context_with_mock_store();
2708 let tool = BashTool;
2709
2710 let result = tool
2712 .execute_with_context(
2713 json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
2714 &context,
2715 )
2716 .await;
2717 if let ToolExecutionResult::Success(output) = &result {
2718 assert_eq!(output["exit_code"], 0, "Nested write should succeed");
2719 } else {
2720 panic!("Expected success, got: {:?}", result);
2721 }
2722
2723 let result = tool
2725 .execute_with_context(
2726 json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
2727 &context,
2728 )
2729 .await;
2730 if let ToolExecutionResult::Success(output) = result {
2731 assert_eq!(output["stdout"], "deep\n");
2732 } else {
2733 panic!("Expected success on read");
2734 }
2735 }
2736
2737 #[test]
2742 fn test_bashkit_tool_description_is_nonempty() {
2743 let desc = BASHKIT_TOOL.description();
2744 assert!(
2745 !desc.is_empty(),
2746 "bashkit tool description should not be empty"
2747 );
2748 assert!(
2750 desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
2751 "description should mention bash or command, got: {}",
2752 desc
2753 );
2754 }
2755
2756 #[test]
2757 fn test_bashkit_tool_system_prompt_is_nonempty() {
2758 let prompt = BASHKIT_TOOL.system_prompt();
2759 assert!(
2760 !prompt.is_empty(),
2761 "bashkit system prompt should not be empty"
2762 );
2763 assert!(
2764 prompt.contains("everruns"),
2765 "system prompt should contain configured identity 'everruns', got: {}",
2766 prompt
2767 );
2768 }
2769
2770 #[test]
2771 fn test_bashkit_static_description_matches_tool() {
2772 let direct_desc = BASHKIT_TOOL.description();
2774 let static_desc: &str = &TOOL_DESCRIPTION;
2775 assert_eq!(static_desc, direct_desc);
2776
2777 let direct_prompt = BASHKIT_TOOL.system_prompt();
2778 let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
2779 assert!(
2781 static_prompt.starts_with(&direct_prompt),
2782 "system prompt should start with bashkit prompt"
2783 );
2784 assert!(
2785 static_prompt.contains("Output economy"),
2786 "system prompt should include output economy hint"
2787 );
2788 }
2789
2790 #[test]
2791 fn test_bashkit_tool_builder_configuration() {
2792 let _desc = BASHKIT_TOOL.description();
2795 let _prompt = BASHKIT_TOOL.system_prompt();
2796 }
2798
2799 #[test]
2800 fn test_bash_tool_display_name() {
2801 let tool = BashTool;
2802 assert_eq!(tool.display_name(), Some("Bash"));
2803 }
2804
2805 #[test]
2806 fn test_bash_tool_parameters_schema_structure() {
2807 let tool = BashTool;
2808 let schema = tool.parameters_schema();
2809
2810 assert_eq!(schema["type"], "object");
2812 assert!(schema["properties"]["commands"].is_object());
2813
2814 assert!(schema["properties"]["working_dir"].is_object());
2816 assert!(schema["properties"]["timeout_ms"].is_object());
2817
2818 let required = schema["required"].as_array().unwrap();
2820 assert!(required.contains(&json!("commands")));
2821 }
2822
2823 #[test]
2824 fn test_execution_limits_configuration() {
2825 let limits = execution_limits();
2826 let _ = limits;
2829 }
2830
2831 #[test]
2836 fn test_adapter_is_search_capable() {
2837 let session_id = SessionId::new();
2838 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2839 let adapter = SessionFileSystemAdapter::new(session_id, store);
2840
2841 let sc = adapter.as_search_capable();
2842 assert!(
2843 sc.is_some(),
2844 "SessionFileSystemAdapter should be SearchCapable"
2845 );
2846
2847 let provider = sc.unwrap().search_provider(Path::new("/workspace"));
2848 assert!(provider.is_some(), "Should return a SearchProvider");
2849
2850 let caps = provider.unwrap().capabilities();
2851 assert!(caps.content_search, "Should support content search");
2852 assert!(caps.regex, "Should support regex patterns");
2853 }
2854
2855 #[tokio::test]
2856 async fn test_search_provider_returns_grep_results() {
2857 let session_id = SessionId::new();
2858 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2859 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2860
2861 adapter
2863 .write_file(
2864 Path::new("/workspace/hello.txt"),
2865 b"hello world\ngoodbye world",
2866 )
2867 .await
2868 .unwrap();
2869 adapter
2870 .write_file(Path::new("/workspace/other.txt"), b"no match here")
2871 .await
2872 .unwrap();
2873
2874 let sc = adapter.as_search_capable().unwrap();
2875 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
2876
2877 let results = provider
2878 .search(&SearchQuery {
2879 pattern: "hello".into(),
2880 is_regex: false,
2881 case_insensitive: false,
2882 root: PathBuf::from("/workspace"),
2883 glob_filter: None,
2884 max_results: None,
2885 })
2886 .unwrap();
2887
2888 assert_eq!(results.matches.len(), 1);
2889 assert_eq!(
2890 results.matches[0].path,
2891 PathBuf::from("/workspace/hello.txt")
2892 );
2893 assert_eq!(results.matches[0].line_number, 1);
2894 assert_eq!(results.matches[0].line_content, "hello world");
2895 }
2896
2897 #[tokio::test]
2898 async fn test_search_provider_truncates_at_max_results() {
2899 let session_id = SessionId::new();
2900 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2901 let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2902
2903 adapter
2904 .write_file(
2905 Path::new("/workspace/many.txt"),
2906 b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
2907 )
2908 .await
2909 .unwrap();
2910
2911 let sc = adapter.as_search_capable().unwrap();
2912 let provider = sc.search_provider(Path::new("/workspace")).unwrap();
2913
2914 let results = provider
2915 .search(&SearchQuery {
2916 pattern: "match".into(),
2917 is_regex: false,
2918 case_insensitive: false,
2919 root: PathBuf::from("/workspace"),
2920 glob_filter: None,
2921 max_results: Some(2),
2922 })
2923 .unwrap();
2924
2925 assert_eq!(results.matches.len(), 2);
2926 assert!(results.truncated);
2927 }
2928
2929 #[tokio::test]
2930 async fn test_bash_grep_uses_indexed_search() {
2931 let (context, _) = create_context_with_mock_store();
2932 let tool = BashTool;
2933
2934 tool.execute_with_context(
2936 json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
2937 &context,
2938 )
2939 .await;
2940
2941 let result = tool
2943 .execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
2944 .await;
2945
2946 if let ToolExecutionResult::Success(output) = result {
2947 assert_eq!(output["exit_code"], 0);
2948 let stdout = output["stdout"].as_str().unwrap_or("");
2949 assert!(
2950 stdout.contains("fn main") || stdout.contains("fn test"),
2951 "grep -r should find matches via indexed search, got: {}",
2952 stdout
2953 );
2954 } else {
2955 panic!("Expected success result, got: {:?}", result);
2956 }
2957 }
2958
2959 #[test]
2960 fn test_parameters_schema_delegates_to_bashkit() {
2961 let tool = BashTool;
2962 let schema = tool.parameters_schema();
2963 let bashkit_schema = BASHKIT_TOOL.input_schema();
2964
2965 let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
2967 let our_props = schema["properties"].as_object().unwrap();
2968 for key in bashkit_props.keys() {
2969 assert!(
2970 our_props.contains_key(key),
2971 "bashkit property '{key}' missing from parameters_schema"
2972 );
2973 }
2974
2975 let bashkit_required = bashkit_schema["required"].as_array().unwrap();
2977 let our_required = schema["required"].as_array().unwrap();
2978 for req in bashkit_required {
2979 assert!(
2980 our_required.contains(req),
2981 "bashkit required field {req} missing from parameters_schema"
2982 );
2983 }
2984
2985 assert!(
2987 our_props.contains_key("working_dir"),
2988 "working_dir must be in parameters_schema"
2989 );
2990 }
2991
2992 #[test]
2997 fn truncate_for_log_returns_short_strings_unchanged() {
2998 assert_eq!(truncate_for_log("hello", 100), "hello");
2999 assert_eq!(truncate_for_log("", 100), "");
3000 }
3001
3002 #[test]
3003 fn truncate_for_log_stays_within_budget_and_marks() {
3004 let input = "a".repeat(500);
3005 let out = truncate_for_log(&input, 100);
3006 assert!(
3007 out.len() <= 100,
3008 "output exceeded budget: {} bytes",
3009 out.len()
3010 );
3011 assert!(out.ends_with("…[truncated]"));
3012 assert!(out.starts_with('a'));
3013 }
3014
3015 #[test]
3016 fn truncate_for_log_respects_utf8_boundaries() {
3017 let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
3020 let out = truncate_for_log(input, 20);
3021 assert!(out.len() <= 20);
3022 assert!(out.starts_with('🦀'));
3023 assert!(out.ends_with("…[truncated]"));
3024 }
3025
3026 #[test]
3027 fn truncate_for_log_omits_marker_when_budget_is_too_small() {
3028 let input = "abcdefghijklmnop";
3031 let out = truncate_for_log(input, 4);
3032 assert_eq!(out, "abcd");
3033 assert!(out.len() <= 4);
3034 }
3035
3036 #[tokio::test]
3037 async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
3038 use bashkit::hooks::{HookAction, ToolResult};
3039 use std::sync::Arc;
3040 use std::sync::atomic::{AtomicU64, Ordering};
3041
3042 let tool_calls = Arc::new(AtomicU64::new(0));
3043 let counter = tool_calls.clone();
3044
3045 let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
3049 let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
3050 Box::new(move |r: ToolResult| {
3051 counter.fetch_add(1, Ordering::Relaxed);
3052 HookAction::Continue(r)
3053 }),
3054 );
3055
3056 let mut bash = builder.build();
3057 let result = bash.exec("echo hook-smoke").await.unwrap();
3058
3059 assert_eq!(result.exit_code, 0);
3060 assert_eq!(result.stdout.trim(), "hook-smoke");
3061 assert!(
3062 tool_calls.load(Ordering::Relaxed) >= 1,
3063 "after_tool hook should fire at least once for `echo`"
3064 );
3065 }
3066}