vtcode_core/tools/handlers/
shell_handler.rs1use hashbrown::HashMap;
7use std::path::Path;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::Deserialize;
12use serde_json::json;
13
14use super::sandboxing::{Sandboxable, SandboxablePreference};
15use super::tool_handler::{
16 ShellToolCallParams, ToolCallError, ToolHandler, ToolInvocation, ToolKind, ToolOutput,
17 ToolPayload,
18};
19use crate::config::constants::tools;
20use crate::tools::shell::{ShellOutput as CoreShellOutput, ShellRunner};
21
22const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000;
24
25const MAX_SHELL_TIMEOUT_MS: u64 = 300_000;
27
28pub struct ShellHandler {
30 pub default_shell: String,
32 pub inherit_env: bool,
34}
35
36impl Default for ShellHandler {
37 fn default() -> Self {
38 Self {
39 default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
40 inherit_env: true,
41 }
42 }
43}
44
45impl ShellHandler {
46 pub fn new() -> Self {
47 Self::default()
48 }
49
50 pub fn with_shell(shell: impl Into<String>) -> Self {
51 Self {
52 default_shell: shell.into(),
53 inherit_env: true,
54 }
55 }
56
57 fn parse_params(
59 &self,
60 invocation: &ToolInvocation,
61 ) -> Result<ShellToolCallParams, ToolCallError> {
62 match &invocation.payload {
63 ToolPayload::Function { arguments } => {
64 #[derive(Deserialize)]
66 struct SimpleShellArgs {
67 command: String,
68 workdir: Option<String>,
69 timeout_ms: Option<u64>,
70 }
71 let simple: SimpleShellArgs = serde_json::from_str(arguments)
72 .map_err(|e| ToolCallError::respond(format!("Invalid shell arguments: {e}")))?;
73 Ok(ShellToolCallParams {
74 command: vec![simple.command],
75 workdir: simple.workdir,
76 timeout_ms: simple.timeout_ms,
77 sandbox_permissions: None,
78 justification: None,
79 })
80 }
81 ToolPayload::LocalShell { params } => Ok(params.clone()),
82 _ => Err(ToolCallError::respond(
83 "Invalid payload type for shell handler",
84 )),
85 }
86 }
87
88 async fn execute_command(
90 &self,
91 params: &ShellToolCallParams,
92 cwd: &Path,
93 _env: Option<HashMap<String, String>>,
94 ) -> Result<CoreShellOutput, ToolCallError> {
95 let runner = ShellRunner::new(cwd.to_path_buf());
96 let command = params.command.join(" ");
97
98 let timeout_ms = params
99 .timeout_ms
100 .unwrap_or(DEFAULT_SHELL_TIMEOUT_MS)
101 .min(MAX_SHELL_TIMEOUT_MS);
102
103 let result = tokio::time::timeout(Duration::from_millis(timeout_ms), runner.exec(&command))
105 .await
106 .map_err(|_| ToolCallError::Timeout(timeout_ms))?
107 .map_err(ToolCallError::Internal)?;
108
109 Ok(result)
110 }
111}
112
113impl Sandboxable for ShellHandler {
114 fn sandbox_preference(&self) -> SandboxablePreference {
115 SandboxablePreference::Require
116 }
117
118 fn escalate_on_failure(&self) -> bool {
119 true }
121}
122
123#[async_trait]
124impl ToolHandler for ShellHandler {
125 fn kind(&self) -> ToolKind {
126 ToolKind::Function
127 }
128
129 fn matches_kind(&self, payload: &ToolPayload) -> bool {
130 matches!(
131 payload,
132 ToolPayload::Function { .. } | ToolPayload::LocalShell { .. }
133 )
134 }
135
136 async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
137 true
139 }
140
141 async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
142 let params = self.parse_params(&invocation)?;
143 let output = self
144 .execute_command(¶ms, &invocation.turn.cwd, None)
145 .await?;
146
147 let sanitized = output.sanitize_secrets();
149
150 let mut content_text = String::new();
152 if !sanitized.stdout.is_empty() {
153 content_text.push_str(&sanitized.stdout);
154 }
155 if !sanitized.stderr.is_empty() {
156 if !content_text.is_empty() {
157 content_text.push('\n');
158 }
159 content_text.push_str("[stderr]\n");
160 content_text.push_str(&sanitized.stderr);
161 }
162 if sanitized.exit_code != 0 {
163 if !content_text.is_empty() {
164 content_text.push('\n');
165 }
166 content_text.push_str(&format!("[exit code: {}]", sanitized.exit_code));
167 }
168
169 if content_text.is_empty() {
170 content_text = "(no output)".to_string();
171 }
172
173 Ok(ToolOutput::with_success(
174 content_text,
175 sanitized.exit_code == 0,
176 ))
177 }
178}
179
180pub fn create_shell_tool() -> super::tool_handler::ToolSpec {
182 use super::tool_handler::{ResponsesApiTool, ToolSpec};
183
184 ToolSpec::Function(ResponsesApiTool {
185 name: tools::SHELL.to_string(),
186 description: "Execute a shell command and return its output.".to_string(),
187 parameters: json!({
188 "type": "object",
189 "properties": {
190 "command": {
191 "type": "string",
192 "description": "The shell command to execute"
193 },
194 "workdir": {
195 "type": "string",
196 "description": "Working directory for the command (optional)"
197 },
198 "timeout_ms": {
199 "type": "number",
200 "description": "Timeout in milliseconds (default: 30000, max: 300000)"
201 }
202 },
203 "required": ["command"],
204 "additionalProperties": false
205 }),
206 strict: false,
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn shell_handler_keeps_sandbox_retry_enabled() {
216 assert!(ShellHandler::new().escalate_on_failure());
217 }
218
219 #[tokio::test]
220 async fn test_shell_handler_echo() {
221 let handler = ShellHandler::new();
222
223 assert_eq!(handler.kind(), ToolKind::Function);
225 }
226
227 #[test]
228 fn test_shell_handler_matches_kind() {
229 let handler = ShellHandler::new();
230
231 assert!(handler.matches_kind(&ToolPayload::Function {
232 arguments: "{}".to_string()
233 }));
234
235 assert!(handler.matches_kind(&ToolPayload::LocalShell {
236 params: ShellToolCallParams {
237 command: vec!["echo".to_string(), "hello".to_string()],
238 workdir: None,
239 timeout_ms: None,
240 sandbox_permissions: None,
241 justification: None,
242 }
243 }));
244 }
245
246 #[tokio::test]
247 async fn test_shell_handler_is_mutating() {
248 }
250
251 #[test]
252 fn test_create_shell_tool_spec() {
253 let spec = create_shell_tool();
254
255 assert_eq!(spec.name(), "shell");
256 }
257}