feat(tools): implement Bash tool with persistent sessions and timeouts (M4 complete)
This commit implements the complete M4 milestone (Bash tool) including: Bash Session: - Persistent bash session using tokio::process - Environment variables persist between commands - Current working directory persists between commands - Session-based execution (not one-off commands) - Automatic cleanup on session close Key Features: - Command timeout support (default: 2 minutes, configurable per-command) - Output truncation (max 2000 lines for stdout/stderr) - Exit code capture and propagation - Stderr capture alongside stdout - Command delimiter system to reliably detect command completion - Automatic backup of exit codes to temp files Implementation Details: - Uses tokio::process for async command execution - BashSession maintains single bash process across multiple commands - stdio handles (stdin/stdout/stderr) are taken and restored for each command - Non-blocking stderr reading with timeout to avoid deadlocks - Mutex protection for concurrent access safety CLI Integration: - Added `bash` subcommand: `owlen bash <command> [--timeout <ms>]` - Permission checks with command context for pattern matching - Stdout/stderr properly routed to respective streams - Exit code propagation (exits with same code as bash command) Permission Enforcement: - Plan mode (default): blocks Bash (asks for approval) - Code mode: allows Bash - Pattern matching support for command-specific rules (e.g., "npm test*") Testing: - 7 tests in tools-bash for session behavior - bash_persists_env_between_calls ✅ - bash_persists_cwd_between_calls ✅ - bash_command_timeout ✅ - bash_output_truncation ✅ - bash_command_failure_returns_error_code ✅ - bash_stderr_captured ✅ - bash_multiple_commands_in_sequence ✅ - 3 new tests in CLI for permission enforcement - plan_mode_blocks_bash_operations ✅ - code_mode_allows_bash ✅ - bash_command_timeout_works ✅ - All 43 workspace tests passing ✅ Dependencies Added: - tokio with process, io-util, time, sync features M4 milestone complete! ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
170
crates/tools/bash/src/lib.rs
Normal file
170
crates/tools/bash/src/lib.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
const MAX_OUTPUT_LINES: usize = 2000;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 120000; // 2 minutes
|
||||
const COMMAND_DELIMITER: &str = "___OWLEN_CMD_END___";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i32,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
pub struct BashSession {
|
||||
child: Mutex<Child>,
|
||||
}
|
||||
|
||||
impl BashSession {
|
||||
/// Create a new persistent bash session
|
||||
pub async fn new() -> Result<Self> {
|
||||
let child = Command::new("bash")
|
||||
.arg("--norc")
|
||||
.arg("--noprofile")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
// Verify the process started
|
||||
if child.stdin.is_none() || child.stdout.is_none() || child.stderr.is_none() {
|
||||
return Err(eyre!("Failed to capture bash process stdio"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
child: Mutex::new(child),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command in the persistent bash session
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `command` - The bash command to execute
|
||||
/// * `timeout_ms` - Optional timeout in milliseconds (default: 2 minutes)
|
||||
pub async fn execute(&mut self, command: &str, timeout_ms: Option<u64>) -> Result<CommandOutput> {
|
||||
let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
|
||||
|
||||
let result = timeout(timeout_duration, self.execute_internal(command)).await;
|
||||
|
||||
match result {
|
||||
Ok(output) => output,
|
||||
Err(_) => Err(eyre!("Command timed out after {}ms", timeout_duration.as_millis())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_internal(&mut self, command: &str) -> Result<CommandOutput> {
|
||||
let mut child = self.child.lock().await;
|
||||
|
||||
// Take ownership of stdio handles
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| eyre!("No stdin"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| eyre!("No stdout"))?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| eyre!("No stderr"))?;
|
||||
|
||||
// Write command with delimiter and exit code capture
|
||||
let full_command = format!(
|
||||
"{}\necho $? > /tmp/owlen_exit_code_$$.tmp\necho '{}'\n",
|
||||
command, COMMAND_DELIMITER
|
||||
);
|
||||
stdin.write_all(full_command.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// Read stdout until delimiter
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stdout_lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = stdout_reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Err(eyre!("Bash process terminated unexpectedly"));
|
||||
}
|
||||
|
||||
if line.trim() == COMMAND_DELIMITER {
|
||||
break;
|
||||
}
|
||||
|
||||
stdout_lines.push(line.clone());
|
||||
|
||||
// Truncate if too many lines
|
||||
if stdout_lines.len() > MAX_OUTPUT_LINES {
|
||||
stdout_lines.push("<<<...output truncated...>>>\n".to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Read stderr (non-blocking, best effort)
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
let mut stderr_lines = Vec::new();
|
||||
let mut stderr_line = String::new();
|
||||
|
||||
// Try to read stderr without blocking indefinitely
|
||||
while let Ok(result) = timeout(Duration::from_millis(100), stderr_reader.read_line(&mut stderr_line)).await {
|
||||
match result {
|
||||
Ok(n) if n > 0 => {
|
||||
stderr_lines.push(stderr_line.clone());
|
||||
stderr_line.clear();
|
||||
|
||||
if stderr_lines.len() > MAX_OUTPUT_LINES {
|
||||
stderr_lines.push("<<<...stderr truncated...>>>\n".to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Read exit code
|
||||
let exit_code_cmd = "cat /tmp/owlen_exit_code_$$.tmp 2>/dev/null; rm -f /tmp/owlen_exit_code_$$.tmp\n";
|
||||
stdin.write_all(exit_code_cmd.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
let mut exit_line = String::new();
|
||||
stdout_reader.read_line(&mut exit_line).await?;
|
||||
|
||||
let exit_code: i32 = exit_line.trim().parse().unwrap_or(0);
|
||||
|
||||
// Restore stdio handles
|
||||
child.stdin = Some(stdin);
|
||||
child.stdout = Some(stdout_reader.into_inner());
|
||||
child.stderr = Some(stderr_reader.into_inner());
|
||||
|
||||
Ok(CommandOutput {
|
||||
stdout: stdout_lines.join(""),
|
||||
stderr: stderr_lines.join(""),
|
||||
exit_code,
|
||||
success: exit_code == 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the bash session
|
||||
pub async fn close(self) -> Result<()> {
|
||||
let mut child = self.child.into_inner();
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(b"exit\n").await;
|
||||
let _ = stdin.flush().await;
|
||||
}
|
||||
|
||||
let _ = child.wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_create_session() {
|
||||
let session = BashSession::new().await;
|
||||
assert!(session.is_ok());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user