From ac5444db50f735cced92d1902629edd5d5cf6f80 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 31 Jul 2026 11:29:23 +0800 Subject: [PATCH 1/4] refactor: unify sandboxed tool execution --- crates/core/src/tools/handlers/bash.rs | 160 ------ crates/core/src/tools/handlers/mod.rs | 5 +- .../core/src/tools/handlers/shell_command.rs | 103 ++-- crates/core/src/tools/registry_plan.rs | 64 ++- .../src/tools/{bash.txt => shell_command.txt} | 0 crates/core/src/tools/shell_exec.rs | 454 ++++-------------- crates/core/src/tools/shell_exec/tests.rs | 311 ++++++++++++ crates/core/tests/pty_sandbox_e2e.rs | 59 ++- crates/sandbox/src/denial.rs | 49 +- crates/sandbox/src/wrap.rs | 383 ++------------- crates/sandbox/src/wrap/tests.rs | 318 ++++++++++++ crates/tools/src/handler_kind.rs | 1 - crates/utils/process/src/pty/pipe.rs | 11 +- docs/sandbox-tool-redaction-memo.zh-Hans.md | 322 +++++++++++++ 14 files changed, 1245 insertions(+), 995 deletions(-) delete mode 100644 crates/core/src/tools/handlers/bash.rs rename crates/core/src/tools/{bash.txt => shell_command.txt} (100%) create mode 100644 crates/core/src/tools/shell_exec/tests.rs create mode 100644 crates/sandbox/src/wrap/tests.rs create mode 100644 docs/sandbox-tool-redaction-memo.zh-Hans.md diff --git a/crates/core/src/tools/handlers/bash.rs b/crates/core/src/tools/handlers/bash.rs deleted file mode 100644 index 13d900ad..00000000 --- a/crates/core/src/tools/handlers/bash.rs +++ /dev/null @@ -1,160 +0,0 @@ -use async_trait::async_trait; - -use crate::contracts::{ - ToolCallError, ToolContext, ToolProgressSender, ToolResult, ToolResultContent, -}; -use crate::json_schema::JsonSchema; -use crate::shell_exec::{ - ShellExecRequest, default_max_output_tokens, default_timeout_ms, default_yield_time_ms, - execute_shell_command, -}; -use crate::tool_handler::ToolHandler; -use crate::tool_spec::{ToolCapabilityTag, ToolExecutionMode, ToolOutputMode, ToolSpec}; -use crate::tools::client_terminal_shell::{ - ClientTerminalShellRequest, execute_with_client_terminal, -}; - -pub struct BashHandler { - spec: ToolSpec, -} - -impl Default for BashHandler { - fn default() -> Self { - Self::new() - } -} - -impl BashHandler { - pub fn new() -> Self { - Self { - spec: ToolSpec { - name: "shell_command".into(), - description: "Executes a shell command in the selected platform shell with optional timeout and output limits.".into(), - input_schema: JsonSchema::object( - std::collections::BTreeMap::from([ - ("command".to_string(), JsonSchema::string(Some("The command to execute."))), - ("cmd".to_string(), JsonSchema::string(Some("Alias for command"))), - ("timeout".to_string(), JsonSchema::integer(Some("Optional timeout in milliseconds"))), - ("timeout_ms".to_string(), JsonSchema::integer(Some("Alias for timeout"))), - ("workdir".to_string(), JsonSchema::string(Some("The working directory to run the command in"))), - ("description".to_string(), JsonSchema::string(Some("Clear, concise description of what this command does"))), - ("shell".to_string(), JsonSchema::string(Some("Optional shell binary to launch"))), - ("tty".to_string(), JsonSchema::boolean(Some("Whether to allocate a TTY"))), - ("login".to_string(), JsonSchema::boolean(Some("Whether to use login shell semantics"))), - ("yield_time_ms".to_string(), JsonSchema::number(Some("Milliseconds to wait for output before yielding"))), - ("max_output_tokens".to_string(), JsonSchema::number(Some("Maximum output tokens to return"))), - ]), - Some(vec!["command".to_string()]), - None, - ), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![ToolCapabilityTag::ExecuteProcess], - supports_parallel: false, - preparation_feedback: crate::tool_spec::ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }, - } - } -} - -#[async_trait] -impl ToolHandler for BashHandler { - fn spec(&self) -> &ToolSpec { - &self.spec - } - - async fn handle( - &self, - ctx: ToolContext, - input: serde_json::Value, - progress: Option, - ) -> Result { - let command = input - .get("command") - .or_else(|| input.get("cmd")) - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolCallError::InvalidInput("missing 'command' field".into()))?; - - let timeout_ms = input["timeout"] - .as_u64() - .or_else(|| input["timeout_ms"].as_u64()) - .unwrap_or(default_timeout_ms()); - let workdir = input["workdir"] - .as_str() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| ctx.workspace_root.clone()); - let description = input["description"] - .as_str() - .unwrap_or("shell command") - .to_string(); - let shell_override = input["shell"].as_str().map(ToOwned::to_owned); - let tty = input["tty"].as_bool().unwrap_or(false); - let login = input["login"].as_bool().unwrap_or(true); - let yield_time_ms = input["yield_time_ms"] - .as_u64() - .unwrap_or(default_yield_time_ms()); - let max_output_tokens = input["max_output_tokens"] - .as_u64() - .map(|v| v as usize) - .unwrap_or(default_max_output_tokens()); - let terminal_workdir = if workdir.is_absolute() { - workdir.clone() - } else { - ctx.workspace_root.join(&workdir) - }; - - if let Some(result) = execute_with_client_terminal( - &ctx, - ClientTerminalShellRequest { - command: command.to_string(), - workdir: terminal_workdir, - description: description.clone(), - shell_override: shell_override.clone(), - login, - timeout_ms, - max_output_tokens, - }, - progress, - ) - .await? - { - return Ok(result); - } - - let output = execute_shell_command( - ShellExecRequest { - command: command.to_string(), - workdir, - description, - shell_override, - tty, - login, - timeout_ms, - yield_time_ms, - max_output_tokens, - sandbox_profile: ctx.sandbox_profile.clone(), - }, - None, - ctx.cancel_token.clone(), - ) - .await - .map_err(|e| ToolCallError::ExecutionFailed(e.to_string()))?; - - let display = output.display_content; - let text = output.content.into_string(); - let mut result = if output.is_error { - ToolResult::error( - ToolResultContent::Text(text.clone()), - "Command failed", - ToolCallError::ExecutionFailed(text), - ) - } else { - ToolResult::success(ToolResultContent::Text(text), "Command executed") - }; - result.display_content = display; - Ok(result) - } -} diff --git a/crates/core/src/tools/handlers/mod.rs b/crates/core/src/tools/handlers/mod.rs index 30a4a7b0..7f5f033a 100644 --- a/crates/core/src/tools/handlers/mod.rs +++ b/crates/core/src/tools/handlers/mod.rs @@ -1,6 +1,5 @@ mod agent; mod apply_patch; -mod bash; #[cfg(feature = "code-search")] mod code_search; mod edit; @@ -25,7 +24,6 @@ mod websearch; pub(crate) use agent::register_agent_tools; pub use apply_patch::ApplyPatchHandler; -pub use bash::BashHandler; #[cfg(feature = "code-search")] pub use code_search::CodeSearchHandler; pub use edit::EditHandler; @@ -138,7 +136,6 @@ fn build_registry_from_builder( for (kind, name) in handlers { let handler: Arc = match kind { - ToolHandlerKind::Bash => Arc::new(BashHandler::new()), #[cfg(feature = "code-search")] ToolHandlerKind::CodeSearch => { let service = Arc::new( @@ -186,7 +183,7 @@ fn build_registry_from_builder( )), }; let legacy_alias = match kind { - ToolHandlerKind::Bash if name == "shell_command" => Some("bash"), + ToolHandlerKind::ShellCommand if name == "shell_command" => Some("bash"), ToolHandlerKind::Glob if name == "find" => Some("glob"), ToolHandlerKind::Question if name == "request_user_input" => Some("question"), ToolHandlerKind::WebSearch if name == "web_search" => Some("websearch"), diff --git a/crates/core/src/tools/handlers/shell_command.rs b/crates/core/src/tools/handlers/shell_command.rs index f2b4049d..2395302b 100644 --- a/crates/core/src/tools/handlers/shell_command.rs +++ b/crates/core/src/tools/handlers/shell_command.rs @@ -1,21 +1,24 @@ -use std::path::PathBuf; - use async_trait::async_trait; use crate::contracts::{ ToolCallError, ToolContext, ToolProgressSender, ToolResult, ToolResultContent, }; -use crate::json_schema::JsonSchema; +use crate::registry_plan::shell_command_tool_spec; use crate::shell_exec::{ - ShellExecRequest, default_max_output_tokens, default_timeout_ms, default_yield_time_ms, + DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_YIELD_TIME_MS, ShellExecRequest, execute_shell_command, }; use crate::tool_handler::ToolHandler; -use crate::tool_spec::{ToolCapabilityTag, ToolExecutionMode, ToolOutputMode, ToolSpec}; +use crate::tool_spec::ToolSpec; use crate::tools::client_terminal_shell::{ ClientTerminalShellRequest, execute_with_client_terminal, }; +/// Tool adapter for `shell_command` (and the legacy `bash` alias). +/// +/// Parses model input and delegates process execution to [`execute_shell_command`] +/// or the client terminal when available. The ToolSpec comes from +/// [`shell_command_tool_spec`] so the registry plan and handler share one schema. pub struct ShellCommandHandler { spec: ToolSpec, } @@ -29,36 +32,7 @@ impl Default for ShellCommandHandler { impl ShellCommandHandler { pub fn new() -> Self { Self { - spec: ToolSpec { - name: "shell_command".into(), - description: "Executes a shell command with optional timeout.".into(), - input_schema: JsonSchema::object( - std::collections::BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::string(Some("The command to execute.")), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some("Working directory")), - ), - ( - "timeout_ms".to_string(), - JsonSchema::integer(Some("Timeout in milliseconds")), - ), - ]), - Some(vec!["command".to_string()]), - None, - ), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![ToolCapabilityTag::ExecuteProcess], - supports_parallel: false, - preparation_feedback: crate::tool_spec::ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }, + spec: shell_command_tool_spec("shell_command"), } } } @@ -81,15 +55,28 @@ impl ToolHandler for ShellCommandHandler { .and_then(|v| v.as_str()) .ok_or_else(|| ToolCallError::InvalidInput("missing 'command' field".into()))?; - let workdir = input - .get("workdir") - .and_then(|v| v.as_str()) - .map(PathBuf::from) + let timeout_ms = input["timeout"] + .as_u64() + .or_else(|| input["timeout_ms"].as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_MS); + let workdir = input["workdir"] + .as_str() + .map(std::path::PathBuf::from) .unwrap_or_else(|| ctx.workspace_root.clone()); - - let timeout_ms = input["timeout_ms"].as_u64().unwrap_or(default_timeout_ms()); - + let description = input["description"] + .as_str() + .unwrap_or("shell command") + .to_string(); + let shell_override = input["shell"].as_str().map(ToOwned::to_owned); + let tty = input["tty"].as_bool().unwrap_or(false); let login = input["login"].as_bool().unwrap_or(true); + let yield_time_ms = input["yield_time_ms"] + .as_u64() + .unwrap_or(DEFAULT_YIELD_TIME_MS); + let max_output_tokens = input["max_output_tokens"] + .as_u64() + .map(|v| v as usize) + .unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS); let terminal_workdir = if workdir.is_absolute() { workdir.clone() } else { @@ -101,11 +88,11 @@ impl ToolHandler for ShellCommandHandler { ClientTerminalShellRequest { command: command.to_string(), workdir: terminal_workdir, - description: "shell command".into(), - shell_override: None, + description: description.clone(), + shell_override: shell_override.clone(), login, timeout_ms, - max_output_tokens: default_max_output_tokens(), + max_output_tokens, }, progress, ) @@ -118,13 +105,13 @@ impl ToolHandler for ShellCommandHandler { ShellExecRequest { command: command.to_string(), workdir, - description: "shell command".into(), - shell_override: None, - tty: false, + description, + shell_override, + tty, login, timeout_ms, - yield_time_ms: default_yield_time_ms(), - max_output_tokens: default_max_output_tokens(), + yield_time_ms, + max_output_tokens, sandbox_profile: ctx.sandbox_profile.clone(), }, None, @@ -133,18 +120,18 @@ impl ToolHandler for ShellCommandHandler { .await .map_err(|e| ToolCallError::ExecutionFailed(e.to_string()))?; + let display = output.display_content; let text = output.content.into_string(); - if output.is_error { - Ok(ToolResult::error( + let mut result = if output.is_error { + ToolResult::error( ToolResultContent::Text(text.clone()), "Command failed", ToolCallError::ExecutionFailed(text), - )) + ) } else { - Ok(ToolResult::success( - ToolResultContent::Text(text), - "Command executed", - )) - } + ToolResult::success(ToolResultContent::Text(text), "Command executed") + }; + result.display_content = display; + Ok(result) } } diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs index 74d73279..fe2aa7a9 100644 --- a/crates/core/src/tools/registry_plan.rs +++ b/crates/core/src/tools/registry_plan.rs @@ -8,7 +8,7 @@ use crate::tool_spec::{ use crate::tools::websearch_prompt::web_search_prompt; use devo_config::AppConfig; -const BASH_DESCRIPTION: &str = include_str!("bash.txt"); +const SHELL_COMMAND_DESCRIPTION: &str = include_str!("shell_command.txt"); const READ_DESCRIPTION: &str = include_str!("read.txt"); const WRITE_DESCRIPTION: &str = include_str!("write.txt"); const EDIT_DESCRIPTION: &str = include_str!("edit.txt"); @@ -69,8 +69,9 @@ impl ToolPlanConfig { pub fn validate(&self) { // No incompatible combinations currently exist. - // - use_shell_command and use_unified_exec are independent (shell_command replaces bash, - // unified exec adds new tools) + // - use_shell_command and use_unified_exec are independent (shell_command is the + // canonical shell tool name; setting use_shell_command false keeps legacy "bash") + // - unified exec adds new tools alongside shell_command // - code_search is a read-only search tool and does not conflict with either // - all can be true simultaneously with no conflict } @@ -90,6 +91,23 @@ impl Default for ToolPlanConfig { } } +/// Shared ToolSpec for the shell tool (`shell_command`, or legacy name `bash`). +pub(crate) fn shell_command_tool_spec(name: impl Into) -> ToolSpec { + ToolSpec { + name: name.into(), + description: shell_command_description(), + input_schema: shell_command_schema(), + output_mode: ToolOutputMode::Mixed, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![ToolCapabilityTag::ExecuteProcess], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + } +} + fn shell_command_schema() -> JsonSchema { JsonSchema::object( BTreeMap::from([ @@ -159,7 +177,7 @@ fn shell_command_schema() -> JsonSchema { ) } -fn bash_description() -> String { +fn shell_command_description() -> String { let chaining = if cfg!(windows) { "If commands depend on each other and must run sequentially, use a single PowerShell command string. In Windows PowerShell 5.1, do not rely on Bash chaining semantics like `cmd1 && cmd2`; prefer `cmd1; if ($?) { cmd2 }` when the later command depends on earlier success." } else { @@ -168,7 +186,7 @@ fn bash_description() -> String { let shell = if cfg!(windows) { "powershell" } else { "bash" }; - BASH_DESCRIPTION + SHELL_COMMAND_DESCRIPTION .replace( "${directory}", &std::env::current_dir().map_or_else(|_| ".".to_string(), |p| p.display().to_string()), @@ -703,37 +721,14 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { if config.use_shell_command { plan.push( - ToolSpec { - name: "shell_command".to_string(), - description: bash_description(), - input_schema: shell_command_schema(), - output_mode: ToolOutputMode::Mixed, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![ToolCapabilityTag::ExecuteProcess], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }, - ToolHandlerKind::Bash, + shell_command_tool_spec("shell_command"), + ToolHandlerKind::ShellCommand, ); } else { + // Legacy tool name; same handler and schema as shell_command. plan.push( - ToolSpec { - name: "bash".to_string(), - description: bash_description(), - input_schema: shell_command_schema(), - output_mode: ToolOutputMode::Mixed, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![ToolCapabilityTag::ExecuteProcess], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }, - ToolHandlerKind::Bash, + shell_command_tool_spec("bash"), + ToolHandlerKind::ShellCommand, ); } @@ -1108,7 +1103,8 @@ mod tests { assert!( plan.handlers .iter() - .any(|(kind, name)| *kind == ToolHandlerKind::Bash && name == "shell_command") + .any(|(kind, name)| *kind == ToolHandlerKind::ShellCommand + && name == "shell_command") ); } diff --git a/crates/core/src/tools/bash.txt b/crates/core/src/tools/shell_command.txt similarity index 100% rename from crates/core/src/tools/bash.txt rename to crates/core/src/tools/shell_command.txt diff --git a/crates/core/src/tools/shell_exec.rs b/crates/core/src/tools/shell_exec.rs index 3ddd58fe..3e77c410 100644 --- a/crates/core/src/tools/shell_exec.rs +++ b/crates/core/src/tools/shell_exec.rs @@ -14,9 +14,9 @@ use crate::events::ToolProgressSender; use crate::invocation::FunctionToolOutput; const MAX_METADATA_LENGTH: usize = 30_000; -const DEFAULT_TIMEOUT_MS: u64 = 120_000; -const DEFAULT_YIELD_TIME_MS: u64 = 1_000; -const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; +pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; +pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000; +pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; const TRUNCATED_SUFFIX: &str = "\n\n... [truncated]"; #[cfg(not(unix))] @@ -61,12 +61,22 @@ fn try_windows_sandbox_launch( } } +/// Input to [`execute_shell_command`]: the caller's raw request before shell +/// resolution or pipe/PTY branching. +/// +/// `shell_override` / `login` select the interpreter; `tty` chooses the +/// execution path. Shared runtime knobs (workdir, timeouts, sandbox, …) are +/// forwarded into whichever path runs. pub(crate) struct ShellExecRequest { pub command: String, pub workdir: PathBuf, pub description: String, + /// Optional shell name/alias (`bash`, `pwsh`, `cmd`, …). `None` uses the + /// platform default. pub shell_override: Option, + /// When true, run under a PTY via [`run_with_pty`]; otherwise pipe spawn. pub tty: bool, + /// Prefer login-shell args (e.g. `bash -lc`) when resolving the shell. pub login: bool, pub timeout_ms: u64, pub yield_time_ms: u64, @@ -74,6 +84,10 @@ pub(crate) struct ShellExecRequest { pub sandbox_profile: Option, } +/// Resolved arguments for [`run_with_pty`] after `ShellExecRequest` has been +/// normalized: shell override/login → [`ShellSpec`], and the command possibly +/// rewritten (e.g. PowerShell UTF-8 prelude). Does not carry `tty` / +/// `shell_override` / `login` because those are already applied. struct PtyRunConfig { shell: ShellSpec, command_to_run: String, @@ -85,15 +99,22 @@ struct PtyRunConfig { sandbox_profile: Option, } +/// RAII guard around a PTY-spawned child process. +/// +/// Ensures the child is killed if the guard is dropped while still armed +/// (timeout, cancel, or early return). Call [`Self::disarm`] after a clean +/// exit so [`Drop`] does not kill an already-reaped process. struct PtyChildGuard { child: Option>, } impl PtyChildGuard { + /// Take ownership of `child` and keep the guard armed. fn new(child: Box) -> Self { Self { child: Some(child) } } + /// Non-blocking poll for exit status; panics if already disarmed. fn try_wait(&mut self) -> std::io::Result> { self.child .as_mut() @@ -101,6 +122,7 @@ impl PtyChildGuard { .try_wait() } + /// Force-kill the child and wait for it to exit (best-effort). fn kill_and_wait(&mut self) { if let Some(child) = self.child.as_mut() { let _ = child.kill(); @@ -108,12 +130,14 @@ impl PtyChildGuard { } } + /// Release ownership without killing; subsequent [`Drop`] is a no-op. fn disarm(mut self) { self.child.take(); } } impl Drop for PtyChildGuard { + /// Kill the child if the guard was dropped while still armed. fn drop(&mut self) { if let Some(child) = self.child.as_mut() { let _ = child.kill(); @@ -121,53 +145,18 @@ impl Drop for PtyChildGuard { } } -pub(crate) fn default_timeout_ms() -> u64 { - DEFAULT_TIMEOUT_MS -} - -pub(crate) fn default_yield_time_ms() -> u64 { - DEFAULT_YIELD_TIME_MS -} - -pub(crate) fn default_max_output_tokens() -> usize { - DEFAULT_MAX_OUTPUT_TOKENS -} - -#[allow(dead_code)] -pub(crate) fn windows_destructive_filesystem_guidance() -> &'static str { - r#"Windows safety rules: -- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations. -- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked."# -} - -#[allow(dead_code)] -pub(crate) fn shell_command_description() -> String { - if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. - -Examples of valid command strings: - -- ls -a (show hidden): "Get-ChildItem -Force" -- recursive find by name: "Get-ChildItem -Recurse -Filter *.py" -- recursive grep: "Get-ChildItem -Path C:\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" -- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}" -- setting an env var: "$env:FOO='bar'; echo $env:FOO" -- running an inline Python script: "@'\nprint('Hello, world!')\n'@ | python -" - -{}"#, - windows_destructive_filesystem_guidance() - ) - } else { - "Runs a shell command and returns its output.\n- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary.".to_string() - } -} - +/// Run a shell command from a [`ShellExecRequest`]. +/// +/// Resolves the shell and command, then either delegates to [`run_with_pty`] +/// when `tty` is set, or spawns a non-interactive pipe process (stdout/stderr +/// captured). Applies sandbox wrapping when a profile is set, waits for +/// completion (or cancel/timeout), and returns truncated tool output. pub(crate) async fn execute_shell_command( request: ShellExecRequest, progress: Option, cancel_token: CancellationToken, ) -> anyhow::Result { + // --- Validate request & normalize shell/command --- let ShellExecRequest { command, workdir, @@ -189,6 +178,7 @@ pub(crate) async fn execute_shell_command( } let shell = resolve_shell(shell_override.as_deref(), login); + // PowerShell often emits mojibake without an explicit UTF-8 console encoding. let command_to_run = if cfg!(windows) && shell.program.eq_ignore_ascii_case("powershell") { format!( concat!( @@ -204,6 +194,7 @@ pub(crate) async fn execute_shell_command( command }; + // --- PTY path (interactive / TTY) --- if tty { return run_with_pty( PtyRunConfig { @@ -222,12 +213,18 @@ pub(crate) async fn execute_shell_command( .await; } + // --- Pipe path: sandbox wrap + build Command --- info!(command = %command_to_run, shell = shell.program, "executing shell command"); let command_preview = preview(&command_to_run); - // Linux pipe spawns compose a bwrap wrapper with the pre_exec sandbox when - // the profile needs enforcement Landlock cannot express (deny paths, - // network restriction); everything else runs unwrapped. + // Unix (`cfg(unix)` covers Linux *and* macOS): decide whether to launch through + // an OS sandbox wrapper. `wrap_command_for_profile` picks the launcher: + // - macOS: `sandbox-exec` with a Seatbelt profile (full policy). Seatbelt is + // never applied via `pre_exec` after fork in a multithreaded process. + // - Linux: Landlock/`pre_exec` usually carries the profile; `bwrap` is added + // only when PipeComposed needs what Landlock cannot express (deny paths, + // network restriction). + // Windows uses the separate `try_windows_sandbox_launch` path below. #[cfg(unix)] let sandbox_wrap = match devo_sandbox::wrap_command_for_profile( sandbox_profile.as_deref(), @@ -259,6 +256,7 @@ pub(crate) async fn execute_shell_command( } }; + // Prefer OS wrapper (`sandbox-exec` / `bwrap` / Windows launcher); else bare shell. let mut child = match &sandbox_wrap { devo_sandbox::SandboxWrap::Wrapped(wrapped) => { let mut child = Command::new(&wrapped.program); @@ -298,16 +296,14 @@ pub(crate) async fn execute_shell_command( .current_dir(&workdir) .kill_on_drop(true); + // --- Apply in-process sandbox (Unix pre_exec) and env --- #[cfg(unix)] { let sandbox_workspace = workdir.clone(); - let helper_enforces = matches!( - &sandbox_wrap, - devo_sandbox::SandboxWrap::Wrapped(wrapped) if wrapped.helper_enforces - ); - let sandbox_plan = if helper_enforces { - None - } else { + // `requires_child_apply` is false on macOS (Seatbelt is only via + // `sandbox-exec`) and when a Linux wrapper already enforces the full + // policy. Otherwise resolve Landlock/seccomp for `pre_exec`. + let sandbox_plan = if sandbox_wrap.requires_child_apply() { match devo_util_process::sandbox::resolve_profile_for_spawn( sandbox_profile.as_deref(), &sandbox_workspace, @@ -319,8 +315,15 @@ pub(crate) async fn execute_shell_command( ))); } } + } else { + None }; unsafe { + // `pre_exec` runs in the child after `fork`, before `exec`. Apply the + // parent-resolved Landlock/seccomp plan here so only the spawned + // command is sandboxed (parent stays unrestricted). Config must not + // be loaded in this hook — resolve above in the parent. Skipped when + // `sandbox_plan` is `None` (macOS / fully wrapped Linux). child.pre_exec(move || { devo_util_process::sandbox::apply_resolved_in_child(sandbox_plan.as_ref()) }); @@ -336,6 +339,7 @@ pub(crate) async fn execute_shell_command( #[cfg(unix)] apply_sandbox_proxy_env(&mut child, sandbox_profile.as_deref(), &workdir); + // --- Spawn and schedule sandbox placeholder cleanup --- let spawned = match child.spawn() { Ok(child) => child, Err(error) => { @@ -356,6 +360,7 @@ pub(crate) async fn execute_shell_command( }); } + // --- Wait for exit, cancel, or timeout --- let result = tokio::select! { result = timeout(Duration::from_millis(timeout_ms), spawned.wait_with_output()) => result, _ = cancel_token.cancelled() => { @@ -363,6 +368,7 @@ pub(crate) async fn execute_shell_command( } }; + // --- Build success / error tool output --- match result { Ok(Ok(output)) => { let stdout = String::from_utf8_lossy(&output.stdout); @@ -524,11 +530,18 @@ fn apply_sandbox_proxy_env( } } +/// Run a command attached to a pseudo-terminal (PTY). +/// +/// Used when [`ShellExecRequest::tty`] is true. Opens a PTY, optionally wraps +/// the spawn in an OS sandbox launcher (no `pre_exec` on this path), reads +/// master output on a background thread, and polls the child until exit, +/// timeout, or cancel. Returns truncated tool output with TTY metadata. async fn run_with_pty( config: PtyRunConfig, progress: Option, cancel_token: CancellationToken, ) -> anyhow::Result { + // --- Open PTY --- let PtyRunConfig { shell, command_to_run, @@ -549,9 +562,11 @@ async fn run_with_pty( }) .map_err(|error| anyhow::anyhow!("failed to open PTY: {error}"))?; - // PTY spawns have no pre_exec hook: enforce the profile by wrapping the - // command in the OS sandbox launcher (macOS sandbox-exec, Linux bwrap). - // The wrapped child must NOT also apply the profile (no nested sandboxes). + // --- Sandbox wrap (OS launcher only; no nested in-child apply) --- + // PTY spawns have no `pre_exec` hook. Unix: `wrap_command_for_profile(PtyOnly)` + // wraps with macOS `sandbox-exec` or Linux `bwrap` carrying the full profile. + // Windows: `try_windows_sandbox_launch` below. Do not also apply the profile + // in-process (no nested sandboxes). #[cfg(unix)] let sandbox_wrap = match devo_sandbox::wrap_command_for_profile( sandbox_profile.as_deref(), @@ -585,6 +600,7 @@ async fn run_with_pty( #[cfg(not(unix))] let _ = sandbox_profile; + // --- Build CommandBuilder (wrapper or bare shell) --- let mut builder = match &sandbox_wrap { devo_sandbox::SandboxWrap::Wrapped(wrapped) => { let mut builder = CommandBuilder::new(&wrapped.program); @@ -614,6 +630,7 @@ async fn run_with_pty( CommandBuilder::new(shell.program) } }; + // Windows sandbox launch already embeds the full command line. #[cfg(not(unix))] if windows_launch.is_none() { builder.args(shell.args); @@ -637,6 +654,7 @@ async fn run_with_pty( builder.env(key, value); } + // --- Spawn on slave, guard child, drop slave fd --- let child = pair .slave .spawn_command(builder) @@ -655,6 +673,7 @@ async fn run_with_pty( let mut child = PtyChildGuard::new(child); drop(pair.slave); + // --- Background reader: master → channel --- let mut reader = pair .master .try_clone_reader() @@ -675,6 +694,7 @@ async fn run_with_pty( } }); + // --- Poll loop: drain output, wait for exit / timeout / cancel --- let started = Instant::now(); let sleep_ms = yield_time_ms.max(10); let timeout = Duration::from_millis(timeout_ms); @@ -684,6 +704,7 @@ async fn run_with_pty( let mut cancelled = false; loop { + // Non-blocking drain so progress can stream while the child still runs. while let Ok(chunk) = rx.try_recv() { output.extend_from_slice(&chunk); if let Some(ref sender) = progress { @@ -716,6 +737,7 @@ async fn run_with_pty( } } + // --- Final drain + tool result --- while let Ok(chunk) = rx.try_recv() { output.extend_from_slice(&chunk); } @@ -733,6 +755,7 @@ async fn run_with_pty( "command cancelled\n{text}" ))); } + // Clean exit: release ownership so Drop does not kill a finished process. child.disarm(); let is_error = exit_code.unwrap_or(1) != 0; @@ -761,317 +784,4 @@ async fn run_with_pty( } #[cfg(test)] -mod tests { - use super::*; - use crate::ToolContent; - use pretty_assertions::assert_eq; - use std::hint::black_box; - use std::time::Instant; - - #[tokio::test] - async fn execute_shell_command_non_tty_sends_progress() { - let cmd = "echo stream_test"; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - - let result = execute_shell_command( - ShellExecRequest { - command: cmd.to_string(), - workdir: std::env::current_dir().unwrap_or_default(), - description: "test".into(), - shell_override: None, - tty: false, - login: false, - timeout_ms: 5000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - Some(tx), - CancellationToken::new(), - ) - .await; - - assert!(result.is_ok(), "command should succeed: {:?}", result.err()); - // Progress channel should have received output - if let Ok(chunk) = rx.try_recv() { - assert!(!chunk.is_empty(), "progress chunk should not be empty"); - } - } - - #[tokio::test] - async fn execute_shell_command_progress_none_does_not_crash() { - let cmd = "echo test"; - let result = execute_shell_command( - ShellExecRequest { - command: cmd.to_string(), - workdir: std::env::current_dir().unwrap_or_default(), - description: "test".into(), - shell_override: None, - tty: false, - login: false, - timeout_ms: 5000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - None, - CancellationToken::new(), - ) - .await; - assert!(result.is_ok()); - } - - #[cfg(unix)] - #[tokio::test] - async fn execute_shell_command_cancels_non_tty_process() { - let cancel_token = CancellationToken::new(); - let cancel_task_token = cancel_token.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(50)).await; - cancel_task_token.cancel(); - }); - - let result = execute_shell_command( - ShellExecRequest { - command: "sleep 5; echo should_not_print".to_string(), - workdir: std::env::current_dir().unwrap_or_default(), - description: "cancel test".into(), - shell_override: None, - tty: false, - login: false, - timeout_ms: 10_000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - None, - cancel_token, - ) - .await - .expect("execute shell command"); - - assert!(result.is_error); - assert_eq!(result.content.into_string(), "command cancelled"); - } - - #[cfg(unix)] - #[tokio::test] - async fn aborting_tty_command_kills_pty_child() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let started_marker = temp_dir.path().join("started"); - let delayed_marker = temp_dir.path().join("delayed"); - let quote_path = |path: &std::path::Path| { - format!("'{}'", path.display().to_string().replace('\'', "'\\''")) - }; - let command = format!( - "touch {}; sleep 2; touch {}", - quote_path(&started_marker), - quote_path(&delayed_marker) - ); - let cancel_token = CancellationToken::new(); - let task_cancel_token = cancel_token.clone(); - let task = tokio::spawn(execute_shell_command( - ShellExecRequest { - command, - workdir: temp_dir.path().to_path_buf(), - description: "abort PTY test".into(), - shell_override: Some("bash".to_string()), - tty: true, - login: false, - timeout_ms: 10_000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - None, - task_cancel_token, - )); - - for _ in 0..50 { - if started_marker.exists() { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - assert!(started_marker.exists(), "PTY command should have started"); - cancel_token.cancel(); - task.abort(); - let _ = task.await; - tokio::time::sleep(Duration::from_millis(2_500)).await; - - assert!( - !delayed_marker.exists(), - "aborted PTY command should not reach delayed marker" - ); - } - - #[tokio::test] - async fn execute_shell_command_success_metadata_is_mixed() { - let result = execute_shell_command( - ShellExecRequest { - command: "echo metadata_test".to_string(), - workdir: std::env::current_dir().unwrap_or_default(), - description: "metadata test".into(), - shell_override: None, - tty: false, - login: false, - timeout_ms: 5000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - None, - CancellationToken::new(), - ) - .await - .expect("execute shell command"); - - assert!(!result.is_error); - match result.content { - ToolContent::Mixed { - text: Some(text), - json: Some(metadata), - } => { - assert!(text.contains("metadata_test")); - assert_eq!(metadata["description"], "metadata test"); - } - content => panic!("expected mixed success output, got {content:?}"), - } - } - - #[tokio::test] - async fn execute_shell_command_error_output_is_text_only() { - let result = execute_shell_command( - ShellExecRequest { - command: "exit 7".to_string(), - workdir: std::env::current_dir().unwrap_or_default(), - description: "error test".into(), - shell_override: None, - tty: false, - login: false, - timeout_ms: 5000, - yield_time_ms: 100, - max_output_tokens: 100, - sandbox_profile: None, - }, - None, - CancellationToken::new(), - ) - .await - .expect("execute shell command"); - - assert!(result.is_error); - assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); - } - - use super::{merge_streams, platform_shell_program, preview, resolve_shell, truncate_output}; - - #[test] - #[cfg(windows)] - fn resolve_shell_prefers_powershell_alias() { - let spec = resolve_shell(Some("pwsh"), true); - assert_eq!(spec.program, "powershell"); - assert_eq!(spec.args, &["-NoLogo", "-NoProfile", "-Command"]); - } - - #[test] - #[cfg(windows)] - fn resolve_shell_prefers_cmd_alias() { - let spec = resolve_shell(Some("cmd.exe"), true); - assert_eq!(spec.program, "cmd"); - assert_eq!(spec.args, &["/C"]); - } - - #[test] - fn resolve_shell_defaults_to_platform_shell_login() { - let spec = resolve_shell(None, true); - assert_eq!(spec.program, platform_shell_program(true)); - } - - #[test] - fn preview_truncates_long_text() { - let long = "a".repeat(30_001); - let result = preview(&long); - assert!(result.ends_with("\n\n...")); - } - - #[test] - fn truncate_output_handles_zero_tokens() { - assert_eq!(truncate_output("text", 0), ""); - } - - #[test] - fn truncate_output_limits_length() { - let input = "a".repeat(200); - let result = truncate_output(&input, 10); - assert!(result.ends_with("\n\n... [truncated]")); - assert!(result.len() < input.len()); - } - - #[test] - fn truncate_output_preserves_utf8_boundaries() { - assert_eq!(truncate_output("😀😀😀", 1), "😀😀😀"); - assert_eq!( - truncate_output("😀😀😀😀😀", 1), - "😀😀😀😀\n\n... [truncated]" - ); - } - - #[test] - #[ignore] - fn bench_truncate_output_ascii_no_truncation() { - let input = "shell output line\n".repeat(256); - let iterations = 200_000; - let expected_len = input.len(); - let started = Instant::now(); - let mut total_len = 0usize; - - for _ in 0..iterations { - total_len += black_box(truncate_output(black_box(&input), black_box(2_000))).len(); - } - - let elapsed = started.elapsed(); - assert_eq!(total_len, expected_len * iterations); - println!( - "truncate_output_ascii_no_truncation iterations={iterations} bytes={expected_len} elapsed_ms={} per_call_us={:.2}", - elapsed.as_secs_f64() * 1_000.0, - elapsed.as_secs_f64() * 1_000_000.0 / iterations as f64 - ); - } - - #[test] - #[ignore] - fn bench_truncate_output_ascii_large_truncation() { - let input = "shell output line\n".repeat(8_192); - let iterations = 50_000; - let expected_len = truncate_output(&input, 1_000).len(); - let started = Instant::now(); - let mut total_len = 0usize; - - for _ in 0..iterations { - total_len += black_box(truncate_output(black_box(&input), black_box(1_000))).len(); - } - - let elapsed = started.elapsed(); - assert_eq!(total_len, expected_len * iterations); - println!( - "truncate_output_ascii_large_truncation iterations={iterations} bytes={} elapsed_ms={} per_call_us={:.2}", - input.len(), - elapsed.as_secs_f64() * 1_000.0, - elapsed.as_secs_f64() * 1_000_000.0 / iterations as f64 - ); - } - - #[test] - fn merge_streams_combines_stdout_and_stderr() { - let result = merge_streams("out", "err"); - assert!(result.contains("out")); - assert!(result.contains("[stderr]")); - assert!(result.contains("err")); - } - - #[test] - fn merge_streams_no_output() { - assert_eq!(merge_streams("", ""), ""); - } -} +mod tests; diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs new file mode 100644 index 00000000..2943e778 --- /dev/null +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -0,0 +1,311 @@ +use super::*; +use crate::ToolContent; +use pretty_assertions::assert_eq; +use std::hint::black_box; +use std::time::Instant; + +#[tokio::test] +async fn execute_shell_command_non_tty_sends_progress() { + let cmd = "echo stream_test"; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + + let result = execute_shell_command( + ShellExecRequest { + command: cmd.to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + Some(tx), + CancellationToken::new(), + ) + .await; + + assert!(result.is_ok(), "command should succeed: {:?}", result.err()); + // Progress channel should have received output + if let Ok(chunk) = rx.try_recv() { + assert!(!chunk.is_empty(), "progress chunk should not be empty"); + } +} + +#[tokio::test] +async fn execute_shell_command_progress_none_does_not_crash() { + let cmd = "echo test"; + let result = execute_shell_command( + ShellExecRequest { + command: cmd.to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + CancellationToken::new(), + ) + .await; + assert!(result.is_ok()); +} + +#[cfg(unix)] +#[tokio::test] +async fn execute_shell_command_cancels_non_tty_process() { + let cancel_token = CancellationToken::new(); + let cancel_task_token = cancel_token.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + cancel_task_token.cancel(); + }); + + let result = execute_shell_command( + ShellExecRequest { + command: "sleep 5; echo should_not_print".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "cancel test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 10_000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + cancel_token, + ) + .await + .expect("execute shell command"); + + assert!(result.is_error); + assert_eq!(result.content.into_string(), "command cancelled"); +} + +#[cfg(unix)] +#[tokio::test] +async fn aborting_tty_command_kills_pty_child() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let started_marker = temp_dir.path().join("started"); + let delayed_marker = temp_dir.path().join("delayed"); + let quote_path = + |path: &std::path::Path| format!("'{}'", path.display().to_string().replace('\'', "'\\''")); + let command = format!( + "touch {}; sleep 2; touch {}", + quote_path(&started_marker), + quote_path(&delayed_marker) + ); + let cancel_token = CancellationToken::new(); + let task_cancel_token = cancel_token.clone(); + let task = tokio::spawn(execute_shell_command( + ShellExecRequest { + command, + workdir: temp_dir.path().to_path_buf(), + description: "abort PTY test".into(), + shell_override: Some("bash".to_string()), + tty: true, + login: false, + timeout_ms: 10_000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + task_cancel_token, + )); + + for _ in 0..50 { + if started_marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(started_marker.exists(), "PTY command should have started"); + cancel_token.cancel(); + task.abort(); + let _ = task.await; + tokio::time::sleep(Duration::from_millis(2_500)).await; + + assert!( + !delayed_marker.exists(), + "aborted PTY command should not reach delayed marker" + ); +} + +#[tokio::test] +async fn execute_shell_command_success_metadata_is_mixed() { + let result = execute_shell_command( + ShellExecRequest { + command: "echo metadata_test".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "metadata test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + CancellationToken::new(), + ) + .await + .expect("execute shell command"); + + assert!(!result.is_error); + match result.content { + ToolContent::Mixed { + text: Some(text), + json: Some(metadata), + } => { + assert!(text.contains("metadata_test")); + assert_eq!(metadata["description"], "metadata test"); + } + content => panic!("expected mixed success output, got {content:?}"), + } +} + +#[tokio::test] +async fn execute_shell_command_error_output_is_text_only() { + let result = execute_shell_command( + ShellExecRequest { + command: "exit 7".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "error test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 5000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + CancellationToken::new(), + ) + .await + .expect("execute shell command"); + + assert!(result.is_error); + assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); +} + +use super::{merge_streams, platform_shell_program, preview, resolve_shell, truncate_output}; + +#[test] +#[cfg(windows)] +fn resolve_shell_prefers_powershell_alias() { + let spec = resolve_shell(Some("pwsh"), true); + assert_eq!(spec.program, "powershell"); + assert_eq!(spec.args, &["-NoLogo", "-NoProfile", "-Command"]); +} + +#[test] +#[cfg(windows)] +fn resolve_shell_prefers_cmd_alias() { + let spec = resolve_shell(Some("cmd.exe"), true); + assert_eq!(spec.program, "cmd"); + assert_eq!(spec.args, &["/C"]); +} + +#[test] +fn resolve_shell_defaults_to_platform_shell_login() { + let spec = resolve_shell(None, true); + assert_eq!(spec.program, platform_shell_program(true)); +} + +#[test] +fn preview_truncates_long_text() { + let long = "a".repeat(30_001); + let result = preview(&long); + assert!(result.ends_with("\n\n...")); +} + +#[test] +fn truncate_output_handles_zero_tokens() { + assert_eq!(truncate_output("text", 0), ""); +} + +#[test] +fn truncate_output_limits_length() { + let input = "a".repeat(200); + let result = truncate_output(&input, 10); + assert!(result.ends_with("\n\n... [truncated]")); + assert!(result.len() < input.len()); +} + +#[test] +fn truncate_output_preserves_utf8_boundaries() { + assert_eq!(truncate_output("😀😀😀", 1), "😀😀😀"); + assert_eq!( + truncate_output("😀😀😀😀😀", 1), + "😀😀😀😀\n\n... [truncated]" + ); +} + +#[test] +#[ignore] +fn bench_truncate_output_ascii_no_truncation() { + let input = "shell output line\n".repeat(256); + let iterations = 200_000; + let expected_len = input.len(); + let started = Instant::now(); + let mut total_len = 0usize; + + for _ in 0..iterations { + total_len += black_box(truncate_output(black_box(&input), black_box(2_000))).len(); + } + + let elapsed = started.elapsed(); + assert_eq!(total_len, expected_len * iterations); + println!( + "truncate_output_ascii_no_truncation iterations={iterations} bytes={expected_len} elapsed_ms={} per_call_us={:.2}", + elapsed.as_secs_f64() * 1_000.0, + elapsed.as_secs_f64() * 1_000_000.0 / iterations as f64 + ); +} + +#[test] +#[ignore] +fn bench_truncate_output_ascii_large_truncation() { + let input = "shell output line\n".repeat(8_192); + let iterations = 50_000; + let expected_len = truncate_output(&input, 1_000).len(); + let started = Instant::now(); + let mut total_len = 0usize; + + for _ in 0..iterations { + total_len += black_box(truncate_output(black_box(&input), black_box(1_000))).len(); + } + + let elapsed = started.elapsed(); + assert_eq!(total_len, expected_len * iterations); + println!( + "truncate_output_ascii_large_truncation iterations={iterations} bytes={} elapsed_ms={} per_call_us={:.2}", + input.len(), + elapsed.as_secs_f64() * 1_000.0, + elapsed.as_secs_f64() * 1_000_000.0 / iterations as f64 + ); +} + +#[test] +fn merge_streams_combines_stdout_and_stderr() { + let result = merge_streams("out", "err"); + assert!(result.contains("out")); + assert!(result.contains("[stderr]")); + assert!(result.contains("err")); +} + +#[test] +fn merge_streams_no_output() { + assert_eq!(merge_streams("", ""), ""); +} diff --git a/crates/core/tests/pty_sandbox_e2e.rs b/crates/core/tests/pty_sandbox_e2e.rs index 3312f508..3747c96b 100644 --- a/crates/core/tests/pty_sandbox_e2e.rs +++ b/crates/core/tests/pty_sandbox_e2e.rs @@ -43,14 +43,19 @@ fn temp_workspace(tag: &str) -> (PathBuf, TempDirGuard) { (workspace, guard) } -async fn run_pty_command(command: &str, workspace: &Path, process_id: i32) -> String { +async fn run_sandboxed_command( + command: &str, + workspace: &Path, + process_id: i32, + tty: bool, +) -> String { let (process, mut rx) = UnifiedExecProcess::spawn_with_sandbox( process_id, command, workspace, /*shell*/ Some("bash"), /*login*/ false, - /*tty*/ true, + tty, Some(PROFILE.to_string()), ) .await @@ -80,14 +85,20 @@ async fn pty_spawn_enforces_deny_read_and_write() { } // A denied file must not be readable from inside the PTY. - let output = run_pty_command("cat secret.txt", &workspace, 1).await; + let output = run_sandboxed_command("cat secret.txt", &workspace, 1, /*tty*/ true).await; assert!( !output.contains(MARKER), "PTY child read a denied path:\n{output}" ); // A denied file must not be writable from inside the PTY. - let _ = run_pty_command("echo hijacked >> secret.txt", &workspace, 2).await; + let _ = run_sandboxed_command( + "echo hijacked >> secret.txt", + &workspace, + 2, + /*tty*/ true, + ) + .await; assert_eq!( std::fs::read_to_string(workspace.join("secret.txt")).expect("read denied file"), format!("SECRET={MARKER}"), @@ -95,9 +106,47 @@ async fn pty_spawn_enforces_deny_read_and_write() { ); // A non-denied file stays readable (the sandbox did not break the PTY). - let output = run_pty_command("cat control.txt", &workspace, 3).await; + let output = run_sandboxed_command("cat control.txt", &workspace, 3, /*tty*/ true).await; assert!( output.contains("hello workspace"), "PTY child must still read the control file:\n{output}" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn pipe_spawn_enforces_profile_without_child_side_seatbelt() { + let (workspace, _guard) = temp_workspace("pipe"); + + match devo_sandbox::wrap_command_for_profile( + Some(PROFILE), + &workspace, + devo_sandbox::WrapMode::PipeComposed, + &devo_sandbox::SandboxLogger::new(), + ) { + Ok(devo_sandbox::SandboxWrap::Wrapped(_)) => {} + other => { + eprintln!("skipping: no pipe sandbox launcher available ({other:?})"); + return; + } + } + + for process_id in 10..20 { + let output = run_sandboxed_command( + "cat control.txt", + &workspace, + process_id, + /*tty*/ false, + ) + .await; + assert!( + output.contains("hello workspace"), + "pipe child must read the control file:\n{output}" + ); + } + + let output = run_sandboxed_command("cat secret.txt", &workspace, 20, /*tty*/ false).await; + assert!( + !output.contains(MARKER), + "pipe child read a denied path:\n{output}" + ); +} diff --git a/crates/sandbox/src/denial.rs b/crates/sandbox/src/denial.rs index 4848e606..bc31836c 100644 --- a/crates/sandbox/src/denial.rs +++ b/crates/sandbox/src/denial.rs @@ -31,22 +31,30 @@ pub fn is_likely_sandbox_denied( false } -/// Seatbelt (and similar) often kill the sandboxed process with a signal and -/// leave empty stdout/stderr. `ExitStatus::code()` is then `None`, which callers -/// historically report as `-1` without a useful hint. +/// Detect a signal that specifically indicates sandbox enforcement. +/// +/// Empty output is not evidence by itself: a child can crash in `pre_exec` or +/// abort for unrelated reasons. Linux seccomp's `SIGSYS` is the only bare +/// signal classified as a denial; other signals require a denial keyword. pub fn is_likely_sandbox_denied_after_signal( sandbox_was_active: bool, signal: Option, stdout: &str, stderr: &str, ) -> bool { - if !sandbox_was_active || signal.is_none() { + if !sandbox_was_active { return false; } if streams_contain_sandbox_keyword(stdout, stderr) { return true; } - stdout.trim().is_empty() && stderr.trim().is_empty() + #[cfg(unix)] + if signal == Some(libc::SIGSYS) { + return true; + } + #[cfg(not(unix))] + let _ = signal; + false } fn streams_contain_sandbox_keyword(stdout: &str, stderr: &str) -> bool { @@ -201,9 +209,17 @@ mod tests { assert!(is_likely_sandbox_denied(true, 128 + libc::SIGSYS, "", "")); } + #[cfg(unix)] #[test] - fn empty_signal_death_under_sandbox_is_denial() { - assert!(is_likely_sandbox_denied_after_signal(true, Some(9), "", "")); + fn unrelated_empty_signal_death_is_not_denial() { + for signal in [libc::SIGTRAP, libc::SIGABRT, libc::SIGKILL] { + assert!(!is_likely_sandbox_denied_after_signal( + true, + Some(signal), + "", + "" + )); + } assert!(!is_likely_sandbox_denied_after_signal( false, Some(9), @@ -215,6 +231,17 @@ mod tests { )); } + #[cfg(unix)] + #[test] + fn sigsys_signal_detects_denial() { + assert!(is_likely_sandbox_denied_after_signal( + true, + Some(libc::SIGSYS), + "", + "" + )); + } + #[test] fn shell_error_message_prefixes_sandbox_hint() { let message = shell_error_message( @@ -240,17 +267,17 @@ mod tests { assert_eq!(message, "exit code 1\n[stderr]\noperation not permitted"); } + #[cfg(unix)] #[test] - fn shell_error_message_with_signal_prefixes_empty_kill() { + fn shell_error_message_with_signal_reports_unrelated_crash() { let message = shell_error_message_with_signal( Some("workspace"), /*exit_code*/ None, - Some(9), + Some(libc::SIGTRAP), "", "", "", ); - assert!(message.starts_with("SANDBOX_DENIED:")); - assert!(message.contains("exit code 137")); + assert_eq!(message, format!("exit code {}\n", 128 + libc::SIGTRAP)); } } diff --git a/crates/sandbox/src/wrap.rs b/crates/sandbox/src/wrap.rs index 778424ab..1e4d8342 100644 --- a/crates/sandbox/src/wrap.rs +++ b/crates/sandbox/src/wrap.rs @@ -4,10 +4,10 @@ //! //! Two composition modes: //! -//! - [`WrapMode::PipeComposed`]: pipe spawns already apply the profile via -//! `pre_exec` Landlock/Seatbelt, so the wrapper only adds what those kernel -//! primitives cannot express (Linux read-deny bind-overs and network -//! restriction). +//! - [`WrapMode::PipeComposed`]: Linux pipe spawns apply the profile via +//! `pre_exec` Landlock, so the wrapper only adds what Landlock cannot express. +//! macOS pipe spawns use `sandbox-exec`, because applying Seatbelt after +//! `fork()` is not safe in a multi-threaded process. //! - [`WrapMode::PtyOnly`]: PTY spawns have no `pre_exec` hook, so the //! wrapper carries the full profile policy. //! @@ -68,6 +68,20 @@ pub enum SandboxWrap { Wrapped(WrappedCommand), } +impl SandboxWrap { + /// Whether the spawner must still apply a resolved profile in `pre_exec`. + /// + /// macOS never applies Seatbelt after `fork()`: active profiles use the + /// `sandbox-exec` wrapper, while an unavailable wrapper preserves the + /// existing warn-and-run-unwrapped behavior. + pub fn requires_child_apply(&self) -> bool { + if cfg!(target_os = "macos") { + return false; + } + !matches!(self, Self::Wrapped(wrapped) if wrapped.helper_enforces) + } +} + /// A launcher invocation that sandboxes the original command. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WrappedCommand { @@ -81,8 +95,8 @@ pub struct WrappedCommand { /// [`remove_placeholder_dir`] [`PLACEHOLDER_CLEANUP_DELAY`] after a /// successful spawn (mounts are not up when `spawn` returns). pub placeholder_dir: Option, - /// When true, the Linux helper applies Landlock/seccomp inside the wrap; - /// the parent must not also apply a `pre_exec` plan onto the helper. + /// When true, the launcher applies the complete sandbox policy; the parent + /// must not also apply a `pre_exec` plan onto it. pub helper_enforces: bool, } @@ -263,21 +277,14 @@ fn wrap_for_platform( { // `config` and bwrap availability are Linux-only inputs. let _ = (config, launchers.bwrap); - match mode { - // Pipe children get full Seatbelt enforcement via pre_exec; a - // wrapper would add nothing on macOS. - WrapMode::PipeComposed => Ok(SandboxWrap::None), - // PTY spawns have no pre_exec hook: `sandbox-exec -p ` - // carries the full profile. - WrapMode::PtyOnly => macos_pty_wrap( - profile_name, - resolved, - workspace, - launchers.sandbox_exec, - mode, - logger, - ), - } + macos_wrap( + profile_name, + resolved, + workspace, + launchers.sandbox_exec, + mode, + logger, + ) } #[cfg(not(any(target_os = "linux", target_os = "macos")))] { @@ -295,11 +302,11 @@ fn wrap_for_platform( } } -/// macOS PTY wrap: `sandbox-exec -p ` carrying the full profile. Never -/// fails closed — a missing launcher, a build failure, or a failed precheck -/// warns, records an event, and runs unwrapped (user decision). +/// macOS wrap: `sandbox-exec -p ` carrying the full profile for both pipe +/// and PTY children. Never fails closed — a missing launcher, a build failure, +/// or a failed precheck warns, records an event, and runs unwrapped. #[cfg(all(feature = "enforce", target_os = "macos"))] -fn macos_pty_wrap( +fn macos_wrap( profile_name: &ProfileName, resolved: &SandboxProfile, workspace: &Path, @@ -310,7 +317,7 @@ fn macos_pty_wrap( if !sandbox_exec_available { tracing::warn!( profile = %profile_name, - "sandbox-exec is not available; PTY child runs WITHOUT sandbox enforcement \ + "sandbox-exec is not available; child runs WITHOUT sandbox enforcement \ (deny paths, filesystem policy, and network restriction are NOT enforced)" ); log_wrap_event( @@ -331,7 +338,7 @@ fn macos_pty_wrap( tracing::warn!( profile = %profile_name, error = %error, - "could not build the Seatbelt profile; PTY child runs WITHOUT \ + "could not build the Seatbelt profile; child runs WITHOUT \ sandbox enforcement" ); log_wrap_event( @@ -346,7 +353,7 @@ fn macos_pty_wrap( if !crate::seatbelt::sandbox_exec_accepts_profile(&sbpl) { tracing::warn!( profile = %profile_name, - "sandbox-exec rejected the generated Seatbelt profile; PTY child runs \ + "sandbox-exec rejected the generated Seatbelt profile; child runs \ WITHOUT sandbox enforcement" ); log_wrap_event( @@ -363,7 +370,8 @@ fn macos_pty_wrap( } tracing::info!( profile = %profile_name, - "spawning PTY command inside sandbox-exec (Seatbelt)" + mode = ?mode, + "spawning command inside sandbox-exec (Seatbelt)" ); log_wrap_event( logger, @@ -375,14 +383,14 @@ fn macos_pty_wrap( program: "/usr/bin/sandbox-exec".to_string(), prefix_args: vec!["-p".to_string(), sbpl], placeholder_dir: None, - helper_enforces: false, + helper_enforces: true, })) } /// Without the `enforce` feature there is no sbpl emitter; warn, record, and /// run unwrapped (never fail closed). #[cfg(all(not(feature = "enforce"), target_os = "macos"))] -fn macos_pty_wrap( +fn macos_wrap( profile_name: &ProfileName, _resolved: &SandboxProfile, workspace: &Path, @@ -392,7 +400,7 @@ fn macos_pty_wrap( ) -> anyhow::Result { tracing::warn!( profile = %profile_name, - "built without the 'enforce' feature; PTY child runs WITHOUT sandbox enforcement" + "built without the 'enforce' feature; child runs WITHOUT sandbox enforcement" ); log_wrap_event( logger, @@ -622,313 +630,4 @@ fn cleanup_stale_placeholder_dirs_in(root: &Path, now: SystemTime) { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn temp_workspace(tag: &str, toml_body: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("system clock after Unix epoch") - .as_nanos(); - let workspace = - std::env::temp_dir().join(format!("devo-wrap-{tag}-{}-{nanos}", std::process::id())); - let devo = workspace.join(".devo"); - std::fs::create_dir_all(&devo).expect("create sandbox config directory"); - std::fs::write(devo.join("sandbox.toml"), toml_body).expect("write sandbox config"); - workspace - } - - fn resolved_profile(deny: &[&str], restrict_network: bool) -> SandboxProfile { - SandboxProfile { - name: "test".to_string(), - read_only: vec![], - read_write: vec![], - deny: deny.iter().map(PathBuf::from).collect(), - default_read: true, - restrict_network, - } - } - - #[test] - fn none_and_off_profiles_never_wrap() { - let workspace = Path::new("/tmp"); - let logger = SandboxLogger::new(); - for profile in [None, Some("off"), Some("none")] { - for mode in [WrapMode::PtyOnly, WrapMode::PipeComposed] { - assert_eq!( - wrap_command_for_profile(profile, workspace, mode, &logger) - .expect("off/None profiles are not errors"), - SandboxWrap::None, - "profile {profile:?} in mode {mode:?} must not wrap" - ); - } - } - assert!( - logger.take_events().is_empty(), - "off/None profiles must not record events" - ); - } - - #[test] - fn undefined_custom_profile_is_an_error() { - let workspace = temp_workspace("missing", ""); - let error = wrap_command_for_profile( - Some("devo-test-missing-profile-xyz"), - &workspace, - WrapMode::PipeComposed, - &SandboxLogger::new(), - ) - .expect_err("an unresolvable profile name must fail, not silently unwrap"); - assert!( - error.to_string().contains("not found"), - "unexpected error: {error:#}" - ); - let _ = std::fs::remove_dir_all(&workspace); - } - - #[test] - #[cfg(target_os = "macos")] - fn macos_pipe_never_wraps_and_pty_wraps_via_sandbox_exec() { - let workspace = temp_workspace( - "macos", - "[profiles.wrapdeny]\nextends = \"workspace\"\ndeny = [\"secret.txt\"]\n", - ); - // Pipe children enforce via pre_exec Seatbelt, so they never wrap. - assert_eq!( - wrap_command_for_profile( - Some("wrapdeny"), - &workspace, - WrapMode::PipeComposed, - &SandboxLogger::new(), - ) - .expect("valid profile resolves"), - SandboxWrap::None, - "macOS PipeComposed must not wrap" - ); - match wrap_command_for_profile( - Some("wrapdeny"), - &workspace, - WrapMode::PtyOnly, - &SandboxLogger::new(), - ) - .expect("valid profile resolves") - { - SandboxWrap::Wrapped(wrapped) => { - assert_eq!(wrapped.program, "/usr/bin/sandbox-exec"); - assert_eq!(wrapped.prefix_args.len(), 2, "{wrapped:?}"); - assert_eq!(wrapped.prefix_args[0], "-p"); - let sbpl = &wrapped.prefix_args[1]; - assert!(sbpl.contains("(deny default)"), "{sbpl}"); - assert!(sbpl.contains("(allow pseudo-tty)"), "{sbpl}"); - assert!(sbpl.contains("(deny file-read*"), "{sbpl}"); - assert_eq!(wrapped.placeholder_dir, None); - } - SandboxWrap::None => assert!( - !Path::new("/usr/bin/sandbox-exec").is_file(), - "sandbox-exec exists but the PTY wrap was declined" - ), - } - let _ = std::fs::remove_dir_all(&workspace); - } - - #[test] - #[cfg(all(feature = "enforce", target_os = "macos"))] - fn macos_pty_wrap_without_launcher_records_not_enforced() { - let logger = SandboxLogger::new(); - let wrap = macos_pty_wrap( - &ProfileName::Workspace, - &resolved_profile(&["secret.txt"], false), - Path::new("/tmp"), - /*sandbox_exec_available*/ false, - WrapMode::PtyOnly, - &logger, - ) - .expect("a missing launcher is a warn-and-release, not an error"); - - assert_eq!(wrap, SandboxWrap::None); - let events = logger.take_events(); - assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); - let event = &events[0]; - assert!(matches!( - event.event_type, - crate::types::SandboxEventType::NotEnforced - )); - assert_eq!(event.profile, "workspace"); - assert_eq!(event.mode.as_deref(), Some("PtyOnly")); - assert_eq!(event.launcher.as_deref(), Some("sandbox-exec")); - assert_eq!(event.enforced, Some(false)); - } - - #[test] - #[cfg(all(feature = "enforce", target_os = "macos"))] - fn macos_pty_wrap_success_records_profile_applied() { - if !Path::new("/usr/bin/sandbox-exec").is_file() { - eprintln!("skipping: sandbox-exec not available on this machine"); - return; - } - let workspace = temp_workspace( - "macoslog", - "[profiles.wraplog]\nextends = \"workspace\"\ndeny = [\"secret.txt\"]\n", - ); - let profile: ProfileName = "wraplog".parse().expect("valid custom profile name"); - let config = load_sandbox_config(&workspace).expect("load sandbox config"); - let resolved = profile - .resolve_profile(&workspace, &config) - .expect("custom profile resolves"); - let logger = SandboxLogger::new(); - - let wrap = macos_pty_wrap( - &profile, - &resolved, - &workspace, - /*sandbox_exec_available*/ true, - WrapMode::PtyOnly, - &logger, - ) - .expect("wrap construction must not fail"); - - assert!(matches!(wrap, SandboxWrap::Wrapped(_)), "{wrap:?}"); - let events = logger.take_events(); - assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); - let event = &events[0]; - assert!(matches!( - event.event_type, - crate::types::SandboxEventType::ProfileApplied - )); - assert_eq!(event.profile, "wraplog"); - assert_eq!(event.mode.as_deref(), Some("PtyOnly")); - assert_eq!(event.launcher.as_deref(), Some("/usr/bin/sandbox-exec")); - assert_eq!(event.enforced, Some(true)); - assert_eq!( - event.deny_paths.as_deref(), - Some(&["secret.txt".to_string()][..]) - ); - let _ = std::fs::remove_dir_all(&workspace); - } - - #[test] - #[cfg(target_os = "linux")] - fn linux_wrap_without_bwrap_records_not_enforced() { - let logger = SandboxLogger::new(); - let wrap = linux_wrap( - &ProfileName::Workspace, - &SandboxConfig::default(), - &resolved_profile(&["secret.txt"], false), - Path::new("/tmp"), - WrapMode::PipeComposed, - LauncherAvailability { - sandbox_exec: false, - bwrap: false, - }, - &logger, - ) - .expect("a missing bwrap is a warn-and-release, not an error"); - - assert_eq!(wrap, SandboxWrap::None); - let events = logger.take_events(); - assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); - let event = &events[0]; - assert!(matches!( - event.event_type, - crate::types::SandboxEventType::NotEnforced - )); - assert_eq!(event.profile, "workspace"); - assert_eq!(event.mode.as_deref(), Some("PipeComposed")); - assert_eq!(event.launcher.as_deref(), Some("bwrap")); - assert_eq!(event.enforced, Some(false)); - } - - #[test] - fn launcher_override_values() { - assert_eq!(launcher_override(None), LauncherOverride::Auto); - assert_eq!(launcher_override(Some("auto")), LauncherOverride::Auto); - assert_eq!(launcher_override(Some("none")), LauncherOverride::None); - assert_eq!(launcher_override(Some("bwrap")), LauncherOverride::Bwrap); - assert_eq!( - launcher_override(Some("sandbox-exec")), - LauncherOverride::SandboxExec - ); - assert_eq!(launcher_override(Some("garbage")), LauncherOverride::Auto); - } - - #[test] - fn linux_wrap_adds_enforcement_only_for_deny_or_network_in_pipe_mode() { - let deny_profile = resolved_profile(&["secret.txt"], false); - let net_profile = resolved_profile(&[], true); - let plain_profile = resolved_profile(&[], false); - - assert!(linux_wrap_adds_enforcement( - &deny_profile, - WrapMode::PipeComposed - )); - assert!(linux_wrap_adds_enforcement( - &net_profile, - WrapMode::PipeComposed - )); - assert!(!linux_wrap_adds_enforcement( - &plain_profile, - WrapMode::PipeComposed - )); - for profile in [&deny_profile, &net_profile, &plain_profile] { - assert!( - linux_wrap_adds_enforcement(profile, WrapMode::PtyOnly), - "PTY wraps always carry the full policy" - ); - } - } - - #[test] - fn placeholder_dir_name_guard_rejects_other_paths() { - assert!(is_placeholder_dir_name(Path::new( - "/home/u/.devo/bwrap-placeholder.abc123" - ))); - assert!(!is_placeholder_dir_name(Path::new("/home/u/.devo"))); - assert!(!is_placeholder_dir_name(Path::new("/"))); - assert!(!is_placeholder_dir_name(Path::new( - "/home/u/.devo/bwrap-placeholder" - ))); - } - - #[test] - fn remove_placeholder_dir_refuses_foreign_directories() { - let root = std::env::temp_dir().join(format!( - "devo-wrap-guard-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("system clock after Unix epoch") - .as_nanos() - )); - std::fs::create_dir_all(root.join("keep")).expect("create foreign directory"); - remove_placeholder_dir(&root.join("keep")); - assert!(root.join("keep").is_dir(), "foreign directory must survive"); - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn janitor_removes_only_stale_placeholder_dirs() { - let nanos = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("system clock after Unix epoch") - .as_nanos(); - let root = - std::env::temp_dir().join(format!("devo-janitor-{}-{nanos}", std::process::id())); - let placeholder = root.join("bwrap-placeholder.test01"); - std::fs::create_dir_all(&placeholder).expect("create placeholder directory"); - std::fs::write(placeholder.join("sandbox-blocked-0"), "x").expect("write placeholder file"); - std::fs::create_dir_all(root.join("keep")).expect("create foreign directory"); - - // Young placeholders survive a normal sweep. - cleanup_stale_placeholder_dirs_in(&root, SystemTime::now()); - assert!(placeholder.is_dir(), "young placeholder must survive"); - - // A clock far in the future makes everything look stale: the - // placeholder goes, the foreign directory stays. - let far_future = SystemTime::now() + Duration::from_secs(72 * 60 * 60); - cleanup_stale_placeholder_dirs_in(&root, far_future); - assert!(!placeholder.exists(), "stale placeholder must be removed"); - assert!(root.join("keep").is_dir(), "foreign directory must survive"); - let _ = std::fs::remove_dir_all(&root); - } -} +mod tests; diff --git a/crates/sandbox/src/wrap/tests.rs b/crates/sandbox/src/wrap/tests.rs new file mode 100644 index 00000000..fe03d1e3 --- /dev/null +++ b/crates/sandbox/src/wrap/tests.rs @@ -0,0 +1,318 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn temp_workspace(tag: &str, toml_body: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let workspace = + std::env::temp_dir().join(format!("devo-wrap-{tag}-{}-{nanos}", std::process::id())); + let devo = workspace.join(".devo"); + std::fs::create_dir_all(&devo).expect("create sandbox config directory"); + std::fs::write(devo.join("sandbox.toml"), toml_body).expect("write sandbox config"); + workspace +} + +fn resolved_profile(deny: &[&str], restrict_network: bool) -> SandboxProfile { + SandboxProfile { + name: "test".to_string(), + read_only: vec![], + read_write: vec![], + deny: deny.iter().map(PathBuf::from).collect(), + default_read: true, + restrict_network, + } +} + +#[test] +fn none_and_off_profiles_never_wrap() { + let workspace = Path::new("/tmp"); + let logger = SandboxLogger::new(); + for profile in [None, Some("off"), Some("none")] { + for mode in [WrapMode::PtyOnly, WrapMode::PipeComposed] { + assert_eq!( + wrap_command_for_profile(profile, workspace, mode, &logger) + .expect("off/None profiles are not errors"), + SandboxWrap::None, + "profile {profile:?} in mode {mode:?} must not wrap" + ); + } + } + assert!( + logger.take_events().is_empty(), + "off/None profiles must not record events" + ); +} + +#[test] +fn undefined_custom_profile_is_an_error() { + let workspace = temp_workspace("missing", ""); + let error = wrap_command_for_profile( + Some("devo-test-missing-profile-xyz"), + &workspace, + WrapMode::PipeComposed, + &SandboxLogger::new(), + ) + .expect_err("an unresolvable profile name must fail, not silently unwrap"); + assert!( + error.to_string().contains("not found"), + "unexpected error: {error:#}" + ); + let _ = std::fs::remove_dir_all(&workspace); +} + +#[test] +#[cfg(target_os = "macos")] +fn macos_pipe_and_pty_wrap_via_sandbox_exec() { + let workspace = temp_workspace( + "macos", + "[profiles.wrapdeny]\nextends = \"workspace\"\ndeny = [\"secret.txt\"]\n", + ); + for mode in [WrapMode::PipeComposed, WrapMode::PtyOnly] { + match wrap_command_for_profile(Some("wrapdeny"), &workspace, mode, &SandboxLogger::new()) + .expect("valid profile resolves") + { + SandboxWrap::Wrapped(wrapped) => { + assert_eq!(wrapped.program, "/usr/bin/sandbox-exec"); + assert_eq!(wrapped.prefix_args.len(), 2, "{wrapped:?}"); + assert_eq!(wrapped.prefix_args[0], "-p"); + let sbpl = &wrapped.prefix_args[1]; + assert!(sbpl.contains("(deny default)"), "{sbpl}"); + assert!(sbpl.contains("(allow pseudo-tty)"), "{sbpl}"); + assert!(sbpl.contains("(deny file-read*"), "{sbpl}"); + assert_eq!(wrapped.placeholder_dir, None); + assert!(wrapped.helper_enforces); + } + SandboxWrap::None => assert!( + !Path::new("/usr/bin/sandbox-exec").is_file(), + "sandbox-exec exists but the {mode:?} wrap was declined" + ), + } + } + let _ = std::fs::remove_dir_all(&workspace); +} + +#[test] +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn macos_wrap_without_launcher_records_not_enforced() { + let logger = SandboxLogger::new(); + let wrap = macos_wrap( + &ProfileName::Workspace, + &resolved_profile(&["secret.txt"], false), + Path::new("/tmp"), + /*sandbox_exec_available*/ false, + WrapMode::PtyOnly, + &logger, + ) + .expect("a missing launcher is a warn-and-release, not an error"); + + assert_eq!(wrap, SandboxWrap::None); + let events = logger.take_events(); + assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); + let event = &events[0]; + assert!(matches!( + event.event_type, + crate::types::SandboxEventType::NotEnforced + )); + assert_eq!(event.profile, "workspace"); + assert_eq!(event.mode.as_deref(), Some("PtyOnly")); + assert_eq!(event.launcher.as_deref(), Some("sandbox-exec")); + assert_eq!(event.enforced, Some(false)); +} + +#[test] +#[cfg(all(feature = "enforce", target_os = "macos"))] +fn macos_wrap_success_records_profile_applied() { + if !Path::new("/usr/bin/sandbox-exec").is_file() { + eprintln!("skipping: sandbox-exec not available on this machine"); + return; + } + let workspace = temp_workspace( + "macoslog", + "[profiles.wraplog]\nextends = \"workspace\"\ndeny = [\"secret.txt\"]\n", + ); + let profile: ProfileName = "wraplog".parse().expect("valid custom profile name"); + let config = load_sandbox_config(&workspace).expect("load sandbox config"); + let resolved = profile + .resolve_profile(&workspace, &config) + .expect("custom profile resolves"); + let logger = SandboxLogger::new(); + + let wrap = macos_wrap( + &profile, + &resolved, + &workspace, + /*sandbox_exec_available*/ true, + WrapMode::PtyOnly, + &logger, + ) + .expect("wrap construction must not fail"); + + assert!(matches!(&wrap, SandboxWrap::Wrapped(_)), "{wrap:?}"); + let events = logger.take_events(); + assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); + let event = &events[0]; + assert!(matches!( + event.event_type, + crate::types::SandboxEventType::ProfileApplied + )); + assert_eq!(event.profile, "wraplog"); + assert_eq!(event.mode.as_deref(), Some("PtyOnly")); + assert_eq!(event.launcher.as_deref(), Some("/usr/bin/sandbox-exec")); + assert_eq!(event.enforced, Some(true)); + assert_eq!( + event.deny_paths.as_deref(), + Some(&["secret.txt".to_string()][..]) + ); + let SandboxWrap::Wrapped(wrapped) = wrap else { + panic!("macOS wrapper must enforce through sandbox-exec"); + }; + assert!(wrapped.helper_enforces); + let _ = std::fs::remove_dir_all(&workspace); +} + +#[test] +#[cfg(target_os = "linux")] +fn linux_wrap_without_bwrap_records_not_enforced() { + let logger = SandboxLogger::new(); + let wrap = linux_wrap( + &ProfileName::Workspace, + &SandboxConfig::default(), + &resolved_profile(&["secret.txt"], false), + Path::new("/tmp"), + WrapMode::PipeComposed, + LauncherAvailability { + sandbox_exec: false, + bwrap: false, + }, + &logger, + ) + .expect("a missing bwrap is a warn-and-release, not an error"); + + assert_eq!(wrap, SandboxWrap::None); + let events = logger.take_events(); + assert_eq!(events.len(), 1, "expected exactly one event: {events:?}"); + let event = &events[0]; + assert!(matches!( + event.event_type, + crate::types::SandboxEventType::NotEnforced + )); + assert_eq!(event.profile, "workspace"); + assert_eq!(event.mode.as_deref(), Some("PipeComposed")); + assert_eq!(event.launcher.as_deref(), Some("bwrap")); + assert_eq!(event.enforced, Some(false)); +} + +#[test] +fn launcher_override_values() { + assert_eq!(launcher_override(None), LauncherOverride::Auto); + assert_eq!(launcher_override(Some("auto")), LauncherOverride::Auto); + assert_eq!(launcher_override(Some("none")), LauncherOverride::None); + assert_eq!(launcher_override(Some("bwrap")), LauncherOverride::Bwrap); + assert_eq!( + launcher_override(Some("sandbox-exec")), + LauncherOverride::SandboxExec + ); + assert_eq!(launcher_override(Some("garbage")), LauncherOverride::Auto); +} + +#[test] +#[cfg(target_os = "macos")] +fn macos_never_applies_seatbelt_in_child() { + assert!(!SandboxWrap::None.requires_child_apply()); + assert!( + !SandboxWrap::Wrapped(WrappedCommand { + program: "/usr/bin/sandbox-exec".to_string(), + prefix_args: vec![], + placeholder_dir: None, + helper_enforces: true, + }) + .requires_child_apply() + ); +} + +#[test] +#[cfg(target_os = "linux")] +fn linux_direct_spawn_still_applies_landlock_in_child() { + assert!(SandboxWrap::None.requires_child_apply()); +} + +#[test] +fn linux_wrap_adds_enforcement_only_for_deny_or_network_in_pipe_mode() { + let deny_profile = resolved_profile(&["secret.txt"], false); + let net_profile = resolved_profile(&[], true); + let plain_profile = resolved_profile(&[], false); + + assert!(linux_wrap_adds_enforcement( + &deny_profile, + WrapMode::PipeComposed + )); + assert!(linux_wrap_adds_enforcement( + &net_profile, + WrapMode::PipeComposed + )); + assert!(!linux_wrap_adds_enforcement( + &plain_profile, + WrapMode::PipeComposed + )); + for profile in [&deny_profile, &net_profile, &plain_profile] { + assert!( + linux_wrap_adds_enforcement(profile, WrapMode::PtyOnly), + "PTY wraps always carry the full policy" + ); + } +} + +#[test] +fn placeholder_dir_name_guard_rejects_other_paths() { + assert!(is_placeholder_dir_name(Path::new( + "/home/u/.devo/bwrap-placeholder.abc123" + ))); + assert!(!is_placeholder_dir_name(Path::new("/home/u/.devo"))); + assert!(!is_placeholder_dir_name(Path::new("/"))); + assert!(!is_placeholder_dir_name(Path::new( + "/home/u/.devo/bwrap-placeholder" + ))); +} + +#[test] +fn remove_placeholder_dir_refuses_foreign_directories() { + let root = std::env::temp_dir().join(format!( + "devo-wrap-guard-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("keep")).expect("create foreign directory"); + remove_placeholder_dir(&root.join("keep")); + assert!(root.join("keep").is_dir(), "foreign directory must survive"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn janitor_removes_only_stale_placeholder_dirs() { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("devo-janitor-{}-{nanos}", std::process::id())); + let placeholder = root.join("bwrap-placeholder.test01"); + std::fs::create_dir_all(&placeholder).expect("create placeholder directory"); + std::fs::write(placeholder.join("sandbox-blocked-0"), "x").expect("write placeholder file"); + std::fs::create_dir_all(root.join("keep")).expect("create foreign directory"); + + // Young placeholders survive a normal sweep. + cleanup_stale_placeholder_dirs_in(&root, SystemTime::now()); + assert!(placeholder.is_dir(), "young placeholder must survive"); + + // A clock far in the future makes everything look stale: the + // placeholder goes, the foreign directory stays. + let far_future = SystemTime::now() + Duration::from_secs(72 * 60 * 60); + cleanup_stale_placeholder_dirs_in(&root, far_future); + assert!(!placeholder.exists(), "stale placeholder must be removed"); + assert!(root.join("keep").is_dir(), "foreign directory must survive"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/tools/src/handler_kind.rs b/crates/tools/src/handler_kind.rs index dc5bed31..00d9b43c 100644 --- a/crates/tools/src/handler_kind.rs +++ b/crates/tools/src/handler_kind.rs @@ -1,6 +1,5 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ToolHandlerKind { - Bash, CodeSearch, ShellCommand, Read, diff --git a/crates/utils/process/src/pty/pipe.rs b/crates/utils/process/src/pty/pipe.rs index d47c5390..973fa6e9 100644 --- a/crates/utils/process/src/pty/pipe.rs +++ b/crates/utils/process/src/pty/pipe.rs @@ -154,15 +154,10 @@ async fn spawn_process_with_stdin_mode( #[cfg(unix)] let sandbox_workspace = cwd.to_path_buf(); #[cfg(unix)] - let helper_enforces = matches!( - &sandbox_wrap, - devo_sandbox::SandboxWrap::Wrapped(wrapped) if wrapped.helper_enforces - ); - #[cfg(unix)] - let sandbox_plan = if helper_enforces { - None - } else { + let sandbox_plan = if sandbox_wrap.requires_child_apply() { crate::sandbox::resolve_profile_for_spawn(sandbox_profile.as_deref(), &sandbox_workspace)? + } else { + None }; #[cfg(unix)] unsafe { diff --git a/docs/sandbox-tool-redaction-memo.zh-Hans.md b/docs/sandbox-tool-redaction-memo.zh-Hans.md new file mode 100644 index 00000000..00e4bfd9 --- /dev/null +++ b/docs/sandbox-tool-redaction-memo.zh-Hans.md @@ -0,0 +1,322 @@ +# Devo 工具隔离与敏感信息脱敏备忘录 + +## 目的 + +本文记录当前 Devo 的工具执行边界、OS sandbox 覆盖范围,以及工具输出中的敏感信息脱敏现状。后续修复应以本文的审计结论为基线,避免把应用层权限、OS 子进程 sandbox 和输出脱敏混成同一条链路。 + +## 当前结论 + +当前 Devo 只有外部子进程执行路径稳定进入 OS sandbox。原生文件工具、搜索工具、网络工具和技能加载工具大多直接在 Devo 主进程中运行,因此不会自动受到 macOS `sandbox-exec` 或 Linux Landlock/bwrap 的约束。 + +仓库中已经存在 `devo-safety` 的正则 secret detector 和 `SecretRedactor`,但当前没有发现任何 runtime 调用点把它接入 `ToolResult`、模型请求、协议事件、TUI 展示或日志 layer。`redact_secrets_in_logs` 目前只作为配置字段和初始化日志字段存在,没有实际的日志脱敏实现。 + +## 一、OS sandbox 覆盖范围 + +### 已进入 OS sandbox 的路径 + +- `shell_command` / `bash`:通过普通 pipe 或 PTY 启动 shell。 +- `exec_command`:通过 unified exec process 启动子进程。 +- unified exec 的 PTY process。 +- `write_stdin`:本身不创建进程,只操作已经启动的进程,因此继承启动时的 sandbox。 + +关键入口: + +- `crates/core/src/tools/shell_exec.rs` +- `crates/core/src/tools/unified_exec/process.rs` +- `crates/utils/process/src/pty/pipe.rs` +- `crates/sandbox/src/wrap.rs` + +macOS 当前使用父进程构造的 `sandbox-exec -p ` wrapper。Linux 使用 `devo-linux-sandbox`、`bwrap` 和 pipe 路径中的 Landlock/nono 组合。profile 在父进程中解析,child 的 `pre_exec` 只接收已经解析的 enforcement plan。 + +### 未进入 OS sandbox 的工具 + +以下工具在当前实现中没有把 `sandbox_profile` 传入统一的 OS sandbox spawn 边界: + +- `read` +- `write` +- `edit` +- `apply_patch` +- `find` +- `grep` +- `code_search` +- `webfetch` +- `web_search` +- `skill` +- MCP tools + +具体情况: + +1. `read`、`write`、`edit` 使用 `tokio::fs` 或普通文件 API,在 Devo 主进程内读写文件。 +2. `apply_patch` 使用内部 patch executor 直接修改文件。 +3. `find` 和 `grep` 会启动 `rg`,但 `crates/core/src/tools/handlers/ripgrep.rs` 中的 `run_rg` 没有应用 `ToolContext.sandbox_profile`,因此这个 `rg` 子进程是普通未包装进程。 +4. `code_search` 通过 `CodeSearchService` 构建/查询索引,主要在 Devo 进程内访问 workspace。 +5. `webfetch` 和 `web_search` 在 Devo 进程内使用 HTTP client。它们可以使用 proxy 配置,但 `restrict_network` 不会自动变成 macOS Seatbelt 网络 deny。 +6. `skill` 直接递归查找并读取 `SKILL.md` 及其相邻文件。 +7. MCP tool 的调用交给 `McpManager`;Devo 当前没有在 MCP tool 调用边界增加统一的 OS sandbox。MCP server 是否隔离取决于 server 自己的启动实现。 + +`plan`、`update_goal`、`question`、`ToolSearch`、agent 协调等工具不直接操作 workspace 或网络,当前没有 OS sandbox 并不构成同类文件/网络隔离缺口。 + +## 二、应用层权限与 OS sandbox 的区别 + +`ToolExecutionMode::ReadOnly`、permission router、capability tags 和用户确认属于 Devo 应用层权限模型,不等于 macOS App Sandbox 或 Seatbelt。 + +当前 router 可以: + +- 在工具执行前进行 permission check。 +- 根据审批结果允许或拒绝工具调用。 +- 对 shell family 的 `SANDBOX_DENIED` 做一次 `off` 重试。 +- 当 profile 有 deny-read 路径时,禁止静默关闭 sandbox。 + +但这些逻辑不会自动把原生 `read`、`write`、`find`、`code_search` 等工具放进 OS sandbox。应用层授权和 OS capability 必须分别审计。 + +## 三、DEVO_HOME 与 auth.json + +当前 `crates/sandbox/src/paths.rs` 的 `essential_writable_paths` 把整个 `DEVO_HOME` 加入 writable roots。当前没有发现对: + +```text +$DEVO_HOME/auth.json +``` + +单独生成 deny-read 或 deny-write 规则的实现。 + +因此目前实际语义是: + +```text +DEVO_HOME 可写 +DEVO_HOME/auth.json 也随目录可写 +``` + +这不符合“DEVO_HOME 默认可读写,但 auth.json 不应被工具读写”的目标。该目标需要同时覆盖: + +- shell / PTY / pipe 子进程; +- 原生 read/write/edit/apply_patch; +- find/grep/code_search 索引; +- skill 文件加载; +- 可能访问本地文件的 MCP tool; +- 允许用户显式查看或更新 auth.json 的配置流程。 + +## 四、SecretRedactor 当前状态 + +### 已存在的基础设施 + +`crates/safety/src/lib.rs` 已经定义: + +- `REDACTED_SECRET_PLACEHOLDER = "[REDACTED_SECRET]"`。 +- `SecretMatchConfidence`。 +- `SecretDetector` 和 `SecretDetectorRegistry`。 +- `RegexSecretDetector`。 +- `InMemorySecretDetectorRegistry::with_default_detectors()`。 +- `SecretRedactor::redact()`。 +- `RedactionResult` 和 `RedactionReport`。 + +默认 detector 当前包括: + +- OpenAI 风格 `sk-...` key。 +- AWS access key id。 +- Bearer token。 +- `api_key`、`token`、`secret`、`password` 赋值形式。 + +这些规则在 `devo-safety` 单元测试中能够把匹配内容替换为 `[REDACTED_SECRET]`。 + +### 当前缺失的 wire + +对整个仓库进行 `SecretRedactor`、`RegexSecretDetector`、`RedactionResult` 和 `redact(` 的调用点搜索,目前只找到定义和 `devo-safety` 测试,没有找到 runtime 接线。 + +当前 `ToolResult` 的主要路径是: + +```text +ToolHandler::handle + ↓ +ToolResult + ↓ +router / query loop + ↓ +QueryEvent::ToolResult + ↓ +RequestContent::ToolResult / ContentBlock::ToolResult + ↓ +provider request、protocol event、ACP/TUI projection +``` + +在这些边界上目前没有统一执行 `SecretRedactor`: + +- `crates/core/src/tools/contracts.rs` 的 `ToolResult`。 +- `crates/core/src/tools/router.rs` 的工具返回处理。 +- `crates/core/src/query/mod.rs` 的 tool result message 构造。 +- `crates/core/src/query/event.rs` 的 `QueryEvent::ToolResult`。 +- `crates/protocol/src/event.rs` 的 `ToolResultPayload`。 +- `crates/protocol/src/acp_event_to_update.rs` 的 `raw_output`、`content` 投影。 +- TUI 的 tool result 和 tool output delta 事件。 + +因此当前存在的风险是:一个工具只要返回包含 API key 的文本或 JSON,该值可能继续进入: + +- 模型下一轮的 tool result message。 +- ACP/server protocol payload。 +- TUI transcript、raw output 或 tool cell。 +- durable history 或诊断记录。 +- 日志中的结构化字段或错误文本。 + +### 日志配置也尚未真正接线 + +`LoggingConfig.redact_secrets_in_logs` 的默认值是 `true`,但 `crates/core/src/logging.rs` 当前只是把该布尔值写入 `tracing initialized` 事件,没有安装 redaction tracing layer,也没有调用 `SecretRedactor` 处理日志字段。 + +所以需要区分: + +```text +配置字段存在 是 +SecretRedactor 类型存在 是 +工具输出 wire 否 +模型请求 wire 否 +协议/TUI wire 否 +日志实际过滤 未发现 +``` + +## 五、建议的修复架构 + +### 1. 统一 policy context + +为每次 tool invocation 构造不可变的 `ToolSecurityContext`,至少包含: + +- workspace root。 +- sandbox profile。 +- readable roots。 +- writable roots。 +- deny paths。 +- network policy。 +- `DEVO_HOME` 和 `auth.json` 的特殊规则。 +- active `SecretRedactor`。 + +所有本地文件、搜索、网络和子进程工具都必须从这个 context 获取策略,而不是各自决定是否检查。 + +### 2. 先修复文件/搜索访问边界 + +优先级建议: + +1. 给 `read`、`write`、`edit`、`apply_patch` 增加统一路径 capability check。 +2. 让 `find`、`grep`、`code_search` 使用同一套 readable roots 和 deny paths。 +3. 修复 `run_rg`:如果保留外部 `rg`,必须经过统一 child spawn wrapper;更理想的是让搜索服务接受显式 filesystem policy。 +4. 让 `skill` 只能读取允许的 skill roots,不能通过 workspace 递归搜索绕过 deny。 +5. 对 MCP tool 明确声明 filesystem/network capability;没有声明的 tool 默认 deny 或 ask。 + +### 3. 处理 auth.json + +不要只依赖 shell profile。应将 auth 文件定义成专门的 `SecretPath`: + +- 默认禁止工具读取。 +- 默认禁止工具写入。 +- 日常 provider resolution 在受控的 config/auth 组件内完成。 +- 用户显式修改 credential 时走专用配置流程。 +- 对返回错误、diagnostic 和日志继续做 secret redaction。 + +## 六、SecretRedactor 的接线方案 + +建议设置两个明确的边界: + +### 模型可见边界 + +在 tool result 进入下一轮模型请求之前执行 redaction: + +```text +ToolResult + → normalize text / JSON + → SecretRedactor + → model-visible ToolResult +``` + +必须覆盖 `Text`、`Json` 和 `Mixed` 三种 `ToolResultContent`,不能只处理字符串 variant。JSON 应递归处理所有 string value,同时保留 JSON 结构。 + +### 外部可见边界 + +在协议事件、ACP update、TUI transcript 和 durable history 写入前,使用同一个 redacted representation,避免模型看不到但 UI 或日志仍显示原文。 + +建议明确区分: + +```text +canonical_internal_result 原始值,仅短生命周期、最小范围保留 +model_visible_result 脱敏后 +protocol_visible_result 脱敏后 +display_result 脱敏后或更紧凑版本 +log_result 脱敏后 +``` + +不建议把同一个包含原始 secret 的 `ToolResult` 同时用于模型、wire、UI 和日志。 + +### 流式输出 + +`ToolOutputDelta` 不能只对每个 chunk 独立调用正则,因为一个 secret 可能跨 chunk: + +```text +chunk 1: sk-123456789 +chunk 2: 012345678901234 +``` + +需要一个有界的 streaming redactor buffer,保留 detector 最大匹配长度附近的尾部,只有确认不可能形成跨 chunk secret 后才输出。命令最终结果也要再做一次完整 redaction。 + +### Redaction report + +`RedactionReport` 可以用于 telemetry,但不能把原始 match 文本写入 report、日志或协议。建议只保留: + +- detector id。 +- count。 +- confidence。 +- tool name。 +- tool call id。 + +不要记录 secret 的原文、完整 offset 上下文或未脱敏 JSON。 + +## 七、必须补的测试 + +### Sandbox + +- 原生 `read` 读取 deny path 被拒绝。 +- 原生 `write`、`edit`、`apply_patch` 修改 deny path 被拒绝。 +- `find`、`grep`、`code_search` 不返回 deny path 内容。 +- `skill` 不读取 deny path 下的 `SKILL.md` 或引用文件。 +- `DEVO_HOME` 普通状态文件可访问,但 `auth.json` 不可访问。 +- pipe、PTY、non-PTY 具有一致的 deny 语义。 +- MCP tool 未声明 capability 时不能任意访问 workspace 或网络。 + +### Redaction + +- `ToolResultContent::Text` 脱敏。 +- `ToolResultContent::Json` 递归脱敏。 +- `ToolResultContent::Mixed` 的 text 和 JSON 都脱敏。 +- secret 跨 streaming chunk 时仍能脱敏。 +- 多 detector 重叠匹配保持最长/最高 confidence 规则。 +- provider request 不包含原始 key。 +- ACP raw output、content、TUI transcript 和日志都不包含原始 key。 +- redaction report 不包含 secret 原文。 +- 没有命中时保持 byte-for-byte 或结构等价,避免无意义改变工具输出。 + +## 八、建议的实施顺序 + +1. 先接通 `SecretRedactor` 到 model-visible tool result,并覆盖 Text/JSON/Mixed。 +2. 再接通 protocol、ACP、TUI、history 和日志边界。 +3. 为原生文件工具抽象统一 filesystem capability checker。 +4. 为 `find`、`grep`、`code_search` 接入同一 checker,并修复 `run_rg` 子进程边界。 +5. 增加 `auth.json` 特殊 deny 规则和专用 credential 操作路径。 +6. 最后处理 skill、MCP 和其他扩展工具的 capability 声明及默认 deny。 + +## 审计依据 + +- `crates/sandbox/src/wrap.rs` +- `crates/sandbox/src/profiles.rs` +- `crates/sandbox/src/paths.rs` +- `crates/core/src/tools/shell_exec.rs` +- `crates/core/src/tools/unified_exec/process.rs` +- `crates/utils/process/src/pty/pipe.rs` +- `crates/core/src/tools/handlers/read.rs` +- `crates/core/src/tools/handlers/file_write.rs` +- `crates/core/src/tools/handlers/edit.rs` +- `crates/core/src/tools/handlers/apply_patch.rs` +- `crates/core/src/tools/handlers/ripgrep.rs` +- `crates/core/src/tools/handlers/code_search.rs` +- `crates/core/src/tools/handlers/webfetch.rs` +- `crates/core/src/tools/handlers/websearch.rs` +- `crates/core/src/tools/handlers/skill.rs` +- `crates/safety/src/lib.rs` +- `crates/core/src/logging.rs` +- `crates/config/src/logging.rs` +- `crates/core/src/query/mod.rs` +- `crates/protocol/src/event.rs` +- `crates/protocol/src/acp_event_to_update.rs` From ebc9ce784c75953bc617a59103280f8efceb3847 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 31 Jul 2026 13:10:29 +0800 Subject: [PATCH 2/4] refactor: streamline terminal execution --- .../devo-ai-sdk/src/v2/acp-client-support.ts | 7 +- .../devo-ai-sdk/src/v2/client.test.ts | 4 +- crates/cli/src/prompt_command.rs | 2 - crates/client/src/acp_terminal.rs | 600 ------------- crates/client/src/client_core.rs | 40 +- crates/client/src/lib.rs | 1 - crates/client/src/stdio.rs | 12 +- crates/client/src/websocket.rs | 7 - .../core/src/tools/client_terminal_shell.rs | 652 --------------- crates/core/src/tools/handlers/agent.rs | 1 - crates/core/src/tools/handlers/apply_patch.rs | 1 - crates/core/src/tools/handlers/code_search.rs | 1 - crates/core/src/tools/handlers/edit.rs | 1 - .../core/src/tools/handlers/exec_command.rs | 1 - crates/core/src/tools/handlers/file_write.rs | 1 - crates/core/src/tools/handlers/goal_update.rs | 1 - crates/core/src/tools/handlers/read.rs | 1 - .../core/src/tools/handlers/shell_command.rs | 34 +- crates/core/src/tools/handlers/tool_search.rs | 2 - crates/core/src/tools/mod.rs | 7 +- crates/core/src/tools/registry.rs | 1 - crates/core/src/tools/router.rs | 9 - crates/core/src/tools/shell_exec.rs | 787 ------------------ crates/core/src/tools/shell_exec/launch.rs | 346 ++++++++ crates/core/src/tools/shell_exec/mod.rs | 136 +++ crates/core/src/tools/shell_exec/pipe.rs | 144 ++++ crates/core/src/tools/shell_exec/pty.rs | 222 +++++ crates/core/src/tools/shell_exec/resolve.rs | 101 +++ crates/core/src/tools/shell_exec/tests.rs | 112 ++- crates/protocol/README.md | 7 - crates/protocol/src/acp.rs | 124 +-- crates/protocol/src/acp_client_io.rs | 79 -- crates/protocol/src/acp_common.rs | 1 - crates/protocol/src/acp_event_to_update.rs | 36 +- crates/protocol/src/acp_schema_aliases.rs | 25 - crates/protocol/src/acp_session_update.rs | 5 - crates/protocol/src/acp_ts.rs | 56 -- crates/protocol/src/event.rs | 2 - crates/server/src/client.rs | 4 +- crates/server/src/protocol.rs | 39 +- crates/server/src/runtime.rs | 1 - crates/server/src/runtime/acp_terminal.rs | 330 -------- .../src/runtime/turn_exec/event_stream.rs | 15 - crates/server/src/runtime/turn_exec/query.rs | 5 +- crates/server/src/runtime/turn_exec/shell.rs | 2 - crates/server/src/runtime/turn_exec/tests.rs | 6 - crates/server/src/runtime/turn_exec/trace.rs | 10 +- crates/tools/src/client_terminal.rs | 102 --- crates/tools/src/contracts.rs | 11 - crates/tools/src/lib.rs | 5 - crates/tui/src/worker.rs | 318 +------ crates/tui/src/worker/acp_events.rs | 223 +---- 52 files changed, 1133 insertions(+), 3507 deletions(-) delete mode 100644 crates/client/src/acp_terminal.rs delete mode 100644 crates/core/src/tools/client_terminal_shell.rs delete mode 100644 crates/core/src/tools/shell_exec.rs create mode 100644 crates/core/src/tools/shell_exec/launch.rs create mode 100644 crates/core/src/tools/shell_exec/mod.rs create mode 100644 crates/core/src/tools/shell_exec/pipe.rs create mode 100644 crates/core/src/tools/shell_exec/pty.rs create mode 100644 crates/core/src/tools/shell_exec/resolve.rs delete mode 100644 crates/server/src/runtime/acp_terminal.rs delete mode 100644 crates/tools/src/client_terminal.rs diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts index ddcf554e..62dd7129 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts @@ -365,20 +365,15 @@ function enrichedToolInput( function outputFromAcpToolContent(content: unknown): string { if (!Array.isArray(content)) return "" const textParts: string[] = [] - const terminalParts: string[] = [] for (const item of content) { if (!item || typeof item !== "object") continue const value = item as Record if (value.type === "content") { const text = textFromUpdate({ content: value.content }) if (text) textParts.push(text) - continue - } - if (value.type === "terminal" && typeof value.terminalId === "string") { - terminalParts.push(`Terminal ${value.terminalId}`) } } - return [...textParts, ...terminalParts].join("\n\n") + return textParts.join("\n\n") } function toolStateStatus(value: unknown, existingStatus: unknown): "completed" | "error" | "pending" | "running" { diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts index bc0e6ebe..2c31194e 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts @@ -1012,7 +1012,6 @@ describe("ACP desktop SDK session mapping", () => { rawInput: {}, content: [ { type: "diff", path: "src/main.rs", oldText: "old\n", newText: "new\n" }, - { type: "terminal", terminalId: "term-1" }, { type: "content", content: { type: "text", text: "applied" } }, ], locations: [{ path: "src/main.rs", line: 12 }], @@ -1057,12 +1056,11 @@ describe("ACP desktop SDK session mapping", () => { newString: "new\n", path: "src/main.rs", }, - output: "applied\n\nTerminal term-1", + output: "applied", title: "Patch file", metadata: { acpContent: [ { type: "diff", path: "src/main.rs", oldText: "old\n", newText: "new\n" }, - { type: "terminal", terminalId: "term-1" }, { type: "content", content: { type: "text", text: "applied" } }, ], acpLocations: [{ path: "src/main.rs", line: 12 }], diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 40078d31..a0f8f13c 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -106,7 +106,6 @@ pub(crate) async fn run_prompt( collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: std::sync::Arc::new(devo_core::tools::FileReadLedger::new()), local_web_search: None, hooks: (!app_config.hooks.is_empty()).then(|| devo_core::HookRuntimeContext { @@ -530,7 +529,6 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> { Some(message.as_str()) } devo_core::tools::ToolProgress::Completion { summary } => Some(summary.as_str()), - devo_core::tools::ToolProgress::Terminal { .. } => None, }; if let Some(delta) = delta { write_jsonl(&PromptJsonlEvent::ToolProgress { diff --git a/crates/client/src/acp_terminal.rs b/crates/client/src/acp_terminal.rs deleted file mode 100644 index d750a2b2..00000000 --- a/crates/client/src/acp_terminal.rs +++ /dev/null @@ -1,600 +0,0 @@ -use std::collections::HashMap; -use std::process::ExitStatus; -use std::process::Stdio; -use std::sync::Arc; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; - -use devo_protocol::ACP_TERMINAL_CREATE_METHOD; -use devo_protocol::ACP_TERMINAL_KILL_METHOD; -use devo_protocol::ACP_TERMINAL_OUTPUT_METHOD; -use devo_protocol::ACP_TERMINAL_RELEASE_METHOD; -use devo_protocol::ACP_TERMINAL_WAIT_FOR_EXIT_METHOD; -use devo_protocol::AcpTerminalCreateParams; -use devo_protocol::AcpTerminalCreateResult; -use devo_protocol::AcpTerminalExitStatus; -use devo_protocol::AcpTerminalOutputResult; -use devo_protocol::AcpTerminalParams; -use devo_protocol::AcpTerminalWaitForExitResult; -use devo_protocol::acp_success_response; -use tokio::io::AsyncRead; -use tokio::io::AsyncReadExt; -use tokio::process::Child; -use tokio::process::Command; -use tokio::sync::Mutex; -use tokio::sync::Notify; -use tokio::sync::mpsc; -use tokio::time::Duration; - -use crate::client_core::ServerNotificationMessage; - -static ACP_TERMINAL_NEXT_ID: AtomicU64 = AtomicU64::new(1); -pub const ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD: &str = "_devo/acp_terminal/output"; -const ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT: usize = 1024 * 1024; - -#[derive(Clone, Default)] -pub(crate) struct AcpTerminalManager { - terminals: Arc>>>, -} - -struct AcpTerminalHandle { - state: Mutex, - exit_notify: Notify, -} - -struct AcpTerminalState { - child: Child, - output: String, - truncated: bool, - output_byte_limit: usize, - exit_status: Option, -} - -impl AcpTerminalManager { - pub(crate) fn new() -> Self { - Self::default() - } - - async fn insert(&self, terminal_id: String, terminal: Arc) { - self.terminals.lock().await.insert(terminal_id, terminal); - } - - async fn get(&self, terminal_id: &str) -> std::result::Result, String> { - self.terminals - .lock() - .await - .get(terminal_id) - .cloned() - .ok_or_else(|| format!("unknown terminalId {terminal_id}")) - } - - pub(crate) async fn output( - &self, - terminal_id: &str, - ) -> std::result::Result { - acp_terminal_output(terminal_id, self.clone()).await - } - - pub(crate) async fn release_all(&self) { - let terminals = self.terminals.lock().await.drain().collect::>(); - for (_, terminal) in terminals { - let _ = kill_acp_terminal_if_running(&terminal).await; - } - } -} - -pub(crate) async fn handle_acp_terminal_request( - request_id: serde_json::Value, - method: &str, - params: serde_json::Value, - terminals: AcpTerminalManager, - notifications_tx: mpsc::UnboundedSender, -) -> std::result::Result { - match method { - ACP_TERMINAL_CREATE_METHOD => { - let params = serde_json::from_value::(params) - .map_err(|error| format!("invalid terminal/create params: {error}"))?; - let terminal_id = create_acp_terminal(params, terminals, notifications_tx).await?; - Ok(acp_success_response( - request_id, - AcpTerminalCreateResult { - terminal_id, - meta: None, - }, - )) - } - ACP_TERMINAL_OUTPUT_METHOD => { - let params = serde_json::from_value::(params) - .map_err(|error| format!("invalid terminal/output params: {error}"))?; - let result = acp_terminal_output(¶ms.terminal_id, terminals).await?; - Ok(acp_success_response(request_id, result)) - } - ACP_TERMINAL_WAIT_FOR_EXIT_METHOD => { - let params = serde_json::from_value::(params) - .map_err(|error| format!("invalid terminal/wait_for_exit params: {error}"))?; - let status = wait_for_acp_terminal_exit(¶ms.terminal_id, terminals).await?; - Ok(acp_success_response( - request_id, - AcpTerminalWaitForExitResult { - exit_code: status.exit_code, - signal: status.signal, - meta: None, - }, - )) - } - ACP_TERMINAL_KILL_METHOD => { - let params = serde_json::from_value::(params) - .map_err(|error| format!("invalid terminal/kill params: {error}"))?; - kill_acp_terminal(¶ms.terminal_id, terminals).await?; - Ok(acp_success_response(request_id, serde_json::json!({}))) - } - ACP_TERMINAL_RELEASE_METHOD => { - let params = serde_json::from_value::(params) - .map_err(|error| format!("invalid terminal/release params: {error}"))?; - release_acp_terminal(¶ms.terminal_id, terminals).await?; - Ok(acp_success_response(request_id, serde_json::json!({}))) - } - _ => Err(format!("unknown ACP terminal method {method}")), - } -} - -async fn create_acp_terminal( - params: AcpTerminalCreateParams, - terminals: AcpTerminalManager, - notifications_tx: mpsc::UnboundedSender, -) -> std::result::Result { - if params.command.trim().is_empty() { - return Err("terminal/create params.command must not be empty".to_string()); - } - if let Some(cwd) = params.cwd.as_ref() - && !cwd.is_absolute() - { - return Err("terminal/create params.cwd must be absolute".to_string()); - } - - let terminal_id = format!( - "term_{}", - ACP_TERMINAL_NEXT_ID.fetch_add(1, Ordering::SeqCst) - ); - let mut command = Command::new(¶ms.command); - command.args(params.args); - for env in params.env { - command.env(env.name, env.value); - } - if let Some(cwd) = params.cwd { - command.current_dir(cwd); - } - command.stdin(Stdio::null()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - command.kill_on_drop(true); - - let mut child = command - .spawn() - .map_err(|error| format!("failed to spawn terminal command: {error}"))?; - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let terminal = Arc::new(AcpTerminalHandle { - state: Mutex::new(AcpTerminalState { - child, - output: String::new(), - truncated: false, - output_byte_limit: params - .output_byte_limit - .unwrap_or(ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT), - exit_status: None, - }), - exit_notify: Notify::new(), - }); - terminals - .insert(terminal_id.clone(), Arc::clone(&terminal)) - .await; - - if let Some(stdout) = stdout { - tokio::spawn(read_acp_terminal_output( - terminal_id.clone(), - stdout, - Arc::clone(&terminal), - notifications_tx.clone(), - )); - } - if let Some(stderr) = stderr { - tokio::spawn(read_acp_terminal_output( - terminal_id.clone(), - stderr, - Arc::clone(&terminal), - notifications_tx, - )); - } - tokio::spawn(watch_acp_terminal_exit(Arc::clone(&terminal))); - Ok(terminal_id) -} - -async fn read_acp_terminal_output( - terminal_id: String, - mut reader: R, - terminal: Arc, - notifications_tx: mpsc::UnboundedSender, -) where - R: AsyncRead + Unpin + Send + 'static, -{ - let mut buffer = [0u8; 8192]; - let mut pending_utf8 = Vec::new(); - loop { - match reader.read(&mut buffer).await { - Ok(0) => break, - Ok(read_len) => { - pending_utf8.extend_from_slice(&buffer[..read_len]); - for delta in take_terminal_utf8_chunks(&mut pending_utf8) { - append_acp_terminal_output(&terminal, &delta).await; - let _ = notifications_tx.send(ServerNotificationMessage { - method: ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD.to_string(), - params: serde_json::json!({ - "terminalId": terminal_id.clone(), - "delta": delta, - }), - }); - } - } - Err(error) => { - tracing::debug!(%error, terminal_id, "failed to read ACP terminal output"); - break; - } - } - } - if !pending_utf8.is_empty() { - let delta = String::from_utf8_lossy(&pending_utf8).to_string(); - append_acp_terminal_output(&terminal, &delta).await; - let _ = notifications_tx.send(ServerNotificationMessage { - method: ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD.to_string(), - params: serde_json::json!({ - "terminalId": terminal_id, - "delta": delta, - }), - }); - } -} - -async fn watch_acp_terminal_exit(terminal: Arc) { - loop { - { - let mut state = terminal.state.lock().await; - match refresh_acp_terminal_exit_status(&mut state) { - Ok(Some(_)) => { - terminal.exit_notify.notify_waiters(); - return; - } - Ok(None) => {} - Err(error) => { - tracing::debug!(%error, "failed to watch ACP terminal exit"); - terminal.exit_notify.notify_waiters(); - return; - } - } - } - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -async fn append_acp_terminal_output(terminal: &AcpTerminalHandle, delta: &str) { - if delta.is_empty() { - return; - } - let mut state = terminal.state.lock().await; - state.output.push_str(delta); - let output_byte_limit = state.output_byte_limit; - if truncate_from_start_on_char_boundary(&mut state.output, output_byte_limit) { - state.truncated = true; - } -} - -fn take_terminal_utf8_chunks(buffer: &mut Vec) -> Vec { - let mut chunks = Vec::new(); - let mut consumed = 0usize; - while consumed < buffer.len() { - match std::str::from_utf8(&buffer[consumed..]) { - Ok(text) => { - if !text.is_empty() { - chunks.push(text.to_string()); - } - consumed = buffer.len(); - break; - } - Err(error) => { - let valid_up_to = error.valid_up_to(); - if valid_up_to > 0 { - let end = consumed + valid_up_to; - chunks.push( - std::str::from_utf8(&buffer[consumed..end]) - .expect("valid UTF-8 prefix") - .to_string(), - ); - consumed = end; - } - if let Some(error_len) = error.error_len() { - chunks.push("\u{FFFD}".to_string()); - consumed += error_len; - } else { - break; - } - } - } - } - if consumed > 0 { - buffer.drain(..consumed); - } - chunks -} - -fn truncate_from_start_on_char_boundary(text: &mut String, limit: usize) -> bool { - if text.len() <= limit { - return false; - } - if limit == 0 { - text.clear(); - return true; - } - let target = text.len().saturating_sub(limit); - let split_index = text - .char_indices() - .find_map(|(index, _)| (index >= target).then_some(index)) - .unwrap_or(text.len()); - text.drain(..split_index); - true -} - -async fn acp_terminal_output( - terminal_id: &str, - terminals: AcpTerminalManager, -) -> std::result::Result { - let terminal = terminals.get(terminal_id).await?; - let mut state = terminal.state.lock().await; - if refresh_acp_terminal_exit_status(&mut state)?.is_some() { - terminal.exit_notify.notify_waiters(); - } - Ok(AcpTerminalOutputResult { - output: state.output.clone(), - truncated: state.truncated, - exit_status: state.exit_status.clone(), - meta: None, - }) -} - -async fn wait_for_acp_terminal_exit( - terminal_id: &str, - terminals: AcpTerminalManager, -) -> std::result::Result { - let terminal = terminals.get(terminal_id).await?; - loop { - let notified = terminal.exit_notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - { - let mut state = terminal.state.lock().await; - if let Some(status) = refresh_acp_terminal_exit_status(&mut state)? { - return Ok(status); - } - } - notified.await; - } -} - -async fn kill_acp_terminal( - terminal_id: &str, - terminals: AcpTerminalManager, -) -> std::result::Result<(), String> { - let terminal = terminals.get(terminal_id).await?; - kill_acp_terminal_if_running(&terminal).await -} - -async fn release_acp_terminal( - terminal_id: &str, - terminals: AcpTerminalManager, -) -> std::result::Result<(), String> { - let terminal = terminals - .terminals - .lock() - .await - .remove(terminal_id) - .ok_or_else(|| format!("unknown terminalId {terminal_id}"))?; - kill_acp_terminal_if_running(&terminal).await -} - -async fn kill_acp_terminal_if_running( - terminal: &AcpTerminalHandle, -) -> std::result::Result<(), String> { - let mut state = terminal.state.lock().await; - if refresh_acp_terminal_exit_status(&mut state)?.is_none() - && let Err(error) = state.child.start_kill() - { - return Err(format!("failed to kill terminal: {error}")); - } - Ok(()) -} - -fn refresh_acp_terminal_exit_status( - state: &mut AcpTerminalState, -) -> std::result::Result, String> { - if let Some(status) = state.exit_status.clone() { - return Ok(Some(status)); - } - let Some(status) = state - .child - .try_wait() - .map_err(|error| format!("failed to query terminal exit status: {error}"))? - else { - return Ok(None); - }; - let status = acp_terminal_exit_status_from_process_status(status); - state.exit_status = Some(status.clone()); - Ok(Some(status)) -} - -fn acp_terminal_exit_status_from_process_status(status: ExitStatus) -> AcpTerminalExitStatus { - #[cfg(unix)] - let signal = { - use std::os::unix::process::ExitStatusExt; - status.signal().map(|signal| signal.to_string()) - }; - #[cfg(not(unix))] - let signal = None; - AcpTerminalExitStatus { - exit_code: status.code(), - signal, - } -} - -#[cfg(test)] -mod tests { - use devo_protocol::AcpSuccessResponse; - use pretty_assertions::assert_eq; - use tokio::sync::mpsc; - use tokio::time::Duration; - use tokio::time::timeout; - - use super::*; - - #[tokio::test] - async fn acp_terminal_methods_run_command_and_release() { - let session_id = devo_protocol::SessionId::new(); - let terminals = AcpTerminalManager::new(); - let (notifications_tx, mut notifications_rx) = mpsc::unbounded_channel(); - let (command, args) = short_terminal_command(); - let create_response = handle_acp_terminal_request( - serde_json::json!(1), - ACP_TERMINAL_CREATE_METHOD, - serde_json::to_value(AcpTerminalCreateParams { - session_id, - command, - args, - env: Vec::new(), - cwd: Some(std::env::current_dir().expect("current dir")), - output_byte_limit: Some(128), - meta: None, - }) - .expect("serialize terminal/create params"), - terminals.clone(), - notifications_tx, - ) - .await - .expect("terminal/create succeeds"); - let create: AcpSuccessResponse = - serde_json::from_value(create_response).expect("decode terminal/create response"); - let terminal_id = create.result.terminal_id; - let terminal_params = AcpTerminalParams { - session_id, - terminal_id: terminal_id.clone(), - meta: None, - }; - - let wait_response = timeout( - Duration::from_secs(5), - handle_acp_terminal_request( - serde_json::json!(2), - ACP_TERMINAL_WAIT_FOR_EXIT_METHOD, - serde_json::to_value(&terminal_params).expect("serialize wait params"), - terminals.clone(), - mpsc::unbounded_channel().0, - ), - ) - .await - .expect("terminal exits before timeout") - .expect("terminal/wait_for_exit succeeds"); - let wait: AcpSuccessResponse = - serde_json::from_value(wait_response).expect("decode wait response"); - assert_eq!(wait.result.exit_code, Some(0)); - - let output = timeout(Duration::from_secs(5), async { - loop { - let output = acp_terminal_output(&terminal_id, terminals.clone()) - .await - .expect("terminal output exists"); - if output.output.contains("acp-terminal") { - return output; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("terminal output captured before timeout"); - assert!(!output.truncated); - assert!(output.output.contains("acp-terminal")); - - let mut saw_output_notification = false; - while let Ok(notification) = notifications_rx.try_recv() { - saw_output_notification |= notification.method - == ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD - && notification.params.get("terminalId") - == Some(&serde_json::json!(terminal_id.clone())) - && notification - .params - .get("delta") - .and_then(serde_json::Value::as_str) - .is_some_and(|delta| delta.contains("acp-terminal")); - } - assert!(saw_output_notification); - - handle_acp_terminal_request( - serde_json::json!(3), - ACP_TERMINAL_KILL_METHOD, - serde_json::to_value(&terminal_params).expect("serialize kill params"), - terminals.clone(), - mpsc::unbounded_channel().0, - ) - .await - .expect("terminal/kill succeeds"); - handle_acp_terminal_request( - serde_json::json!(4), - ACP_TERMINAL_RELEASE_METHOD, - serde_json::to_value(&terminal_params).expect("serialize release params"), - terminals.clone(), - mpsc::unbounded_channel().0, - ) - .await - .expect("terminal/release succeeds"); - assert!(terminals.terminals.lock().await.is_empty()); - assert!( - acp_terminal_output(&terminal_id, terminals) - .await - .expect_err("released terminal is removed") - .contains("unknown terminalId") - ); - } - - #[cfg(windows)] - fn short_terminal_command() -> (String, Vec) { - ( - "cmd".to_string(), - vec!["/C".to_string(), "echo acp-terminal".to_string()], - ) - } - - #[cfg(unix)] - fn short_terminal_command() -> (String, Vec) { - ( - "sh".to_string(), - vec!["-c".to_string(), "printf 'acp-terminal\\n'".to_string()], - ) - } - - #[test] - fn terminal_utf8_chunks_keep_split_character_boundary() { - let mut buffer = vec![0xE2, 0x82]; - assert_eq!(take_terminal_utf8_chunks(&mut buffer), Vec::::new()); - assert_eq!(buffer, vec![0xE2, 0x82]); - - buffer.extend([0xAC, b'\n']); - assert_eq!( - take_terminal_utf8_chunks(&mut buffer), - vec!["€\n".to_string()] - ); - assert!(buffer.is_empty()); - } - - #[test] - fn truncate_keeps_valid_utf8_boundary() { - let mut text = "a€b".to_string(); - assert!(truncate_from_start_on_char_boundary(&mut text, 2)); - assert_eq!(text, "b"); - } -} diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 9732ba61..57b20a44 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -5,7 +5,7 @@ //! delegate protocol logic here. Incoming messages are classified as: //! //! - **Server → client requests** (`id` + `method`): handled asynchronously; the -//! response echoes the same JSON-RPC `id` (see `fs/read`, permissions, terminal). +//! response echoes the same JSON-RPC `id` (see `fs/read`, permissions). //! - **Server responses** (`id` + `result`/`error`, no `method`): matched against //! [`PendingResponses`] via numeric `id` to complete a client-initiated `request`. //! - **Notifications** (no `id`): forwarded on the notification channel. @@ -37,8 +37,6 @@ use crate::acp_fs::handle_acp_fs_request; use crate::acp_permissions::AcpPendingPermissions; use crate::acp_permissions::handle_acp_request_permission; use crate::acp_permissions::resolve_acp_permission_response; -use crate::acp_terminal::AcpTerminalManager; -use crate::acp_terminal::handle_acp_terminal_request; pub const ACP_PROMPT_STARTED_NOTIFICATION_METHOD: &str = "_devo/acp_prompt/started"; pub const ACP_PROMPT_COMPLETED_NOTIFICATION_METHOD: &str = "_devo/acp_prompt/completed"; @@ -92,7 +90,6 @@ pub(crate) struct ServerClientReaderState { writer: ClientWriter, pending: PendingResponses, acp_pending_permissions: AcpPendingPermissions, - acp_terminals: AcpTerminalManager, notifications_tx: mpsc::UnboundedSender, } @@ -100,7 +97,6 @@ pub(crate) struct ServerClientCore { writer: ClientWriter, pending: PendingResponses, acp_pending_permissions: AcpPendingPermissions, - acp_terminals: AcpTerminalManager, acp_agent_capabilities: Option, client_capabilities: AcpClientCapabilities, next_request_id: AtomicU64, @@ -115,7 +111,6 @@ impl ServerClientCore { writer, pending: Arc::new(Mutex::new(HashMap::new())), acp_pending_permissions: Arc::new(Mutex::new(HashMap::new())), - acp_terminals: AcpTerminalManager::new(), acp_agent_capabilities: None, client_capabilities, next_request_id: AtomicU64::new(1), @@ -129,7 +124,6 @@ impl ServerClientCore { writer: self.writer.clone(), pending: Arc::clone(&self.pending), acp_pending_permissions: Arc::clone(&self.acp_pending_permissions), - acp_terminals: self.acp_terminals.clone(), notifications_tx: self.notifications_tx.clone(), } } @@ -197,16 +191,6 @@ impl ServerClientCore { }) } - pub(crate) async fn acp_terminal_output_snapshot( - &self, - terminal_id: &str, - ) -> Result { - self.acp_terminals - .output(terminal_id) - .await - .map_err(anyhow::Error::msg) - } - pub(crate) async fn session_start( &mut self, params: SessionStartParams, @@ -431,7 +415,6 @@ impl ServerClientCore { pub(crate) async fn shutdown(&self) { self.writer.close(); - self.acp_terminals.release_all().await; } pub(crate) async fn agent_list(&mut self, params: AgentListParams) -> Result { @@ -784,7 +767,6 @@ impl ServerClientReaderState { "server reader stopped with pending responses" ); } - self.acp_terminals.release_all().await; } fn handle_notification(&self, notification: NotificationEnvelope) { @@ -841,26 +823,6 @@ impl ServerClientReaderState { Ok(response) => response, Err(message) => acp_client_error_response(id, -32603, message), } - } else if matches!( - method, - ACP_TERMINAL_CREATE_METHOD - | ACP_TERMINAL_OUTPUT_METHOD - | ACP_TERMINAL_WAIT_FOR_EXIT_METHOD - | ACP_TERMINAL_KILL_METHOD - | ACP_TERMINAL_RELEASE_METHOD - ) { - match handle_acp_terminal_request( - id.clone(), - method, - params, - self.acp_terminals, - self.notifications_tx, - ) - .await - { - Ok(response) => response, - Err(message) => acp_client_error_response(id, -32603, message), - } } else { acp_client_error_response(id, -32601, format!("unknown client method {method}")) }; diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 5be10fc0..14d6b197 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -6,7 +6,6 @@ mod acp_fs; mod acp_permissions; -mod acp_terminal; mod client_core; mod events; mod protocol_trace; diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 5258b2c5..ead50bda 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -28,7 +28,6 @@ use crate::client_core::ServerClientCore; use crate::protocol_trace::ProtocolTrace; use crate::protocol_trace::TraceDirection; -pub use crate::acp_terminal::ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD; pub use crate::client_core::ServerNotificationMessage; const SERVER_CHILD_STDIN_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100); @@ -127,13 +126,6 @@ impl StdioServerClient { Ok(result) } - pub async fn acp_terminal_output_snapshot( - &self, - terminal_id: &str, - ) -> Result { - self.core.acp_terminal_output_snapshot(terminal_id).await - } - pub async fn session_start( &mut self, params: SessionStartParams, @@ -511,7 +503,7 @@ mod tests { write_text_file: true, meta: None, }, - terminal: true, + terminal: false, meta: None, } } @@ -568,7 +560,7 @@ mod tests { write_text_file: false, meta: None, }, - terminal: true, + terminal: false, meta: None, }; let (mut client, pending) = diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index 68808ced..8bcd609f 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -102,13 +102,6 @@ impl WebSocketServerClient { self.core.initialize().await } - pub async fn acp_terminal_output_snapshot( - &self, - terminal_id: &str, - ) -> Result { - self.core.acp_terminal_output_snapshot(terminal_id).await - } - pub async fn session_start( &mut self, params: SessionStartParams, diff --git a/crates/core/src/tools/client_terminal_shell.rs b/crates/core/src/tools/client_terminal_shell.rs deleted file mode 100644 index 8f840664..00000000 --- a/crates/core/src/tools/client_terminal_shell.rs +++ /dev/null @@ -1,652 +0,0 @@ -use devo_protocol::approx_bytes_for_tokens; -use std::path::PathBuf; -use std::time::Duration; - -use serde_json::json; - -use crate::contracts::ToolCallError; -use crate::contracts::ToolContext; -use crate::contracts::ToolProgress; -use crate::contracts::ToolProgressSender; -use crate::contracts::ToolResult; -use crate::contracts::ToolResultContent; -use crate::tools::ClientTerminalCreate; -use crate::tools::ClientTerminalCreateRequest; -use crate::tools::ClientTerminalEnv; -use crate::tools::ClientTerminalOutput; -use crate::tools::ClientTerminalRequest; - -use super::shell_exec::preview; -use super::shell_exec::truncate_output; - -pub(crate) struct ClientTerminalShellRequest { - pub command: String, - pub workdir: PathBuf, - pub description: String, - pub shell_override: Option, - pub login: bool, - pub timeout_ms: u64, - pub max_output_tokens: usize, -} - -struct TerminalShellSpec { - program: &'static str, - args: &'static [&'static str], -} - -pub(crate) async fn execute_with_client_terminal( - ctx: &ToolContext, - request: ClientTerminalShellRequest, - progress: Option, -) -> Result, ToolCallError> { - let Some(client_terminal) = ctx.client_terminal.clone() else { - return Ok(None); - }; - let (command, args) = terminal_command_parts( - &request.command, - request.shell_override.as_deref(), - request.login, - ); - let env = client_terminal_env(); - let create = client_terminal - .clone() - .create( - ClientTerminalCreateRequest { - session_id: ctx.session_id.clone(), - command, - args, - env, - cwd: Some(request.workdir.clone()), - output_byte_limit: Some(approx_bytes_for_tokens(request.max_output_tokens)), - }, - ctx.cancel_token.clone(), - ) - .await?; - let ClientTerminalCreate::Created { terminal_id } = create else { - return Ok(None); - }; - - if let Some(progress) = progress { - let _ = progress.send(ToolProgress::Terminal { - terminal_id: terminal_id.clone(), - }); - } - - let terminal_request = ClientTerminalRequest { - session_id: ctx.session_id.clone(), - terminal_id: terminal_id.clone(), - }; - let wait = client_terminal - .clone() - .wait_for_exit( - terminal_request.clone(), - Duration::from_millis(request.timeout_ms), - ctx.cancel_token.clone(), - ) - .await; - let wait_status = match wait { - Ok(status) => status, - Err(ToolCallError::TimedOut(seconds)) => { - let cleanup_token = tokio_util::sync::CancellationToken::new(); - let _ = client_terminal - .clone() - .kill(terminal_request.clone(), cleanup_token.clone()) - .await; - let output = terminal_output_snapshot( - client_terminal.clone(), - terminal_request.clone(), - cleanup_token.clone(), - ) - .await; - let _ = client_terminal - .release(terminal_request, cleanup_token) - .await; - return Ok(Some(terminal_error_result( - &terminal_id, - &request, - output, - "Command timed out", - ToolCallError::TimedOut(seconds), - ))); - } - Err(ToolCallError::Cancelled) => { - let cleanup_token = tokio_util::sync::CancellationToken::new(); - let _ = client_terminal - .clone() - .kill(terminal_request.clone(), cleanup_token.clone()) - .await; - let _ = client_terminal - .release(terminal_request, cleanup_token) - .await; - return Err(ToolCallError::Cancelled); - } - Err(error) => { - let cleanup_token = tokio_util::sync::CancellationToken::new(); - let _ = client_terminal - .clone() - .kill(terminal_request.clone(), cleanup_token.clone()) - .await; - let output = terminal_output_snapshot( - client_terminal.clone(), - terminal_request.clone(), - cleanup_token.clone(), - ) - .await; - let _ = client_terminal - .release(terminal_request, cleanup_token) - .await; - return Ok(Some(terminal_error_result( - &terminal_id, - &request, - output, - "Command failed", - error, - ))); - } - }; - - let output = terminal_output_snapshot( - client_terminal.clone(), - terminal_request.clone(), - ctx.cancel_token.clone(), - ) - .await?; - let _ = client_terminal - .release(terminal_request, ctx.cancel_token.clone()) - .await; - - let success = wait_status.exit_code == Some(0) && wait_status.signal.is_none(); - let metadata = terminal_result_metadata(&terminal_id, &request, Some(output.clone())); - if success { - Ok(Some(ToolResult::success( - ToolResultContent::Json(metadata), - "Command executed", - ))) - } else { - Ok(Some(ToolResult::error( - ToolResultContent::Json(metadata), - "Command failed", - ToolCallError::ExecutionFailed(format_terminal_failure(&wait_status)), - ))) - } -} - -async fn terminal_output_snapshot( - client_terminal: std::sync::Arc, - request: ClientTerminalRequest, - cancel_token: tokio_util::sync::CancellationToken, -) -> Result { - client_terminal.output(request, cancel_token).await -} - -fn terminal_error_result( - terminal_id: &str, - request: &ClientTerminalShellRequest, - output: Result, - summary: &str, - error: ToolCallError, -) -> ToolResult { - let metadata = terminal_result_metadata(terminal_id, request, output.ok()); - ToolResult::error(ToolResultContent::Json(metadata), summary, error) -} - -fn terminal_result_metadata( - terminal_id: &str, - request: &ClientTerminalShellRequest, - output: Option, -) -> serde_json::Value { - let output_text = output - .as_ref() - .map(|output| truncate_output(&output.output, request.max_output_tokens)) - .unwrap_or_default(); - let truncated = output.as_ref().is_some_and(|output| output.truncated); - let exit_status = output - .as_ref() - .and_then(|output| output.exit_status.as_ref()) - .map(|status| { - json!({ - "exitCode": status.exit_code, - "signal": status.signal, - }) - }); - json!({ - "content": [ - { - "type": "terminal", - "terminalId": terminal_id, - } - ], - "terminalId": terminal_id, - "output": output_text, - "truncated": truncated, - "exitStatus": exit_status, - "command": preview(&request.command), - "description": request.description, - "cwd": request.workdir, - }) -} - -fn format_terminal_failure(status: &crate::tools::ClientTerminalExitStatus) -> String { - match (&status.exit_code, &status.signal) { - (Some(code), _) => format!("exit code {code}"), - (None, Some(signal)) => format!("signal {signal}"), - (None, None) => "command exited without status".to_string(), - } -} - -fn terminal_command_parts( - command: &str, - shell_override: Option<&str>, - login: bool, -) -> (String, Vec) { - let shell = resolve_terminal_shell(shell_override, login); - let command_to_run = if cfg!(windows) && shell.program.eq_ignore_ascii_case("powershell") { - let mut command_to_run = concat!( - "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "$OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "[System.Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); " - ) - .to_string(); - command_to_run.push_str(command); - command_to_run - } else { - command.to_string() - }; - let mut args = shell - .args - .iter() - .map(ToString::to_string) - .collect::>(); - args.push(command_to_run); - (shell.program.to_string(), args) -} - -fn resolve_terminal_shell(shell: Option<&str>, login: bool) -> TerminalShellSpec { - let shell = shell.unwrap_or(""); - let normalized = shell.to_ascii_lowercase(); - - if normalized.contains("powershell") || normalized == "pwsh" || normalized == "powershell" { - return TerminalShellSpec { - program: "powershell", - args: &["-NoLogo", "-NoProfile", "-Command"], - }; - } - - if normalized.ends_with("cmd") || normalized.ends_with("cmd.exe") || normalized == "cmd" { - return TerminalShellSpec { - program: "cmd", - args: &["/C"], - }; - } - - if normalized.contains("zsh") { - return TerminalShellSpec { - program: "zsh", - args: if login { &["-lc"] } else { &["-c"] }, - }; - } - - if normalized.contains("bash") { - return TerminalShellSpec { - program: "bash", - args: if login { &["-lc"] } else { &["-c"] }, - }; - } - - platform_terminal_shell(login) -} - -fn platform_terminal_shell(login: bool) -> TerminalShellSpec { - if cfg!(windows) { - TerminalShellSpec { - program: "powershell", - args: &["-NoProfile", "-Command"], - } - } else { - TerminalShellSpec { - program: "bash", - args: if login { &["-lc"] } else { &["-c"] }, - } - } -} - -fn client_terminal_env() -> Vec { - if cfg!(windows) { - vec![ClientTerminalEnv { - name: "PYTHONUTF8".to_string(), - value: "1".to_string(), - }] - } else { - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use async_trait::async_trait; - use pretty_assertions::assert_eq; - use tokio::sync::Mutex; - use tokio_util::sync::CancellationToken; - - use super::*; - use crate::contracts::ToolAgentScope; - use crate::contracts::ToolBudgets; - use crate::contracts::ToolTerminalStatus; - use crate::invocation::ToolCallId; - use crate::tools::ClientTerminal; - - #[derive(Clone)] - struct FakeClientTerminal { - create: ClientTerminalCreate, - wait: Result, - output: Result, - calls: Arc>>, - } - - #[async_trait] - impl ClientTerminal for FakeClientTerminal { - async fn create( - self: Arc, - request: ClientTerminalCreateRequest, - _cancel_token: CancellationToken, - ) -> Result { - self.calls.lock().await.push(format!( - "create:{}:{}", - request.command, - request.args.join(" ") - )); - Ok(self.create.clone()) - } - - async fn output( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result { - self.calls.lock().await.push("output".to_string()); - self.output.clone() - } - - async fn wait_for_exit( - self: Arc, - _request: ClientTerminalRequest, - timeout: Duration, - _cancel_token: CancellationToken, - ) -> Result { - self.calls - .lock() - .await - .push(format!("wait:{}", timeout.as_millis())); - self.wait.clone() - } - - async fn kill( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - self.calls.lock().await.push("kill".to_string()); - Ok(()) - } - - async fn release( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - self.calls.lock().await.push("release".to_string()); - Ok(()) - } - } - - fn fake_terminal( - create: ClientTerminalCreate, - wait: Result, - output: Result, - ) -> Arc { - Arc::new(FakeClientTerminal { - create, - wait, - output, - calls: Arc::new(Mutex::new(Vec::new())), - }) - } - - fn context(client_terminal: Arc) -> ToolContext { - ToolContext { - tool_call_id: ToolCallId("call-1".to_string()), - session_id: devo_protocol::SessionId::new().to_string(), - turn_id: None, - workspace_root: std::env::current_dir().expect("current dir"), - budgets: ToolBudgets { - output_limit_bytes: 1024, - wall_time_limit_ms: None, - }, - cancel_token: CancellationToken::new(), - agent_scope: ToolAgentScope::Parent, - collaboration_mode: devo_protocol::CollaborationMode::Build, - agent_coordinator: None, - client_filesystem: None, - client_terminal: Some(client_terminal), - file_read_ledger: None, - network_proxy: None, - network_no_proxy: None, - sandbox_profile: None, - } - } - - fn request() -> ClientTerminalShellRequest { - ClientTerminalShellRequest { - command: "echo hi".to_string(), - workdir: std::env::current_dir().expect("current dir"), - description: "test command".to_string(), - shell_override: Some("bash".to_string()), - login: false, - timeout_ms: 1234, - max_output_tokens: 128, - } - } - - fn ok_status() -> crate::tools::ClientTerminalExitStatus { - crate::tools::ClientTerminalExitStatus { - exit_code: Some(0), - signal: None, - } - } - - fn output(exit_code: Option) -> ClientTerminalOutput { - ClientTerminalOutput { - output: "hello\n".to_string(), - truncated: false, - exit_status: Some(crate::tools::ClientTerminalExitStatus { - exit_code, - signal: None, - }), - } - } - - fn result_json(result: ToolResult) -> serde_json::Value { - match result.content { - ToolResultContent::Json(json) => json, - content => panic!("expected json result, got {content:?}"), - } - } - - #[test] - fn terminal_metadata_preserves_empty_output() { - let request = request(); - let metadata = terminal_result_metadata( - "term_1", - &request, - Some(ClientTerminalOutput { - output: String::new(), - truncated: false, - exit_status: Some(ok_status()), - }), - ); - - assert_eq!( - metadata, - serde_json::json!({ - "content": [{ - "type": "terminal", - "terminalId": "term_1" - }], - "terminalId": "term_1", - "output": "", - "truncated": false, - "exitStatus": { - "exitCode": 0, - "signal": null - }, - "command": "echo hi", - "description": "test command", - "cwd": request.workdir - }) - ); - } - - #[tokio::test] - async fn client_terminal_success_returns_terminal_content_and_releases() { - let terminal = fake_terminal( - ClientTerminalCreate::Created { - terminal_id: "term_1".to_string(), - }, - Ok(ok_status()), - Ok(output(Some(0))), - ); - let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); - - let result = - execute_with_client_terminal(&context(terminal.clone()), request(), Some(progress_tx)) - .await - .expect("client terminal succeeds") - .expect("client terminal used"); - - assert!(matches!( - result.structured_status, - ToolTerminalStatus::Completed - )); - assert_eq!( - progress_rx.recv().await, - Some(ToolProgress::Terminal { - terminal_id: "term_1".to_string() - }) - ); - let json = result_json(result); - assert_eq!(json["content"][0]["type"], "terminal"); - assert_eq!(json["content"][0]["terminalId"], "term_1"); - assert_eq!(json["output"], "hello\n"); - assert_eq!( - terminal.calls.lock().await.as_slice(), - &[ - "create:bash:-c echo hi".to_string(), - "wait:1234".to_string(), - "output".to_string(), - "release".to_string(), - ] - ); - } - - #[tokio::test] - async fn client_terminal_unsupported_returns_none_for_local_fallback() { - let terminal = fake_terminal( - ClientTerminalCreate::Unsupported, - Ok(ok_status()), - Ok(output(Some(0))), - ); - - let result = execute_with_client_terminal(&context(terminal), request(), None) - .await - .expect("unsupported is not an error"); - - assert!(result.is_none()); - } - - #[tokio::test] - async fn client_terminal_nonzero_exit_returns_failed_terminal_result() { - let terminal = fake_terminal( - ClientTerminalCreate::Created { - terminal_id: "term_1".to_string(), - }, - Ok(crate::tools::ClientTerminalExitStatus { - exit_code: Some(7), - signal: None, - }), - Ok(output(Some(7))), - ); - - let result = execute_with_client_terminal(&context(terminal), request(), None) - .await - .expect("client terminal completes") - .expect("client terminal used"); - - assert!(matches!( - result.structured_status, - ToolTerminalStatus::Failed(ToolCallError::ExecutionFailed(_)) - )); - let json = result_json(result); - assert_eq!(json["content"][0]["terminalId"], "term_1"); - assert_eq!(json["exitStatus"]["exitCode"], 7); - } - - #[tokio::test] - async fn client_terminal_timeout_kills_and_releases_terminal() { - let terminal = fake_terminal( - ClientTerminalCreate::Created { - terminal_id: "term_1".to_string(), - }, - Err(ToolCallError::TimedOut(1)), - Ok(output(None)), - ); - - let result = execute_with_client_terminal(&context(terminal.clone()), request(), None) - .await - .expect("timeout becomes failed tool result") - .expect("client terminal used"); - - assert!(matches!( - result.structured_status, - ToolTerminalStatus::Failed(ToolCallError::TimedOut(1)) - )); - assert_eq!( - terminal.calls.lock().await.as_slice(), - &[ - "create:bash:-c echo hi".to_string(), - "wait:1234".to_string(), - "kill".to_string(), - "output".to_string(), - "release".to_string(), - ] - ); - } - - #[tokio::test] - async fn client_terminal_cancel_kills_and_releases_terminal() { - let terminal = fake_terminal( - ClientTerminalCreate::Created { - terminal_id: "term_1".to_string(), - }, - Err(ToolCallError::Cancelled), - Ok(output(None)), - ); - - let error = execute_with_client_terminal(&context(terminal.clone()), request(), None) - .await - .expect_err("cancel remains a tool cancellation"); - - assert!(matches!(error, ToolCallError::Cancelled)); - assert_eq!( - terminal.calls.lock().await.as_slice(), - &[ - "create:bash:-c echo hi".to_string(), - "wait:1234".to_string(), - "kill".to_string(), - "release".to_string(), - ] - ); - } -} diff --git a/crates/core/src/tools/handlers/agent.rs b/crates/core/src/tools/handlers/agent.rs index 2428dcfd..a3db8666 100644 --- a/crates/core/src/tools/handlers/agent.rs +++ b/crates/core/src/tools/handlers/agent.rs @@ -924,7 +924,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/apply_patch.rs b/crates/core/src/tools/handlers/apply_patch.rs index 93726618..ef14ae42 100644 --- a/crates/core/src/tools/handlers/apply_patch.rs +++ b/crates/core/src/tools/handlers/apply_patch.rs @@ -186,7 +186,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/code_search.rs b/crates/core/src/tools/handlers/code_search.rs index 85a8c7d8..f4eabfd3 100644 --- a/crates/core/src/tools/handlers/code_search.rs +++ b/crates/core/src/tools/handlers/code_search.rs @@ -290,7 +290,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/edit.rs b/crates/core/src/tools/handlers/edit.rs index f63107f0..0c8aa59d 100644 --- a/crates/core/src/tools/handlers/edit.rs +++ b/crates/core/src/tools/handlers/edit.rs @@ -383,7 +383,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: Some(ledger), network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/exec_command.rs b/crates/core/src/tools/handlers/exec_command.rs index 4b34acce..4bca526a 100644 --- a/crates/core/src/tools/handlers/exec_command.rs +++ b/crates/core/src/tools/handlers/exec_command.rs @@ -616,7 +616,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/file_write.rs b/crates/core/src/tools/handlers/file_write.rs index 9e10ef68..89f5686a 100644 --- a/crates/core/src/tools/handlers/file_write.rs +++ b/crates/core/src/tools/handlers/file_write.rs @@ -203,7 +203,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: Some(client_filesystem), - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/goal_update.rs b/crates/core/src/tools/handlers/goal_update.rs index 90f07dfc..58ba6267 100644 --- a/crates/core/src/tools/handlers/goal_update.rs +++ b/crates/core/src/tools/handlers/goal_update.rs @@ -148,7 +148,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/read.rs b/crates/core/src/tools/handlers/read.rs index beefd9bd..370ca9ff 100644 --- a/crates/core/src/tools/handlers/read.rs +++ b/crates/core/src/tools/handlers/read.rs @@ -256,7 +256,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/handlers/shell_command.rs b/crates/core/src/tools/handlers/shell_command.rs index 2395302b..fa31b9b7 100644 --- a/crates/core/src/tools/handlers/shell_command.rs +++ b/crates/core/src/tools/handlers/shell_command.rs @@ -10,15 +10,12 @@ use crate::shell_exec::{ }; use crate::tool_handler::ToolHandler; use crate::tool_spec::ToolSpec; -use crate::tools::client_terminal_shell::{ - ClientTerminalShellRequest, execute_with_client_terminal, -}; /// Tool adapter for `shell_command` (and the legacy `bash` alias). /// -/// Parses model input and delegates process execution to [`execute_shell_command`] -/// or the client terminal when available. The ToolSpec comes from -/// [`shell_command_tool_spec`] so the registry plan and handler share one schema. +/// Parses model input and runs the command locally via [`execute_shell_command`]. +/// The ToolSpec comes from [`shell_command_tool_spec`] so the registry plan and +/// handler share one schema. pub struct ShellCommandHandler { spec: ToolSpec, } @@ -47,7 +44,7 @@ impl ToolHandler for ShellCommandHandler { &self, ctx: ToolContext, input: serde_json::Value, - progress: Option, + _progress: Option, ) -> Result { let command = input .get("command") @@ -77,29 +74,6 @@ impl ToolHandler for ShellCommandHandler { .as_u64() .map(|v| v as usize) .unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS); - let terminal_workdir = if workdir.is_absolute() { - workdir.clone() - } else { - ctx.workspace_root.join(&workdir) - }; - - if let Some(result) = execute_with_client_terminal( - &ctx, - ClientTerminalShellRequest { - command: command.to_string(), - workdir: terminal_workdir, - description: description.clone(), - shell_override: shell_override.clone(), - login, - timeout_ms, - max_output_tokens, - }, - progress, - ) - .await? - { - return Ok(result); - } let output = execute_shell_command( ShellExecRequest { diff --git a/crates/core/src/tools/handlers/tool_search.rs b/crates/core/src/tools/handlers/tool_search.rs index b611ea36..49586735 100644 --- a/crates/core/src/tools/handlers/tool_search.rs +++ b/crates/core/src/tools/handlers/tool_search.rs @@ -455,7 +455,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, @@ -594,7 +593,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index a1e5548d..76c91bc4 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -2,7 +2,6 @@ pub mod contracts { pub use devo_tools::contracts::*; } -pub(crate) mod client_terminal_shell; pub mod deferred_loading; pub mod errors { pub use devo_tools::errors::*; @@ -52,10 +51,8 @@ pub use contracts::{ }; pub use deferred_loading::*; pub use devo_tools::{ - AgentToolCoordinator, ClientFilesystem, ClientTerminal, ClientTerminalCreate, - ClientTerminalCreateRequest, ClientTerminalEnv, ClientTerminalExitStatus, ClientTerminalOutput, - ClientTerminalRequest, ClientTextFileRead, ClientTextFileWrite, FileReadFreshnessError, - FileReadLedger, + AgentToolCoordinator, ClientFilesystem, ClientTextFileRead, ClientTextFileWrite, + FileReadFreshnessError, FileReadLedger, }; pub use errors::*; pub use events::ToolEvent; diff --git a/crates/core/src/tools/registry.rs b/crates/core/src/tools/registry.rs index ae0884d6..d7423c7f 100644 --- a/crates/core/src/tools/registry.rs +++ b/crates/core/src/tools/registry.rs @@ -523,7 +523,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: None, network_proxy: None, network_no_proxy: None, diff --git a/crates/core/src/tools/router.rs b/crates/core/src/tools/router.rs index 1e67b9a6..c9a6e0da 100644 --- a/crates/core/src/tools/router.rs +++ b/crates/core/src/tools/router.rs @@ -21,7 +21,6 @@ use crate::tool_spec::ToolCapabilityTag; use crate::tools::deferred_loading::is_subagent_agent_coordination_tool; use devo_tools::AgentToolCoordinator; use devo_tools::ClientFilesystem; -use devo_tools::ClientTerminal; use devo_tools::FileReadLedger; use devo_tools::ToolAgentScope; use tokio_util::sync::CancellationToken; @@ -333,7 +332,6 @@ impl ToolRuntime { collaboration_mode: self.context.collaboration_mode, agent_coordinator: self.context.agent_coordinator.clone(), client_filesystem: self.context.client_filesystem.clone(), - client_terminal: self.context.client_terminal.clone(), file_read_ledger: Some(Arc::clone(&self.context.file_read_ledger)), network_proxy: self.context.network_proxy.clone(), network_no_proxy: self.context.network_no_proxy.clone(), @@ -614,7 +612,6 @@ pub struct ToolRuntimeContext { pub collaboration_mode: devo_protocol::CollaborationMode, pub agent_coordinator: Option>, pub client_filesystem: Option>, - pub client_terminal: Option>, pub file_read_ledger: Arc, pub local_web_search: Option, pub hooks: Option, @@ -634,7 +631,6 @@ impl Default for ToolRuntimeContext { collaboration_mode: devo_protocol::CollaborationMode::default(), agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: Arc::new(FileReadLedger::new()), local_web_search: None, hooks: None, @@ -661,10 +657,6 @@ impl std::fmt::Debug for ToolRuntimeContext { "client_filesystem", &self.client_filesystem.as_ref().map(|_| ""), ) - .field( - "client_terminal", - &self.client_terminal.as_ref().map(|_| ""), - ) .field("file_read_ledger", &"") .field( "local_web_search", @@ -1622,7 +1614,6 @@ mod tests { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger: std::sync::Arc::new(devo_tools::FileReadLedger::new()), local_web_search: None, hooks: None, diff --git a/crates/core/src/tools/shell_exec.rs b/crates/core/src/tools/shell_exec.rs deleted file mode 100644 index 3e77c410..00000000 --- a/crates/core/src/tools/shell_exec.rs +++ /dev/null @@ -1,787 +0,0 @@ -use devo_protocol::approx_bytes_for_tokens; -use portable_pty::{Child, CommandBuilder, ExitStatus, PtySize, native_pty_system}; -use serde_json::json; -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::mpsc; -use std::time::Instant; -use tokio::process::Command; -use tokio::time::{Duration, timeout}; -use tokio_util::sync::CancellationToken; -use tracing::info; - -use crate::events::ToolProgressSender; -use crate::invocation::FunctionToolOutput; - -const MAX_METADATA_LENGTH: usize = 30_000; -pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; -pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000; -pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; -const TRUNCATED_SUFFIX: &str = "\n\n... [truncated]"; - -#[cfg(not(unix))] -fn try_windows_sandbox_launch( - sandbox_profile: Option<&str>, - workdir: &std::path::Path, - shell: &ShellSpec, - command: &str, -) -> anyhow::Result> { - use std::sync::Once; - if !devo_windows_sandbox::should_wrap_profile(sandbox_profile) { - return Ok(None); - } - let profile = sandbox_profile.expect("checked by should_wrap_profile"); - let profile_name = profile - .parse::() - .map_err(|error| anyhow::anyhow!("invalid sandbox profile '{profile}': {error}"))?; - let config = devo_sandbox::load_sandbox_config(workdir)?; - let resolved = profile_name.resolve_profile(workdir, &config)?; - let request = devo_windows_sandbox::WindowsSandboxRequest { - command: command.to_string(), - shell_program: shell.program.to_string(), - shell_args: shell.args.iter().map(|arg| arg.to_string()).collect(), - cwd: workdir.to_path_buf(), - readable_roots: resolved.read_only, - writable_roots: resolved.read_write, - deny_read: resolved.deny, - restrict_network: resolved.restrict_network, - }; - match devo_windows_sandbox::prepare_windows_sandbox_launch(&request)? { - Some(launch) => Ok(Some(launch)), - None => { - static WARNED: Once = Once::new(); - WARNED.call_once(|| { - tracing::warn!( - "Windows sandbox profile is active but launch preparation is not wired yet; \ - commands run unwrapped" - ); - }); - Ok(None) - } - } -} - -/// Input to [`execute_shell_command`]: the caller's raw request before shell -/// resolution or pipe/PTY branching. -/// -/// `shell_override` / `login` select the interpreter; `tty` chooses the -/// execution path. Shared runtime knobs (workdir, timeouts, sandbox, …) are -/// forwarded into whichever path runs. -pub(crate) struct ShellExecRequest { - pub command: String, - pub workdir: PathBuf, - pub description: String, - /// Optional shell name/alias (`bash`, `pwsh`, `cmd`, …). `None` uses the - /// platform default. - pub shell_override: Option, - /// When true, run under a PTY via [`run_with_pty`]; otherwise pipe spawn. - pub tty: bool, - /// Prefer login-shell args (e.g. `bash -lc`) when resolving the shell. - pub login: bool, - pub timeout_ms: u64, - pub yield_time_ms: u64, - pub max_output_tokens: usize, - pub sandbox_profile: Option, -} - -/// Resolved arguments for [`run_with_pty`] after `ShellExecRequest` has been -/// normalized: shell override/login → [`ShellSpec`], and the command possibly -/// rewritten (e.g. PowerShell UTF-8 prelude). Does not carry `tty` / -/// `shell_override` / `login` because those are already applied. -struct PtyRunConfig { - shell: ShellSpec, - command_to_run: String, - workdir: PathBuf, - description: String, - timeout_ms: u64, - yield_time_ms: u64, - max_output_tokens: usize, - sandbox_profile: Option, -} - -/// RAII guard around a PTY-spawned child process. -/// -/// Ensures the child is killed if the guard is dropped while still armed -/// (timeout, cancel, or early return). Call [`Self::disarm`] after a clean -/// exit so [`Drop`] does not kill an already-reaped process. -struct PtyChildGuard { - child: Option>, -} - -impl PtyChildGuard { - /// Take ownership of `child` and keep the guard armed. - fn new(child: Box) -> Self { - Self { child: Some(child) } - } - - /// Non-blocking poll for exit status; panics if already disarmed. - fn try_wait(&mut self) -> std::io::Result> { - self.child - .as_mut() - .expect("PTY child guard must hold child while active") - .try_wait() - } - - /// Force-kill the child and wait for it to exit (best-effort). - fn kill_and_wait(&mut self) { - if let Some(child) = self.child.as_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - } - - /// Release ownership without killing; subsequent [`Drop`] is a no-op. - fn disarm(mut self) { - self.child.take(); - } -} - -impl Drop for PtyChildGuard { - /// Kill the child if the guard was dropped while still armed. - fn drop(&mut self) { - if let Some(child) = self.child.as_mut() { - let _ = child.kill(); - } - } -} - -/// Run a shell command from a [`ShellExecRequest`]. -/// -/// Resolves the shell and command, then either delegates to [`run_with_pty`] -/// when `tty` is set, or spawns a non-interactive pipe process (stdout/stderr -/// captured). Applies sandbox wrapping when a profile is set, waits for -/// completion (or cancel/timeout), and returns truncated tool output. -pub(crate) async fn execute_shell_command( - request: ShellExecRequest, - progress: Option, - cancel_token: CancellationToken, -) -> anyhow::Result { - // --- Validate request & normalize shell/command --- - let ShellExecRequest { - command, - workdir, - description, - shell_override, - tty, - login, - timeout_ms, - yield_time_ms, - max_output_tokens, - sandbox_profile, - } = request; - - if !workdir.exists() { - return Ok(FunctionToolOutput::error(format!( - "working directory does not exist: {}", - workdir.display() - ))); - } - - let shell = resolve_shell(shell_override.as_deref(), login); - // PowerShell often emits mojibake without an explicit UTF-8 console encoding. - let command_to_run = if cfg!(windows) && shell.program.eq_ignore_ascii_case("powershell") { - format!( - concat!( - "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "$OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "[System.Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", - "{}" - ), - command - ) - } else { - command - }; - - // --- PTY path (interactive / TTY) --- - if tty { - return run_with_pty( - PtyRunConfig { - shell, - command_to_run, - workdir, - description, - timeout_ms, - yield_time_ms, - max_output_tokens, - sandbox_profile, - }, - progress, - cancel_token, - ) - .await; - } - - // --- Pipe path: sandbox wrap + build Command --- - info!(command = %command_to_run, shell = shell.program, "executing shell command"); - let command_preview = preview(&command_to_run); - - // Unix (`cfg(unix)` covers Linux *and* macOS): decide whether to launch through - // an OS sandbox wrapper. `wrap_command_for_profile` picks the launcher: - // - macOS: `sandbox-exec` with a Seatbelt profile (full policy). Seatbelt is - // never applied via `pre_exec` after fork in a multithreaded process. - // - Linux: Landlock/`pre_exec` usually carries the profile; `bwrap` is added - // only when PipeComposed needs what Landlock cannot express (deny paths, - // network restriction). - // Windows uses the separate `try_windows_sandbox_launch` path below. - #[cfg(unix)] - let sandbox_wrap = match devo_sandbox::wrap_command_for_profile( - sandbox_profile.as_deref(), - &workdir, - devo_sandbox::WrapMode::PipeComposed, - &devo_sandbox::SandboxLogger::new(), - ) { - Ok(wrap) => wrap, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to set up sandbox: {error}" - ))); - } - }; - #[cfg(not(unix))] - let sandbox_wrap = devo_sandbox::SandboxWrap::None; - #[cfg(not(unix))] - let windows_launch = match try_windows_sandbox_launch( - sandbox_profile.as_deref(), - &workdir, - &shell, - &command_to_run, - ) { - Ok(launch) => launch, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to set up Windows sandbox: {error}" - ))); - } - }; - - // Prefer OS wrapper (`sandbox-exec` / `bwrap` / Windows launcher); else bare shell. - let mut child = match &sandbox_wrap { - devo_sandbox::SandboxWrap::Wrapped(wrapped) => { - let mut child = Command::new(&wrapped.program); - child - .args(&wrapped.prefix_args) - .arg(shell.program) - .args(shell.args) - .arg(&command_to_run); - child - } - devo_sandbox::SandboxWrap::None => { - #[cfg(not(unix))] - if let Some(launch) = &windows_launch { - let mut child = Command::new(&launch.program); - child.args(&launch.args); - for (key, value) in &launch.env { - child.env(key, value); - } - child - } else { - let mut child = Command::new(shell.program); - child.args(shell.args).arg(&command_to_run); - child - } - #[cfg(unix)] - { - let mut child = Command::new(shell.program); - child.args(shell.args).arg(&command_to_run); - child - } - } - }; - child - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .current_dir(&workdir) - .kill_on_drop(true); - - // --- Apply in-process sandbox (Unix pre_exec) and env --- - #[cfg(unix)] - { - let sandbox_workspace = workdir.clone(); - // `requires_child_apply` is false on macOS (Seatbelt is only via - // `sandbox-exec`) and when a Linux wrapper already enforces the full - // policy. Otherwise resolve Landlock/seccomp for `pre_exec`. - let sandbox_plan = if sandbox_wrap.requires_child_apply() { - match devo_util_process::sandbox::resolve_profile_for_spawn( - sandbox_profile.as_deref(), - &sandbox_workspace, - ) { - Ok(plan) => plan, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to resolve sandbox profile: {error}" - ))); - } - } - } else { - None - }; - unsafe { - // `pre_exec` runs in the child after `fork`, before `exec`. Apply the - // parent-resolved Landlock/seccomp plan here so only the spawned - // command is sandboxed (parent stays unrestricted). Config must not - // be loaded in this hook — resolve above in the parent. Skipped when - // `sandbox_plan` is `None` (macOS / fully wrapped Linux). - child.pre_exec(move || { - devo_util_process::sandbox::apply_resolved_in_child(sandbox_plan.as_ref()) - }); - } - } - #[cfg(not(unix))] - let _ = &sandbox_profile; - - if cfg!(windows) { - child.env("PYTHONUTF8", "1"); - } - - #[cfg(unix)] - apply_sandbox_proxy_env(&mut child, sandbox_profile.as_deref(), &workdir); - - // --- Spawn and schedule sandbox placeholder cleanup --- - let spawned = match child.spawn() { - Ok(child) => child, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to spawn process: {error}" - ))); - } - }; - // bwrap mounts are not up when spawn returns, so the placeholder directory - // must outlive the launch; remove it after a delay instead. - if let devo_sandbox::SandboxWrap::Wrapped(wrapped) = &sandbox_wrap - && let Some(directory) = &wrapped.placeholder_dir - { - let directory = directory.clone(); - tokio::spawn(async move { - tokio::time::sleep(devo_sandbox::PLACEHOLDER_CLEANUP_DELAY).await; - devo_sandbox::remove_placeholder_dir(&directory); - }); - } - - // --- Wait for exit, cancel, or timeout --- - let result = tokio::select! { - result = timeout(Duration::from_millis(timeout_ms), spawned.wait_with_output()) => result, - _ = cancel_token.cancelled() => { - return Ok(FunctionToolOutput::error("command cancelled")); - } - }; - - // --- Build success / error tool output --- - match result { - Ok(Ok(output)) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - let result_text = merge_streams(&stdout, &stderr); - if let Some(ref sender) = progress { - let _ = sender.send(result_text.clone()); - } - let result_text = truncate_output(&result_text, max_output_tokens); - if output.status.success() { - Ok(FunctionToolOutput::success_with_metadata( - result_text.clone(), - json!({ - "output": preview(&result_text), - "command": command_preview, - "exit": output.status.code(), - "description": description, - "cwd": workdir, - "yield_time_ms": yield_time_ms, - }), - )) - } else { - #[cfg(unix)] - let unix_signal = { - use std::os::unix::process::ExitStatusExt; - output.status.signal() - }; - #[cfg(not(unix))] - let unix_signal: Option = None; - let error_message = devo_sandbox::shell_error_message_with_signal( - sandbox_profile.as_deref(), - output.status.code(), - unix_signal, - &stdout, - &stderr, - &result_text, - ); - Ok(FunctionToolOutput::error(error_message)) - } - } - Ok(Err(error)) => Ok(FunctionToolOutput::error(format!( - "failed to spawn process: {error}" - ))), - Err(_) => Ok(FunctionToolOutput::error(format!( - "command timed out after {timeout_ms}ms" - ))), - } -} - -struct ShellSpec { - program: &'static str, - args: &'static [&'static str], -} - -fn resolve_shell(shell: Option<&str>, login: bool) -> ShellSpec { - let shell = shell.unwrap_or(""); - let normalized = shell.to_ascii_lowercase(); - - if normalized.contains("powershell") || normalized == "pwsh" || normalized == "powershell" { - return ShellSpec { - program: "powershell", - args: &["-NoLogo", "-NoProfile", "-Command"], - }; - } - - if normalized.ends_with("cmd") || normalized.ends_with("cmd.exe") || normalized == "cmd" { - return ShellSpec { - program: "cmd", - args: &["/C"], - }; - } - - if normalized.contains("zsh") { - return ShellSpec { - program: "zsh", - args: if login { &["-lc"] } else { &["-c"] }, - }; - } - - if normalized.contains("bash") { - return ShellSpec { - program: "bash", - args: if login { &["-lc"] } else { &["-c"] }, - }; - } - - if login { - platform_shell(true) - } else { - platform_shell(false) - } -} - -#[cfg(test)] -pub(crate) fn platform_shell_program(login: bool) -> &'static str { - platform_shell(login).program -} - -pub(crate) fn preview(text: &str) -> String { - if text.len() <= MAX_METADATA_LENGTH { - return text.to_string(); - } - format!("{}\n\n...", &text[..MAX_METADATA_LENGTH]) -} - -pub(crate) fn truncate_output(text: &str, max_output_tokens: usize) -> String { - if max_output_tokens == 0 { - return String::new(); - } - let max_chars = approx_bytes_for_tokens(max_output_tokens); - if text.len() <= max_chars { - return text.to_string(); - } - let mut out: String = text.chars().take(max_chars).collect(); - if out.len() < text.len() { - out.push_str(TRUNCATED_SUFFIX); - } - out -} - -pub(crate) fn merge_streams(stdout: &str, stderr: &str) -> String { - let mut result = String::new(); - if !stdout.is_empty() { - result.push_str(stdout); - } - if !stderr.is_empty() { - if !result.is_empty() { - result.push('\n'); - } - result.push_str("[stderr]\n"); - result.push_str(stderr); - } - result -} - -fn platform_shell(login: bool) -> ShellSpec { - if cfg!(windows) { - ShellSpec { - program: "powershell", - args: &["-NoProfile", "-Command"], - } - } else { - ShellSpec { - program: "bash", - args: if login { &["-lc"] } else { &["-c"] }, - } - } -} - -#[cfg(unix)] -fn apply_sandbox_proxy_env( - child: &mut Command, - sandbox_profile: Option<&str>, - workdir: &std::path::Path, -) { - for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile(sandbox_profile, workdir) { - child.env(key, value); - } -} - -/// Run a command attached to a pseudo-terminal (PTY). -/// -/// Used when [`ShellExecRequest::tty`] is true. Opens a PTY, optionally wraps -/// the spawn in an OS sandbox launcher (no `pre_exec` on this path), reads -/// master output on a background thread, and polls the child until exit, -/// timeout, or cancel. Returns truncated tool output with TTY metadata. -async fn run_with_pty( - config: PtyRunConfig, - progress: Option, - cancel_token: CancellationToken, -) -> anyhow::Result { - // --- Open PTY --- - let PtyRunConfig { - shell, - command_to_run, - workdir, - description, - timeout_ms, - yield_time_ms, - max_output_tokens, - sandbox_profile, - } = config; - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 120, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|error| anyhow::anyhow!("failed to open PTY: {error}"))?; - - // --- Sandbox wrap (OS launcher only; no nested in-child apply) --- - // PTY spawns have no `pre_exec` hook. Unix: `wrap_command_for_profile(PtyOnly)` - // wraps with macOS `sandbox-exec` or Linux `bwrap` carrying the full profile. - // Windows: `try_windows_sandbox_launch` below. Do not also apply the profile - // in-process (no nested sandboxes). - #[cfg(unix)] - let sandbox_wrap = match devo_sandbox::wrap_command_for_profile( - sandbox_profile.as_deref(), - &workdir, - devo_sandbox::WrapMode::PtyOnly, - &devo_sandbox::SandboxLogger::new(), - ) { - Ok(wrap) => wrap, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to set up sandbox: {error}" - ))); - } - }; - #[cfg(not(unix))] - let sandbox_wrap = devo_sandbox::SandboxWrap::None; - #[cfg(not(unix))] - let windows_launch = match try_windows_sandbox_launch( - sandbox_profile.as_deref(), - &workdir, - &shell, - &command_to_run, - ) { - Ok(launch) => launch, - Err(error) => { - return Ok(FunctionToolOutput::error(format!( - "failed to set up Windows sandbox: {error}" - ))); - } - }; - #[cfg(not(unix))] - let _ = sandbox_profile; - - // --- Build CommandBuilder (wrapper or bare shell) --- - let mut builder = match &sandbox_wrap { - devo_sandbox::SandboxWrap::Wrapped(wrapped) => { - let mut builder = CommandBuilder::new(&wrapped.program); - builder.args(&wrapped.prefix_args); - builder.arg(shell.program); - builder - } - devo_sandbox::SandboxWrap::None => { - #[cfg(not(unix))] - if let Some(launch) = &windows_launch { - let mut builder = CommandBuilder::new(&launch.program); - builder.args( - launch - .args - .iter() - .map(|arg| arg.as_str()) - .collect::>(), - ); - for (key, value) in &launch.env { - builder.env(key, value); - } - builder - } else { - CommandBuilder::new(shell.program) - } - #[cfg(unix)] - CommandBuilder::new(shell.program) - } - }; - // Windows sandbox launch already embeds the full command line. - #[cfg(not(unix))] - if windows_launch.is_none() { - builder.args(shell.args); - builder.arg(&command_to_run); - } - #[cfg(unix)] - { - builder.args(shell.args); - builder.arg(&command_to_run); - } - builder.cwd(&workdir); - if cfg!(windows) { - builder.env("PYTHONUTF8", "1"); - builder.env("TERM", "xterm-256color"); - builder.env("COLORTERM", "truecolor"); - } - #[cfg(unix)] - for (key, value) in - devo_sandbox::proxy_env_for_sandbox_profile(sandbox_profile.as_deref(), &workdir) - { - builder.env(key, value); - } - - // --- Spawn on slave, guard child, drop slave fd --- - let child = pair - .slave - .spawn_command(builder) - .map_err(|error| anyhow::anyhow!("failed to spawn PTY command: {error}"))?; - // bwrap mounts are not up when spawn returns, so the placeholder directory - // must outlive the launch; remove it after a delay instead. - if let devo_sandbox::SandboxWrap::Wrapped(wrapped) = &sandbox_wrap - && let Some(directory) = &wrapped.placeholder_dir - { - let directory = directory.clone(); - tokio::spawn(async move { - tokio::time::sleep(devo_sandbox::PLACEHOLDER_CLEANUP_DELAY).await; - devo_sandbox::remove_placeholder_dir(&directory); - }); - } - let mut child = PtyChildGuard::new(child); - drop(pair.slave); - - // --- Background reader: master → channel --- - let mut reader = pair - .master - .try_clone_reader() - .map_err(|error| anyhow::anyhow!("failed to clone PTY reader: {error}"))?; - let (tx, rx) = mpsc::channel::>(); - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - loop { - match std::io::Read::read(&mut reader, &mut buffer) { - Ok(0) => break, - Ok(size) => { - if tx.send(buffer[..size].to_vec()).is_err() { - break; - } - } - Err(_) => break, - } - } - }); - - // --- Poll loop: drain output, wait for exit / timeout / cancel --- - let started = Instant::now(); - let sleep_ms = yield_time_ms.max(10); - let timeout = Duration::from_millis(timeout_ms); - let mut output = Vec::new(); - let mut exit_code = None; - let mut timed_out = false; - let mut cancelled = false; - - loop { - // Non-blocking drain so progress can stream while the child still runs. - while let Ok(chunk) = rx.try_recv() { - output.extend_from_slice(&chunk); - if let Some(ref sender) = progress { - let text = String::from_utf8_lossy(&chunk).into_owned(); - let _ = sender.send(text); - } - } - - if let Some(status) = child - .try_wait() - .map_err(|error| anyhow::anyhow!("failed to poll PTY child: {error}"))? - { - exit_code = Some(status.exit_code() as i32); - break; - } - - if started.elapsed() >= timeout { - timed_out = true; - child.kill_and_wait(); - break; - } - - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {} - _ = cancel_token.cancelled() => { - cancelled = true; - child.kill_and_wait(); - break; - } - } - } - - // --- Final drain + tool result --- - while let Ok(chunk) = rx.try_recv() { - output.extend_from_slice(&chunk); - } - - let mut text = String::from_utf8_lossy(&output).into_owned(); - text = truncate_output(&text, max_output_tokens); - - if timed_out { - return Ok(FunctionToolOutput::error(format!( - "command timed out after {timeout_ms}ms\n{text}" - ))); - } - if cancelled { - return Ok(FunctionToolOutput::error(format!( - "command cancelled\n{text}" - ))); - } - // Clean exit: release ownership so Drop does not kill a finished process. - child.disarm(); - - let is_error = exit_code.unwrap_or(1) != 0; - let content = if is_error { - let code = exit_code.unwrap_or(-1); - devo_sandbox::shell_error_message(sandbox_profile.as_deref(), code, &text, "", &text) - } else { - text.clone() - }; - if is_error { - return Ok(FunctionToolOutput::error(content)); - } - - Ok(FunctionToolOutput::success_with_metadata( - content, - json!({ - "output": preview(&text), - "command": command_to_run, - "exit": exit_code, - "description": description, - "cwd": workdir, - "yield_time_ms": yield_time_ms, - "tty": true, - }), - )) -} - -#[cfg(test)] -mod tests; diff --git a/crates/core/src/tools/shell_exec/launch.rs b/crates/core/src/tools/shell_exec/launch.rs new file mode 100644 index 00000000..61cc6bae --- /dev/null +++ b/crates/core/src/tools/shell_exec/launch.rs @@ -0,0 +1,346 @@ +//! Shared pre-spawn sandbox preparation for shell_exec pipe and PTY paths. +//! +//! This is intentionally **not** the `devo_util_process` pipe backend: that +//! module owns process handles, channels, and lifecycle. Here we only decide +//! wrap / child-apply / placeholder cleanup / proxy env so both executors +//! stay consistent without sharing spawn semantics. +//! +//! # Platform sandbox implementation +//! +//! - **macOS** — Always through `/usr/bin/sandbox-exec` with a generated +//! Seatbelt profile. Pipe and PTY both use this wrapper; Seatbelt is never +//! applied in `pre_exec` after `fork` (unsafe in a multithreaded parent). If +//! `sandbox-exec` is missing or rejects the profile, the child runs +//! unwrapped (warn-and-release). +//! - **Linux** — Two layers compose by mode: +//! - *Pipe* (`PipeComposed`): Landlock + seccomp are resolved in the parent +//! and applied in the child via `pre_exec`. A `bwrap` / +//! `devo-linux-sandbox` wrapper is added only when the profile needs what +//! Landlock cannot express (deny-read bind-overs, network restriction). +//! - *PTY* (`PtyOnly`): no `pre_exec`, so the wrapper carries the full +//! policy whenever a profile is active. Temporary bwrap placeholder dirs +//! are cleaned up after spawn. +//! - **Windows** — `wrap_command_for_profile` is a no-op. Enforcement goes +//! through `try_windows_sandbox_launch` / `devo_windows_sandbox`, which +//! builds a launcher embedding the full command line plus read/write/deny +//! roots and optional network restriction. If launch prep is not wired yet, +//! the child runs unwrapped (one-time warning). +//! +//! Active profiles may also inject proxy-related env vars on Unix so outbound +//! traffic can be steered through the sandbox proxy when configured. + +use std::path::{Path, PathBuf}; + +use portable_pty::CommandBuilder; +use tokio::process::Command; + +use super::resolve::ShellSpec; + +/// Resolved sandbox launch configuration for one shell spawn. +/// +/// See the module docs for how this plan maps onto macOS Seatbelt, Linux +/// Landlock/`bwrap`, and Windows sandbox launch. +pub(crate) struct SandboxLaunchPlan { + wrap: devo_sandbox::SandboxWrap, + #[cfg(not(unix))] + windows_launch: Option, + #[cfg(unix)] + child_apply_plan: Option, + sandbox_profile: Option, + workdir: PathBuf, +} + +impl SandboxLaunchPlan { + /// Prepare a pipe-mode launch (`WrapMode::PipeComposed` + optional `pre_exec`). + pub(crate) fn prepare_pipe( + sandbox_profile: Option<&str>, + workdir: &Path, + shell: &ShellSpec, + command: &str, + ) -> Result { + Self::prepare( + sandbox_profile, + workdir, + shell, + command, + devo_sandbox::WrapMode::PipeComposed, + /*attach_child_apply*/ true, + ) + } + + /// Prepare a PTY-mode launch (`WrapMode::PtyOnly`; no `pre_exec`). + pub(crate) fn prepare_pty( + sandbox_profile: Option<&str>, + workdir: &Path, + shell: &ShellSpec, + command: &str, + ) -> Result { + Self::prepare( + sandbox_profile, + workdir, + shell, + command, + devo_sandbox::WrapMode::PtyOnly, + /*attach_child_apply*/ false, + ) + } + + fn prepare( + sandbox_profile: Option<&str>, + workdir: &Path, + shell: &ShellSpec, + command: &str, + mode: devo_sandbox::WrapMode, + attach_child_apply: bool, + ) -> Result { + // Platform wrap decision (details in module docs): + // - macOS → sandbox-exec / Seatbelt + // - Linux → optional bwrap / linux-sandbox helper (composes with pre_exec) + // - Windows → SandboxWrap::None; see try_windows_sandbox_launch below + #[cfg(unix)] + let wrap = match devo_sandbox::wrap_command_for_profile( + sandbox_profile, + workdir, + mode, + &devo_sandbox::SandboxLogger::new(), + ) { + Ok(wrap) => wrap, + Err(error) => return Err(format!("failed to set up sandbox: {error}")), + }; + #[cfg(not(unix))] + let wrap = { + let _ = mode; + devo_sandbox::SandboxWrap::None + }; + + #[cfg(not(unix))] + let windows_launch = try_windows_sandbox_launch(sandbox_profile, workdir, shell, command)?; + #[cfg(unix)] + let _ = (shell, command); + + #[cfg(unix)] + let child_apply_plan = if attach_child_apply && wrap.requires_child_apply() { + // `requires_child_apply` is false on macOS (Seatbelt is only via + // `sandbox-exec`) and when a Linux wrapper already enforces the full + // policy. Otherwise resolve Landlock/seccomp for `pre_exec`. + match devo_util_process::sandbox::resolve_profile_for_spawn(sandbox_profile, workdir) { + Ok(plan) => plan, + Err(error) => { + return Err(format!("failed to resolve sandbox profile: {error}")); + } + } + } else { + None + }; + + Ok(Self { + wrap, + #[cfg(not(unix))] + windows_launch, + #[cfg(unix)] + child_apply_plan, + sandbox_profile: sandbox_profile.map(str::to_string), + workdir: workdir.to_path_buf(), + }) + } + + #[cfg(test)] + pub(crate) fn wrap(&self) -> &devo_sandbox::SandboxWrap { + &self.wrap + } + + pub(crate) fn placeholder_dir(&self) -> Option<&Path> { + match &self.wrap { + devo_sandbox::SandboxWrap::Wrapped(wrapped) => wrapped.placeholder_dir.as_deref(), + devo_sandbox::SandboxWrap::None => None, + } + } + + /// Schedule delayed removal of a bwrap placeholder directory after spawn. + /// + /// bwrap mounts are not up when spawn returns, so the directory must + /// outlive the launch. + pub(crate) fn schedule_placeholder_cleanup(&self) { + let Some(directory) = self.placeholder_dir().map(Path::to_path_buf) else { + return; + }; + + tokio::spawn(async move { + tokio::time::sleep(devo_sandbox::PLACEHOLDER_CLEANUP_DELAY).await; + devo_sandbox::remove_placeholder_dir(&directory); + }); + } + + /// Build a tokio pipe [`Command`] from this plan (shell + command args). + pub(crate) fn build_tokio_command(&self, shell: &ShellSpec, command: &str) -> Command { + // Prefer OS wrapper (`sandbox-exec` / `bwrap` / Windows launcher); else bare shell. + let mut child = match &self.wrap { + devo_sandbox::SandboxWrap::Wrapped(wrapped) => { + let mut child = Command::new(&wrapped.program); + child + .args(&wrapped.prefix_args) + .arg(shell.program) + .args(shell.args) + .arg(command); + child + } + devo_sandbox::SandboxWrap::None => { + #[cfg(not(unix))] + if let Some(launch) = &self.windows_launch { + let mut child = Command::new(&launch.program); + child.args(&launch.args); + for (key, value) in &launch.env { + child.env(key, value); + } + child + } else { + let mut child = Command::new(shell.program); + child.args(shell.args).arg(command); + child + } + #[cfg(unix)] + { + let mut child = Command::new(shell.program); + child.args(shell.args).arg(command); + child + } + } + }; + + #[cfg(unix)] + { + let sandbox_plan = self.child_apply_plan.clone(); + unsafe { + // `pre_exec` runs in the child after `fork`, before `exec`. Apply the + // parent-resolved Landlock/seccomp plan here so only the spawned + // command is sandboxed (parent stays unrestricted). Config must not + // be loaded in this hook — resolve above in the parent. Skipped when + // `sandbox_plan` is `None` (macOS / fully wrapped Linux). + child.pre_exec(move || { + devo_util_process::sandbox::apply_resolved_in_child(sandbox_plan.as_ref()) + }); + } + } + + #[cfg(unix)] + for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile( + self.sandbox_profile.as_deref(), + &self.workdir, + ) { + child.env(key, value); + } + + child + } + + /// Build a portable-pty [`CommandBuilder`] from this plan. + pub(crate) fn build_pty_command_builder( + &self, + shell: &ShellSpec, + command: &str, + ) -> CommandBuilder { + let mut builder = match &self.wrap { + devo_sandbox::SandboxWrap::Wrapped(wrapped) => { + let mut builder = CommandBuilder::new(&wrapped.program); + builder.args(&wrapped.prefix_args); + builder.arg(shell.program); + builder + } + devo_sandbox::SandboxWrap::None => { + #[cfg(not(unix))] + if let Some(launch) = &self.windows_launch { + let mut builder = CommandBuilder::new(&launch.program); + builder.args( + launch + .args + .iter() + .map(|arg| arg.as_str()) + .collect::>(), + ); + for (key, value) in &launch.env { + builder.env(key, value); + } + builder + } else { + CommandBuilder::new(shell.program) + } + #[cfg(unix)] + CommandBuilder::new(shell.program) + } + }; + + // Windows sandbox launch already embeds the full command line. + #[cfg(not(unix))] + if self.windows_launch.is_none() { + builder.args(shell.args); + builder.arg(command); + } + #[cfg(unix)] + { + builder.args(shell.args); + builder.arg(command); + } + + #[cfg(unix)] + for (key, value) in devo_sandbox::proxy_env_for_sandbox_profile( + self.sandbox_profile.as_deref(), + &self.workdir, + ) { + builder.env(key, value); + } + + builder + } +} + +#[cfg(not(unix))] +/// Build a Windows sandbox launcher when the profile requires wrapping. +/// +/// Unlike Unix, Windows does not use `SandboxWrap` / `pre_exec`. The +/// `devo_windows_sandbox` crate prepares a process launch with the resolved +/// read-only, read-write, and deny roots (plus optional network restriction) +/// and embeds the shell command line in that launch. +fn try_windows_sandbox_launch( + sandbox_profile: Option<&str>, + workdir: &Path, + shell: &ShellSpec, + command: &str, +) -> Result, String> { + use std::sync::Once; + if !devo_windows_sandbox::should_wrap_profile(sandbox_profile) { + return Ok(None); + } + let profile = sandbox_profile.expect("checked by should_wrap_profile"); + let profile_name = profile + .parse::() + .map_err(|error| format!("invalid sandbox profile '{profile}': {error}"))?; + let config = devo_sandbox::load_sandbox_config(workdir) + .map_err(|error| format!("failed to set up Windows sandbox: {error}"))?; + let resolved = profile_name + .resolve_profile(workdir, &config) + .map_err(|error| format!("failed to set up Windows sandbox: {error}"))?; + let request = devo_windows_sandbox::WindowsSandboxRequest { + command: command.to_string(), + shell_program: shell.program.to_string(), + shell_args: shell.args.iter().map(|arg| arg.to_string()).collect(), + cwd: workdir.to_path_buf(), + readable_roots: resolved.read_only, + writable_roots: resolved.read_write, + deny_read: resolved.deny, + restrict_network: resolved.restrict_network, + }; + match devo_windows_sandbox::prepare_windows_sandbox_launch(&request) { + Ok(Some(launch)) => Ok(Some(launch)), + Ok(None) => { + static WARNED: Once = Once::new(); + WARNED.call_once(|| { + tracing::warn!( + "Windows sandbox profile is active but launch preparation is not wired yet; \ + commands run unwrapped" + ); + }); + Ok(None) + } + Err(error) => Err(format!("failed to set up Windows sandbox: {error}")), + } +} diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs new file mode 100644 index 00000000..1bafaf94 --- /dev/null +++ b/crates/core/src/tools/shell_exec/mod.rs @@ -0,0 +1,136 @@ +//! Local shell command execution (pipe and PTY). +//! +//! # Layout +//! - [`resolve`]: shell binary / command normalization +//! - [`launch`]: shared [`SandboxLaunchPlan`] (wrap / pre_exec / placeholder) +//! - [`pipe`]: non-TTY spawn + pipe-specific result formatting +//! - [`pty`]: TTY spawn + PTY-specific result formatting + +mod launch; +mod pipe; +mod pty; +mod resolve; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; + +use devo_protocol::approx_bytes_for_tokens; +use tokio_util::sync::CancellationToken; + +use crate::events::ToolProgressSender; +use crate::invocation::FunctionToolOutput; + +#[cfg(test)] +pub(crate) use launch::SandboxLaunchPlan; +#[cfg(test)] +pub(crate) use resolve::platform_shell_program; +use resolve::{normalize_command_for_shell, resolve_shell}; + +use pipe::run_with_pipes; +use pty::run_with_pty; +use resolve::ResolvedShellRun; + +const MAX_METADATA_LENGTH: usize = 30_000; +pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; +pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000; +pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; +const TRUNCATED_SUFFIX: &str = "\n\n... [truncated]"; + +/// Input to [`execute_shell_command`]: the caller's raw request before shell +/// resolution or pipe/PTY branching. +/// +/// `shell_override` / `login` select the interpreter; `tty` chooses the +/// execution path. Shared runtime knobs (workdir, timeouts, sandbox, …) are +/// forwarded into whichever path runs. +pub(crate) struct ShellExecRequest { + pub command: String, + pub workdir: PathBuf, + pub description: String, + /// Optional shell name/alias (`bash`, `pwsh`, `cmd`, …). `None` uses the + /// platform default. + pub shell_override: Option, + /// When true, run under a PTY via [`run_with_pty`]; otherwise pipe spawn. + pub tty: bool, + /// Prefer login-shell args (e.g. `bash -lc`) when resolving the shell. + pub login: bool, + pub timeout_ms: u64, + pub yield_time_ms: u64, + pub max_output_tokens: usize, + pub sandbox_profile: Option, +} + +/// Run a shell command from a [`ShellExecRequest`]. +/// +/// Resolves the shell and command, then delegates to [`run_with_pty`] or +/// [`run_with_pipes`]. Applies sandbox wrapping when a profile is set. +pub(crate) async fn execute_shell_command( + request: ShellExecRequest, + progress: Option, + cancel_token: CancellationToken, +) -> anyhow::Result { + let ShellExecRequest { + command, + workdir, + description, + shell_override, + tty, + login, + timeout_ms, + yield_time_ms, + max_output_tokens, + sandbox_profile, + } = request; + + if !workdir.exists() { + return Ok(FunctionToolOutput::error(format!( + "working directory does not exist: {}", + workdir.display() + ))); + } + + let shell = resolve_shell(shell_override.as_deref(), login); + let command_to_run = normalize_command_for_shell(&shell, command); + let run = ResolvedShellRun { + shell, + command_to_run, + workdir, + description, + timeout_ms, + yield_time_ms, + max_output_tokens, + sandbox_profile, + }; + + if tty { + run_with_pty(run, progress, cancel_token).await + } else { + run_with_pipes(run, progress, cancel_token).await + } +} + +// TODO: Preview truncation belongs on the client, not the server. Move +// `preview` (and callers that stuff truncated text into tool metadata) to +// the client side so the server returns full output / structured metadata. +pub(crate) fn preview(text: &str) -> String { + if text.len() <= MAX_METADATA_LENGTH { + return text.to_string(); + } + format!("{}\n\n...", &text[..MAX_METADATA_LENGTH]) +} + +pub(crate) fn truncate_output(text: &str, max_output_tokens: usize) -> String { + if max_output_tokens == 0 { + return String::new(); + } + let max_chars = approx_bytes_for_tokens(max_output_tokens); + if text.len() <= max_chars { + return text.to_string(); + } + let mut out: String = text.chars().take(max_chars).collect(); + if out.len() < text.len() { + out.push_str(TRUNCATED_SUFFIX); + } + out +} diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs new file mode 100644 index 00000000..0ec06faf --- /dev/null +++ b/crates/core/src/tools/shell_exec/pipe.rs @@ -0,0 +1,144 @@ +//! Non-interactive pipe spawn path for shell_exec. + +use std::process::Stdio; + +use serde_json::json; +use tokio::time::{Duration, timeout}; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::events::ToolProgressSender; +use crate::invocation::FunctionToolOutput; + +use super::launch::SandboxLaunchPlan; +use super::preview; +use super::resolve::ResolvedShellRun; +use super::truncate_output; + +/// Run a command with piped stdout/stderr (non-TTY). +/// +/// Applies [`SandboxLaunchPlan::prepare_pipe`], waits for completion (or +/// cancel/timeout), and formats pipe-specific tool output (merged streams, +/// Unix signal-aware sandbox errors). +pub(crate) async fn run_with_pipes( + run: ResolvedShellRun, + progress: Option, + cancel_token: CancellationToken, +) -> anyhow::Result { + let ResolvedShellRun { + shell, + command_to_run, + workdir, + description, + timeout_ms, + yield_time_ms, + max_output_tokens, + sandbox_profile, + } = run; + + info!(command = %command_to_run, shell = shell.program, "executing shell command"); + let command_preview = preview(&command_to_run); + + let plan = match SandboxLaunchPlan::prepare_pipe( + sandbox_profile.as_deref(), + &workdir, + &shell, + &command_to_run, + ) { + Ok(plan) => plan, + Err(error) => return Ok(FunctionToolOutput::error(error)), + }; + + let mut child = plan.build_tokio_command(&shell, &command_to_run); + child + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(&workdir) + .kill_on_drop(true); + + if cfg!(windows) { + child.env("PYTHONUTF8", "1"); + } + + let spawned = match child.spawn() { + Ok(child) => child, + Err(error) => { + return Ok(FunctionToolOutput::error(format!( + "failed to spawn process: {error}" + ))); + } + }; + plan.schedule_placeholder_cleanup(); + + let result = tokio::select! { + result = timeout(Duration::from_millis(timeout_ms), spawned.wait_with_output()) => result, + _ = cancel_token.cancelled() => { + return Ok(FunctionToolOutput::error("command cancelled")); + } + }; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let result_text = merge_streams(&stdout, &stderr); + if let Some(ref sender) = progress { + let _ = sender.send(result_text.clone()); + } + let result_text = truncate_output(&result_text, max_output_tokens); + if output.status.success() { + Ok(FunctionToolOutput::success_with_metadata( + result_text.clone(), + json!({ + "output": preview(&result_text), + "command": command_preview, + "exit": output.status.code(), + "description": description, + "cwd": workdir, + "yield_time_ms": yield_time_ms, + }), + )) + } else { + #[cfg(unix)] + let unix_signal = { + use std::os::unix::process::ExitStatusExt; + output.status.signal() + }; + #[cfg(not(unix))] + let unix_signal: Option = None; + let error_message = devo_sandbox::shell_error_message_with_signal( + sandbox_profile.as_deref(), + output.status.code(), + unix_signal, + &stdout, + &stderr, + &result_text, + ); + Ok(FunctionToolOutput::error(error_message)) + } + } + Ok(Err(error)) => Ok(FunctionToolOutput::error(format!( + "failed to spawn process: {error}" + ))), + Err(_) => Ok(FunctionToolOutput::error(format!( + "command timed out after {timeout_ms}ms" + ))), + } +} + +pub(crate) fn merge_streams(stdout: &str, stderr: &str) -> String { + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("[stderr]\n"); + result.push_str(stderr); + } + result +} diff --git a/crates/core/src/tools/shell_exec/pty.rs b/crates/core/src/tools/shell_exec/pty.rs new file mode 100644 index 00000000..fc578dab --- /dev/null +++ b/crates/core/src/tools/shell_exec/pty.rs @@ -0,0 +1,222 @@ +//! PTY-backed shell execution path. + +use std::sync::mpsc; +use std::time::Instant; + +use portable_pty::{Child, ExitStatus, PtySize, native_pty_system}; +use serde_json::json; +use tokio::time::Duration; +use tokio_util::sync::CancellationToken; + +use crate::events::ToolProgressSender; +use crate::invocation::FunctionToolOutput; + +use super::launch::SandboxLaunchPlan; +use super::preview; +use super::resolve::ResolvedShellRun; +use super::truncate_output; + +/// RAII guard around a PTY-spawned child process. +/// +/// Ensures the child is killed if the guard is dropped while still armed +/// (timeout, cancel, or early return). Call [`Self::disarm`] after a clean +/// exit so [`Drop`] does not kill an already-reaped process. +struct PtyChildGuard { + child: Option>, +} + +impl PtyChildGuard { + fn new(child: Box) -> Self { + Self { child: Some(child) } + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child + .as_mut() + .expect("PTY child guard must hold child while active") + .try_wait() + } + + fn kill_and_wait(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + fn disarm(mut self) { + self.child.take(); + } +} + +impl Drop for PtyChildGuard { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + } + } +} + +/// Run a command attached to a pseudo-terminal (PTY). +/// +/// Opens a PTY, applies [`SandboxLaunchPlan::prepare_pty`], reads master output +/// on a background thread, and polls until exit, timeout, or cancel. Formats +/// PTY-specific tool output (single stream, `tty: true` metadata). +pub(crate) async fn run_with_pty( + run: ResolvedShellRun, + progress: Option, + cancel_token: CancellationToken, +) -> anyhow::Result { + let ResolvedShellRun { + shell, + command_to_run, + workdir, + description, + timeout_ms, + yield_time_ms, + max_output_tokens, + sandbox_profile, + } = run; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|error| anyhow::anyhow!("failed to open PTY: {error}"))?; + + // PTY spawns have no `pre_exec` hook. Unix: wrap with macOS `sandbox-exec` + // or Linux `bwrap` carrying the full profile. Windows: launcher via plan. + let plan = match SandboxLaunchPlan::prepare_pty( + sandbox_profile.as_deref(), + &workdir, + &shell, + &command_to_run, + ) { + Ok(plan) => plan, + Err(error) => return Ok(FunctionToolOutput::error(error)), + }; + + let mut builder = plan.build_pty_command_builder(&shell, &command_to_run); + builder.cwd(&workdir); + if cfg!(windows) { + builder.env("PYTHONUTF8", "1"); + builder.env("TERM", "xterm-256color"); + builder.env("COLORTERM", "truecolor"); + } + + let child = pair + .slave + .spawn_command(builder) + .map_err(|error| anyhow::anyhow!("failed to spawn PTY command: {error}"))?; + plan.schedule_placeholder_cleanup(); + let mut child = PtyChildGuard::new(child); + drop(pair.slave); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|error| anyhow::anyhow!("failed to clone PTY reader: {error}"))?; + let (tx, rx) = mpsc::channel::>(); + std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + loop { + match std::io::Read::read(&mut reader, &mut buffer) { + Ok(0) => break, + Ok(size) => { + if tx.send(buffer[..size].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let started = Instant::now(); + let sleep_ms = yield_time_ms.max(10); + let timeout = Duration::from_millis(timeout_ms); + let mut output = Vec::new(); + let mut exit_code = None; + let mut timed_out = false; + let mut cancelled = false; + + loop { + while let Ok(chunk) = rx.try_recv() { + output.extend_from_slice(&chunk); + if let Some(ref sender) = progress { + let text = String::from_utf8_lossy(&chunk).into_owned(); + let _ = sender.send(text); + } + } + + if let Some(status) = child + .try_wait() + .map_err(|error| anyhow::anyhow!("failed to poll PTY child: {error}"))? + { + exit_code = Some(status.exit_code() as i32); + break; + } + + if started.elapsed() >= timeout { + timed_out = true; + child.kill_and_wait(); + break; + } + + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {} + _ = cancel_token.cancelled() => { + cancelled = true; + child.kill_and_wait(); + break; + } + } + } + + while let Ok(chunk) = rx.try_recv() { + output.extend_from_slice(&chunk); + } + + let mut text = String::from_utf8_lossy(&output).into_owned(); + text = truncate_output(&text, max_output_tokens); + + if timed_out { + return Ok(FunctionToolOutput::error(format!( + "command timed out after {timeout_ms}ms\n{text}" + ))); + } + if cancelled { + return Ok(FunctionToolOutput::error(format!( + "command cancelled\n{text}" + ))); + } + child.disarm(); + + let is_error = exit_code.unwrap_or(1) != 0; + let content = if is_error { + let code = exit_code.unwrap_or(-1); + devo_sandbox::shell_error_message(sandbox_profile.as_deref(), code, &text, "", &text) + } else { + text.clone() + }; + if is_error { + return Ok(FunctionToolOutput::error(content)); + } + + Ok(FunctionToolOutput::success_with_metadata( + content, + json!({ + "output": preview(&text), + "command": command_to_run, + "exit": exit_code, + "description": description, + "cwd": workdir, + "yield_time_ms": yield_time_ms, + "tty": true, + }), + )) +} diff --git a/crates/core/src/tools/shell_exec/resolve.rs b/crates/core/src/tools/shell_exec/resolve.rs new file mode 100644 index 00000000..c0c3b28f --- /dev/null +++ b/crates/core/src/tools/shell_exec/resolve.rs @@ -0,0 +1,101 @@ +//! Shell binary resolution and command rewriting. + +use std::path::PathBuf; + +/// Resolved shell program + argv prefix used to run a command string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ShellSpec { + pub(crate) program: &'static str, + pub(crate) args: &'static [&'static str], +} + +/// Map an optional shell override (and login flag) to a concrete [`ShellSpec`]. +pub(crate) fn resolve_shell(shell: Option<&str>, login: bool) -> ShellSpec { + let shell = shell.unwrap_or(""); + let normalized = shell.to_ascii_lowercase(); + + if normalized.contains("powershell") || normalized == "pwsh" || normalized == "powershell" { + return ShellSpec { + program: "powershell", + args: &["-NoLogo", "-NoProfile", "-Command"], + }; + } + + if normalized.ends_with("cmd") || normalized.ends_with("cmd.exe") || normalized == "cmd" { + return ShellSpec { + program: "cmd", + args: &["/C"], + }; + } + + if normalized.contains("zsh") { + return ShellSpec { + program: "zsh", + args: if login { &["-lc"] } else { &["-c"] }, + }; + } + + if normalized.contains("bash") { + return ShellSpec { + program: "bash", + args: if login { &["-lc"] } else { &["-c"] }, + }; + } + + if login { + platform_shell(true) + } else { + platform_shell(false) + } +} + +fn platform_shell(login: bool) -> ShellSpec { + if cfg!(windows) { + ShellSpec { + program: "powershell", + args: &["-NoProfile", "-Command"], + } + } else { + ShellSpec { + program: "bash", + args: if login { &["-lc"] } else { &["-c"] }, + } + } +} + +#[cfg(test)] +pub(crate) fn platform_shell_program(login: bool) -> &'static str { + platform_shell(login).program +} + +/// Rewrite the command string for PowerShell UTF-8 console encoding when needed. +pub(crate) fn normalize_command_for_shell(shell: &ShellSpec, command: String) -> String { + // PowerShell often emits mojibake without an explicit UTF-8 console encoding. + if cfg!(windows) && shell.program.eq_ignore_ascii_case("powershell") { + format!( + concat!( + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "$OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "[System.Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "{}" + ), + command + ) + } else { + command + } +} + +/// Shared post-resolution knobs for pipe and PTY runners. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedShellRun { + pub(crate) shell: ShellSpec, + pub(crate) command_to_run: String, + pub(crate) workdir: PathBuf, + pub(crate) description: String, + pub(crate) timeout_ms: u64, + pub(crate) yield_time_ms: u64, + pub(crate) max_output_tokens: usize, + pub(crate) sandbox_profile: Option, +} diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index 2943e778..46b958bf 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -2,7 +2,9 @@ use super::*; use crate::ToolContent; use pretty_assertions::assert_eq; use std::hint::black_box; -use std::time::Instant; +use std::path::Path; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; #[tokio::test] async fn execute_shell_command_non_tty_sends_progress() { @@ -199,7 +201,109 @@ async fn execute_shell_command_error_output_is_text_only() { assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); } -use super::{merge_streams, platform_shell_program, preview, resolve_shell, truncate_output}; +use super::{SandboxLaunchPlan, platform_shell_program, preview, resolve_shell, truncate_output}; + +#[cfg(unix)] +#[tokio::test] +async fn execute_shell_command_pipe_times_out() { + let result = execute_shell_command( + ShellExecRequest { + command: "sleep 5".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "timeout test".into(), + shell_override: None, + tty: false, + login: false, + timeout_ms: 100, + yield_time_ms: 50, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + CancellationToken::new(), + ) + .await + .expect("execute shell command"); + + assert!(result.is_error); + assert!( + result + .content + .into_string() + .contains("command timed out after 100ms") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_pipe_and_pty_launch_plans_wrap_via_sandbox_exec() { + let workspace = temp_sandbox_workspace( + "shell-exec-macos", + "[profiles.wrapdeny]\nextends = \"workspace\"\ndeny = [\"secret.txt\"]\n", + ); + let shell = resolve_shell(Some("bash"), false); + for (label, plan) in [ + ( + "pipe", + SandboxLaunchPlan::prepare_pipe(Some("wrapdeny"), &workspace, &shell, "echo hi"), + ), + ( + "pty", + SandboxLaunchPlan::prepare_pty(Some("wrapdeny"), &workspace, &shell, "echo hi"), + ), + ] { + let plan = plan.unwrap_or_else(|error| panic!("{label} prepare failed: {error}")); + match plan.wrap() { + devo_sandbox::SandboxWrap::Wrapped(wrapped) => { + assert_eq!(wrapped.program, "/usr/bin/sandbox-exec", "{label}"); + assert!(wrapped.helper_enforces, "{label}"); + } + devo_sandbox::SandboxWrap::None => assert!( + !Path::new("/usr/bin/sandbox-exec").is_file(), + "{label}: sandbox-exec exists but wrap was declined" + ), + } + } + let _ = std::fs::remove_dir_all(&workspace); +} + +#[cfg(windows)] +#[test] +fn windows_inactive_profile_prepare_pipe_builds_direct_command() { + let workdir = std::env::current_dir().unwrap_or_default(); + let shell = resolve_shell(None, false); + let plan = SandboxLaunchPlan::prepare_pipe(None, &workdir, &shell, "echo hi") + .expect("inactive profile should prepare"); + assert!(matches!(plan.wrap(), devo_sandbox::SandboxWrap::None)); + let _cmd = plan.build_tokio_command(&shell, "echo hi"); +} + +#[cfg(windows)] +#[test] +fn windows_inactive_profile_prepare_pty_builds_builder() { + let workdir = std::env::current_dir().unwrap_or_default(); + let shell = resolve_shell(None, false); + let plan = SandboxLaunchPlan::prepare_pty(None, &workdir, &shell, "echo hi") + .expect("inactive profile should prepare"); + assert!(matches!(plan.wrap(), devo_sandbox::SandboxWrap::None)); + let _builder = plan.build_pty_command_builder(&shell, "echo hi"); +} + +#[cfg(target_os = "macos")] +fn temp_sandbox_workspace(tag: &str, toml_body: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let workspace = std::env::temp_dir().join(format!( + "devo-shell-exec-{tag}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(workspace.join(".devo")).expect("create .devo"); + std::fs::write(workspace.join(".devo").join("sandbox.toml"), toml_body) + .expect("write sandbox.toml"); + workspace +} #[test] #[cfg(windows)] @@ -299,7 +403,7 @@ fn bench_truncate_output_ascii_large_truncation() { #[test] fn merge_streams_combines_stdout_and_stderr() { - let result = merge_streams("out", "err"); + let result = super::pipe::merge_streams("out", "err"); assert!(result.contains("out")); assert!(result.contains("[stderr]")); assert!(result.contains("err")); @@ -307,5 +411,5 @@ fn merge_streams_combines_stdout_and_stderr() { #[test] fn merge_streams_no_output() { - assert_eq!(merge_streams("", ""), ""); + assert_eq!(super::pipe::merge_streams("", ""), ""); } diff --git a/crates/protocol/README.md b/crates/protocol/README.md index 699f3569..85f54cea 100644 --- a/crates/protocol/README.md +++ b/crates/protocol/README.md @@ -50,13 +50,6 @@ The current server-to-client ACP request methods are: runtime action. - `fs/read_text_file`: ask the client to read an absolute text-file path. - `fs/write_text_file`: ask the client to write text to an absolute file path. -- `terminal/create`: ask the client to create a terminal-backed process. -- `terminal/output`: ask the client for a terminal output snapshot. -- `terminal/wait_for_exit`: ask the client to wait for a terminal process to - exit. -- `terminal/kill`: ask the client to kill a terminal process. -- `terminal/release`: ask the client to release a terminal process and clean up - associated state. Devo-specific client-to-server APIs are sent with the `_devo/` method prefix. The prefix is applied by the client transport, then removed by the server before diff --git a/crates/protocol/src/acp.rs b/crates/protocol/src/acp.rs index 8fcb9b57..e755d725 100644 --- a/crates/protocol/src/acp.rs +++ b/crates/protocol/src/acp.rs @@ -18,11 +18,6 @@ pub const ACP_SESSION_SET_MODE_METHOD: &str = "session/set_mode"; pub const ACP_SESSION_SET_CONFIG_OPTION_METHOD: &str = "session/set_config_option"; pub const ACP_FS_READ_TEXT_FILE_METHOD: &str = "fs/read_text_file"; pub const ACP_FS_WRITE_TEXT_FILE_METHOD: &str = "fs/write_text_file"; -pub const ACP_TERMINAL_CREATE_METHOD: &str = "terminal/create"; -pub const ACP_TERMINAL_OUTPUT_METHOD: &str = "terminal/output"; -pub const ACP_TERMINAL_WAIT_FOR_EXIT_METHOD: &str = "terminal/wait_for_exit"; -pub const ACP_TERMINAL_KILL_METHOD: &str = "terminal/kill"; -pub const ACP_TERMINAL_RELEASE_METHOD: &str = "terminal/release"; pub const ACP_JSONRPC_VERSION: &str = "2.0"; pub const DEVO_EXTENSION_METHOD_PREFIX: &str = "_devo/"; pub const DEVO_ORIGINAL_METHOD_META: &str = "devo/originalMethod"; @@ -92,7 +87,6 @@ mod tests { use crate::ServerEvent; use crate::SessionId; use crate::ToolCallPayload; - use crate::ToolResultPayload; use crate::TurnId; use crate::acp_client_io::*; use crate::acp_common::*; @@ -610,16 +604,11 @@ mod tests { status: Some(AcpToolCallStatus::Completed), raw_input: Some(serde_json::json!({ "path": path_json.clone() })), raw_output: Some(serde_json::json!({ "changed": true })), - content: vec![ - AcpToolCallContent::Diff { - path: path.clone(), - old_text: Some("old\n".to_string()), - new_text: "new\n".to_string(), - }, - AcpToolCallContent::Terminal { - terminal_id: "term_1".to_string(), - }, - ], + content: vec![AcpToolCallContent::Diff { + path: path.clone(), + old_text: Some("old\n".to_string()), + new_text: "new\n".to_string(), + }], locations: vec![AcpToolCallLocation { path: path.clone(), line: None, @@ -643,10 +632,6 @@ mod tests { "path": path_json.clone(), "oldText": "old\n", "newText": "new\n" - }, - { - "type": "terminal", - "terminalId": "term_1" } ], "locations": [ @@ -1001,68 +986,6 @@ mod tests { ); } - #[test] - fn tool_result_metadata_content_can_emit_terminal_content() { - let session_id = SessionId::new(); - let turn_id = TurnId::new(); - let item_id = ItemId::new(); - let raw_output = serde_json::json!({ - "content": [ - { - "type": "terminal", - "terminalId": "term_1" - } - ], - "output": "done\n", - "truncated": false, - "exitStatus": { - "exitCode": 0, - "signal": null - } - }); - let payload_value = serde_json::to_value(ToolResultPayload { - tool_call_id: "call-1".to_string(), - tool_name: Some("shell_command".to_string()), - input: Some(serde_json::json!({"command": "echo done"})), - content: raw_output.clone(), - display_content: None, - is_error: false, - summary: "Command executed".to_string(), - }) - .expect("serialize tool result payload"); - let event = ServerEvent::ItemCompleted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: 1, - item_seq: None, - }, - item: crate::ItemEnvelope { - item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: payload_value, - }, - }); - - assert_eq!( - strip_update_activity_at(acp_update_from_server_event(&event)), - Some(AcpSessionUpdate::ToolCallUpdate { - tool_call_id: "call-1".to_string(), - title: Some("Command executed".to_string()), - kind: Some(AcpToolKind::Execute), - status: Some(AcpToolCallStatus::Completed), - raw_input: Some(serde_json::json!({"command": "echo done"})), - raw_output: Some(raw_output), - content: vec![AcpToolCallContent::Terminal { - terminal_id: "term_1".to_string(), - }], - locations: Vec::new(), - meta: Some(turn_item_meta(&turn_id, &item_id)), - }) - ); - } - #[test] fn usage_update_size_uses_context_window() { let session_id = SessionId::new(); @@ -1200,7 +1123,6 @@ mod tests { turn_id, tool_call_id: "call-1".to_string(), status: "in_progress".to_string(), - terminal_id: None, }); let (_, update_value) = acp_notification_from_server_event("tool_call/status_updated", &update); @@ -1221,42 +1143,6 @@ mod tests { ); } - #[test] - fn tool_status_update_can_emit_terminal_content() { - let session_id = SessionId::new(); - let turn_id = TurnId::new(); - let update = ServerEvent::ToolCallStatusUpdated(crate::ToolCallStatusUpdatedPayload { - session_id, - turn_id, - tool_call_id: "call-1".to_string(), - status: "in_progress".to_string(), - terminal_id: Some("term_1".to_string()), - }); - let (_, update_value) = - acp_notification_from_server_event("tool_call/status_updated", &update); - let mut update_json = update_value["update"].clone(); - assert_activity_at(&update_json); - strip_json_activity_at(&mut update_json); - - assert_eq!( - update_json, - serde_json::json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": "call-1", - "status": "in_progress", - "content": [ - { - "type": "terminal", - "terminalId": "term_1" - } - ], - "_meta": { - "devo/turnId": turn_id.to_string() - } - }) - ); - } - #[test] fn native_session_update_omits_devo_event_meta() { let session_id = SessionId::new(); diff --git a/crates/protocol/src/acp_client_io.rs b/crates/protocol/src/acp_client_io.rs index c2f8b630..92992974 100644 --- a/crates/protocol/src/acp_client_io.rs +++ b/crates/protocol/src/acp_client_io.rs @@ -5,8 +5,6 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -use crate::AcpEnvVariable; -use crate::AcpTerminalId; use crate::SessionId; use crate::acp::AcpMeta; @@ -42,80 +40,3 @@ pub struct AcpFsWriteTextFileParams { #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, } - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -pub struct AcpTerminalCreateParams { - pub session_id: SessionId, - pub command: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub args: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub env: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde( - default, - rename = "outputByteLimit", - skip_serializing_if = "Option::is_none" - )] - pub output_byte_limit: Option, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AcpTerminalCreateResult { - #[serde(rename = "terminalId")] - pub terminal_id: AcpTerminalId, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -pub struct AcpTerminalParams { - pub session_id: SessionId, - #[serde(rename = "terminalId")] - pub terminal_id: AcpTerminalId, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AcpTerminalOutputResult { - pub output: String, - pub truncated: bool, - #[serde( - default, - rename = "exitStatus", - skip_serializing_if = "Option::is_none" - )] - pub exit_status: Option, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AcpTerminalWaitForExitResult { - #[serde(default, rename = "exitCode", skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signal: Option, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AcpTerminalExitStatus { - #[serde(default, rename = "exitCode", skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signal: Option, -} diff --git a/crates/protocol/src/acp_common.rs b/crates/protocol/src/acp_common.rs index b5c70433..bf9d8450 100644 --- a/crates/protocol/src/acp_common.rs +++ b/crates/protocol/src/acp_common.rs @@ -26,7 +26,6 @@ pub type AcpMessageId = String; pub type AcpPermissionOptionId = String; pub type AcpProtocolVersion = u16; pub type AcpRequestId = serde_json::Value; -pub type AcpTerminalId = String; pub type AcpToolCallId = String; fn jsonrpc_version() -> String { diff --git a/crates/protocol/src/acp_event_to_update.rs b/crates/protocol/src/acp_event_to_update.rs index 3115e6ac..8d0a164d 100644 --- a/crates/protocol/src/acp_event_to_update.rs +++ b/crates/protocol/src/acp_event_to_update.rs @@ -94,8 +94,7 @@ fn should_preserve_original_tool_event(event: &ServerEvent) -> bool { | ItemKind::CommandExecution | ItemKind::FileChange ), - // Keep status updates on the ACP surface so terminal content can still - // arrive via tool_call_update; TUI ignores title-less status updates. + // Keep status updates on the ACP surface; TUI ignores title-less status updates. _ => false, } } @@ -186,28 +185,17 @@ pub(crate) fn acp_update_from_server_event(event: &ServerEvent) -> Option { - let content = payload - .terminal_id - .as_ref() - .map(|terminal_id| { - vec![AcpToolCallContent::Terminal { - terminal_id: terminal_id.clone(), - }] - }) - .unwrap_or_default(); - Some(AcpSessionUpdate::ToolCallUpdate { - tool_call_id: payload.tool_call_id.clone(), - title: None, - kind: None, - status: acp_tool_call_status_from_str(payload.status.as_str()), - raw_input: None, - raw_output: None, - content, - locations: Vec::new(), - meta: Some(acp_activity_meta_from_turn_id(&payload.turn_id)), - }) - } + ServerEvent::ToolCallStatusUpdated(payload) => Some(AcpSessionUpdate::ToolCallUpdate { + tool_call_id: payload.tool_call_id.clone(), + title: None, + kind: None, + status: acp_tool_call_status_from_str(payload.status.as_str()), + raw_input: None, + raw_output: None, + content: Vec::new(), + locations: Vec::new(), + meta: Some(acp_activity_meta_from_turn_id(&payload.turn_id)), + }), ServerEvent::ItemDelta { delta_kind, payload, diff --git a/crates/protocol/src/acp_schema_aliases.rs b/crates/protocol/src/acp_schema_aliases.rs index ff69c880..26183243 100644 --- a/crates/protocol/src/acp_schema_aliases.rs +++ b/crates/protocol/src/acp_schema_aliases.rs @@ -43,12 +43,6 @@ use crate::AcpSetConfigOptionResult; use crate::AcpSetModeParams; use crate::AcpSetModeResult; use crate::AcpSuccessResponse; -use crate::AcpTerminalCreateParams; -use crate::AcpTerminalCreateResult; -use crate::AcpTerminalId; -use crate::AcpTerminalOutputResult; -use crate::AcpTerminalParams; -use crate::AcpTerminalWaitForExitResult; use crate::AcpToolCallContent; use crate::AcpToolCallLocation; use crate::AcpToolCallStatus; @@ -62,8 +56,6 @@ pub type AcpAuthMethodAgent = crate::AcpAuthMethod; pub type AcpCancelNotification = AcpCancelParams; pub type AcpCloseSessionRequest = AcpCloseSessionParams; pub type AcpCloseSessionResponse = AcpCloseSessionResult; -pub type AcpCreateTerminalRequest = AcpTerminalCreateParams; -pub type AcpCreateTerminalResponse = AcpTerminalCreateResult; pub type AcpDeleteSessionRequest = AcpDeleteSessionParams; pub type AcpDeleteSessionResponse = AcpDeleteSessionResult; pub type AcpEmbeddedResourceResource = AcpEmbeddedResource; @@ -73,8 +65,6 @@ pub type AcpExtRequest = AcpClientRequest; pub type AcpExtResponse = AcpSuccessResponse; pub type AcpInitializeRequest = AcpInitializeParams; pub type AcpInitializeResponse = AcpInitializeResult; -pub type AcpKillTerminalRequest = AcpTerminalParams; -pub type AcpKillTerminalResponse = crate::AcpEmptyResult; pub type AcpListSessionsRequest = AcpListSessionsParams; pub type AcpListSessionsResponse = AcpListSessionsResult; pub type AcpLoadSessionRequest = AcpLoadSessionParams; @@ -88,8 +78,6 @@ pub type AcpPromptRequest = AcpPromptParams; pub type AcpPromptResponse = AcpPromptResult; pub type AcpReadTextFileRequest = AcpFsReadTextFileParams; pub type AcpReadTextFileResponse = AcpFsReadTextFileResult; -pub type AcpReleaseTerminalRequest = AcpTerminalParams; -pub type AcpReleaseTerminalResponse = crate::AcpEmptyResult; pub type AcpRequestPermissionRequest = AcpRequestPermissionParams; pub type AcpRequestPermissionOutcome = AcpPermissionOutcome; pub type AcpResumeSessionRequest = AcpResumeSessionParams; @@ -98,10 +86,6 @@ pub type AcpSetSessionConfigOptionRequest = AcpSetConfigOptionParams; pub type AcpSetSessionConfigOptionResponse = AcpSetConfigOptionResult; pub type AcpSetSessionModeRequest = AcpSetModeParams; pub type AcpSetSessionModeResponse = AcpSetModeResult; -pub type AcpTerminalOutputRequest = AcpTerminalParams; -pub type AcpTerminalOutputResponse = AcpTerminalOutputResult; -pub type AcpWaitForTerminalExitRequest = AcpTerminalParams; -pub type AcpWaitForTerminalExitResponse = AcpTerminalWaitForExitResult; pub type AcpWriteTextFileRequest = AcpFsWriteTextFileParams; pub type AcpWriteTextFileResponse = crate::AcpEmptyResult; pub type AcpUnstructuredCommandInput = String; @@ -188,15 +172,6 @@ pub struct AcpDiff { pub meta: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AcpTerminal { - #[serde(rename = "terminalId")] - pub terminal_id: AcpTerminalId, - #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] - pub meta: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpToolCall { diff --git a/crates/protocol/src/acp_session_update.rs b/crates/protocol/src/acp_session_update.rs index 3598fc9c..506a2c0b 100644 --- a/crates/protocol/src/acp_session_update.rs +++ b/crates/protocol/src/acp_session_update.rs @@ -9,7 +9,6 @@ use crate::AcpMessageId; use crate::AcpPermissionOptionId; use crate::AcpSessionConfigOption; use crate::AcpSessionModeId; -use crate::AcpTerminalId; use crate::AcpToolCallId; use crate::SessionId; use crate::acp::AcpMeta; @@ -271,10 +270,6 @@ pub enum AcpToolCallContent { #[serde(rename = "newText")] new_text: String, }, - Terminal { - #[serde(rename = "terminalId")] - terminal_id: AcpTerminalId, - }, } impl AcpToolCallContent { diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index 7acd25a7..c74431e3 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -132,12 +132,6 @@ pub fn generate_acp_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); output.push_str("export type AcpResumeSessionResult = AcpLoadSessionResult;\n"); output.push_str("export type AcpCloseSessionParams = AcpSessionActionParams;\n"); @@ -470,11 +464,6 @@ fn register_acp_schemas( schema::(schemas); schema::(schemas); schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); method( methods, @@ -619,51 +608,6 @@ fn register_acp_schemas( ..MethodSchemaBinding::default() }, ); - method( - methods, - ACP_TERMINAL_CREATE_METHOD, - MethodSchemaBinding { - incoming_request: Some("AcpTerminalCreateParams"), - outgoing_response: Some("AcpTerminalCreateResult"), - ..MethodSchemaBinding::default() - }, - ); - method( - methods, - ACP_TERMINAL_OUTPUT_METHOD, - MethodSchemaBinding { - incoming_request: Some("AcpTerminalParams"), - outgoing_response: Some("AcpTerminalOutputResult"), - ..MethodSchemaBinding::default() - }, - ); - method( - methods, - ACP_TERMINAL_WAIT_FOR_EXIT_METHOD, - MethodSchemaBinding { - incoming_request: Some("AcpTerminalParams"), - outgoing_response: Some("AcpTerminalWaitForExitResult"), - ..MethodSchemaBinding::default() - }, - ); - method( - methods, - ACP_TERMINAL_KILL_METHOD, - MethodSchemaBinding { - incoming_request: Some("AcpTerminalParams"), - outgoing_response: Some("AcpEmptyResult"), - ..MethodSchemaBinding::default() - }, - ); - method( - methods, - ACP_TERMINAL_RELEASE_METHOD, - MethodSchemaBinding { - incoming_request: Some("AcpTerminalParams"), - outgoing_response: Some("AcpEmptyResult"), - ..MethodSchemaBinding::default() - }, - ); } fn register_devo_protocol_schemas( diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index e8be4eaf..fae9318f 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -194,8 +194,6 @@ pub struct ToolCallStatusUpdatedPayload { pub turn_id: TurnId, pub tool_call_id: String, pub status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub terminal_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/server/src/client.rs b/crates/server/src/client.rs index 413d3150..46de575c 100644 --- a/crates/server/src/client.rs +++ b/crates/server/src/client.rs @@ -1,5 +1,5 @@ pub use devo_client::{ ACP_PROMPT_COMPLETED_NOTIFICATION_METHOD, ACP_PROMPT_STARTED_NOTIFICATION_METHOD, - ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD, ServerNotificationMessage, StdioServerClient, - StdioServerClientConfig, WebSocketServerClient, WebSocketServerClientConfig, + ServerNotificationMessage, StdioServerClient, StdioServerClientConfig, WebSocketServerClient, + WebSocketServerClientConfig, }; diff --git a/crates/server/src/protocol.rs b/crates/server/src/protocol.rs index ac0b860b..6da52da3 100644 --- a/crates/server/src/protocol.rs +++ b/crates/server/src/protocol.rs @@ -4,31 +4,28 @@ pub use devo_protocol::{ ACP_SESSION_DELETE_METHOD, ACP_SESSION_LIST_METHOD, ACP_SESSION_LOAD_METHOD, ACP_SESSION_NEW_METHOD, ACP_SESSION_PROMPT_METHOD, ACP_SESSION_RESUME_METHOD, ACP_SESSION_SET_CONFIG_OPTION_METHOD, ACP_SESSION_SET_MODE_METHOD, ACP_SESSION_UPDATE_METHOD, - ACP_TERMINAL_CREATE_METHOD, ACP_TERMINAL_KILL_METHOD, ACP_TERMINAL_OUTPUT_METHOD, - ACP_TERMINAL_RELEASE_METHOD, ACP_TERMINAL_WAIT_FOR_EXIT_METHOD, AcpAgentCapabilities, - AcpAnnotations, AcpAuthCapabilities, AcpAuthMethod, AcpAuthenticateParams, - AcpAuthenticateResult, AcpBlobResourceContents, AcpCancelParams, AcpClientCapabilities, - AcpClientNotification, AcpCloseSessionParams, AcpCloseSessionResult, AcpContentBlock, - AcpDeleteSessionParams, AcpDeleteSessionResult, AcpEmbeddedResource, AcpEmptyAuthResult, - AcpEmptyResult, AcpEnvVariable, AcpErrorCode, AcpErrorResponse, AcpFileSystemCapabilities, - AcpFsReadTextFileParams, AcpFsReadTextFileResult, AcpFsWriteTextFileParams, AcpHttpHeader, - AcpImplementation, AcpInitializeParams, AcpInitializeResult, AcpListSessionsParams, - AcpListSessionsResult, AcpLoadSessionParams, AcpLoadSessionResult, AcpLogoutResult, - AcpMcpCapabilities, AcpMcpServer, AcpMcpServerHttp, AcpMcpServerHttpType, AcpMcpServerSse, - AcpMcpServerSseType, AcpMcpServerStdio, AcpNewSessionParams, AcpNewSessionResult, AcpPlanEntry, - AcpPlanEntryPriority, AcpPlanEntryStatus, AcpPromptCapabilities, AcpPromptParams, - AcpPromptResult, AcpResumeSessionParams, AcpResumeSessionResult, AcpRole, + AcpAgentCapabilities, AcpAnnotations, AcpAuthCapabilities, AcpAuthMethod, + AcpAuthenticateParams, AcpAuthenticateResult, AcpBlobResourceContents, AcpCancelParams, + AcpClientCapabilities, AcpClientNotification, AcpCloseSessionParams, AcpCloseSessionResult, + AcpContentBlock, AcpDeleteSessionParams, AcpDeleteSessionResult, AcpEmbeddedResource, + AcpEmptyAuthResult, AcpEmptyResult, AcpEnvVariable, AcpErrorCode, AcpErrorResponse, + AcpFileSystemCapabilities, AcpFsReadTextFileParams, AcpFsReadTextFileResult, + AcpFsWriteTextFileParams, AcpHttpHeader, AcpImplementation, AcpInitializeParams, + AcpInitializeResult, AcpListSessionsParams, AcpListSessionsResult, AcpLoadSessionParams, + AcpLoadSessionResult, AcpLogoutResult, AcpMcpCapabilities, AcpMcpServer, AcpMcpServerHttp, + AcpMcpServerHttpType, AcpMcpServerSse, AcpMcpServerSseType, AcpMcpServerStdio, + AcpNewSessionParams, AcpNewSessionResult, AcpPlanEntry, AcpPlanEntryPriority, + AcpPlanEntryStatus, AcpPromptCapabilities, AcpPromptParams, AcpPromptResult, + AcpResumeSessionParams, AcpResumeSessionResult, AcpRole, AcpSessionAdditionalDirectoriesCapabilities, AcpSessionCapabilities, AcpSessionCloseCapabilities, AcpSessionDeleteCapabilities, AcpSessionListCapabilities, AcpSessionNotification, AcpSessionResumeCapabilities, AcpSessionUpdate, AcpSetConfigOptionParams, AcpSetConfigOptionResult, AcpSetModeParams, AcpSetModeResult, - AcpStopReason, AcpSuccessResponse, AcpTerminalCreateParams, AcpTerminalCreateResult, - AcpTerminalExitStatus, AcpTerminalOutputResult, AcpTerminalParams, - AcpTerminalWaitForExitResult, AcpTextResourceContents, AcpToolCallContent, AcpToolCallLocation, - AcpToolCallStatus, AcpToolKind, AcpUnsupportedMcpServer, AgentInfo, AgentListParams, - AgentListResult, AgentMailboxMessage, AgentMessageParams, AgentMessageResult, AgentOutputEvent, - AgentStatusParams, ClientMethod, ClientNotification, ClientRequest, CloseAgentParams, - CloseAgentResult, DEVO_SESSION_META, DEVO_SESSION_RESUME_META, ErrorResponse, + AcpStopReason, AcpSuccessResponse, AcpTextResourceContents, AcpToolCallContent, + AcpToolCallLocation, AcpToolCallStatus, AcpToolKind, AcpUnsupportedMcpServer, AgentInfo, + AgentListParams, AgentListResult, AgentMailboxMessage, AgentMessageParams, AgentMessageResult, + AgentOutputEvent, AgentStatusParams, ClientMethod, ClientNotification, ClientRequest, + CloseAgentParams, CloseAgentResult, DEVO_SESSION_META, DEVO_SESSION_RESUME_META, ErrorResponse, MessageEditPreviousParams, MessageEditPreviousResult, MessageEditWorkspaceRestorePolicy, ModelCatalogEntry, ModelCatalogParams, ModelCatalogResult, ModelConfigParams, ModelConfigResult, ModelSavedEntry, ModelSavedParams, ModelSavedResult, NotificationEnvelope, diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index cf75a808..340bcacc 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -132,7 +132,6 @@ use crate::usage_ledger::UsageLedger; use crate::workspace_changes::ActiveWorkspaceBaseline; mod acp_fs; -mod acp_terminal; mod active_turn; mod agents; mod approval; diff --git a/crates/server/src/runtime/acp_terminal.rs b/crates/server/src/runtime/acp_terminal.rs deleted file mode 100644 index 300e68e7..00000000 --- a/crates/server/src/runtime/acp_terminal.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use devo_core::tools::ClientTerminal; -use devo_core::tools::ClientTerminalCreate; -use devo_core::tools::ClientTerminalCreateRequest; -use devo_core::tools::ClientTerminalEnv; -use devo_core::tools::ClientTerminalExitStatus; -use devo_core::tools::ClientTerminalOutput; -use devo_core::tools::ClientTerminalRequest; -use tokio_util::sync::CancellationToken; - -use super::*; - -const ACP_TERMINAL_CLIENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -impl ServerRuntime { - async fn create_acp_client_terminal_with_cancel( - &self, - session_id: SessionId, - request: ClientTerminalCreateRequest, - cancel_token: CancellationToken, - ) -> Result { - if let Some(cwd) = request.cwd.as_ref() - && !cwd.is_absolute() - { - return Err(ToolCallError::InvalidInput( - "terminal/create cwd must be absolute".to_string(), - )); - } - let Some(connection_id) = self - .active_acp_connection_with_terminal_capability(session_id) - .await - else { - return Ok(ClientTerminalCreate::Unsupported); - }; - let params = crate::AcpTerminalCreateParams { - session_id, - command: request.command, - args: request.args, - env: request.env.into_iter().map(acp_terminal_env).collect(), - cwd: request.cwd, - output_byte_limit: request.output_byte_limit, - meta: None, - }; - let response = self - .send_request_to_connection_with_timeout( - connection_id, - crate::ACP_TERMINAL_CREATE_METHOD, - serde_json::to_value(params).expect("serialize ACP terminal/create params"), - ACP_TERMINAL_CLIENT_REQUEST_TIMEOUT, - cancel_token, - ) - .await - .map_err(|error| acp_terminal_request_error("terminal/create", error))?; - let response = serde_json::from_value::(response).map_err( - |error| { - ToolCallError::ExecutionFailed(format!("invalid terminal/create response: {error}")) - }, - )?; - Ok(ClientTerminalCreate::Created { - terminal_id: response.terminal_id, - }) - } - - async fn acp_client_terminal_output_with_cancel( - &self, - session_id: SessionId, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result { - let Some(connection_id) = self - .active_acp_connection_with_terminal_capability(session_id) - .await - else { - return Err(ToolCallError::ExecutionFailed( - "client terminal capability is unavailable".to_string(), - )); - }; - let params = crate::AcpTerminalParams { - session_id, - terminal_id: request.terminal_id, - meta: None, - }; - let response = self - .send_request_to_connection_with_timeout( - connection_id, - crate::ACP_TERMINAL_OUTPUT_METHOD, - serde_json::to_value(params).expect("serialize ACP terminal/output params"), - ACP_TERMINAL_CLIENT_REQUEST_TIMEOUT, - cancel_token, - ) - .await - .map_err(|error| acp_terminal_request_error("terminal/output", error))?; - let response = serde_json::from_value::(response).map_err( - |error| { - ToolCallError::ExecutionFailed(format!("invalid terminal/output response: {error}")) - }, - )?; - Ok(ClientTerminalOutput { - output: response.output, - truncated: response.truncated, - exit_status: response.exit_status.map(client_terminal_exit_status), - }) - } - - async fn wait_for_acp_client_terminal_exit_with_cancel( - &self, - session_id: SessionId, - request: ClientTerminalRequest, - timeout: Duration, - cancel_token: CancellationToken, - ) -> Result { - let Some(connection_id) = self - .active_acp_connection_with_terminal_capability(session_id) - .await - else { - return Err(ToolCallError::ExecutionFailed( - "client terminal capability is unavailable".to_string(), - )); - }; - let params = crate::AcpTerminalParams { - session_id, - terminal_id: request.terminal_id, - meta: None, - }; - let response = self - .send_request_to_connection_with_timeout( - connection_id, - crate::ACP_TERMINAL_WAIT_FOR_EXIT_METHOD, - serde_json::to_value(params).expect("serialize ACP terminal/wait_for_exit params"), - timeout, - cancel_token, - ) - .await - .map_err(|error| acp_terminal_wait_error(timeout, error))?; - let response = serde_json::from_value::(response) - .map_err(|error| { - ToolCallError::ExecutionFailed(format!( - "invalid terminal/wait_for_exit response: {error}" - )) - })?; - Ok(ClientTerminalExitStatus { - exit_code: response.exit_code, - signal: response.signal, - }) - } - - async fn kill_acp_client_terminal_with_cancel( - &self, - session_id: SessionId, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - self.send_acp_client_terminal_empty_method( - session_id, - request, - crate::ACP_TERMINAL_KILL_METHOD, - cancel_token, - ) - .await - } - - async fn release_acp_client_terminal_with_cancel( - &self, - session_id: SessionId, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - self.send_acp_client_terminal_empty_method( - session_id, - request, - crate::ACP_TERMINAL_RELEASE_METHOD, - cancel_token, - ) - .await - } - - async fn send_acp_client_terminal_empty_method( - &self, - session_id: SessionId, - request: ClientTerminalRequest, - method: &str, - cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - let Some(connection_id) = self - .active_acp_connection_with_terminal_capability(session_id) - .await - else { - return Err(ToolCallError::ExecutionFailed( - "client terminal capability is unavailable".to_string(), - )); - }; - let params = crate::AcpTerminalParams { - session_id, - terminal_id: request.terminal_id, - meta: None, - }; - self.send_request_to_connection_with_timeout( - connection_id, - method, - serde_json::to_value(params).expect("serialize ACP terminal params"), - ACP_TERMINAL_CLIENT_REQUEST_TIMEOUT, - cancel_token, - ) - .await - .map_err(|error| acp_terminal_request_error(method, error))?; - Ok(()) - } - - async fn active_acp_connection_with_terminal_capability( - &self, - session_id: SessionId, - ) -> Option { - let connection_id = self.active_turns.active_connection_id(session_id).await?; - let connections = self.connections.lock().await; - let connection = connections.get(&connection_id)?; - connection - .acp_client_capabilities - .terminal - .then_some(connection_id) - } -} - -#[async_trait::async_trait] -impl ClientTerminal for ServerRuntime { - async fn create( - self: Arc, - request: ClientTerminalCreateRequest, - cancel_token: CancellationToken, - ) -> Result { - let session_id = SessionId::try_from(request.session_id.as_str()) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - self.create_acp_client_terminal_with_cancel(session_id, request, cancel_token) - .await - } - - async fn output( - self: Arc, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result { - let session_id = SessionId::try_from(request.session_id.as_str()) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - self.acp_client_terminal_output_with_cancel(session_id, request, cancel_token) - .await - } - - async fn wait_for_exit( - self: Arc, - request: ClientTerminalRequest, - timeout: Duration, - cancel_token: CancellationToken, - ) -> Result { - let session_id = SessionId::try_from(request.session_id.as_str()) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - self.wait_for_acp_client_terminal_exit_with_cancel( - session_id, - request, - timeout, - cancel_token, - ) - .await - } - - async fn kill( - self: Arc, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - let session_id = SessionId::try_from(request.session_id.as_str()) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - self.kill_acp_client_terminal_with_cancel(session_id, request, cancel_token) - .await - } - - async fn release( - self: Arc, - request: ClientTerminalRequest, - cancel_token: CancellationToken, - ) -> Result<(), ToolCallError> { - let session_id = SessionId::try_from(request.session_id.as_str()) - .map_err(|error| ToolCallError::InvalidInput(error.to_string()))?; - self.release_acp_client_terminal_with_cancel(session_id, request, cancel_token) - .await - } -} - -fn acp_terminal_env(env: ClientTerminalEnv) -> crate::AcpEnvVariable { - crate::AcpEnvVariable { - name: env.name, - value: env.value, - meta: None, - } -} - -fn client_terminal_exit_status(status: crate::AcpTerminalExitStatus) -> ClientTerminalExitStatus { - ClientTerminalExitStatus { - exit_code: status.exit_code, - signal: status.signal, - } -} - -fn acp_terminal_wait_error(timeout: Duration, error: String) -> ToolCallError { - if error.starts_with("client request timed out") { - return ToolCallError::TimedOut(timeout.as_secs()); - } - acp_terminal_request_error("terminal/wait_for_exit", error) -} - -fn acp_terminal_request_error(method: &str, error: String) -> ToolCallError { - if error == "client request cancelled" { - return ToolCallError::Cancelled; - } - ToolCallError::ExecutionFailed(format!("client {method} failed: {error}")) -} - -#[cfg(test)] -mod tests { - #[test] - fn client_capabilities_gate_terminal_methods() { - let capabilities = crate::AcpClientCapabilities { - terminal: true, - ..crate::AcpClientCapabilities::default() - }; - - assert!(capabilities.terminal); - assert!(!crate::AcpClientCapabilities::default().terminal); - } -} diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index d5df8a7f..c9de5487 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -213,7 +213,6 @@ pub(crate) fn spawn_turn_event_stream( turn_id: turn_for_events.turn_id, tool_call_id: id, status: "in_progress".to_string(), - terminal_id: None, }, )) .await; @@ -714,20 +713,6 @@ async fn handle_tool_progress( None => message, }), devo_core::tools::ToolProgress::Completion { summary } => Some(summary), - devo_core::tools::ToolProgress::Terminal { terminal_id } => { - runtime - .broadcast_event(ServerEvent::ToolCallStatusUpdated( - devo_protocol::ToolCallStatusUpdatedPayload { - session_id, - turn_id, - tool_call_id: tool_use_id.clone(), - status: "in_progress".to_string(), - terminal_id: Some(terminal_id), - }, - )) - .await; - None - } }; let Some(content) = content else { return; diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index 40126757..c00448b8 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use devo_core::tools::{ - AgentToolCoordinator, ClientFilesystem, ClientTerminal, ToolAgentScope, ToolCall, - ToolExecutionOptions, ToolRuntime, ToolRuntimeContext, + AgentToolCoordinator, ClientFilesystem, ToolAgentScope, ToolCall, ToolExecutionOptions, + ToolRuntime, ToolRuntimeContext, }; use devo_core::{Message, QueryEvent, QueryOptions, TurnConfig, query}; use tokio::sync::mpsc; @@ -121,7 +121,6 @@ impl ServerRuntime { collaboration_mode, agent_coordinator: Some(Arc::clone(self) as Arc), client_filesystem: Some(Arc::clone(self) as Arc), - client_terminal: Some(Arc::clone(self) as Arc), file_read_ledger: Arc::clone(&state.file_read_ledger), local_web_search: match &turn_config.web_search { devo_core::ResolvedWebSearchConfig::Local(config) => Some(config.clone()), diff --git a/crates/server/src/runtime/turn_exec/shell.rs b/crates/server/src/runtime/turn_exec/shell.rs index d27b25b6..01ac0c55 100644 --- a/crates/server/src/runtime/turn_exec/shell.rs +++ b/crates/server/src/runtime/turn_exec/shell.rs @@ -93,7 +93,6 @@ impl ServerRuntime { collaboration_mode: devo_protocol::CollaborationMode::Build, agent_coordinator: None, client_filesystem: None, - client_terminal: None, file_read_ledger, local_web_search: None, hooks: self.hook_context_for_session(session_id).await, @@ -114,7 +113,6 @@ impl ServerRuntime { turn_id: tool_execution_start_turn_id, tool_call_id, status: "in_progress".to_string(), - terminal_id: None, }, )) .await; diff --git a/crates/server/src/runtime/turn_exec/tests.rs b/crates/server/src/runtime/turn_exec/tests.rs index 239966ba..1a0f6269 100644 --- a/crates/server/src/runtime/turn_exec/tests.rs +++ b/crates/server/src/runtime/turn_exec/tests.rs @@ -518,12 +518,6 @@ fn lifecycle_and_control_query_events_are_must_deliver() { is_error: false, summary: "read README.md".to_string(), }, - devo_core::QueryEvent::ToolProgress { - tool_use_id: "tool-1".to_string(), - progress: devo_core::tools::ToolProgress::Terminal { - terminal_id: "terminal-1".to_string(), - }, - }, devo_core::QueryEvent::TextDelta("text".to_string()), devo_core::QueryEvent::ReasoningDelta("reasoning".to_string()), devo_core::QueryEvent::TurnComplete { diff --git a/crates/server/src/runtime/turn_exec/trace.rs b/crates/server/src/runtime/turn_exec/trace.rs index 1f843e25..522f6fd7 100644 --- a/crates/server/src/runtime/turn_exec/trace.rs +++ b/crates/server/src/runtime/turn_exec/trace.rs @@ -30,10 +30,6 @@ pub(super) fn query_event_delivery_policy(event: &QueryEvent) -> QueryEventDeliv | QueryEvent::ToolUseStart { .. } | QueryEvent::ToolExecutionStart { .. } | QueryEvent::ToolResult { .. } - | QueryEvent::ToolProgress { - progress: devo_core::tools::ToolProgress::Terminal { .. }, - .. - } | QueryEvent::TurnComplete { .. } => QueryEventDeliveryPolicy::MustDeliver, } } @@ -75,11 +71,7 @@ pub(super) fn query_event_trace_delta_len(event: &QueryEvent) -> usize { | devo_core::tools::ToolProgress::Completion { summary: delta }, .. } => delta.len(), - QueryEvent::ToolProgress { - progress: devo_core::tools::ToolProgress::Terminal { .. }, - .. - } - | QueryEvent::ProviderRetryStatus(_) + QueryEvent::ProviderRetryStatus(_) | QueryEvent::ContextCompactionStarted | QueryEvent::ContextCompactionCompleted | QueryEvent::ContextCompactionFailed { .. } diff --git a/crates/tools/src/client_terminal.rs b/crates/tools/src/client_terminal.rs deleted file mode 100644 index 26eeed76..00000000 --- a/crates/tools/src/client_terminal.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use tokio_util::sync::CancellationToken; - -use crate::contracts::ToolCallError; - -/// Environment variable passed to a client-owned terminal command. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientTerminalEnv { - pub name: String, - pub value: String, -} - -/// Request to create a terminal command in the active client environment. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientTerminalCreateRequest { - pub session_id: String, - pub command: String, - pub args: Vec, - pub env: Vec, - pub cwd: Option, - pub output_byte_limit: Option, -} - -/// Request targeting an existing client terminal. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientTerminalRequest { - pub session_id: String, - pub terminal_id: String, -} - -/// Result of an optional client-backed terminal create operation. -/// -/// Implementations return `Unsupported` when the connected client did not -/// advertise ACP `terminal` support. Tool handlers should then fall back to -/// their normal server-side command execution behavior. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClientTerminalCreate { - Unsupported, - Created { terminal_id: String }, -} - -/// Terminal process exit status reported by the active client. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientTerminalExitStatus { - pub exit_code: Option, - pub signal: Option, -} - -/// Snapshot of output retained by the active client terminal. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientTerminalOutput { - pub output: String, - pub truncated: bool, - pub exit_status: Option, -} - -/// Runtime bridge for client-owned terminal execution. -/// -/// Implementations should call terminal capabilities exposed by the active -/// client, such as ACP `terminal/create`, `terminal/output`, -/// `terminal/wait_for_exit`, `terminal/kill`, and `terminal/release`. -/// Tool handlers use `Unsupported` to keep server-side fallback behavior when -/// no client terminal capability is available. -#[async_trait] -pub trait ClientTerminal: Send + Sync { - async fn create( - self: Arc, - _request: ClientTerminalCreateRequest, - _cancel_token: CancellationToken, - ) -> Result { - Ok(ClientTerminalCreate::Unsupported) - } - - async fn output( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result; - - async fn wait_for_exit( - self: Arc, - _request: ClientTerminalRequest, - _timeout: Duration, - _cancel_token: CancellationToken, - ) -> Result; - - async fn kill( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result<(), ToolCallError>; - - async fn release( - self: Arc, - _request: ClientTerminalRequest, - _cancel_token: CancellationToken, - ) -> Result<(), ToolCallError>; -} diff --git a/crates/tools/src/contracts.rs b/crates/tools/src/contracts.rs index d08f8e8f..1557071a 100644 --- a/crates/tools/src/contracts.rs +++ b/crates/tools/src/contracts.rs @@ -13,7 +13,6 @@ use serde::Deserialize; use serde::Serialize; use crate::client_fs::ClientFilesystem; -use crate::client_terminal::ClientTerminal; use crate::coordinator::AgentToolCoordinator; use crate::file_read_ledger::FileReadLedger; use crate::invocation::ToolCallId; @@ -52,7 +51,6 @@ pub struct ToolContext { pub collaboration_mode: CollaborationMode, pub agent_coordinator: Option>, pub client_filesystem: Option>, - pub client_terminal: Option>, /// Session-scoped ledger of files read/written by tools (used by `edit`). pub file_read_ledger: Option>, pub network_proxy: Option, @@ -82,10 +80,6 @@ impl std::fmt::Debug for ToolContext { "client_filesystem", &self.client_filesystem.as_ref().map(|_| ""), ) - .field( - "client_terminal", - &self.client_terminal.as_ref().map(|_| ""), - ) .field( "file_read_ledger", &self.file_read_ledger.as_ref().map(|_| ""), @@ -237,8 +231,6 @@ pub enum ToolProgress { message: String, percent: Option, }, - /// A client-owned terminal became visible for this tool call. - Terminal { terminal_id: String }, /// Tool execution completed (terminal). Completion { summary: String }, } @@ -381,9 +373,6 @@ mod tests { message: "50% done".into(), percent: Some(50), }, - ToolProgress::Terminal { - terminal_id: "term_1".into(), - }, ToolProgress::Completion { summary: "Build complete".into(), }, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 8c790de2..cf37b6f2 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -1,5 +1,4 @@ pub mod client_fs; -pub mod client_terminal; pub mod contracts; pub mod coordinator; pub mod errors; @@ -13,10 +12,6 @@ pub mod tool_spec; pub mod tool_summary; pub use client_fs::{ClientFilesystem, ClientTextFileRead, ClientTextFileWrite}; -pub use client_terminal::{ - ClientTerminal, ClientTerminalCreate, ClientTerminalCreateRequest, ClientTerminalEnv, - ClientTerminalExitStatus, ClientTerminalOutput, ClientTerminalRequest, -}; pub use contracts::{ RedactionState, SessionMode, ToolAgentScope, ToolCallError, ToolContext, ToolPermissionProfile, ToolProgress, ToolProgressSender, ToolResult, ToolResultContent, ToolTerminalStatus, diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index e6d8769b..177e52e1 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -47,7 +47,6 @@ use devo_protocol::SessionPlanStepStatus; use devo_protocol::SpawnAgentParams; use devo_protocol::ThreadGoalStatus; use devo_protocol::TurnFailedPayload; -use devo_server::ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD; use devo_server::AcpDeleteSessionParams; use devo_server::ApprovalDecisionPayload; use devo_server::ApprovalRequestPayload; @@ -90,7 +89,6 @@ use crate::events::PlanStep; use crate::events::PlanStepStatus; use crate::events::SessionListEntry; use crate::events::SubagentMonitorAgent; -use crate::events::SubagentMonitorEvent; use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; @@ -99,20 +97,15 @@ use crate::events::WorkerEvent; mod acp_events; mod subagent_events; -#[cfg(test)] -use acp_events::acp_terminal_output_event; -use acp_events::acp_terminal_output_event_with_session; use acp_events::parse_acp_session_notification; use acp_events::session_metadata_from_acp_update; use acp_events::spawn_agent_result_from_acp_update; use acp_events::spawn_task_message_from_acp_update; -use acp_events::subagent_monitor_events_from_acp_session_notification_with_terminal_state; +use acp_events::subagent_monitor_events_from_acp_session_notification; use acp_events::subagent_monitor_events_from_unwrapped_server_notification; #[cfg(test)] use acp_events::worker_events_from_acp_notification; -#[cfg(test)] -use acp_events::worker_events_from_acp_notification_with_terminal_state; -use acp_events::worker_events_from_acp_session_notification_with_terminal_state; +use acp_events::worker_events_from_acp_session_notification; const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_millis(100); const WORKER_ABORT_JOIN_TIMEOUT: Duration = Duration::from_millis(500); @@ -147,20 +140,6 @@ struct EnsureSessionOutcome { created: bool, } -fn acp_terminal_snapshot_delta( - previous_output: &mut String, - output: String, - truncated: bool, -) -> Option { - let delta = if truncated || !output.starts_with(previous_output.as_str()) { - output.clone() - } else { - output[previous_output.len()..].to_string() - }; - *previous_output = output; - (!delta.is_empty()).then_some(delta) -} - fn should_apply_terminal_turn_usage_fallback( saw_usage_update_for_turn: bool, has_authoritative_usage_totals: bool, @@ -880,11 +859,6 @@ async fn run_worker_inner( let mut input_history_cursor: Option = None; let mut active_reference_search_id: Option = None; let mut active_shell_process_ids: HashSet = HashSet::new(); - let mut visible_acp_terminal_ids: HashSet = HashSet::new(); - let mut visible_acp_terminal_session_ids: HashMap = HashMap::new(); - let mut private_acp_terminal_ids: HashSet = HashSet::new(); - let mut pending_acp_terminal_output: HashMap = HashMap::new(); - let mut polled_acp_terminal_output: HashMap = HashMap::new(); let mut next_shell_process_index = 1_u64; if let Some(initial_session_id) = config.initial_session_id { @@ -950,8 +924,6 @@ async fn run_worker_inner( } } let _ = emit_skills_list(&mut client, &session_cwd, event_tx, false).await; - let mut acp_terminal_poll = tokio::time::interval(Duration::from_millis(250)); - acp_terminal_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { tokio::select! { @@ -1216,11 +1188,6 @@ async fn run_worker_inner( session_id = None; child_agent_sessions.clear(); btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); active_turn_id = None; active_reference_search_id = None; last_query_total_tokens = 0; @@ -1669,11 +1636,6 @@ async fn run_worker_inner( session_id = Some(next_session_id); child_agent_sessions.clear(); btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); session_cwd = result.session.cwd.clone(); input_history_cursor = None; let active_agent_label = @@ -2006,11 +1968,6 @@ async fn run_worker_inner( session_id = Some(next_session_id); child_agent_sessions.clear(); btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); session_cwd = resumed.session.cwd.clone(); input_history_cursor = None; let active_agent_label = @@ -2326,55 +2283,6 @@ async fn run_worker_inner( } } } - _ = acp_terminal_poll.tick(), if !visible_acp_terminal_ids.is_empty() => { - let terminal_ids = visible_acp_terminal_ids - .iter() - .filter(|terminal_id| !private_acp_terminal_ids.contains(*terminal_id)) - .cloned() - .collect::>(); - for terminal_id in terminal_ids { - match client.acp_terminal_output_snapshot(&terminal_id).await { - Ok(snapshot) => { - if let Some(delta) = acp_terminal_snapshot_delta( - polled_acp_terminal_output - .entry(terminal_id.clone()) - .or_default(), - snapshot.output, - snapshot.truncated, - ) { - if let Some(owner_session_id) = - visible_acp_terminal_session_ids.get(&terminal_id).copied() - && Some(owner_session_id) != session_id - { - let _ = event_tx.send(WorkerEvent::SubagentMonitor { - event: SubagentMonitorEvent::ToolOutputDelta { - session_id: owner_session_id, - tool_use_id: terminal_id.clone(), - delta, - }, - }); - } else { - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: terminal_id.clone(), - delta, - }); - } - } - if snapshot.exit_status.is_some() { - visible_acp_terminal_ids.remove(&terminal_id); - visible_acp_terminal_session_ids.remove(&terminal_id); - polled_acp_terminal_output.remove(&terminal_id); - } - } - Err(error) => { - tracing::debug!(%error, terminal_id, "failed to poll ACP terminal output"); - visible_acp_terminal_ids.remove(&terminal_id); - visible_acp_terminal_session_ids.remove(&terminal_id); - polled_acp_terminal_output.remove(&terminal_id); - } - } - } - } notification = client.recv_notification() => { match notification { Some(notification) => { @@ -2407,24 +2315,6 @@ async fn run_worker_inner( }); continue; } - if method == ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD { - if let Some(terminal_id) = - params.get("terminalId").and_then(serde_json::Value::as_str) - { - private_acp_terminal_ids.insert(terminal_id.to_string()); - polled_acp_terminal_output.remove(terminal_id); - } - if let Some(event) = acp_terminal_output_event_with_session( - ¶ms, - &visible_acp_terminal_ids, - &mut pending_acp_terminal_output, - session_id, - &visible_acp_terminal_session_ids, - ) { - let _ = event_tx.send(event); - } - continue; - } if method == ACP_SESSION_UPDATE_METHOD { let Some(notification) = parse_acp_session_notification(¶ms) else { continue; @@ -2450,24 +2340,14 @@ async fn run_worker_inner( ) .await; } - for event in worker_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_acp_terminal_ids, - &mut pending_acp_terminal_output, - Some(&mut visible_acp_terminal_session_ids), - ) { + for event in worker_events_from_acp_session_notification(notification) { let _ = event_tx.send(event); } continue; } if child_agent_sessions.contains(¬ification_session_id) { - for event in subagent_monitor_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_acp_terminal_ids, - &mut pending_acp_terminal_output, - &mut visible_acp_terminal_session_ids, - ) { + for event in subagent_monitor_events_from_acp_session_notification(notification) { let _ = event_tx.send(event); } } @@ -4715,7 +4595,6 @@ mod tests { use chrono::Utc; use pretty_assertions::assert_eq; use std::collections::HashMap; - use std::collections::HashSet; use std::future::pending; use std::path::PathBuf; use std::time::Duration; @@ -4731,8 +4610,6 @@ mod tests { use super::QueryWorkerHandle; use super::ShellCommandExecStart; - use super::acp_terminal_output_event; - use super::acp_terminal_snapshot_delta; use super::btw_agent_prompt; use super::btw_spawn_params; use super::handle_completed_item; @@ -4749,7 +4626,6 @@ mod tests { use super::tool_call_started_event; use super::truncate_tool_output; use super::worker_events_from_acp_notification; - use super::worker_events_from_acp_notification_with_terminal_state; use crate::events::PlanStep; use crate::events::PlanStepStatus; use crate::events::SessionListEntry; @@ -5877,156 +5753,6 @@ mod tests { ); } - #[test] - fn raw_acp_terminal_content_and_output_emit_command_rows() { - let session_id = SessionId::new(); - let events = worker_events_from_acp_notification( - &serde_json::json!({ - "sessionId": session_id, - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-1", - "content": [ - { - "type": "terminal", - "terminalId": "term_1" - } - ] - } - }), - Some(session_id), - ); - assert_eq!( - events, - vec![WorkerEvent::ToolCall { - tool_use_id: "term_1".to_string(), - summary: "Terminal term_1".to_string(), - preparing: false, - parsed_commands: None, - }] - ); - - let visible_terminal_ids = HashSet::from(["term_1".to_string()]); - let mut pending_terminal_output = HashMap::new(); - assert_eq!( - acp_terminal_output_event( - &serde_json::json!({ - "terminalId": "term_1", - "delta": "hello\n" - }), - &visible_terminal_ids, - &mut pending_terminal_output, - ), - Some(WorkerEvent::ToolOutputDelta { - tool_use_id: "term_1".to_string(), - delta: "hello\n".to_string(), - }) - ); - } - - #[test] - fn raw_acp_terminal_rows_are_deduplicated_and_early_output_is_buffered() { - let session_id = SessionId::new(); - let mut visible_terminal_ids = HashSet::new(); - let mut pending_terminal_output = HashMap::new(); - - assert_eq!( - acp_terminal_output_event( - &serde_json::json!({ - "terminalId": "term_1", - "delta": "early\n" - }), - &visible_terminal_ids, - &mut pending_terminal_output, - ), - None - ); - assert_eq!( - pending_terminal_output.get("term_1"), - Some(&"early\n".to_string()) - ); - - let first_events = worker_events_from_acp_notification_with_terminal_state( - &serde_json::json!({ - "sessionId": session_id, - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-1", - "content": [ - { - "type": "terminal", - "terminalId": "term_1" - } - ] - } - }), - Some(session_id), - &mut visible_terminal_ids, - &mut pending_terminal_output, - ); - assert_eq!( - first_events, - vec![ - WorkerEvent::ToolCall { - tool_use_id: "term_1".to_string(), - summary: "Terminal term_1".to_string(), - preparing: false, - parsed_commands: None, - }, - WorkerEvent::ToolOutputDelta { - tool_use_id: "term_1".to_string(), - delta: "early\n".to_string(), - }, - ] - ); - - let second_events = worker_events_from_acp_notification_with_terminal_state( - &serde_json::json!({ - "sessionId": session_id, - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-1", - "content": [ - { - "type": "terminal", - "terminalId": "term_1" - } - ] - } - }), - Some(session_id), - &mut visible_terminal_ids, - &mut pending_terminal_output, - ); - assert_eq!(second_events, Vec::new()); - } - - #[test] - fn acp_terminal_snapshot_delta_emits_incremental_output() { - let mut previous_output = String::new(); - - assert_eq!( - acp_terminal_snapshot_delta(&mut previous_output, "hello".to_string(), false), - Some("hello".to_string()) - ); - assert_eq!( - acp_terminal_snapshot_delta(&mut previous_output, "hello world".to_string(), false), - Some(" world".to_string()) - ); - assert_eq!( - acp_terminal_snapshot_delta(&mut previous_output, "hello world".to_string(), false), - None - ); - assert_eq!( - acp_terminal_snapshot_delta(&mut previous_output, "world".to_string(), true), - Some("world".to_string()) - ); - assert_eq!( - acp_terminal_snapshot_delta(&mut previous_output, "fresh".to_string(), false), - Some("fresh".to_string()) - ); - } - #[test] fn completed_apply_patch_tool_result_emits_patch_applied() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -6484,17 +6210,7 @@ mod tests { } })) .expect("ACP session notification"); - let mut visible_terminal_ids = HashSet::new(); - let mut pending_terminal_output = HashMap::new(); - let mut terminal_session_ids = HashMap::new(); - - let events = - super::subagent_monitor_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_terminal_ids, - &mut pending_terminal_output, - &mut terminal_session_ids, - ); + let events = super::subagent_monitor_events_from_acp_session_notification(notification); assert_eq!( events, @@ -6559,17 +6275,7 @@ mod tests { "_meta": meta })) .expect("ACP session notification"); - let mut visible_terminal_ids = HashSet::new(); - let mut pending_terminal_output = HashMap::new(); - let mut terminal_session_ids = HashMap::new(); - - let events = - super::subagent_monitor_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_terminal_ids, - &mut pending_terminal_output, - &mut terminal_session_ids, - ); + let events = super::subagent_monitor_events_from_acp_session_notification(notification); assert_eq!( events, @@ -6652,17 +6358,7 @@ mod tests { } })) .expect("ACP session notification"); - let mut visible_terminal_ids = HashSet::new(); - let mut pending_terminal_output = HashMap::new(); - let mut terminal_session_ids = HashMap::new(); - - let events = - super::subagent_monitor_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_terminal_ids, - &mut pending_terminal_output, - &mut terminal_session_ids, - ); + let events = super::subagent_monitor_events_from_acp_session_notification(notification); assert_eq!( events, diff --git a/crates/tui/src/worker/acp_events.rs b/crates/tui/src/worker/acp_events.rs index 824817f3..28f9b4cb 100644 --- a/crates/tui/src/worker/acp_events.rs +++ b/crates/tui/src/worker/acp_events.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::collections::HashSet; use devo_core::SessionId; use devo_protocol::AcpContentBlock; @@ -37,46 +36,14 @@ struct AcpToolCallEventData { content: Vec, } -struct AcpTerminalRenderState<'a> { - visible_terminal_ids: &'a mut HashSet, - pending_terminal_output: &'a mut HashMap, - terminal_session_ids: Option<&'a mut HashMap>, - owner_session_id: Option, -} - -impl AcpTerminalRenderState<'_> { - fn mark_visible(&mut self, terminal_id: &str) -> Option { - if let (Some(owner_session_id), Some(terminal_session_ids)) = ( - self.owner_session_id, - self.terminal_session_ids.as_deref_mut(), - ) { - terminal_session_ids.insert(terminal_id.to_string(), owner_session_id); - } - if self.visible_terminal_ids.insert(terminal_id.to_string()) { - Some( - self.pending_terminal_output - .remove(terminal_id) - .unwrap_or_default(), - ) - } else { - None - } - } -} - -struct AcpSessionUpdateRender<'a> { +struct AcpSessionUpdateRender { session_id: SessionId, update: AcpSessionUpdate, - terminal_state: AcpTerminalRenderState<'a>, } -impl From> for Vec { - fn from(render: AcpSessionUpdateRender<'_>) -> Self { - let AcpSessionUpdateRender { - session_id, - update, - terminal_state, - } = render; +impl From for Vec { + fn from(render: AcpSessionUpdateRender) -> Self { + let AcpSessionUpdateRender { session_id, update } = render; match update { AcpSessionUpdate::AgentMessageChunk { content, @@ -170,7 +137,6 @@ impl From> for Vec { content, }, kind, - terminal_state, ), AcpSessionUpdate::ToolCallUpdate { tool_call_id, @@ -191,7 +157,6 @@ impl From> for Vec { content, }, kind, - terminal_state, ), AcpSessionUpdate::UserMessageChunk { .. } | AcpSessionUpdate::SessionInfoUpdate { title: None, .. } => Vec::new(), @@ -199,78 +164,10 @@ impl From> for Vec { } } -#[cfg(test)] -pub(super) fn acp_terminal_output_event( - params: &serde_json::Value, - visible_terminal_ids: &HashSet, - pending_terminal_output: &mut HashMap, -) -> Option { - acp_terminal_output_event_with_session( - params, - visible_terminal_ids, - pending_terminal_output, - None, - &HashMap::new(), - ) -} - -pub(super) fn acp_terminal_output_event_with_session( - params: &serde_json::Value, - visible_terminal_ids: &HashSet, - pending_terminal_output: &mut HashMap, - active_session_id: Option, - terminal_session_ids: &HashMap, -) -> Option { - let terminal_id = params.get("terminalId")?.as_str()?.to_string(); - let delta = params.get("delta")?.as_str()?.to_string(); - if delta.is_empty() { - return None; - } - if !visible_terminal_ids.contains(&terminal_id) { - pending_terminal_output - .entry(terminal_id) - .or_default() - .push_str(&delta); - return None; - } - if let Some(owner_session_id) = terminal_session_ids.get(&terminal_id).copied() - && Some(owner_session_id) != active_session_id - { - return Some(WorkerEvent::SubagentMonitor { - event: SubagentMonitorEvent::ToolOutputDelta { - session_id: owner_session_id, - tool_use_id: terminal_id, - delta, - }, - }); - } - Some(WorkerEvent::ToolOutputDelta { - tool_use_id: terminal_id, - delta, - }) -} - #[cfg(test)] pub(super) fn worker_events_from_acp_notification( params: &serde_json::Value, active_session_id: Option, -) -> Vec { - let mut visible_terminal_ids = HashSet::new(); - let mut pending_terminal_output = HashMap::new(); - worker_events_from_acp_notification_with_terminal_state( - params, - active_session_id, - &mut visible_terminal_ids, - &mut pending_terminal_output, - ) -} - -#[cfg(test)] -pub(super) fn worker_events_from_acp_notification_with_terminal_state( - params: &serde_json::Value, - active_session_id: Option, - visible_terminal_ids: &mut HashSet, - pending_terminal_output: &mut HashMap, ) -> Vec { let Some(notification) = parse_acp_session_notification(params) else { return Vec::new(); @@ -278,12 +175,7 @@ pub(super) fn worker_events_from_acp_notification_with_terminal_state( if Some(notification.session_id) != active_session_id { return Vec::new(); } - worker_events_from_acp_session_notification_with_terminal_state( - notification, - visible_terminal_ids, - pending_terminal_output, - None, - ) + worker_events_from_acp_session_notification(notification) } pub(super) fn parse_acp_session_notification( @@ -292,21 +184,12 @@ pub(super) fn parse_acp_session_notification( serde_json::from_value::(params.clone()).ok() } -pub(super) fn worker_events_from_acp_session_notification_with_terminal_state( +pub(super) fn worker_events_from_acp_session_notification( notification: AcpSessionNotification, - visible_terminal_ids: &mut HashSet, - pending_terminal_output: &mut HashMap, - terminal_session_ids: Option<&mut HashMap>, ) -> Vec { Vec::from(AcpSessionUpdateRender { session_id: notification.session_id, update: notification.update, - terminal_state: AcpTerminalRenderState { - visible_terminal_ids, - pending_terminal_output, - terminal_session_ids, - owner_session_id: Some(notification.session_id), - }, }) } @@ -362,11 +245,8 @@ pub(super) fn spawn_agent_result_from_acp_update( } } -pub(super) fn subagent_monitor_events_from_acp_session_notification_with_terminal_state( +pub(super) fn subagent_monitor_events_from_acp_session_notification( notification: AcpSessionNotification, - visible_terminal_ids: &mut HashSet, - pending_terminal_output: &mut HashMap, - terminal_session_ids: &mut HashMap, ) -> Vec { if let Some(events) = subagent_monitor_events_from_wrapped_server_event(¬ification) { return events; @@ -435,12 +315,6 @@ pub(super) fn subagent_monitor_events_from_acp_session_notification_with_termina raw_output, content, }, - AcpTerminalRenderState { - visible_terminal_ids, - pending_terminal_output, - terminal_session_ids: Some(terminal_session_ids), - owner_session_id: Some(session_id), - }, ), AcpSessionUpdate::ToolCallUpdate { tool_call_id, @@ -461,12 +335,6 @@ pub(super) fn subagent_monitor_events_from_acp_session_notification_with_termina raw_output, content, }, - AcpTerminalRenderState { - visible_terminal_ids, - pending_terminal_output, - terminal_session_ids: Some(terminal_session_ids), - owner_session_id: Some(session_id), - }, ), AcpSessionUpdate::UserMessageChunk { content, .. } => acp_content_display_text(&content) .into_iter() @@ -619,7 +487,6 @@ fn turn_usage_payload_from_acp_meta( fn worker_events_from_acp_tool_call( tool_call: AcpToolCallEventData, kind: AcpToolKind, - terminal_state: AcpTerminalRenderState<'_>, ) -> Vec { let title = tool_call .title @@ -642,24 +509,20 @@ fn worker_events_from_acp_tool_call( input, }); } - events.extend(worker_events_from_acp_tool_content( - AcpToolCallEventData { - tool_call_id, - title: Some(title), - status: Some(status), - raw_input: tool_call.raw_input, - raw_output: tool_call.raw_output, - content: tool_call.content, - }, - terminal_state, - )); + events.extend(worker_events_from_acp_tool_content(AcpToolCallEventData { + tool_call_id, + title: Some(title), + status: Some(status), + raw_input: tool_call.raw_input, + raw_output: tool_call.raw_output, + content: tool_call.content, + })); events } fn worker_events_from_acp_tool_call_update( tool_call: AcpToolCallEventData, kind: Option, - terminal_state: AcpTerminalRenderState<'_>, ) -> Vec { let mut events = Vec::new(); if let Some(input) = tool_call.raw_input.clone() { @@ -679,17 +542,11 @@ fn worker_events_from_acp_tool_call_update( parsed_commands: Vec::new(), }); } - events.extend(worker_events_from_acp_tool_content( - tool_call, - terminal_state, - )); + events.extend(worker_events_from_acp_tool_content(tool_call)); events } -fn worker_events_from_acp_tool_content( - tool_call: AcpToolCallEventData, - mut terminal_state: AcpTerminalRenderState<'_>, -) -> Vec { +fn worker_events_from_acp_tool_content(tool_call: AcpToolCallEventData) -> Vec { let mut events = Vec::new(); let mut changes = HashMap::new(); let mut text_parts = Vec::new(); @@ -707,22 +564,6 @@ fn worker_events_from_acp_tool_content( } => { changes.insert(path, file_change_from_acp_diff(old_text, new_text)); } - AcpToolCallContent::Terminal { terminal_id } => { - if let Some(delta) = terminal_state.mark_visible(&terminal_id) { - events.push(WorkerEvent::ToolCall { - tool_use_id: terminal_id.clone(), - summary: format!("Terminal {terminal_id}"), - preparing: false, - parsed_commands: None, - }); - if !delta.is_empty() { - events.push(WorkerEvent::ToolOutputDelta { - tool_use_id: terminal_id, - delta, - }); - } - } - } } } if !changes.is_empty() { @@ -772,7 +613,6 @@ fn worker_events_from_acp_tool_content( fn subagent_events_from_acp_tool_call( session_id: SessionId, tool_call: AcpToolCallEventData, - terminal_state: AcpTerminalRenderState<'_>, ) -> Vec { let title = tool_call .title @@ -799,7 +639,6 @@ fn subagent_events_from_acp_tool_call( raw_output: tool_call.raw_output, content: tool_call.content, }, - terminal_state, )); events } @@ -807,7 +646,6 @@ fn subagent_events_from_acp_tool_call( fn subagent_events_from_acp_tool_call_update( session_id: SessionId, tool_call: AcpToolCallEventData, - terminal_state: AcpTerminalRenderState<'_>, ) -> Vec { let mut events = Vec::new(); // Status-only updates must not overwrite the live title with a generic @@ -821,18 +659,13 @@ fn subagent_events_from_acp_tool_call_update( }, }); } - events.extend(subagent_events_from_acp_tool_content( - session_id, - tool_call, - terminal_state, - )); + events.extend(subagent_events_from_acp_tool_content(session_id, tool_call)); events } fn subagent_events_from_acp_tool_content( session_id: SessionId, tool_call: AcpToolCallEventData, - mut terminal_state: AcpTerminalRenderState<'_>, ) -> Vec { let mut events = Vec::new(); let mut text_parts = Vec::new(); @@ -847,26 +680,6 @@ fn subagent_events_from_acp_tool_content( AcpToolCallContent::Diff { .. } => { diff_count += 1; } - AcpToolCallContent::Terminal { terminal_id } => { - if let Some(delta) = terminal_state.mark_visible(&terminal_id) { - events.push(WorkerEvent::SubagentMonitor { - event: SubagentMonitorEvent::ToolCall { - session_id, - tool_use_id: terminal_id.clone(), - summary: format!("Terminal {terminal_id}"), - }, - }); - if !delta.is_empty() { - events.push(WorkerEvent::SubagentMonitor { - event: SubagentMonitorEvent::ToolOutputDelta { - session_id, - tool_use_id: terminal_id, - delta, - }, - }); - } - } - } } } From c888ddf72d366e6caf989e97b45701b9ed3b8790 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 31 Jul 2026 13:35:52 +0800 Subject: [PATCH 3/4] fix: improve shell process cancellation --- crates/core/src/tools/shell_exec/pipe.rs | 116 +++++++++++++++++----- crates/core/src/tools/shell_exec/pty.rs | 17 +++- crates/core/src/tools/shell_exec/tests.rs | 93 +++++++++++++++-- 3 files changed, 188 insertions(+), 38 deletions(-) diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs index 0ec06faf..dc23193d 100644 --- a/crates/core/src/tools/shell_exec/pipe.rs +++ b/crates/core/src/tools/shell_exec/pipe.rs @@ -3,7 +3,9 @@ use std::process::Stdio; use serde_json::json; -use tokio::time::{Duration, timeout}; +use tokio::io::AsyncReadExt; +use tokio::process::Child; +use tokio::time::Duration; use tokio_util::sync::CancellationToken; use tracing::info; @@ -19,7 +21,9 @@ use super::truncate_output; /// /// Applies [`SandboxLaunchPlan::prepare_pipe`], waits for completion (or /// cancel/timeout), and formats pipe-specific tool output (merged streams, -/// Unix signal-aware sandbox errors). +/// Unix signal-aware sandbox errors). On cancel/timeout, already-written +/// stdout/stderr are drained and included in the error text (same shape as +/// the PTY path). pub(crate) async fn run_with_pipes( run: ResolvedShellRun, progress: Option, @@ -57,11 +61,16 @@ pub(crate) async fn run_with_pipes( .current_dir(&workdir) .kill_on_drop(true); + // Own process group so cancel/timeout can SIGKILL the shell *and* its + // descendants (e.g. `sleep`); otherwise pipes stay open until children exit. + #[cfg(unix)] + child.process_group(0); + if cfg!(windows) { child.env("PYTHONUTF8", "1"); } - let spawned = match child.spawn() { + let mut child = match child.spawn() { Ok(child) => child, Err(error) => { return Ok(FunctionToolOutput::error(format!( @@ -71,30 +80,54 @@ pub(crate) async fn run_with_pipes( }; plan.schedule_placeholder_cleanup(); - let result = tokio::select! { - result = timeout(Duration::from_millis(timeout_ms), spawned.wait_with_output()) => result, - _ = cancel_token.cancelled() => { - return Ok(FunctionToolOutput::error("command cancelled")); - } + let stdout_task = spawn_stream_reader(child.stdout.take(), progress.clone()); + let stderr_task = spawn_stream_reader(child.stderr.take(), progress); + + enum WaitOutcome { + Exited(std::process::ExitStatus), + Cancelled, + TimedOut, + WaitError(std::io::Error), + } + + let outcome = tokio::select! { + status = child.wait() => match status { + Ok(status) => WaitOutcome::Exited(status), + Err(error) => WaitOutcome::WaitError(error), + }, + _ = cancel_token.cancelled() => WaitOutcome::Cancelled, + _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => WaitOutcome::TimedOut, }; - match result { - Ok(Ok(output)) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + match &outcome { + WaitOutcome::Cancelled | WaitOutcome::TimedOut => { + kill_and_wait(&mut child).await; + } + WaitOutcome::Exited(_) | WaitOutcome::WaitError(_) => {} + } + + let stdout = String::from_utf8_lossy(&stdout_task.await.unwrap_or_default()).into_owned(); + let stderr = String::from_utf8_lossy(&stderr_task.await.unwrap_or_default()).into_owned(); + let result_text = truncate_output(&merge_streams(&stdout, &stderr), max_output_tokens); - let result_text = merge_streams(&stdout, &stderr); - if let Some(ref sender) = progress { - let _ = sender.send(result_text.clone()); - } - let result_text = truncate_output(&result_text, max_output_tokens); - if output.status.success() { + match outcome { + WaitOutcome::Cancelled => Ok(FunctionToolOutput::error(format!( + "command cancelled\n{result_text}" + ))), + WaitOutcome::TimedOut => Ok(FunctionToolOutput::error(format!( + "command timed out after {timeout_ms}ms\n{result_text}" + ))), + WaitOutcome::WaitError(error) => Ok(FunctionToolOutput::error(format!( + "failed to spawn process: {error}" + ))), + WaitOutcome::Exited(status) => { + if status.success() { Ok(FunctionToolOutput::success_with_metadata( result_text.clone(), json!({ "output": preview(&result_text), "command": command_preview, - "exit": output.status.code(), + "exit": status.code(), "description": description, "cwd": workdir, "yield_time_ms": yield_time_ms, @@ -104,13 +137,13 @@ pub(crate) async fn run_with_pipes( #[cfg(unix)] let unix_signal = { use std::os::unix::process::ExitStatusExt; - output.status.signal() + status.signal() }; #[cfg(not(unix))] let unix_signal: Option = None; let error_message = devo_sandbox::shell_error_message_with_signal( sandbox_profile.as_deref(), - output.status.code(), + status.code(), unix_signal, &stdout, &stderr, @@ -119,15 +152,44 @@ pub(crate) async fn run_with_pipes( Ok(FunctionToolOutput::error(error_message)) } } - Ok(Err(error)) => Ok(FunctionToolOutput::error(format!( - "failed to spawn process: {error}" - ))), - Err(_) => Ok(FunctionToolOutput::error(format!( - "command timed out after {timeout_ms}ms" - ))), } } +fn spawn_stream_reader( + pipe: Option, + progress: Option, +) -> tokio::task::JoinHandle> +where + R: AsyncReadExt + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let mut buffer = Vec::new(); + let Some(mut pipe) = pipe else { + return buffer; + }; + let mut chunk = [0u8; 8192]; + loop { + match pipe.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + if let Some(ref sender) = progress { + let _ = sender.send(String::from_utf8_lossy(&chunk[..n]).into_owned()); + } + buffer.extend_from_slice(&chunk[..n]); + } + Err(_) => break, + } + } + buffer + }) +} + +async fn kill_and_wait(child: &mut Child) { + let _ = devo_util_process::process_group::kill_child_process_group(child); + let _ = child.start_kill(); + let _ = child.wait().await; +} + pub(crate) fn merge_streams(stdout: &str, stderr: &str) -> String { let mut result = String::new(); if !stdout.is_empty() { diff --git a/crates/core/src/tools/shell_exec/pty.rs b/crates/core/src/tools/shell_exec/pty.rs index fc578dab..261a644c 100644 --- a/crates/core/src/tools/shell_exec/pty.rs +++ b/crates/core/src/tools/shell_exec/pty.rs @@ -39,7 +39,7 @@ impl PtyChildGuard { fn kill_and_wait(&mut self) { if let Some(child) = self.child.as_mut() { - let _ = child.kill(); + kill_pty_child(child); let _ = child.wait(); } } @@ -52,11 +52,24 @@ impl PtyChildGuard { impl Drop for PtyChildGuard { fn drop(&mut self) { if let Some(child) = self.child.as_mut() { - let _ = child.kill(); + kill_pty_child(child); } } } +/// Kill the PTY child and its process group. +/// +/// `portable-pty` on Unix already runs `setsid()` in the child, so the shell is +/// the session/process-group leader. A direct `Child::kill` only targets that +/// PID; descendants such as `sleep` keep the PTY slave open. Signal the whole +/// group first, then fall back to the direct kill. +fn kill_pty_child(child: &mut Box) { + if let Some(pid) = child.process_id() { + let _ = devo_util_process::process_group::kill_process_group_by_pid(pid); + } + let _ = child.kill(); +} + /// Run a command attached to a pseudo-terminal (PTY). /// /// Opens a PTY, applies [`SandboxLaunchPlan::prepare_pty`], reads master output diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index 46b958bf..3b87b815 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -65,13 +65,14 @@ async fn execute_shell_command_cancels_non_tty_process() { let cancel_token = CancellationToken::new(); let cancel_task_token = cancel_token.clone(); tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(50)).await; + tokio::time::sleep(Duration::from_millis(200)).await; cancel_task_token.cancel(); }); + let started = Instant::now(); let result = execute_shell_command( ShellExecRequest { - command: "sleep 5; echo should_not_print".to_string(), + command: "echo cancelled_output; sleep 5; echo should_not_print".to_string(), workdir: std::env::current_dir().unwrap_or_default(), description: "cancel test".into(), shell_override: None, @@ -88,8 +89,74 @@ async fn execute_shell_command_cancels_non_tty_process() { .await .expect("execute shell command"); + assert!( + started.elapsed() < Duration::from_secs(2), + "cancel should not wait for descendant sleep to finish" + ); assert!(result.is_error); - assert_eq!(result.content.into_string(), "command cancelled"); + let text = result.content.into_string(); + assert!( + text.starts_with("command cancelled"), + "expected cancel prefix, got {text:?}" + ); + assert!( + text.contains("cancelled_output"), + "expected retained stdout, got {text:?}" + ); + assert!( + !text.contains("should_not_print"), + "cancelled command should not reach later output, got {text:?}" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn execute_shell_command_cancels_tty_process() { + let cancel_token = CancellationToken::new(); + let cancel_task_token = cancel_token.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + cancel_task_token.cancel(); + }); + + let started = Instant::now(); + let result = execute_shell_command( + ShellExecRequest { + command: "echo cancelled_output; sleep 5; echo should_not_print".to_string(), + workdir: std::env::current_dir().unwrap_or_default(), + description: "pty cancel test".into(), + shell_override: Some("bash".to_string()), + tty: true, + login: false, + timeout_ms: 10_000, + yield_time_ms: 100, + max_output_tokens: 100, + sandbox_profile: None, + }, + None, + cancel_token, + ) + .await + .expect("execute shell command"); + + assert!( + started.elapsed() < Duration::from_secs(2), + "cancel should not wait for descendant sleep to finish" + ); + assert!(result.is_error); + let text = result.content.into_string(); + assert!( + text.starts_with("command cancelled"), + "expected cancel prefix, got {text:?}" + ); + assert!( + text.contains("cancelled_output"), + "expected retained output, got {text:?}" + ); + assert!( + !text.contains("should_not_print"), + "cancelled command should not reach later output, got {text:?}" + ); } #[cfg(unix)] @@ -206,15 +273,16 @@ use super::{SandboxLaunchPlan, platform_shell_program, preview, resolve_shell, t #[cfg(unix)] #[tokio::test] async fn execute_shell_command_pipe_times_out() { + let started = Instant::now(); let result = execute_shell_command( ShellExecRequest { - command: "sleep 5".to_string(), + command: "echo before_timeout; sleep 5".to_string(), workdir: std::env::current_dir().unwrap_or_default(), description: "timeout test".into(), shell_override: None, tty: false, login: false, - timeout_ms: 100, + timeout_ms: 200, yield_time_ms: 50, max_output_tokens: 100, sandbox_profile: None, @@ -225,12 +293,19 @@ async fn execute_shell_command_pipe_times_out() { .await .expect("execute shell command"); + assert!( + started.elapsed() < Duration::from_secs(2), + "timeout should not wait for descendant sleep to finish" + ); assert!(result.is_error); + let text = result.content.into_string(); + assert!( + text.contains("command timed out after 200ms"), + "expected timeout prefix, got {text:?}" + ); assert!( - result - .content - .into_string() - .contains("command timed out after 100ms") + text.contains("before_timeout"), + "expected retained stdout, got {text:?}" ); } From 74b6a45daef7ba9dc4d7568e8c766d150fe9a5dd Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 31 Jul 2026 18:44:18 +0800 Subject: [PATCH 4/4] docs: add MCP configuration guide. --- README.ja.md | 3 +- README.md | 3 +- README.ru.md | 3 +- README.zh-Hans.md | 1 + README.zh-Hant.md | 1 + docs/configuration.ja.md | 81 +++++++++++++++++++++++++++++++++ docs/configuration.md | 83 ++++++++++++++++++++++++++++++++++ docs/configuration.ru.md | 84 +++++++++++++++++++++++++++++++++++ docs/configuration.zh-Hans.md | 78 ++++++++++++++++++++++++++++++++ docs/configuration.zh-Hant.md | 78 ++++++++++++++++++++++++++++++++ 10 files changed, 412 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index 067dff84..624dc7b1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -61,7 +61,8 @@ Desktop 体験、terminal workflow、ランタイムの動作、ワークスペ Anthropic 互換、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 OpenRouter、またはローカルエンドポイントを利用できます。 - **MCP サポート** - [Model Context Protocol](https://modelcontextprotocol.io/) - サーバーを通じて外部ツールとコンテキストを接続できます。 + サーバーを通じて外部ツールとコンテキストを接続できます。設定方法は + [設定](./docs/configuration.ja.md#mcp-サーバー) を参照してください。 - **Skill サポート** - 再利用可能なワークフロー、手順、スクリプト、参照資料を [Agent Skills](https://agentskills.io/) としてパッケージ化できます。 - **長時間タスクのサポート** - 複数ターンにまたがる作業でも Devo が自動的にコンテキストを管理し、 diff --git a/README.md b/README.md index 5d6ca4b2..f4846285 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ runtime behavior, and workspace execution under your control. OpenAI-compatible, Anthropic-compatible, DeepSeek, Qwen, Kimi, GLM, MiniMax, Xiaomi MiMo, OpenRouter, or local endpoints. - **MCP support** - Connect external tools and context through - [Model Context Protocol](https://modelcontextprotocol.io/) servers. + [Model Context Protocol](https://modelcontextprotocol.io/) servers. See + [Configuration](./docs/configuration.md#mcp-servers) for setup. - **Skill support** - Package repeatable workflows, instructions, scripts, and references as reusable [Agent Skills](https://agentskills.io/). - **Long-running task support** - Let Devo manage context automatically across diff --git a/README.ru.md b/README.ru.md index e32bab36..55d28258 100644 --- a/README.ru.md +++ b/README.ru.md @@ -62,7 +62,8 @@ Devo предназначен для команд, которым нужен cod для OpenAI-совместимых, Anthropic-совместимых, DeepSeek, Qwen, Kimi, GLM, MiniMax, Xiaomi MiMo, OpenRouter или локальных endpoint. - **Поддержка MCP** - Подключайте внешние инструменты и контекст через серверы - [Model Context Protocol](https://modelcontextprotocol.io/). + [Model Context Protocol](https://modelcontextprotocol.io/). Настройка описана в + [Конфигурации](./docs/configuration.ru.md#mcp-серверы). - **Поддержка Skill** - Упаковывайте повторяемые workflow, инструкции, скрипты и справочные материалы как переиспользуемые [Agent Skills](https://agentskills.io/). diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 2a0abd43..82c4872a 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -57,6 +57,7 @@ Desktop 体验、终端工作流以及工作区执行边界的团队。 OpenRouter 或本地端点。 - **MCP 支持** - 通过 [Model Context Protocol](https://modelcontextprotocol.io/) 服务器连接外部工具和上下文。 + 配置方式见 [配置](./docs/configuration.zh-Hans.md#mcp-服务器)。 - **Skill 支持** - 将可复用工作流、说明、脚本和参考资料打包成可复用的 [Agent Skills](https://agentskills.io/)。 - **长任务支持** - 让 Devo 在多轮工作中自动管理上下文,避免任务变长后丢失上下文。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 40cb3d19..679acd45 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -57,6 +57,7 @@ Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。 OpenRouter 或本地端點。 - **MCP 支援** - 透過 [Model Context Protocol](https://modelcontextprotocol.io/) 伺服器連接外部工具和上下文。 + 配置方式見 [配置](./docs/configuration.zh-Hant.md#mcp-伺服器)。 - **Skill 支援** - 將可重複工作流程、說明、腳本和參考資料打包成可重用的 [Agent Skills](https://agentskills.io/)。 - **長時間任務支援** - 讓 Devo 在多輪工作中自動管理上下文,避免任務變長後丟失脈絡。 diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index 45d70cc1..55b3fd88 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -206,3 +206,84 @@ collapse_reasoning = true `[model.]` へ手動でコピーし、対応する provider と model binding を追加または 保持してください。API key は `auth.json` に置き、`[providers.].credential` から参照します。 + +## MCP サーバー + +Devo は、ユーザーまたは workspace の `config.toml` の `[mcp]` で設定した +[Model Context Protocol](https://modelcontextprotocol.io/) サーバーに接続します。 +各サーバーは `servers` 配列の 1 エントリで、`transport` テーブルが接続方式を +決めます。対応トランスポートは `stdio`、`streamable_http`、非推奨の `sse` です。 + +stdio の例: + +```toml +[mcp] +auto_start = true +refresh_on_config_reload = true + +[[mcp.servers]] +id = "filesystem" +display_name = "Filesystem" +enabled = true +startup_policy = "lazy" # eager | lazy | manual +trust_policy = "user" # user | workspace | untrusted +allowed_capabilities = ["tools", "resources", "prompts"] +roots_policy = "workspace" # none | workspace | custom + +[mcp.servers.transport] +kind = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] +# cwd = "/path/to/workdir" +# env = { MY_VAR = "value" } +# env_vars = ["HOME", "PATH"] +``` + +bearer token を使う Streamable HTTP: + +```toml +[[mcp.servers]] +id = "github" +display_name = "GitHub" +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "streamable_http" +url = "https://api.githubcopilot.com/mcp/" +auth = { kind = "bearer_token", token = "replace-me" } +http_headers = { "X-Custom" = "static-value" } +env_http_headers = { "Authorization" = "GITHUB_TOKEN" } +``` + +レガシー SSE トランスポート: + +```toml +[mcp.servers.transport] +kind = "sse" +url = "https://example.com/mcp/sse" +``` + +フィールドの説明: + +- `auto_start` と `refresh_on_config_reload` は既定で `true` です。 +- `startup_policy` は有効なサーバーの起動タイミングを制御します: `eager` は + ブートストラップ時、`lazy` は初回利用時、`manual` は明示的な要求のみです。 +- stdio では `env` がリテラル値を渡し、`env_vars` がローカル環境から継承する + 変数名のリストです。stdio では `{ name = "X", source = "remote" }` は + サポートされません。 +- HTTP トランスポートでは、`http_headers` がリテラルヘッダーを渡し、 + `env_http_headers` はヘッダー名を、値を供給する環境変数名に対応付けます。 +- `allowed_capabilities` が空の場合は制限なしです。現時点のランタイムは主に + `tools` を扱い、リソース読み取りはまだ接続されていません。 +- `output_limits` は `max_tool_output_bytes`(既定 1 MiB)と + `max_resource_bytes`(既定 10 MiB)を設定します。 +- トップレベルの `mcp_oauth_credentials_store` は `auto`(既定)、`file`、 + `keyring` のいずれかで、OAuth 認証情報の保存先を選びます。 +- token を `config.toml` に直接書くより、環境変数からヘッダーや値を注入する + ことを推奨します。`auth_ref` は各サーバーレコードに存在しますが、ランタイム + にはまだ接続されていません。 + +マージ動作: `[mcp]` は他のテーブルと同じくフィールド単位でマージされますが、 +`servers` は配列です。したがって、workspace の `[[mcp.servers]]` リストは +ユーザーレベルのリストを `id` 単位でマージせず置き換えます。 + +TUI の `/mcp list` で設定を確認できます。 diff --git a/docs/configuration.md b/docs/configuration.md index a7f13c6c..1a2880c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -215,3 +215,86 @@ Manually copy the fields you still want into `[model.]` sections in the user or workspace `config.toml`, then add or retain the matching provider and model binding. Keep API keys in `auth.json`; refer to them from `[providers.].credential`. + +## MCP Servers + +Devo connects to [Model Context Protocol](https://modelcontextprotocol.io/) +servers configured in user or workspace `config.toml` under `[mcp]`. Each server +is one entry in the `servers` array, and its `transport` table selects how Devo +connects. Supported transports are `stdio`, `streamable_http`, and the deprecated +`sse`. + +Stdio example: + +```toml +[mcp] +auto_start = true +refresh_on_config_reload = true + +[[mcp.servers]] +id = "filesystem" +display_name = "Filesystem" +enabled = true +startup_policy = "lazy" # eager | lazy | manual +trust_policy = "user" # user | workspace | untrusted +allowed_capabilities = ["tools", "resources", "prompts"] +roots_policy = "workspace" # none | workspace | custom + +[mcp.servers.transport] +kind = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] +# cwd = "/path/to/workdir" +# env = { MY_VAR = "value" } +# env_vars = ["HOME", "PATH"] +``` + +Streamable HTTP with a bearer token: + +```toml +[[mcp.servers]] +id = "github" +display_name = "GitHub" +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "streamable_http" +url = "https://api.githubcopilot.com/mcp/" +auth = { kind = "bearer_token", token = "replace-me" } +http_headers = { "X-Custom" = "static-value" } +env_http_headers = { "Authorization" = "GITHUB_TOKEN" } +``` + +Legacy SSE transport: + +```toml +[mcp.servers.transport] +kind = "sse" +url = "https://example.com/mcp/sse" +``` + +Field notes: + +- `auto_start` and `refresh_on_config_reload` default to `true`. +- `startup_policy` controls when an enabled server starts: `eager` during + bootstrap, `lazy` on first use, or `manual` only by explicit request. +- For stdio, `env` provides literal values and `env_vars` lists names inherited + from the local environment; `{ name = "X", source = "remote" }` is not + supported for stdio. +- For HTTP transports, `http_headers` provides literal headers and + `env_http_headers` maps a header name to the environment variable that + supplies its value. +- Empty `allowed_capabilities` means no restriction. The runtime currently + focuses on `tools`; resource reads are not wired yet. +- `output_limits` sets `max_tool_output_bytes` (default 1 MiB) and + `max_resource_bytes` (default 10 MiB). +- Top-level `mcp_oauth_credentials_store` is `auto` (default), `file`, or + `keyring` and selects where OAuth credentials are stored. +- Prefer environment-injected headers or values over hard-coding tokens into + `config.toml`. `auth_ref` exists on each server record but is not wired to the + runtime yet. + +Merge behavior: `[mcp]` is merged field-wise like other tables, but `servers` is +an array. A project-level `[[mcp.servers]]` list therefore replaces the +user-level list instead of merging by `id`. + +Verify the configuration in the TUI with `/mcp list`. diff --git a/docs/configuration.ru.md b/docs/configuration.ru.md index 0cd7fb6f..b821cb67 100644 --- a/docs/configuration.ru.md +++ b/docs/configuration.ru.md @@ -213,3 +213,87 @@ collapse_reasoning = true workspace `config.toml`, затем добавьте или сохраните соответствующие provider и binding. API key храните в `auth.json` и ссылайтесь на него через `[providers.].credential`. + +## MCP-серверы + +Devo подключается к серверам [Model Context Protocol](https://modelcontextprotocol.io/), +настроенным в пользовательском или workspace `config.toml` в разделе `[mcp]`. +Каждый сервер - это одна запись в массиве `servers`, а его таблица `transport` +определяет способ подключения. Поддерживаются транспорты `stdio`, +`streamable_http` и устаревший `sse`. + +Пример stdio: + +```toml +[mcp] +auto_start = true +refresh_on_config_reload = true + +[[mcp.servers]] +id = "filesystem" +display_name = "Filesystem" +enabled = true +startup_policy = "lazy" # eager | lazy | manual +trust_policy = "user" # user | workspace | untrusted +allowed_capabilities = ["tools", "resources", "prompts"] +roots_policy = "workspace" # none | workspace | custom + +[mcp.servers.transport] +kind = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] +# cwd = "/path/to/workdir" +# env = { MY_VAR = "value" } +# env_vars = ["HOME", "PATH"] +``` + +Streamable HTTP с bearer token: + +```toml +[[mcp.servers]] +id = "github" +display_name = "GitHub" +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "streamable_http" +url = "https://api.githubcopilot.com/mcp/" +auth = { kind = "bearer_token", token = "replace-me" } +http_headers = { "X-Custom" = "static-value" } +env_http_headers = { "Authorization" = "GITHUB_TOKEN" } +``` + +Устаревший SSE-транспорт: + +```toml +[mcp.servers.transport] +kind = "sse" +url = "https://example.com/mcp/sse" +``` + +Примечания к полям: + +- `auto_start` и `refresh_on_config_reload` по умолчанию равны `true`. +- `startup_policy` управляет запуском включенного сервера: `eager` - при + bootstrap, `lazy` - при первом использовании, `manual` - только по явному + запросу. +- Для stdio `env` задает литеральные значения, а `env_vars` - список имен + переменных, наследуемых из локального окружения; `{ name = "X", + source = "remote" }` для stdio не поддерживается. +- Для HTTP-транспортов `http_headers` задает литеральные заголовки, а + `env_http_headers` сопоставляет имя заголовка с переменной окружения, + поставляющей его значение. +- Пустой `allowed_capabilities` означает отсутствие ограничений. Сейчас рантайм + в основном работает с `tools`; чтение resources еще не подключено. +- `output_limits` задает `max_tool_output_bytes` (по умолчанию 1 MiB) и + `max_resource_bytes` (по умолчанию 10 MiB). +- Верхнеуровневый `mcp_oauth_credentials_store` принимает `auto` (по умолчанию), + `file` или `keyring` и выбирает место хранения OAuth-credentials. +- Предпочитайте заголовки или значения из переменных окружения, а не жестко + зашитые token в `config.toml`. Поле `auth_ref` есть в каждой записи сервера, + но пока не подключено к рантайму. + +Поведение при слиянии: `[mcp]` сливается по полям, как другие таблицы, но +`servers` - это массив. Поэтому список `[[mcp.servers]]` уровня проекта заменяет +пользовательский список целиком, а не сливает по `id`. + +Проверить конфигурацию можно в TUI командой `/mcp list`. diff --git a/docs/configuration.zh-Hans.md b/docs/configuration.zh-Hans.md index bf4d923c..29ed89a6 100644 --- a/docs/configuration.zh-Hans.md +++ b/docs/configuration.zh-Hans.md @@ -197,3 +197,81 @@ collapse_reasoning = true 请手动把仍需使用的字段复制到用户或工作区 `config.toml` 的 `[model.]` 段,并添加或保留对应 provider 和 model binding。API key 继续放在 `auth.json`, 通过 `[providers.].credential` 引用。 + +## MCP 服务器 + +Devo 通过用户或工作区 `config.toml` 中的 `[mcp]` 配置 +[Model Context Protocol](https://modelcontextprotocol.io/) 服务器。每个服务器是 +`servers` 数组中的一项,其 `transport` 表决定 Devo 的连接方式。支持的传输方式有 +`stdio`、`streamable_http` 和已弃用的 `sse`。 + +stdio 示例: + +```toml +[mcp] +auto_start = true +refresh_on_config_reload = true + +[[mcp.servers]] +id = "filesystem" +display_name = "Filesystem" +enabled = true +startup_policy = "lazy" # eager | lazy | manual +trust_policy = "user" # user | workspace | untrusted +allowed_capabilities = ["tools", "resources", "prompts"] +roots_policy = "workspace" # none | workspace | custom + +[mcp.servers.transport] +kind = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] +# cwd = "/path/to/workdir" +# env = { MY_VAR = "value" } +# env_vars = ["HOME", "PATH"] +``` + +带 bearer token 的 Streamable HTTP: + +```toml +[[mcp.servers]] +id = "github" +display_name = "GitHub" +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "streamable_http" +url = "https://api.githubcopilot.com/mcp/" +auth = { kind = "bearer_token", token = "replace-me" } +http_headers = { "X-Custom" = "static-value" } +env_http_headers = { "Authorization" = "GITHUB_TOKEN" } +``` + +旧版 SSE 传输: + +```toml +[mcp.servers.transport] +kind = "sse" +url = "https://example.com/mcp/sse" +``` + +字段说明: + +- `auto_start` 与 `refresh_on_config_reload` 默认均为 `true`。 +- `startup_policy` 控制已启用服务器的启动时机:`eager` 在启动阶段启动,`lazy` + 首次使用时启动,`manual` 仅按显式请求启动。 +- stdio 下,`env` 提供字面量值,`env_vars` 列出从本地环境继承的变量名; + stdio 不支持 `{ name = "X", source = "remote" }`。 +- HTTP 传输下,`http_headers` 提供字面量 header,`env_http_headers` 将 header + 名映射到提供其值的环境变量名。 +- `allowed_capabilities` 为空表示不限制。当前运行时主要接入 `tools`,资源读取 + 尚未接线。 +- `output_limits` 设置 `max_tool_output_bytes`(默认 1 MiB)与 + `max_resource_bytes`(默认 10 MiB)。 +- 顶层 `mcp_oauth_credentials_store` 取值为 `auto`(默认)、`file` 或 + `keyring`,选择 OAuth 凭据的存储位置。 +- 尽量用环境变量注入 header 或值,避免把 token 硬编码进 `config.toml`。 + `auth_ref` 字段已存在于每个服务器记录中,但尚未接入运行时。 + +合并行为:`[mcp]` 与其他表一样按字段合并,但 `servers` 是数组。项目级的 +`[[mcp.servers]]` 列表会整体替换用户级列表,而不是按 `id` 合并。 + +可在 TUI 中用 `/mcp list` 验证配置。 diff --git a/docs/configuration.zh-Hant.md b/docs/configuration.zh-Hant.md index 9c9c4f27..29e19f5f 100644 --- a/docs/configuration.zh-Hant.md +++ b/docs/configuration.zh-Hant.md @@ -197,3 +197,81 @@ collapse_reasoning = true 請手動把仍需使用的欄位複製到使用者或工作區 `config.toml` 的 `[model.]` 段,並新增或保留對應 provider 和 model binding。API key 繼續放在 `auth.json`, 透過 `[providers.].credential` 引用。 + +## MCP 伺服器 + +Devo 透過使用者或工作區 `config.toml` 中的 `[mcp]` 設定 +[Model Context Protocol](https://modelcontextprotocol.io/) 伺服器。每個伺服器是 +`servers` 陣列中的一項,其 `transport` 表決定 Devo 的連線方式。支援的傳輸方式有 +`stdio`、`streamable_http` 和已棄用的 `sse`。 + +stdio 範例: + +```toml +[mcp] +auto_start = true +refresh_on_config_reload = true + +[[mcp.servers]] +id = "filesystem" +display_name = "Filesystem" +enabled = true +startup_policy = "lazy" # eager | lazy | manual +trust_policy = "user" # user | workspace | untrusted +allowed_capabilities = ["tools", "resources", "prompts"] +roots_policy = "workspace" # none | workspace | custom + +[mcp.servers.transport] +kind = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] +# cwd = "/path/to/workdir" +# env = { MY_VAR = "value" } +# env_vars = ["HOME", "PATH"] +``` + +帶 bearer token 的 Streamable HTTP: + +```toml +[[mcp.servers]] +id = "github" +display_name = "GitHub" +startup_policy = "lazy" + +[mcp.servers.transport] +kind = "streamable_http" +url = "https://api.githubcopilot.com/mcp/" +auth = { kind = "bearer_token", token = "replace-me" } +http_headers = { "X-Custom" = "static-value" } +env_http_headers = { "Authorization" = "GITHUB_TOKEN" } +``` + +舊版 SSE 傳輸: + +```toml +[mcp.servers.transport] +kind = "sse" +url = "https://example.com/mcp/sse" +``` + +欄位說明: + +- `auto_start` 與 `refresh_on_config_reload` 預設均為 `true`。 +- `startup_policy` 控制已啟用伺服器的啟動時機:`eager` 在啟動階段啟動,`lazy` + 首次使用時啟動,`manual` 僅依明確請求啟動。 +- stdio 下,`env` 提供字面值,`env_vars` 列出從本機環境繼承的變數名稱; + stdio 不支援 `{ name = "X", source = "remote" }`。 +- HTTP 傳輸下,`http_headers` 提供字面 header,`env_http_headers` 將 header + 名稱對應到提供其值的環境變數名稱。 +- `allowed_capabilities` 為空表示不限制。目前執行階段主要接入 `tools`,資源讀取 + 尚未接線。 +- `output_limits` 設定 `max_tool_output_bytes`(預設 1 MiB)與 + `max_resource_bytes`(預設 10 MiB)。 +- 頂層 `mcp_oauth_credentials_store` 取值為 `auto`(預設)、`file` 或 + `keyring`,選擇 OAuth 憑據的儲存位置。 +- 盡量用環境變數注入 header 或值,避免把 token 硬編碼進 `config.toml`。 + `auth_ref` 欄位已存在於每個伺服器記錄中,但尚未接入執行階段。 + +合併行為:`[mcp]` 與其他表一樣依欄位合併,但 `servers` 是陣列。專案級的 +`[[mcp.servers]]` 列表會整體取代使用者級列表,而不是依 `id` 合併。 + +可在 TUI 中用 `/mcp list` 驗證配置。