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>
180 lines
5.2 KiB
Rust
180 lines
5.2 KiB
Rust
use assert_cmd::Command;
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn plan_mode_allows_read_operations() {
|
|
// Create a temp file to read
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "hello world").unwrap();
|
|
|
|
// Read operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("read").arg(file.to_str().unwrap());
|
|
cmd.assert().success().stdout("hello world\n");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_allows_glob_operations() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("a.txt"), "test").unwrap();
|
|
fs::write(dir.path().join("b.txt"), "test").unwrap();
|
|
|
|
let pattern = format!("{}/*.txt", dir.path().display());
|
|
|
|
// Glob operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("glob").arg(&pattern);
|
|
cmd.assert().success();
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_allows_grep_operations() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap();
|
|
|
|
// Grep operation should work in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("grep").arg(dir.path().to_str().unwrap()).arg("hello");
|
|
cmd.assert().success();
|
|
}
|
|
|
|
#[test]
|
|
fn mode_override_via_cli_flag() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "content").unwrap();
|
|
|
|
// Test with --mode code (should also allow read)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("read")
|
|
.arg(file.to_str().unwrap());
|
|
cmd.assert().success().stdout("content\n");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_write_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("new.txt");
|
|
|
|
// Write operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("write").arg(file.to_str().unwrap()).arg("content");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_edit_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "old content").unwrap();
|
|
|
|
// Edit operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("old")
|
|
.arg("new");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn accept_edits_mode_allows_write() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("new.txt");
|
|
|
|
// Write operation should work in acceptEdits mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("acceptEdits")
|
|
.arg("write")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("new content");
|
|
cmd.assert().success();
|
|
|
|
// Verify file was written
|
|
assert_eq!(fs::read_to_string(&file).unwrap(), "new content");
|
|
}
|
|
|
|
#[test]
|
|
fn accept_edits_mode_allows_edit() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
fs::write(&file, "line 1\nline 2\nline 3").unwrap();
|
|
|
|
// Edit operation should work in acceptEdits mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("acceptEdits")
|
|
.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("line 2")
|
|
.arg("modified line");
|
|
cmd.assert().success();
|
|
|
|
// Verify file was edited
|
|
assert_eq!(
|
|
fs::read_to_string(&file).unwrap(),
|
|
"line 1\nmodified line\nline 3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_allows_all_operations() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("test.txt");
|
|
|
|
// Write in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("write")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("initial content");
|
|
cmd.assert().success();
|
|
|
|
// Edit in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("edit")
|
|
.arg(file.to_str().unwrap())
|
|
.arg("initial")
|
|
.arg("modified");
|
|
cmd.assert().success();
|
|
|
|
assert_eq!(fs::read_to_string(&file).unwrap(), "modified content");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_blocks_bash_operations() {
|
|
// Bash operation should be blocked in plan mode (default)
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("bash").arg("echo hello");
|
|
cmd.assert().failure();
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_allows_bash() {
|
|
// Bash operation should work in code mode
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode").arg("code").arg("bash").arg("echo hello");
|
|
cmd.assert().success().stdout("hello\n");
|
|
}
|
|
|
|
#[test]
|
|
fn bash_command_timeout_works() {
|
|
// Test that timeout works
|
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
|
cmd.arg("--mode")
|
|
.arg("code")
|
|
.arg("bash")
|
|
.arg("sleep 10")
|
|
.arg("--timeout")
|
|
.arg("1000");
|
|
cmd.assert().failure();
|
|
}
|