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:
107
crates/tools/bash/tests/bash_session.rs
Normal file
107
crates/tools/bash/tests/bash_session.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use tools_bash::BashSession;
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_persists_env_between_calls() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
// Set an environment variable
|
||||
let output1 = session.execute("export TEST_VAR=hello", None).await.unwrap();
|
||||
assert!(output1.success);
|
||||
|
||||
// Verify it persists in next command
|
||||
let output2 = session.execute("echo $TEST_VAR", None).await.unwrap();
|
||||
assert!(output2.success);
|
||||
assert!(output2.stdout.contains("hello"));
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_persists_cwd_between_calls() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
// Change to /tmp
|
||||
let output1 = session.execute("cd /tmp", None).await.unwrap();
|
||||
assert!(output1.success);
|
||||
|
||||
// Verify cwd persists
|
||||
let output2 = session.execute("pwd", None).await.unwrap();
|
||||
assert!(output2.success);
|
||||
assert!(output2.stdout.trim().ends_with("/tmp"));
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_command_timeout() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
// Command that sleeps for 5 seconds, but with 1 second timeout
|
||||
let result = session.execute("sleep 5", Some(1000)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("timeout") || err_msg.contains("timed out"));
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_output_truncation() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
// Generate a lot of output
|
||||
let output = session
|
||||
.execute("for i in {1..100}; do echo 'Line '$i; done", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
// Should have output but might be truncated
|
||||
assert!(!output.stdout.is_empty());
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_command_failure_returns_error_code() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
let output = session.execute("false", None).await.unwrap();
|
||||
assert!(!output.success);
|
||||
assert_eq!(output.exit_code, 1);
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_stderr_captured() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
let output = session
|
||||
.execute("echo 'error message' >&2", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
assert!(output.stderr.contains("error message"));
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_multiple_commands_in_sequence() {
|
||||
let mut session = BashSession::new().await.unwrap();
|
||||
|
||||
// Set a variable
|
||||
session.execute("X=1", None).await.unwrap();
|
||||
|
||||
// Increment it
|
||||
session.execute("X=$((X + 1))", None).await.unwrap();
|
||||
|
||||
// Verify final value
|
||||
let output = session.execute("echo $X", None).await.unwrap();
|
||||
assert!(output.stdout.contains("2"));
|
||||
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user