use super::{Capability, CapabilityStatus, RiskLevel};
use crate::background::{
BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
};
use crate::exec_tool_result::ExecToolResultPayload;
use crate::session_file::SessionFile;
use crate::tool_types::ToolHints;
use crate::tools::{Tool, ToolExecutionResult};
use crate::traits::{SessionFileSystem, ToolContext};
use crate::typed_id::SessionId;
use async_trait::async_trait;
use bashkit::{
Bash, BashBuilder, BashTool as BashkitTool, DirEntry, ExecutionLimits, FileSystem,
FileSystemExt, FileType, Metadata, OutputCallback, SearchCapabilities, SearchCapable,
SearchMatch as BashkitSearchMatch, SearchProvider, SearchQuery, SearchResults,
Tool as BashkitToolTrait, TraceEventKind, TraceMode,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::SystemTime;
fn execution_limits() -> ExecutionLimits {
ExecutionLimits::new()
.max_commands(1000)
.max_loop_iterations(10000)
.max_function_depth(100)
.max_input_bytes(1_000_000) .max_ast_depth(100)
.parser_timeout(std::time::Duration::from_secs(5))
}
static BASHKIT_TOOL: LazyLock<BashkitTool> = LazyLock::new(|| {
BashkitTool::builder()
.username("everruns")
.hostname("everruns")
.limits(execution_limits())
.env("HOME", "/home/agent")
.env("SHELL", "/bin/bash")
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("WORKSPACE", "/workspace")
.build()
});
static TOOL_DESCRIPTION: LazyLock<String> =
LazyLock::new(|| BASHKIT_TOOL.description().to_string());
static TOOL_SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
let mut prompt = BASHKIT_TOOL.system_prompt().to_string();
prompt.push_str(crate::tool_output_sanitizer::EXEC_OUTPUT_HINT);
prompt
});
static TOOL_INPUT_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
let mut schema = BASHKIT_TOOL.input_schema();
if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
if !props.contains_key("working_dir") {
props.insert(
"working_dir".to_string(),
json!({
"type": "string",
"default": "/workspace",
"description": "Working directory for command execution"
}),
);
}
if !props.contains_key("output") {
props.insert(
"output".to_string(),
crate::tool_output_sanitizer::output_verbosity_schema(),
);
}
}
schema
});
pub struct VirtualBashCapability;
impl Capability for VirtualBashCapability {
fn id(&self) -> &str {
"virtual_bash"
}
fn name(&self) -> &str {
"Virtual Bash"
}
fn description(&self) -> &str {
r#"Execute bash commands in an isolated, sandboxed environment.
> [!NOTE]
> Commands run in a virtual environment with no access to the host system.
> The session filesystem is mounted at root, so you can read and write session files.
> [!TIP]
> Use standard Unix commands like `ls`, `cat`, `grep`, `echo`, and shell features
> like pipes, redirections, and command substitution. Built-in commands support
> `<command> --help`, and many also support `<command> --version`."#
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::High
}
fn icon(&self) -> Option<&str> {
Some("terminal")
}
fn category(&self) -> Option<&str> {
Some("Execution")
}
fn system_prompt_addition(&self) -> Option<&str> {
Some(&TOOL_SYSTEM_PROMPT)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(BashTool)]
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["session_file_system"]
}
fn features(&self) -> Vec<&'static str> {
vec!["file_system"]
}
}
pub struct BashTool;
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn display_name(&self) -> Option<&str> {
Some("Bash")
}
fn description(&self) -> &str {
&TOOL_DESCRIPTION
}
fn parameters_schema(&self) -> Value {
TOOL_INPUT_SCHEMA.clone()
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_long_running(true)
.with_open_world(true)
.with_persist_output(true)
.with_supports_background(true)
.with_concurrency_class("session_workspace")
.with_cpu_bound(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"bash requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let command = match arguments.get("commands").and_then(|v| v.as_str()) {
Some(c) => c,
None => {
return ToolExecutionResult::tool_error("Missing required parameter: commands");
}
};
let working_dir = arguments
.get("working_dir")
.and_then(|v| v.as_str())
.unwrap_or("/workspace");
let timeout_ms = arguments
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(30000)
.min(60000);
let output_mode = arguments
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("auto");
let file_store = match &context.file_store {
Some(store) => store.clone(),
None => {
return ToolExecutionResult::tool_error(
"File system not available in this context",
);
}
};
let session_fs = Arc::new(SessionFileSystemAdapter::new(
context.session_id,
file_store,
));
let locale = context.locale.as_deref().unwrap_or("en-US");
let builder = Bash::builder()
.fs(session_fs)
.cwd(working_dir)
.username("everruns")
.hostname("everruns")
.env("HOME", "/home/agent")
.env("SHELL", "/bin/bash")
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("WORKSPACE", "/workspace")
.env("LANG", locale)
.limits(execution_limits())
.max_memory(10 * 1024 * 1024) .trace_mode(TraceMode::Redacted);
let mut bash = install_observability_hooks(builder, context.session_id).build();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
let output_callback: OutputCallback =
Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
});
let emit_context = context.clone();
let emit_task = tokio::spawn(async move {
while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
if !stdout_chunk.is_empty() {
emit_context
.emit_tool_output("bash", &stdout_chunk, "stdout")
.await;
}
if !stderr_chunk.is_empty() {
emit_context
.emit_tool_output("bash", &stderr_chunk, "stderr")
.await;
}
}
});
let cancel_token = bash.cancellation_token();
let exec_start = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
bash.exec_streaming(command, output_callback),
)
.await;
let exec_duration = exec_start.elapsed();
let _ = emit_task.await;
match result {
Ok(Ok(output)) => {
let commands_executed = output
.events
.iter()
.filter(|e| e.kind == TraceEventKind::CommandExit)
.count();
let fs_reads = output
.events
.iter()
.filter(|e| e.kind == TraceEventKind::FileAccess)
.count();
let fs_writes = output
.events
.iter()
.filter(|e| e.kind == TraceEventKind::FileMutation)
.count();
tracing::info!(
tool = "bash",
duration_ms = exec_duration.as_millis() as u64,
exit_code = output.exit_code,
commands_executed,
fs_reads,
fs_writes,
stdout_bytes = output.stdout.len(),
stderr_bytes = output.stderr.len(),
"bashkit execution completed"
);
let payload = ExecToolResultPayload::new(
&output.stdout,
&output.stderr,
output.exit_code,
output_mode,
);
let ExecToolResultPayload {
stdout,
stderr,
exit_code,
success,
truncated,
total_lines,
raw_output,
} = payload;
ToolExecutionResult::success_with_raw_output(
json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": success,
"truncated": truncated,
"total_lines": total_lines,
}),
raw_output,
)
}
Ok(Err(e)) => {
ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
}
Err(_) => {
cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
let partial = collect_partial_output(partial_rx);
if partial.is_empty() {
ToolExecutionResult::tool_error(format!(
"Command timed out after {}ms",
timeout_ms
))
} else {
use crate::tool_output_sanitizer::{
clean_exec_output, output_verbosity_budget, priority_aware_truncate,
resolve_auto_mode,
};
let effective = resolve_auto_mode(output_mode, 1);
let clean = clean_exec_output(&partial);
let truncated = if let Some(budget) = output_verbosity_budget(effective) {
priority_aware_truncate(&clean, budget)
} else {
clean.clone()
};
ToolExecutionResult::tool_error(format!(
"Command timed out after {}ms. Partial output:\n{}",
timeout_ms, truncated
))
}
}
}
}
fn requires_context(&self) -> bool {
true
}
fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
Some(self)
}
}
#[async_trait]
impl BackgroundExecutableTool for BashTool {
async fn execute_background(
&self,
arguments: Value,
context: ToolContext,
sink: Arc<dyn BackgroundEventSink>,
) -> Result<BackgroundOutcome, ToolExecutionResult> {
let command = match arguments.get("commands").and_then(|v| v.as_str()) {
Some(c) => c,
None => {
return Err(ToolExecutionResult::tool_error(
"Missing required parameter: commands",
));
}
};
let working_dir = arguments
.get("working_dir")
.and_then(|v| v.as_str())
.unwrap_or("/workspace");
let timeout_ms = arguments
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(30000)
.min(60000);
let output_mode = arguments
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("auto");
let file_store = match &context.file_store {
Some(store) => store.clone(),
None => {
return Err(ToolExecutionResult::tool_error(
"File system not available in this context",
));
}
};
let session_fs = Arc::new(SessionFileSystemAdapter::new(
context.session_id,
file_store,
));
let locale = context.locale.as_deref().unwrap_or("en-US");
let builder = Bash::builder()
.fs(session_fs)
.cwd(working_dir)
.username("everruns")
.hostname("everruns")
.env("HOME", "/home/agent")
.env("SHELL", "/bin/bash")
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("WORKSPACE", "/workspace")
.env("LANG", locale)
.limits(execution_limits())
.max_memory(10 * 1024 * 1024)
.trace_mode(TraceMode::Redacted);
let mut bash = install_observability_hooks(builder, context.session_id).build();
let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
let sink_for_output = sink.clone();
let dropped_chunks = Arc::new(AtomicUsize::new(0));
let dropped_chunks_for_callback = dropped_chunks.clone();
let output_callback: OutputCallback =
Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
if tx
.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
.is_err()
{
dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
}
let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
});
let emit_task = tokio::spawn(async move {
while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
if !stdout_chunk.is_empty() {
let _ = sink_for_output.output("stdout", &stdout_chunk).await;
}
if !stderr_chunk.is_empty() {
let _ = sink_for_output.output("stderr", &stderr_chunk).await;
}
}
});
let _ = sink.status("Running bash command").await;
let cancel_token = bash.cancellation_token();
let exec_start = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
bash.exec_streaming(command, output_callback),
)
.await;
let exec_duration = exec_start.elapsed();
let _ = emit_task.await;
let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
if dropped_chunks > 0 {
let _ = sink
.output(
"stderr",
&format!(
"[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
),
)
.await;
}
match result {
Ok(Ok(output)) => {
let payload = ExecToolResultPayload::new(
&output.stdout,
&output.stderr,
output.exit_code,
output_mode,
);
let ExecToolResultPayload {
stdout,
stderr,
exit_code,
success,
truncated,
total_lines,
raw_output,
} = payload;
let _ = sink
.progress(BackgroundProgress {
current: Some(exec_duration.as_millis() as u64),
total: None,
unit: Some("ms".to_string()),
label: Some("runtime".to_string()),
})
.await;
Ok(BackgroundOutcome {
summary: format!(
"Bash command exited with code {} after {} ms",
exit_code,
exec_duration.as_millis()
),
result: json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": success,
"truncated": truncated,
"total_lines": total_lines,
}),
raw_output: Some(raw_output),
})
}
Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
"Bash execution error: {}",
e
))),
Err(_) => {
cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
let partial = collect_partial_output(partial_rx);
if partial.is_empty() {
Err(ToolExecutionResult::tool_error(format!(
"Command timed out after {}ms",
timeout_ms
)))
} else {
use crate::tool_output_sanitizer::{
clean_exec_output, output_verbosity_budget, priority_aware_truncate,
resolve_auto_mode,
};
let effective = resolve_auto_mode(output_mode, 1);
let clean = clean_exec_output(&partial);
let truncated = if let Some(budget) = output_verbosity_budget(effective) {
priority_aware_truncate(&clean, budget)
} else {
clean.clone()
};
Err(ToolExecutionResult::tool_error(format!(
"Command timed out after {}ms. Partial output:\n{}",
timeout_ms, truncated
)))
}
}
}
}
}
fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
builder
.before_tool(Box::new(move |ev: ToolEvent| {
tracing::debug!(
target: "bashkit.hook",
capability = "virtual_bash",
session_id = %session_id,
event = "before_tool",
tool = %ev.name,
arg_count = ev.args.len(),
"builtin invoked"
);
HookAction::Continue(ev)
}))
.after_tool(Box::new(move |res: ToolResult| {
tracing::debug!(
target: "bashkit.hook",
capability = "virtual_bash",
session_id = %session_id,
event = "after_tool",
tool = %res.name,
exit_code = res.exit_code,
stdout_bytes = res.stdout.len(),
"builtin completed"
);
HookAction::Continue(res)
}))
.on_error(Box::new(move |ev: ErrorEvent| {
let preview = truncate_for_log(&ev.message, 256);
tracing::warn!(
target: "bashkit.hook",
capability = "virtual_bash",
session_id = %session_id,
event = "on_error",
message = %preview,
"interpreter error"
);
HookAction::Continue(ev)
}))
}
fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
const MARKER: &str = "…[truncated]";
if msg.len() <= max_bytes {
return msg.to_string();
}
let budget = max_bytes.saturating_sub(MARKER.len());
let mut cut = budget.min(msg.len());
while cut > 0 && !msg.is_char_boundary(cut) {
cut -= 1;
}
if max_bytes > MARKER.len() {
format!("{}{}", &msg[..cut], MARKER)
} else {
let mut cut = max_bytes.min(msg.len());
while cut > 0 && !msg.is_char_boundary(cut) {
cut -= 1;
}
msg[..cut].to_string()
}
}
fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
let mut stdout_buf = String::new();
let mut stderr_buf = String::new();
while let Ok((stdout, stderr)) = rx.try_recv() {
stdout_buf.push_str(&stdout);
stderr_buf.push_str(&stderr);
}
let mut partial = stdout_buf;
if !stderr_buf.is_empty() {
if !partial.is_empty() && !partial.ends_with('\n') {
partial.push('\n');
}
partial.push_str("--- stderr ---\n");
partial.push_str(&stderr_buf);
}
partial
}
pub struct SessionFileSystemAdapter {
session_id: SessionId,
store: Arc<dyn SessionFileSystem>,
}
impl SessionFileSystemAdapter {
pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
Self { session_id, store }
}
const WORKSPACE_PREFIX: &'static str = "/workspace";
fn to_session_path(path: &Path) -> Option<String> {
let path_str = path.to_string_lossy();
let abs_path = if path_str.starts_with('/') {
path_str.to_string()
} else {
format!("/{}", path_str)
};
if abs_path == Self::WORKSPACE_PREFIX {
Some("/".to_string())
} else if let Some(stripped) = abs_path.strip_prefix(Self::WORKSPACE_PREFIX) {
if stripped.starts_with('/') {
Some(stripped.to_string())
} else {
None
}
} else {
None
}
}
}
#[async_trait]
impl FileSystemExt for SessionFileSystemAdapter {}
#[async_trait]
impl FileSystem for SessionFileSystemAdapter {
async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not in workspace: {}", path.display()),
))
})?;
match self.store.read_file(self.session_id, &session_path).await {
Ok(Some(file)) => {
let content = file.content.unwrap_or_default();
SessionFile::decode_content(&content, &file.encoding)
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
}
Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {}", path.display()),
))),
Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
}
}
async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("Cannot write outside workspace: {}", path.display()),
))
})?;
let (encoded, encoding) = SessionFile::encode_content(content);
self.store
.write_file(self.session_id, &session_path, &encoded, &encoding)
.await
.map(|_| ())
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
}
async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("Cannot write outside workspace: {}", path.display()),
))
})?;
let mut existing = match self.store.read_file(self.session_id, &session_path).await {
Ok(Some(file)) => {
let content = file.content.unwrap_or_default();
SessionFile::decode_content(&content, &file.encoding)
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
}
Ok(None) => Vec::new(),
Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
};
existing.extend_from_slice(content);
let (encoded, encoding) = SessionFile::encode_content(&existing);
self.store
.write_file(self.session_id, &session_path, &encoded, &encoding)
.await
.map(|_| ())
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
}
async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"Cannot create directory outside workspace: {}",
path.display()
),
))
})?;
self.store
.create_directory(self.session_id, &session_path)
.await
.map(|_| ())
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
}
async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("Cannot delete outside workspace: {}", path.display()),
))
})?;
self.store
.delete_file(self.session_id, &session_path, recursive)
.await
.map(|_| ())
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
}
async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
if path.to_string_lossy() == "/workspace" {
let now = SystemTime::now();
return Ok(Metadata {
file_type: FileType::Directory,
size: 0,
mode: 0o755,
modified: now,
created: now,
});
}
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not in workspace: {}", path.display()),
))
})?;
match self.store.read_file(self.session_id, &session_path).await {
Ok(Some(file)) => {
let now = SystemTime::now();
let file_type = if file.is_directory {
FileType::Directory
} else {
FileType::File
};
Ok(Metadata {
file_type,
size: file.size_bytes as u64,
mode: 0o755,
modified: now,
created: now,
})
}
Ok(None) => {
match self
.store
.list_directory(self.session_id, &session_path)
.await
{
Ok(_entries) => {
let now = SystemTime::now();
Ok(Metadata {
file_type: FileType::Directory,
size: 0,
mode: 0o755,
modified: now,
created: now,
})
}
Err(_) => Err(bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not found: {}", path.display()),
))),
}
}
Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
}
}
async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
let session_path = Self::to_session_path(path).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not in workspace: {}", path.display()),
))
})?;
let entries = self
.store
.list_directory(self.session_id, &session_path)
.await
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
let now = SystemTime::now();
Ok(entries
.into_iter()
.map(|e| {
let file_type = if e.is_directory {
FileType::Directory
} else {
FileType::File
};
DirEntry {
name: e.name,
metadata: Metadata {
file_type,
size: e.size_bytes as u64,
mode: 0o755,
modified: now,
created: now,
},
}
})
.collect())
}
async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
if path.to_string_lossy() == "/workspace" {
return Ok(true);
}
let session_path = match Self::to_session_path(path) {
Some(p) => p,
None => return Ok(false), };
if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
return Ok(true);
}
if self
.store
.list_directory(self.session_id, &session_path)
.await
.is_ok()
{
return Ok(true);
}
Ok(false)
}
async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
let from_session = Self::to_session_path(from).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Source not in workspace: {}", from.display()),
))
})?;
let content = self.read_file(from).await?;
self.write_file(to, &content).await?;
self.store
.delete_file(self.session_id, &from_session, false)
.await
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
Ok(())
}
async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
let content = self.read_file(from).await?;
self.write_file(to, &content).await
}
async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
Err(bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Symlinks not supported in session filesystem",
)))
}
async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
Err(bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!("Symlinks not supported: {}", path.display()),
)))
}
async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
Ok(())
}
fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
Some(self)
}
}
impl SearchCapable for SessionFileSystemAdapter {
fn search_provider(&self, path: &Path) -> Option<Box<dyn SearchProvider>> {
Self::to_session_path(path)?;
Some(Box::new(SessionSearchProvider {
session_id: self.session_id,
store: self.store.clone(),
}))
}
}
struct SessionSearchProvider {
session_id: SessionId,
store: Arc<dyn SessionFileSystem>,
}
impl SearchProvider for SessionSearchProvider {
fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
let session_id = self.session_id;
let store = self.store.clone();
let root = query.root.to_string_lossy().into_owned();
let max_results = query.max_results;
let pattern = if query.case_insensitive {
format!("(?i){}", query.pattern)
} else {
query.pattern.clone()
};
let session_root =
SessionFileSystemAdapter::to_session_path(Path::new(&root)).ok_or_else(|| {
bashkit::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not in workspace: {}", root),
))
})?;
let path_pattern = if session_root == "/" {
None
} else {
Some(session_root)
};
let matches = std::thread::scope(|s| {
s.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
rt.block_on(async {
store
.grep_files(session_id, &pattern, path_pattern.as_deref())
.await
})
.map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
})
.join()
.unwrap_or_else(|_| {
Err(bashkit::Error::Io(std::io::Error::other(
"search thread panicked",
)))
})
})?;
let truncated = max_results.is_some_and(|max| matches.len() > max);
let matches: Vec<BashkitSearchMatch> = matches
.into_iter()
.take(max_results.unwrap_or(usize::MAX))
.map(|m| {
let vfs_path = format!("{}{}", SessionFileSystemAdapter::WORKSPACE_PREFIX, m.path);
BashkitSearchMatch {
path: PathBuf::from(vfs_path),
line_number: m.line_number,
line_content: m.line,
}
})
.collect();
Ok(SearchResults { matches, truncated })
}
fn capabilities(&self) -> SearchCapabilities {
SearchCapabilities {
regex: true,
glob_filter: false,
content_search: true,
filename_search: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_file::FileInfo;
use crate::traits::SessionFileSystem;
use crate::typed_id::SessionId;
use crate::{FileStat, GrepMatch, Result};
use std::collections::HashMap;
use std::sync::Mutex;
struct MockFileStore {
files: Mutex<HashMap<(SessionId, String), (String, String)>>, directories: Mutex<HashMap<(SessionId, String), bool>>,
}
impl MockFileStore {
fn new() -> Self {
Self {
files: Mutex::new(HashMap::new()),
directories: Mutex::new(HashMap::new()),
}
}
fn normalize_path(path: &str) -> String {
let mut normalized = path.trim().to_string();
if !normalized.starts_with('/') {
normalized = format!("/{}", normalized);
}
if normalized.len() > 1 && normalized.ends_with('/') {
normalized.pop();
}
normalized
}
}
#[async_trait]
impl SessionFileSystem for MockFileStore {
async fn read_file(
&self,
session_id: SessionId,
path: &str,
) -> Result<Option<SessionFile>> {
let path = Self::normalize_path(path);
let files = self.files.lock().unwrap();
if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
Ok(Some(SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.into(),
path: path.clone(),
name: path.split('/').next_back().unwrap_or("").to_string(),
is_directory: false,
is_readonly: false,
content: Some(content.clone()),
encoding: encoding.clone(),
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}))
} else {
Ok(None)
}
}
async fn write_file(
&self,
session_id: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
let path = Self::normalize_path(path);
let mut files = self.files.lock().unwrap();
files.insert(
(session_id, path.clone()),
(content.to_string(), encoding.to_string()),
);
Ok(SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.into(),
path: path.clone(),
name: path.split('/').next_back().unwrap_or("").to_string(),
is_directory: false,
is_readonly: false,
content: Some(content.to_string()),
encoding: encoding.to_string(),
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
async fn delete_file(
&self,
session_id: SessionId,
path: &str,
_recursive: bool,
) -> Result<bool> {
let path = Self::normalize_path(path);
let mut files = self.files.lock().unwrap();
Ok(files.remove(&(session_id, path)).is_some())
}
async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
let path = Self::normalize_path(path);
let files = self.files.lock().unwrap();
let dirs = self.directories.lock().unwrap();
let mut entries = Vec::new();
let is_root = path == "/";
for ((sid, file_path), (content, _)) in files.iter() {
if *sid != session_id {
continue;
}
let parent = if let Some(idx) = file_path.rfind('/') {
if idx == 0 {
"/".to_string()
} else {
file_path[..idx].to_string()
}
} else {
"/".to_string()
};
if parent == path {
entries.push(FileInfo {
id: uuid::Uuid::new_v4(),
session_id: session_id.into(),
path: file_path.clone(),
name: file_path.split('/').next_back().unwrap_or("").to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
});
}
}
if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
let has_children = files
.keys()
.any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
if !has_children {
return Err(anyhow::anyhow!("Directory not found: {}", path).into());
}
}
Ok(entries)
}
async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
let path = Self::normalize_path(path);
let files = self.files.lock().unwrap();
if let Some((content, _)) = files.get(&(session_id, path.clone())) {
Ok(Some(FileStat {
path: path.clone(),
name: path.split('/').next_back().unwrap_or("").to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}))
} else {
Ok(None)
}
}
async fn grep_files(
&self,
session_id: SessionId,
pattern: &str,
path_pattern: Option<&str>,
) -> Result<Vec<GrepMatch>> {
let regex = regex::Regex::new(pattern)
.map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
let files = self.files.lock().unwrap();
let mut matches = Vec::new();
for ((sid, file_path), (content, _)) in files.iter() {
if *sid != session_id {
continue;
}
if let Some(pp) = path_pattern
&& !file_path.starts_with(pp)
{
continue;
}
let decoded = SessionFile::decode_content(content, "utf-8")
.unwrap_or_else(|_| content.as_bytes().to_vec());
let text = String::from_utf8_lossy(&decoded);
for (i, line) in text.lines().enumerate() {
if regex.is_match(line) {
matches.push(GrepMatch {
path: file_path.clone(),
line_number: i + 1,
line: line.to_string(),
});
}
}
}
matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
Ok(matches)
}
async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
let path = Self::normalize_path(path);
let mut dirs = self.directories.lock().unwrap();
dirs.insert((session_id, path.clone()), true);
Ok(FileInfo {
id: uuid::Uuid::new_v4(),
session_id: session_id.into(),
path: path.clone(),
name: path.split('/').next_back().unwrap_or("").to_string(),
is_directory: true,
is_readonly: false,
size_bytes: 0,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
}
#[test]
fn test_capability_metadata() {
let cap = VirtualBashCapability;
assert_eq!(cap.id(), "virtual_bash");
assert_eq!(cap.name(), "Virtual Bash");
assert_eq!(cap.status(), CapabilityStatus::Available);
assert_eq!(cap.risk_level(), RiskLevel::High);
assert_eq!(cap.icon(), Some("terminal"));
assert_eq!(cap.category(), Some("Execution"));
let description = cap.description();
assert!(
description.contains("`<command> --help`"),
"description should advertise built-in help, got: {}",
description
);
assert!(
description.contains("`<command> --version`"),
"description should advertise built-in version support, got: {}",
description
);
}
#[test]
fn test_capability_has_tools() {
let cap = VirtualBashCapability;
let tools = cap.tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name(), "bash");
}
#[test]
fn test_capability_has_system_prompt() {
let cap = VirtualBashCapability;
let prompt = cap.system_prompt_addition().unwrap();
assert!(!prompt.is_empty(), "System prompt should not be empty");
assert!(
prompt.contains("everruns"),
"System prompt should contain configured identity"
);
}
#[test]
fn test_capability_has_dependencies() {
let cap = VirtualBashCapability;
let deps = cap.dependencies();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0], "session_file_system");
}
#[test]
fn test_tool_requires_context() {
assert!(BashTool.requires_context());
}
#[test]
fn test_to_session_path_workspace_root() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace"));
assert_eq!(result, Some("/".to_string()));
}
#[test]
fn test_to_session_path_workspace_file() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspace/file.txt"));
assert_eq!(result, Some("/file.txt".to_string()));
}
#[test]
fn test_to_session_path_workspace_nested() {
let result =
SessionFileSystemAdapter::to_session_path(Path::new("/workspace/dir/subdir/file.txt"));
assert_eq!(result, Some("/dir/subdir/file.txt".to_string()));
}
#[test]
fn test_to_session_path_outside_workspace() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("/tmp/file.txt"));
assert_eq!(result, None);
}
#[test]
fn test_to_session_path_home_outside_workspace() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("/home/agent/file.txt"));
assert_eq!(result, None);
}
#[test]
fn test_to_session_path_workspacefoo_invalid() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("/workspacefoo"));
assert_eq!(result, None);
}
#[test]
fn test_to_session_path_relative_path() {
let result = SessionFileSystemAdapter::to_session_path(Path::new("workspace/file.txt"));
assert_eq!(result, Some("/file.txt".to_string()));
}
#[tokio::test]
async fn test_bash_without_context() {
let tool = BashTool;
let result = tool.execute(json!({"commands": "echo hello"})).await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("requires context"));
} else {
panic!("Expected tool error");
}
}
#[tokio::test]
async fn test_bash_missing_command() {
let tool = BashTool;
let context = ToolContext::new(SessionId::new());
let result = tool.execute_with_context(json!({}), &context).await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("Missing required parameter"));
} else {
panic!("Expected tool error for missing command");
}
}
#[tokio::test]
async fn test_bash_no_file_store() {
let tool = BashTool;
let context = ToolContext::new(SessionId::new());
let result = tool
.execute_with_context(json!({"commands": "echo hello"}), &context)
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("not available"));
} else {
panic!("Expected tool error for missing file store");
}
}
fn create_context_with_mock_store() -> (ToolContext, SessionId) {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let mut context = ToolContext::new(session_id);
context.file_store = Some(store);
(context, session_id)
}
#[tokio::test]
async fn test_bash_echo_command() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo hello world"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "hello world\n");
assert_eq!(output["exit_code"], 0);
assert_eq!(output["success"], true);
} else {
panic!("Expected success result, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_pwd_default_workspace() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "pwd"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "/workspace\n");
assert_eq!(output["exit_code"], 0);
} else {
panic!("Expected success result, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_env_variables() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo $HOME"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "/home/agent\n");
} else {
panic!("Expected success");
}
let result = tool
.execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "/workspace\n");
} else {
panic!("Expected success");
}
let result = tool
.execute_with_context(json!({"commands": "echo $USER"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "everruns\n");
} else {
panic!("Expected success");
}
}
#[tokio::test]
async fn test_bash_lang_env_default() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo $LANG"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "en-US\n");
} else {
panic!("Expected success");
}
}
#[tokio::test]
async fn test_bash_lang_env_from_context_locale() {
let (mut context, _) = create_context_with_mock_store();
context.locale = Some("uk-UA".to_string());
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo $LANG"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "uk-UA\n");
} else {
panic!("Expected success");
}
}
#[tokio::test]
async fn test_bash_write_and_read_file() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "echo 'test content' > /workspace/test.txt"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "test content\n");
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_bash_pipe_command() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo hello | cat"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "hello\n");
assert_eq!(output["exit_code"], 0);
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_bash_arithmetic() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "14\n");
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_bash_command_substitution() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "nested\n");
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_bash_write_outside_workspace_fails() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "echo 'hack' > /tmp/evil.txt"}), &context)
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(
msg.contains("outside workspace") || msg.contains("Permission"),
"Expected workspace error, got: {}",
msg
);
} else if let ToolExecutionResult::Success(output) = result {
let read_result = tool
.execute_with_context(json!({"commands": "cat /tmp/evil.txt"}), &context)
.await;
assert!(
matches!(read_result, ToolExecutionResult::ToolError(_))
|| matches!(&read_result, ToolExecutionResult::Success(o) if o["exit_code"] != 0),
"File should not exist outside workspace"
);
assert!(
output["stderr"]
.as_str()
.unwrap_or("")
.contains("Permission")
|| output["stderr"]
.as_str()
.unwrap_or("")
.contains("workspace")
|| output["exit_code"] != 0,
"Write outside workspace should fail or be blocked"
);
} else {
}
}
#[tokio::test]
async fn test_bash_read_outside_workspace_fails() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(
msg.contains("workspace") || msg.contains("not found"),
"Expected workspace error, got: {}",
msg
);
}
ToolExecutionResult::Success(output) => {
assert_ne!(
output["exit_code"], 0,
"Reading /etc/passwd should fail with non-zero exit"
);
}
_ => panic!("Unexpected result type"),
}
}
#[tokio::test]
async fn test_bash_mkdir_outside_workspace_fails() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "mkdir /tmp/evil_dir"}), &context)
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(
msg.contains("workspace") || msg.contains("Permission"),
"Got: {}",
msg
);
}
ToolExecutionResult::Success(output) => {
assert!(
output["exit_code"] != 0
|| output["stderr"]
.as_str()
.unwrap_or("")
.contains("Permission"),
"mkdir outside workspace should fail"
);
}
_ => {}
}
}
#[tokio::test]
async fn test_bash_custom_working_dir() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({
"commands": "pwd",
"working_dir": "/workspace/mydir"
}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "/workspace/mydir\n");
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_bash_false_command_exit_code() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "false"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 1);
assert_eq!(output["success"], false);
} else {
panic!("Expected success result with non-zero exit code");
}
}
#[tokio::test]
async fn test_bash_true_command_exit_code() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "true"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["success"], true);
} else {
panic!("Expected success result");
}
}
#[tokio::test]
async fn test_adapter_read_write_workspace_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/test.txt"), b"hello")
.await
.unwrap();
let content = adapter
.read_file(Path::new("/workspace/test.txt"))
.await
.unwrap();
assert_eq!(content, b"hello");
}
#[tokio::test]
async fn test_adapter_read_outside_workspace_fails() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("workspace"));
}
#[tokio::test]
async fn test_adapter_write_outside_workspace_fails() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let result = adapter
.write_file(Path::new("/tmp/file.txt"), b"data")
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("workspace"));
}
#[tokio::test]
async fn test_adapter_stat_workspace_root() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
assert!(stat.file_type.is_dir());
}
#[tokio::test]
async fn test_adapter_stat_directory_returns_dir_type() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.mkdir(Path::new("/workspace/mydir"), false)
.await
.unwrap();
let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
assert!(
stat.file_type.is_dir(),
"Expected directory but got file type for /workspace/mydir"
);
}
#[tokio::test]
async fn test_adapter_stat_file_returns_file_type() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/test.txt"), b"hello")
.await
.unwrap();
let stat = adapter
.stat(Path::new("/workspace/test.txt"))
.await
.unwrap();
assert!(
stat.file_type.is_file(),
"Expected file but got directory type for /workspace/test.txt"
);
assert_eq!(stat.size, 5);
}
#[tokio::test]
async fn test_adapter_exists_workspace() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
}
#[tokio::test]
async fn test_adapter_mkdir_and_list() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
adapter
.mkdir(Path::new("/workspace/mydir"), false)
.await
.unwrap();
adapter
.write_file(Path::new("/workspace/mydir/file.txt"), b"content")
.await
.unwrap();
let entries = adapter
.read_dir(Path::new("/workspace/mydir"))
.await
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "file.txt");
}
#[tokio::test]
async fn test_adapter_rename_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/old.txt"), b"data")
.await
.unwrap();
adapter
.rename(
Path::new("/workspace/old.txt"),
Path::new("/workspace/new.txt"),
)
.await
.unwrap();
let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
assert!(old_result.is_err());
let new_content = adapter
.read_file(Path::new("/workspace/new.txt"))
.await
.unwrap();
assert_eq!(new_content, b"data");
}
#[tokio::test]
async fn test_adapter_copy_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/source.txt"), b"copy me")
.await
.unwrap();
adapter
.copy(
Path::new("/workspace/source.txt"),
Path::new("/workspace/dest.txt"),
)
.await
.unwrap();
let source = adapter
.read_file(Path::new("/workspace/source.txt"))
.await
.unwrap();
let dest = adapter
.read_file(Path::new("/workspace/dest.txt"))
.await
.unwrap();
assert_eq!(source, dest);
assert_eq!(source, b"copy me");
}
#[tokio::test]
async fn test_adapter_append_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/log.txt"), b"line1\n")
.await
.unwrap();
adapter
.append_file(Path::new("/workspace/log.txt"), b"line2\n")
.await
.unwrap();
let content = adapter
.read_file(Path::new("/workspace/log.txt"))
.await
.unwrap();
assert_eq!(content, b"line1\nline2\n");
}
#[tokio::test]
async fn test_adapter_symlink_not_supported() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let result = adapter
.symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not supported"));
}
#[tokio::test]
async fn test_adapter_chmod_is_noop() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_bash_max_input_bytes_limit() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
let result = tool
.execute_with_context(json!({"commands": large_script}), &context)
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(
msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
"Expected input size error, got: {}",
msg
);
}
ToolExecutionResult::Success(output) => {
panic!(
"Expected error for oversized script, got success: {:?}",
output
);
}
_ => panic!("Unexpected result type"),
}
}
#[tokio::test]
async fn test_bash_loop_within_limit() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
let result = tool
.execute_with_context(json!({"commands": command}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
} else {
panic!("Expected success for loop within limit: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_function_calls() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let command = r#"
greet() {
echo "Hello, $1!"
}
greet world
"#;
let result = tool
.execute_with_context(json!({"commands": command}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert!(
output["stdout"]
.as_str()
.unwrap_or("")
.contains("Hello, world!")
);
} else {
panic!("Expected success for function call: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_arithmetic_expressions() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let command = "echo $((1 + 2 * 3))";
let result = tool
.execute_with_context(json!({"commands": command}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
} else {
panic!("Expected success for arithmetic expression: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_commands_within_limit() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let command = "for i in $(seq 1 100); do true; done; echo done";
let result = tool
.execute_with_context(json!({"commands": command}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
} else {
panic!("Expected success for commands within limit: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_execute_script_by_absolute_path() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
&context,
)
.await;
assert!(
matches!(result, ToolExecutionResult::Success(_)),
"Failed to create script: {:?}",
result
);
let result = tool
.execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"], "hello\n");
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_execute_script_with_args() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({"commands": "/workspace/greet.sh world awesome"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_execute_script_without_shebang() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"], "simple\n");
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_execute_nonexistent_script() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
let stderr = output["stderr"].as_str().unwrap_or("");
assert!(
stderr.contains("No such file") || stderr.contains("not found"),
"Expected file not found error, got stderr: {}",
stderr
);
} else {
panic!(
"Expected success result with error output, got: {:?}",
result
);
}
}
#[tokio::test]
async fn test_bash_execute_script_in_nested_dir() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let setup = tool
.execute_with_context(
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"}),
&context,
)
.await;
assert!(matches!(setup, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert_eq!(output["stdout"], "navigating dist\n");
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_file_mode_is_executable() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
assert!(
output["stdout"]
.as_str()
.unwrap_or("")
.contains("executable"),
"File should be reported as executable, got: {}",
output["stdout"]
);
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_execute_script_with_exit_code() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
let stdout = output["stdout"].as_str().unwrap_or("");
assert!(stdout.contains("failing"), "Script should have run");
assert!(
stdout.contains("code: 42"),
"Exit code should propagate, got: {}",
stdout
);
} else {
panic!("Expected success, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_overwrite_existing_file() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = &result {
assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
} else {
panic!("Expected success on overwrite, got: {:?}", result);
}
let result = tool
.execute_with_context(
json!({"commands": "cat /workspace/overwrite.txt"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "second\n");
} else {
panic!("Expected success on read, got: {:?}", result);
}
}
#[tokio::test]
async fn test_bash_append_to_existing_file() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "echo 'line1' > /workspace/append.txt"}),
&context,
)
.await;
assert!(matches!(result, ToolExecutionResult::Success(_)));
let result = tool
.execute_with_context(
json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = &result {
assert_eq!(output["exit_code"], 0, "Append should succeed");
} else {
panic!("Expected success on append, got: {:?}", result);
}
let result = tool
.execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "line1\nline2\n");
} else {
panic!("Expected success on read");
}
}
#[tokio::test]
async fn test_adapter_overwrite_existing_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/ow.txt"), b"original")
.await
.unwrap();
adapter
.write_file(Path::new("/workspace/ow.txt"), b"updated")
.await
.unwrap();
let content = adapter
.read_file(Path::new("/workspace/ow.txt"))
.await
.unwrap();
assert_eq!(content, b"updated");
}
#[tokio::test]
async fn test_adapter_append_to_existing_file() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
adapter
.write_file(Path::new("/workspace/ap.txt"), b"AAA")
.await
.unwrap();
adapter
.append_file(Path::new("/workspace/ap.txt"), b"BBB")
.await
.unwrap();
let content = adapter
.read_file(Path::new("/workspace/ap.txt"))
.await
.unwrap();
assert_eq!(content, b"AAABBB");
}
#[tokio::test]
async fn test_bash_redirect_creates_parent_dirs() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
let result = tool
.execute_with_context(
json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = &result {
assert_eq!(output["exit_code"], 0, "Nested write should succeed");
} else {
panic!("Expected success, got: {:?}", result);
}
let result = tool
.execute_with_context(
json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
&context,
)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["stdout"], "deep\n");
} else {
panic!("Expected success on read");
}
}
#[test]
fn test_bashkit_tool_description_is_nonempty() {
let desc = BASHKIT_TOOL.description();
assert!(
!desc.is_empty(),
"bashkit tool description should not be empty"
);
assert!(
desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
"description should mention bash or command, got: {}",
desc
);
}
#[test]
fn test_bashkit_tool_system_prompt_is_nonempty() {
let prompt = BASHKIT_TOOL.system_prompt();
assert!(
!prompt.is_empty(),
"bashkit system prompt should not be empty"
);
assert!(
prompt.contains("everruns"),
"system prompt should contain configured identity 'everruns', got: {}",
prompt
);
}
#[test]
fn test_bashkit_static_description_matches_tool() {
let direct_desc = BASHKIT_TOOL.description();
let static_desc: &str = &TOOL_DESCRIPTION;
assert_eq!(static_desc, direct_desc);
let direct_prompt = BASHKIT_TOOL.system_prompt();
let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
assert!(
static_prompt.starts_with(&direct_prompt),
"system prompt should start with bashkit prompt"
);
assert!(
static_prompt.contains("Output economy"),
"system prompt should include output economy hint"
);
}
#[test]
fn test_bashkit_tool_builder_configuration() {
let _desc = BASHKIT_TOOL.description();
let _prompt = BASHKIT_TOOL.system_prompt();
}
#[test]
fn test_bash_tool_display_name() {
let tool = BashTool;
assert_eq!(tool.display_name(), Some("Bash"));
}
#[test]
fn test_bash_tool_parameters_schema_structure() {
let tool = BashTool;
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["commands"].is_object());
assert!(schema["properties"]["working_dir"].is_object());
assert!(schema["properties"]["timeout_ms"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("commands")));
}
#[test]
fn test_execution_limits_configuration() {
let limits = execution_limits();
let _ = limits;
}
#[test]
fn test_adapter_is_search_capable() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store);
let sc = adapter.as_search_capable();
assert!(
sc.is_some(),
"SessionFileSystemAdapter should be SearchCapable"
);
let provider = sc.unwrap().search_provider(Path::new("/workspace"));
assert!(provider.is_some(), "Should return a SearchProvider");
let caps = provider.unwrap().capabilities();
assert!(caps.content_search, "Should support content search");
assert!(caps.regex, "Should support regex patterns");
}
#[tokio::test]
async fn test_search_provider_returns_grep_results() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
adapter
.write_file(
Path::new("/workspace/hello.txt"),
b"hello world\ngoodbye world",
)
.await
.unwrap();
adapter
.write_file(Path::new("/workspace/other.txt"), b"no match here")
.await
.unwrap();
let sc = adapter.as_search_capable().unwrap();
let provider = sc.search_provider(Path::new("/workspace")).unwrap();
let results = provider
.search(&SearchQuery {
pattern: "hello".into(),
is_regex: false,
case_insensitive: false,
root: PathBuf::from("/workspace"),
glob_filter: None,
max_results: None,
})
.unwrap();
assert_eq!(results.matches.len(), 1);
assert_eq!(
results.matches[0].path,
PathBuf::from("/workspace/hello.txt")
);
assert_eq!(results.matches[0].line_number, 1);
assert_eq!(results.matches[0].line_content, "hello world");
}
#[tokio::test]
async fn test_search_provider_truncates_at_max_results() {
let session_id = SessionId::new();
let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
adapter
.write_file(
Path::new("/workspace/many.txt"),
b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
)
.await
.unwrap();
let sc = adapter.as_search_capable().unwrap();
let provider = sc.search_provider(Path::new("/workspace")).unwrap();
let results = provider
.search(&SearchQuery {
pattern: "match".into(),
is_regex: false,
case_insensitive: false,
root: PathBuf::from("/workspace"),
glob_filter: None,
max_results: Some(2),
})
.unwrap();
assert_eq!(results.matches.len(), 2);
assert!(results.truncated);
}
#[tokio::test]
async fn test_bash_grep_uses_indexed_search() {
let (context, _) = create_context_with_mock_store();
let tool = BashTool;
tool.execute_with_context(
json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
&context,
)
.await;
let result = tool
.execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
.await;
if let ToolExecutionResult::Success(output) = result {
assert_eq!(output["exit_code"], 0);
let stdout = output["stdout"].as_str().unwrap_or("");
assert!(
stdout.contains("fn main") || stdout.contains("fn test"),
"grep -r should find matches via indexed search, got: {}",
stdout
);
} else {
panic!("Expected success result, got: {:?}", result);
}
}
#[test]
fn test_parameters_schema_delegates_to_bashkit() {
let tool = BashTool;
let schema = tool.parameters_schema();
let bashkit_schema = BASHKIT_TOOL.input_schema();
let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
let our_props = schema["properties"].as_object().unwrap();
for key in bashkit_props.keys() {
assert!(
our_props.contains_key(key),
"bashkit property '{key}' missing from parameters_schema"
);
}
let bashkit_required = bashkit_schema["required"].as_array().unwrap();
let our_required = schema["required"].as_array().unwrap();
for req in bashkit_required {
assert!(
our_required.contains(req),
"bashkit required field {req} missing from parameters_schema"
);
}
assert!(
our_props.contains_key("working_dir"),
"working_dir must be in parameters_schema"
);
}
#[test]
fn truncate_for_log_returns_short_strings_unchanged() {
assert_eq!(truncate_for_log("hello", 100), "hello");
assert_eq!(truncate_for_log("", 100), "");
}
#[test]
fn truncate_for_log_stays_within_budget_and_marks() {
let input = "a".repeat(500);
let out = truncate_for_log(&input, 100);
assert!(
out.len() <= 100,
"output exceeded budget: {} bytes",
out.len()
);
assert!(out.ends_with("…[truncated]"));
assert!(out.starts_with('a'));
}
#[test]
fn truncate_for_log_respects_utf8_boundaries() {
let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
let out = truncate_for_log(input, 20);
assert!(out.len() <= 20);
assert!(out.starts_with('🦀'));
assert!(out.ends_with("…[truncated]"));
}
#[test]
fn truncate_for_log_omits_marker_when_budget_is_too_small() {
let input = "abcdefghijklmnop";
let out = truncate_for_log(input, 4);
assert_eq!(out, "abcd");
assert!(out.len() <= 4);
}
#[tokio::test]
async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
use bashkit::hooks::{HookAction, ToolResult};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
let tool_calls = Arc::new(AtomicU64::new(0));
let counter = tool_calls.clone();
let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
Box::new(move |r: ToolResult| {
counter.fetch_add(1, Ordering::Relaxed);
HookAction::Continue(r)
}),
);
let mut bash = builder.build();
let result = bash.exec("echo hook-smoke").await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "hook-smoke");
assert!(
tool_calls.load(Ordering::Relaxed) >= 1,
"after_tool hook should fire at least once for `echo`"
);
}
}