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:
2025-11-01 19:31:36 +01:00
parent 6108b9e3d1
commit d7ddc365ec
7 changed files with 359 additions and 0 deletions

View 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();
}