From 287d8cce22ef28fadfe5f6aad93ea2bc85e06374 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Fri, 23 Oct 2026 09:41:24 +0700 Subject: [PATCH] feat(cli): add auto permission mode --- README.md | 12 + src/acp/mod.rs | 4 +- src/acp/server.rs | 4 +- src/acp/service.rs | 107 +++++++- src/config/runtime.rs | 2 +- src/main.rs | 31 ++- src/ui/components/chat.rs | 3 +- tests/acp_stdio.rs | 26 +- tests/auto_permissions.rs | 535 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 702 insertions(+), 22 deletions(-) create mode 100644 tests/auto_permissions.rs diff --git a/README.md b/README.md index b6f3503..2479b71 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,18 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive - When switching models, you can press `⇆` to change thinking efforts. - Copy on select is disabled by default. Copy is two-step in crabcode. Gets annoying in OpenCode, especially w/ clipboard history. +### Auto-approve Permissions + +Use `--auto` to run tools without permission prompts in the terminal, print mode, or an ACP editor session: + +```sh +crabcode --auto +crabcode --auto -p "Refactor this module" +crabcode acp --auto +``` + +Only enable this for trusted tasks: commands and file changes run without approval. Explicit permission denies, disabled tools, and agent restrictions still apply. Without `--auto`, permission prompts work normally. `--dangerously-skip-permissions` and `--yolo` are equivalent aliases. + ### Shell Completion ```sh diff --git a/src/acp/mod.rs b/src/acp/mod.rs index 223f4ca..256d00e 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -11,8 +11,8 @@ mod service; use anyhow::Result; use std::path::PathBuf; -pub async fn run(cwd: Option) -> Result<()> { - server::run(cwd).await +pub async fn run(cwd: Option, dangerously_skip_permissions: bool) -> Result<()> { + server::run(cwd, dangerously_skip_permissions).await } pub async fn login(cwd: Option) -> Result<()> { diff --git a/src/acp/server.rs b/src/acp/server.rs index ffeeb50..7cd14e4 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -13,7 +13,7 @@ use agent_client_protocol::{Agent, Stdio}; use anyhow::{Context, Result}; use std::path::PathBuf; -pub async fn run(cwd: Option) -> Result<()> { +pub async fn run(cwd: Option, dangerously_skip_permissions: bool) -> Result<()> { let workspace = match cwd { Some(path) => path, None => std::env::current_dir().context("failed to determine current directory")?, @@ -30,7 +30,7 @@ pub async fn run(cwd: Option) -> Result<()> { workspace.display() ) })?; - let service = crate::acp::service::AcpService::new(&workspace) + let service = crate::acp::service::AcpService::new(&workspace, dangerously_skip_permissions) .map_err(|_| anyhow::anyhow!("failed to initialize ACP session storage"))?; let initialize_service = service.clone(); diff --git a/src/acp/service.rs b/src/acp/service.rs index 11a35eb..bb73742 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -28,6 +28,7 @@ pub struct AcpService { sessions: Arc>, session_manager: Arc>, client_capabilities: Arc>, + dangerously_skip_permissions: bool, } // Attachments detach immediately on close, but a turn retains ownership until all @@ -847,7 +848,10 @@ struct AcpSession { } impl AcpService { - pub fn new(initial_workspace: &Path) -> Result { + pub fn new( + initial_workspace: &Path, + dangerously_skip_permissions: bool, + ) -> Result { let session_manager = SessionManager::new() .with_history_for_workspace(initial_workspace) .map_err(|_| internal_error())?; @@ -855,6 +859,7 @@ impl AcpService { sessions: Arc::new(AsyncMutex::new(AcpSessions::default())), session_manager: Arc::new(Mutex::new(session_manager)), client_capabilities: Arc::new(Mutex::new(Default::default())), + dangerously_skip_permissions, }) } @@ -934,7 +939,8 @@ impl AcpService { }; let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); - let tool_permissions = configured_tool_permissions(&cwd, &config); + let tool_permissions = + configured_tool_permissions(&cwd, &config, self.dangerously_skip_permissions); self.sessions.lock().await.attachments.insert( session_id.clone(), AcpSession { @@ -1059,7 +1065,11 @@ impl AcpService { self.sessions.lock().await.attachments.insert( fork_id.clone(), AcpSession { - tool_permissions: configured_tool_permissions(&source.cwd, &source.config), + tool_permissions: configured_tool_permissions( + &source.cwd, + &source.config, + self.dangerously_skip_permissions, + ), ..source.clone() }, ); @@ -1232,7 +1242,8 @@ impl AcpService { reasoning.unwrap_or(crate::model::reasoning::ReasoningEffort::None); let context_window = model_context_window(&config, &models, &provider, &model); let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); - let tool_permissions = configured_tool_permissions(&cwd, &config); + let tool_permissions = + configured_tool_permissions(&cwd, &config, self.dangerously_skip_permissions); let session = AcpSession { cwd, config, @@ -2131,15 +2142,21 @@ fn resolve_model(config: &LoadedConfig) -> (String, String) { .unwrap_or_else(|| ("opencode".to_string(), "big-pickle".to_string())) } -fn configured_tool_permissions(cwd: &Path, config: &LoadedConfig) -> crate::tools::ToolPermissions { +fn configured_tool_permissions( + cwd: &Path, + config: &LoadedConfig, + dangerously_skip_permissions: bool, +) -> crate::tools::ToolPermissions { let mut policies = crate::tools::AgentToolPolicies::default(); for (mode, tools) in config.merged_config.agent_registry.tool_policy_map() { policies = policies.with_custom_tools(mode, tools); } crate::tools::ToolPermissions::new(cwd) .with_agent_policies(policies) + .with_global_tool_config(config.merged_config.tools.clone()) .with_permission_rules(config.merged_config.permission_rules.clone()) .with_agent_permission_rules(config.merged_config.agent_registry.permission_rules_map()) + .dangerously_skip_permissions(dangerously_skip_permissions) } fn tool_permissions(session: &AcpSession) -> crate::tools::ToolPermissions { @@ -3571,7 +3588,7 @@ mod tests { #[test] fn terminal_support_tracks_client_capability() { - let service = AcpService::new(Path::new("/tmp")).unwrap(); + let service = AcpService::new(Path::new("/tmp"), false).unwrap(); assert!(!service.supports_terminals()); service.set_client_capabilities( @@ -3703,6 +3720,81 @@ mod tests { assert_eq!(always.kind, PermissionOptionKind::AllowAlways); } + #[tokio::test] + async fn acp_auto_bypasses_prompts_but_preserves_restrictions() { + use crate::tools::{PermissionPolicyAction, PermissionRule}; + let mut config = empty_config(); + config.merged_config.tools.insert("bash".into(), false); + config.merged_config.permission_rules.push(PermissionRule { + permission: "*".into(), + pattern: "*".into(), + action: PermissionPolicyAction::Ask, + }); + let permissions = configured_tool_permissions(Path::new("/tmp/workspace"), &config, true); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + for _ in 0..4 { + for (tool, params) in [ + ( + "read", + serde_json::json!({"file_path": "/tmp/external/.env"}), + ), + ( + "write", + serde_json::json!({"file_path": "/tmp/external/output.txt", "content": "test"}), + ), + ] { + assert!(permissions + .preflight("build", tool, ¶ms, Some(&tx)) + .await + .is_ok()); + } + } + assert!(permissions + .preflight( + "build", + "bash", + &serde_json::json!({"command": "echo test"}), + Some(&tx) + ) + .await + .is_err()); + assert!(permissions + .preflight( + "plan", + "write", + &serde_json::json!({"file_path": "/tmp/workspace/output.txt", "content": "test"}), + Some(&tx) + ) + .await + .is_err()); + for permission in ["read", "external_directory"] { + let mut denied_config = config.clone(); + denied_config + .merged_config + .permission_rules + .push(PermissionRule { + permission: permission.into(), + pattern: "*".into(), + action: PermissionPolicyAction::Deny, + }); + let denied = + configured_tool_permissions(Path::new("/tmp/workspace"), &denied_config, true); + assert!(denied + .preflight( + "build", + "read", + &serde_json::json!({"file_path": "/tmp/external/file.txt"}), + Some(&tx) + ) + .await + .is_err()); + } + assert!( + rx.try_recv().is_err(), + "auto mode emitted a permission prompt" + ); + } + #[tokio::test] async fn acp_session_reuses_permission_grants_across_turns() { let temp = tempfile::tempdir().expect("temp dir"); @@ -4210,7 +4302,7 @@ mod tests { let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); AcpSession { cwd: config.cwd.clone(), - tool_permissions: configured_tool_permissions(&config.cwd, &config), + tool_permissions: configured_tool_permissions(&config.cwd, &config, false), config, skills, models: Vec::new(), @@ -4257,6 +4349,7 @@ mod tests { sessions: Arc::new(AsyncMutex::new(AcpSessions::default())), session_manager: Arc::new(Mutex::new(manager)), client_capabilities: Arc::new(Mutex::new(Default::default())), + dangerously_skip_permissions: false, }; let session = session_with_config(empty_config()); service.sessions.lock().await.attachments.insert(session_id.clone(), session.clone()); diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 8cda3b3..3afd5d2 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -16,7 +16,7 @@ use crate::tools::{ pub struct ConfigRuntimeOptions { /// When true, deny interactive-only tools (`question`, `update_plan`). pub print_mode: bool, - /// Skip permission prompts (`--dangerously-skip-permissions` / `--yolo`). + /// Skip permission prompts (`--auto` / `--dangerously-skip-permissions` / `--yolo`). pub dangerously_skip_permissions: bool, } diff --git a/src/main.rs b/src/main.rs index a55d66d..05ec350 100644 --- a/src/main.rs +++ b/src/main.rs @@ -781,8 +781,12 @@ pub(crate) struct Args { #[arg(long = "reasoning-effort", value_parser = parse_reasoning_effort_arg)] reasoning_effort: Option, - /// Skip permission prompts in local interactive/print mode (dangerous). Explicit denies still apply. - #[arg(long = "dangerously-skip-permissions", visible_alias = "yolo")] + /// Automatically approve permission requests in interactive, print, and ACP modes (dangerous). Explicit denies still apply. + #[arg( + long = "dangerously-skip-permissions", + visible_aliases = ["auto", "yolo"], + global = true + )] dangerously_skip_permissions: bool, #[arg(long = "emit-logs", hide = true)] @@ -1067,7 +1071,7 @@ async fn main() -> Result<()> { return if *login { crate::acp::login(cwd.clone()).await } else { - crate::acp::run(cwd.clone()).await + crate::acp::run(cwd.clone(), args.dangerously_skip_permissions).await }; } Some(Command::Completion { shell, install }) => { @@ -1336,7 +1340,7 @@ mod tests { #[test] fn parses_permission_bypass_in_interactive_and_print_modes() { - for flag in ["--dangerously-skip-permissions", "--yolo"] { + for flag in ["--dangerously-skip-permissions", "--auto", "--yolo"] { let args = Args::try_parse_from(["crabcode", flag]).unwrap(); assert!(args.dangerously_skip_permissions); assert!(!args.print_mode); @@ -1348,12 +1352,31 @@ mod tests { } } + #[test] + fn parses_permission_bypass_before_and_after_acp() { + for flag in ["--auto", "--dangerously-skip-permissions", "--yolo"] { + for argv in [["crabcode", flag, "acp"], ["crabcode", "acp", flag]] { + let args = Args::try_parse_from(argv).unwrap(); + assert!(args.dangerously_skip_permissions); + assert!(matches!(args.command, Some(Command::Acp { .. }))); + } + } + let args = Args::try_parse_from(["crabcode", "acp"]).unwrap(); + assert!(!args.dangerously_skip_permissions); + } + #[test] fn help_documents_permission_bypass_and_alias() { let help = root_help().unwrap(); assert!(help.contains("--dangerously-skip-permissions")); + assert!(help.contains("auto")); assert!(help.contains("yolo")); assert!(help.contains("Explicit denies still apply")); + let help = Args::try_parse_from(["crabcode", "acp", "--help"]) + .unwrap_err() + .to_string(); + assert!(help.contains("auto")); + assert!(help.contains("Explicit denies still apply")); } #[test] diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 4041b77..bcef8df 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -9074,11 +9074,12 @@ mod tests { #[test] fn test_edit_tool_renders_codex_style_diff_summary() { let chat = Chat::new(); + let path = std::env::current_dir().unwrap().join("README.md"); let content = serde_json::json!({ "name": "edit", "status": "ok", "args": { - "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md", + "file_path": path, "old_string": "alpha\nbeta\nomega", "new_string": "alpha\nbravo\nomega", }, diff --git a/tests/acp_stdio.rs b/tests/acp_stdio.rs index b16988d..67d0a73 100644 --- a/tests/acp_stdio.rs +++ b/tests/acp_stdio.rs @@ -12,19 +12,29 @@ struct AcpProcess { _state: tempfile::TempDir, } +impl Drop for AcpProcess { + fn drop(&mut self) { + self.stdin.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + impl AcpProcess { fn spawn(workspace: &std::path::Path) -> Self { let home = tempfile::tempdir().expect("home"); let config = tempfile::tempdir().expect("config"); let state = tempfile::tempdir().expect("state"); - // Valid session setup must not depend on live models.dev availability. + // A published catalog keeps session setup on the warm, offline path. + // A models.dev cache alone still triggers runtime discovery (ollama ls) + // when no providers are connected, and fails if Ollama is unavailable. let cache = state.path().join("crabcode/cache"); std::fs::create_dir_all(&cache).expect("cache directory"); std::fs::write( - cache.join("models_dev_cache.json"), + cache.join("effective_catalog.json"), serde_json::to_vec(&serde_json::json!({ - "data": {}, "schema_version": 5, - "timestamp": std::time::SystemTime::now() + "models": [], "schema_version": 2, "revision": 1, + "updated_at": std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() })) .unwrap(), @@ -33,6 +43,10 @@ impl AcpProcess { let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode")) .args(["acp", "--cwd"]) .arg(workspace) + .current_dir(workspace) + // Test mode redirects auth/discovery to shared /tmp fixtures; + // these subprocesses must use only their private HOME/XDG dirs. + .env_remove("CRABCODE_TEST_MODE") .env("HOME", home.path()) .env("XDG_CONFIG_HOME", config.path()) .env("XDG_STATE_HOME", state.path()) @@ -241,6 +255,8 @@ fn login_with_piped_stdin_fails_promptly_without_protocol_output_or_secrets() { let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode")) .args(["acp", "--login", "--cwd"]) .arg(workspace.path()) + .current_dir(workspace.path()) + .env_remove("CRABCODE_TEST_MODE") .env("HOME", home.path()) .env("XDG_CONFIG_HOME", config.path()) .env("XDG_STATE_HOME", state.path()) @@ -359,7 +375,7 @@ Wire skill instructions. let (new_session, mut notifications) = process.recv_response(2); let session_id = new_session["result"]["sessionId"] .as_str() - .expect("session id") + .unwrap_or_else(|| panic!("session/new did not return a session id: {new_session}")) .to_string(); while !notifications .iter() diff --git a/tests/auto_permissions.rs b/tests/auto_permissions.rs new file mode 100644 index 0000000..fd2ba4d --- /dev/null +++ b/tests/auto_permissions.rs @@ -0,0 +1,535 @@ +//! Offline, real-process permission tests. No credentials or public services are used. +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +const CONTENT: &str = "written by the offline permission probe\n"; +const ANSWER: &str = "offline permission probe finished"; +const TIMEOUT: Duration = Duration::from_secs(30); + +struct MockProvider { + url: String, + requests: Arc>>, + stop: Arc, + worker: Option>, +} + +impl MockProvider { + fn start(target: PathBuf) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock provider"); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}/v1", listener.local_addr().unwrap()); + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let worker_stop = stop.clone(); + let worker_requests = requests.clone(); + let worker = thread::spawn(move || { + while !worker_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => serve(stream, &target, &worker_requests), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept mock request: {error}"), + } + } + }); + Self { + url, + requests, + stop, + worker: Some(worker), + } + } + + fn tool_result(&self) -> String { + let requests = self.requests.lock().unwrap(); + let results: Vec<_> = requests + .iter() + .flat_map(|request| request["messages"].as_array().into_iter().flatten()) + .filter(|message| message["role"] == "tool") + .collect(); + assert!( + !results.is_empty(), + "provider never received a tool result: {requests:#?}" + ); + results + .iter() + .map(|message| message.to_string()) + .collect::>() + .join("\n") + } +} + +impl Drop for MockProvider { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let result = worker.join(); + if !thread::panicking() { + result.expect("mock provider thread"); + } + } + } +} + +fn serve(mut stream: TcpStream, target: &Path, requests: &Mutex>) { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut first = String::new(); + reader.read_line(&mut first).expect("HTTP request line"); + let mut length = 0; + loop { + let mut line = String::new(); + assert!( + reader.read_line(&mut line).expect("HTTP header") > 0, + "truncated HTTP headers" + ); + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse::().unwrap(); + } + } + } + assert!(length < 8 * 1024 * 1024, "unexpectedly large mock request"); + let (mime, body) = if first.starts_with("GET /v1/models ") { + ( + "application/json", + json!({"data": [{"id": "permission-probe"}]}).to_string(), + ) + } else { + assert!( + first.starts_with("POST /v1/chat/completions "), + "unexpected request: {first}" + ); + let mut body = vec![0; length]; + reader.read_exact(&mut body).expect("HTTP body"); + let request: Value = serde_json::from_slice(&body).expect("provider JSON"); + assert_eq!(request["model"], "permission-probe"); + let has_result = request["messages"] + .as_array() + .unwrap() + .iter() + .rev() + .take_while(|message| message["role"] != "user") + .any(|message| message["role"] == "tool"); + let has_write = request["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|tool| tool["function"]["name"] == "write")); + let (delta, reason) = if !has_result && has_write { + ( + json!({"role": "assistant", "tool_calls": [{"index": 0, "id": "permission-probe-call", "type": "function", "function": { + "name": "write", "arguments": json!({"file_path": target, "content": CONTENT}).to_string() + }}]}), + "tool_calls", + ) + } else { + (json!({"role": "assistant", "content": ANSWER}), "stop") + }; + requests.lock().unwrap().push(request); + let chunks = [ + json!({"id": "offline-probe", "object": "chat.completion.chunk", "created": 1, "model": "permission-probe", "choices": [{"index": 0, "delta": delta, "finish_reason": null}]}), + json!({"id": "offline-probe", "object": "chat.completion.chunk", "created": 1, "model": "permission-probe", "choices": [{"index": 0, "delta": {}, "finish_reason": reason}]}), + ]; + ( + "text/event-stream", + format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + chunks[0], chunks[1] + ), + ) + }; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).expect("mock response"); + stream.flush().unwrap(); +} + +struct Fixture { + root: tempfile::TempDir, + workspace: PathBuf, + target: PathBuf, + provider: MockProvider, +} + +impl Fixture { + fn new(deny: bool) -> Self { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let target = workspace.join("auto-marker.txt"); + let provider = MockProvider::start(target.clone()); + // A path-specific deny keeps `write` advertised so the test exercises + // permission preflight rather than merely hiding the tool from the model. + let edit = if deny { + json!({"*": "ask", "*/auto-marker.txt": "deny"}) + } else { + json!("ask") + }; + std::fs::write(workspace.join("crabcode.json"), json!({ + "model": "offline-auto/permission-probe", + "small_model": "offline-auto/permission-probe", + "enabled_providers": ["offline-auto"], + "provider": {"offline-auto": { + "npm": "@ai-sdk/openai-compatible", + "options": {"baseURL": provider.url, "apiKey": "offline-test-not-a-secret"}, + "models": {"permission-probe": {"tool_call": true, "limit": {"context": 1000000, "output": 1024}}} + }}, + "permission": {"edit": edit}, + "compaction": false, + "watcher": false, + "notifications": false + }).to_string()).unwrap(); + let cache = root.path().join("state/crabcode/cache"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("models_dev_cache.json"), json!({ + "data": {}, "schema_version": 5, + "timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + }).to_string()).unwrap(); + for dir in ["home", "config", "cache"] { + std::fs::create_dir_all(root.path().join(dir)).unwrap(); + } + Self { + root, + workspace, + target, + provider, + } + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_crabcode")); + // Inherited test-mode flags select shared /tmp paths; inherited proxy + // settings can even send loopback traffic through a public proxy. + command + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", self.root.path().join("home")) + .env("XDG_CONFIG_HOME", self.root.path().join("config")) + .env("XDG_STATE_HOME", self.root.path().join("state")) + .env("XDG_CACHE_HOME", self.root.path().join("cache")) + .env("NO_PROXY", "127.0.0.1,localhost") + .env("TERM", "dumb") + .current_dir(&self.workspace) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } +} + +/// Always reap the real binary, including on assertion failures and timeouts. +struct Process { + child: Child, + stdin: Option, + lines: mpsc::Receiver, + stderr: Arc>, +} + +impl Process { + fn spawn(command: &mut Command) -> Self { + let mut child = command.spawn().expect("spawn crabcode"); + let stdin = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + let stderr_pipe = child.stderr.take().unwrap(); + let (tx, lines) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx.send(line.expect("stdout line")).is_err() { + break; + } + } + }); + let stderr = Arc::new(Mutex::new(String::new())); + let captured = stderr.clone(); + thread::spawn(move || { + let mut text = String::new(); + let _ = BufReader::new(stderr_pipe).read_to_string(&mut text); + *captured.lock().unwrap() = text; + }); + Self { + child, + stdin, + lines, + stderr, + } + } + + fn send(&mut self, value: Value) { + let stdin = self.stdin.as_mut().unwrap(); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn request( + &mut self, + id: u64, + method: &str, + params: Value, + messages: &mut Vec, + ) -> Value { + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let response = self.response(id, messages); + assert!( + response.get("error").is_none() && response.get("result").is_some(), + "ACP {method} failed: {response:#?}" + ); + response["result"].clone() + } + + fn response(&mut self, id: u64, messages: &mut Vec) -> Value { + let deadline = Instant::now() + TIMEOUT; + loop { + let line = self + .lines + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|error| { + panic!( + "waiting for ACP response {id}: {error}; stderr: {}", + self.stderr.lock().unwrap() + ) + }); + let message: Value = + serde_json::from_str(&line).expect("ACP JSON (stdout must be protocol-only)"); + // Respond even in --auto tests: an accidental permission request + // should fail assertions, not leave a prompt blocked forever. + if message["method"] == "session/request_permission" { + self.send(json!({"jsonrpc": "2.0", "id": message["id"], "result": {"outcome": {"outcome": "cancelled"}}})); + } + if message["id"] == id && message.get("method").is_none() { + return message; + } + messages.push(message); + } + } + + fn finish(&mut self) { + self.stdin.take(); + let deadline = Instant::now() + TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().unwrap() { + assert!( + status.success(), + "crabcode exited {status}; stderr: {}", + self.stderr.lock().unwrap() + ); + return; + } + assert!( + Instant::now() < deadline, + "crabcode did not exit after stdin EOF" + ); + thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Process { + fn drop(&mut self) { + self.stdin.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn acp_probe(args: &[&str], deny: bool, expect_permission: bool) { + let fixture = Fixture::new(deny); + let mut process = Process::spawn(fixture.command().args(args)); + let mut messages = Vec::new(); + process.send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}})); + assert!(process.response(1, &mut messages).get("result").is_some()); + process.send(json!({"jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {"cwd": fixture.workspace, "mcpServers": []}})); + let response = process.response(2, &mut messages); + let session = response["result"]["sessionId"] + .as_str() + .expect("new session id"); + process.send(json!({"jsonrpc": "2.0", "id": 3, "method": "session/prompt", "params": {"sessionId": session, "prompt": [{"type": "text", "text": "Run the offline write permission probe."}]}})); + let response = process.response(3, &mut messages); + assert_eq!( + response["result"]["stopReason"], "end_turn", + "{response:#?}" + ); + process.finish(); + // Also inspect messages queued before EOF (including late notifications). + messages.extend( + process + .lines + .iter() + .map(|line| serde_json::from_str::(&line).unwrap()), + ); + let permissions = messages + .iter() + .filter(|message| message["method"] == "session/request_permission") + .count(); + assert_eq!(permissions, usize::from(expect_permission), "{messages:#?}"); + assert!( + messages + .iter() + .any(|message| message["params"]["update"]["content"]["text"] + .as_str() + .is_some_and(|text| text.contains(ANSWER))), + "missing final answer: {messages:#?}" + ); + let result = fixture.provider.tool_result(); + if deny || expect_permission { + assert!( + !fixture.target.exists(), + "denied write reached the filesystem" + ); + assert!( + result.to_lowercase().contains("permission"), + "expected permission failure: {result}" + ); + } else { + assert_eq!(std::fs::read_to_string(&fixture.target).unwrap(), CONTENT); + assert!( + result.contains("Created file"), + "expected successful tool result: {result}" + ); + } +} + +#[test] +fn acp_auto_after_subcommand_executes_without_permission_requests() { + acp_probe(&["acp", "--auto"], false, false); +} + +#[test] +fn acp_auto_before_subcommand_executes_without_permission_requests() { + acp_probe(&["--auto", "acp"], false, false); +} + +#[test] +fn acp_without_auto_requests_permission_and_honors_rejection() { + acp_probe(&["acp"], false, true); +} + +#[test] +fn acp_auto_preserves_explicit_config_deny() { + acp_probe(&["acp", "--auto"], true, false); +} + +#[test] +fn acp_auto_preserved_across_close_load_resume_and_fork() { + let fixture = Fixture::new(false); + let mut process = Process::spawn(fixture.command().args(["acp", "--auto"])); + let mut messages = Vec::new(); + process.request( + 1, + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + &mut messages, + ); + let result = process.request( + 2, + "session/new", + json!({"cwd": fixture.workspace, "mcpServers": []}), + &mut messages, + ); + let session = result["sessionId"].as_str().expect("new session id"); + let mut active_session = session.to_owned(); + for (turn, transition) in ["new", "load", "resume", "fork"].into_iter().enumerate() { + let id = 3 + turn as u64 * 3; + if transition == "load" || transition == "resume" { + process.request( + id, + "session/close", + json!({"sessionId": session}), + &mut messages, + ); + process.request( + id + 1, + &format!("session/{transition}"), + json!({"sessionId": session, "cwd": fixture.workspace, "mcpServers": []}), + &mut messages, + ); + } else if transition == "fork" { + let fork = process.request( + id, + "session/fork", + json!({"sessionId": session, "cwd": fixture.workspace, "mcpServers": []}), + &mut messages, + ); + active_session = fork["sessionId"] + .as_str() + .expect("fork session id") + .to_owned(); + assert_ne!(active_session, session); + } + let start = messages.len(); + let result = process.request( + id + 2, + "session/prompt", + json!({"sessionId": active_session, "prompt": [{"type": "text", "text": "Run the offline write permission probe."}]}), + &mut messages, + ); + assert_eq!( + result["stopReason"], "end_turn", + "{transition}: {result:#?}" + ); + assert!( + messages[start..] + .iter() + .all(|message| message["method"] != "session/request_permission"), + "{transition} requested permission: {:#?}", + &messages[start..] + ); + assert!( + messages[start..] + .iter() + .any(|message| message["params"]["update"]["content"]["text"] + .as_str() + .is_some_and(|text| text.contains(ANSWER))), + "{transition} missing final answer: {:#?}", + &messages[start..] + ); + assert_eq!( + std::fs::read_to_string(&fixture.target) + .unwrap_or_else(|error| panic!("{transition} did not write marker: {error}")), + CONTENT, + "{transition}" + ); + // Every turn must execute a fresh write, not reuse an earlier result. + std::fs::remove_file(&fixture.target).unwrap(); + } + process.finish(); + messages.extend( + process + .lines + .iter() + .map(|line| serde_json::from_str::(&line).unwrap()), + ); + assert!( + messages + .iter() + .all(|message| message["method"] != "session/request_permission"), + "unexpected permission request: {messages:#?}" + ); +} + +#[test] +fn print_auto_executes_configured_ask_offline() { + let fixture = Fixture::new(false); + let mut process = Process::spawn(fixture.command().args([ + "--auto", + "-p", + "Run the offline write permission probe.", + ])); + process.finish(); + let output = process.lines.iter().collect::>().join("\n"); + assert!(output.contains(ANSWER), "{output}"); + assert_eq!(std::fs::read_to_string(&fixture.target).unwrap(), CONTENT); + assert!(fixture.provider.tool_result().contains("Created file")); +}