From 97f150db60527bc7ba7e2a672b098a1c862081c7 Mon Sep 17 00:00:00 2001 From: Evan Feenstra Date: Mon, 13 Jul 2026 14:52:24 -0600 Subject: [PATCH] apply the default extension timeout to developer shell tool calls also (#10348) Signed-off-by: Evanfeenstra --- .../platform_extensions/developer/shell.rs | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index c7bbfddd9..fdc48ab27 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -171,6 +171,8 @@ struct TruncationInfo { #[derive(Debug, Deserialize, JsonSchema)] pub struct ShellParams { pub command: String, + /// Maximum time in seconds to allow the command to run before it is killed. + /// If omitted, defaults to DEFAULT_EXTENSION_TIMEOUT. #[serde(default)] pub timeout_secs: Option, } @@ -437,14 +439,10 @@ impl ShellTool { .collect(); let is_error = if execution.timed_out { - if let Some(timeout_secs) = params.timeout_secs { - rendered.push_str(&format!( - "\n\nCommand timed out after {} seconds", - timeout_secs - )); - } else { - rendered.push_str("\n\nCommand timed out"); - } + rendered.push_str(&format!( + "\n\nCommand timed out after {} seconds", + resolve_shell_timeout(params.timeout_secs) + )); true } else { execution.exit_code.unwrap_or(1) != 0 @@ -509,6 +507,14 @@ struct ExecutionOutput { output_collection_error: Option, } +fn resolve_shell_timeout(timeout_secs: Option) -> u64 { + timeout_secs.unwrap_or_else(|| { + crate::config::Config::global() + .get_goose_default_extension_timeout() + .unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT) + }) +} + async fn run_command( command_line: &str, timeout_secs: Option, @@ -516,6 +522,8 @@ async fn run_command( login_path: Option<&str>, cancellation_token: CancellationToken, ) -> Result { + let timeout_secs = Some(resolve_shell_timeout(timeout_secs)); + let mut command = build_shell_command(command_line, working_dir, login_path); command.stdout(Stdio::piped()); @@ -1104,4 +1112,42 @@ mod tests { "should capture output after background cmd" ); } + + #[test] + fn resolve_shell_timeout_prefers_explicit_value() { + assert_eq!(resolve_shell_timeout(Some(42)), 42); + } + + #[test] + fn resolve_shell_timeout_falls_back_to_a_bound_when_absent() { + // The key behavioral guarantee: an omitted timeout no longer means + // "run forever" — it resolves to the default extension timeout. + assert!(resolve_shell_timeout(None) > 0); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn shell_kills_hanging_command_after_explicit_timeout() { + let tool = ShellTool::new_for_test().unwrap(); + let start = std::time::Instant::now(); + let result = tool + .shell(ShellParams { + command: "sleep 30".to_string(), + timeout_secs: Some(1), + }) + .await; + + assert!( + start.elapsed().as_secs() < 10, + "shell should return shortly after the timeout, not wait for the command" + ); + assert_eq!(result.is_error, Some(true)); + let shell_output = extract_shell_output(&result); + assert!(shell_output.timed_out, "command should be marked timed_out"); + assert!( + shell_output.exit_code.is_none(), + "killed process should have no exit code" + ); + assert!(extract_text(&result).contains("Command timed out after 1 seconds")); + } }