Skip to main content

vtcode_core/tools/
shell.rs

1//! High-level shell command runner and workspace utilities
2//!
3//! Provides workspace-safe shell operations (cd, ls, pwd, etc.) and
4//! handles platform-specific shell detection.
5//!
6//! ## Shell Snapshots
7//!
8//! This module integrates with the shell snapshot system to avoid re-running
9//! login scripts for every command. When a snapshot is available, commands
10//! can be executed with the cached environment, significantly improving
11//! startup time.
12
13use anyhow::{Context, Result, bail};
14use serde_json::{Value, json};
15use std::path::{Path, PathBuf};
16use std::process::Stdio;
17use tokio::process::Command;
18use tracing::debug;
19
20use crate::telemetry::perf;
21use crate::tools::command_cache::{InFlightState, cache_output, enter_inflight, finish_inflight, get_cached_output};
22use crate::utils::path::{canonicalize_workspace, ensure_path_within_workspace};
23use crate::utils::validation::validate_path_exists;
24
25use super::shell_snapshot::{ShellSnapshot, global_snapshot_manager};
26
27/// Represents a workspace-safe shell runner
28pub struct ShellRunner<E: CommandExecutor = SystemExecutor> {
29    workspace_root: PathBuf,
30    working_dir: PathBuf,
31    executor: E,
32}
33
34/// Trait for command execution strategies
35#[async_trait::async_trait]
36pub trait CommandExecutor: Send + Sync {
37    /// Execute a command in the given directory
38    async fn execute(&self, command: &str, cwd: &Path) -> Result<ShellOutput>;
39}
40
41/// Standard system command executor.
42///
43/// Every launch clears the inherited environment and rebuilds it from an explicit
44/// map, so callers that already resolved the shared sandbox allowlist
45/// ([`crate::sandboxing::build_sanitized_env`]) hand it to the child instead of
46/// letting the shell inherit the full process environment.
47pub struct SystemExecutor {
48    shell: String,
49    env: hashbrown::HashMap<String, String>,
50}
51
52impl SystemExecutor {
53    /// Create an executor that launches commands with an already-sanitized environment.
54    pub fn with_env(env: hashbrown::HashMap<String, String>) -> Self {
55        Self { shell: resolve_fallback_shell(), env }
56    }
57}
58
59impl Default for SystemExecutor {
60    fn default() -> Self {
61        Self::with_env(crate::sandboxing::build_sanitized_env(
62            &std::env::vars().collect(),
63            true,
64            false,
65            "shell-runner",
66            &[],
67        ))
68    }
69}
70
71#[async_trait::async_trait]
72impl CommandExecutor for SystemExecutor {
73    async fn execute(&self, command_str: &str, cwd: &Path) -> Result<ShellOutput> {
74        let mut tags = hashbrown::HashMap::new();
75        tags.insert("subsystem".to_string(), "shell".to_string());
76        tags.insert("program".to_string(), self.shell.clone());
77        perf::record_value("vtcode.perf.spawn_count", 1.0, tags);
78
79        let mut cmd = Command::new(&self.shell);
80        cmd.arg("-c")
81            .arg(command_str)
82            .current_dir(cwd)
83            .stdout(Stdio::piped())
84            .stderr(Stdio::piped());
85        cmd.env_clear().envs(&self.env);
86
87        let output = cmd.output().await?;
88
89        Ok(ShellOutput {
90            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
91            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
92            exit_code: output.status.code().unwrap_or(-1),
93        })
94    }
95}
96
97/// Executor that only logs commands without executing them
98pub struct DryRunExecutor {
99    pub log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
100}
101
102impl Default for DryRunExecutor {
103    fn default() -> Self {
104        Self {
105            log: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
106        }
107    }
108}
109
110#[async_trait::async_trait]
111impl CommandExecutor for DryRunExecutor {
112    async fn execute(&self, command: &str, _cwd: &Path) -> Result<ShellOutput> {
113        let mut log = self
114            .log
115            .lock()
116            .map_err(|e| anyhow::anyhow!("DryRunExecutor log lock poisoned: {e}"))
117            .context("Failed to record dry-run command")?;
118        log.push(command.to_string());
119        Ok(ShellOutput {
120            stdout: format!("(dry-run) {command}"),
121            stderr: String::new(),
122            exit_code: 0,
123        })
124    }
125}
126
127/// Executor that uses shell snapshots for faster command execution.
128///
129/// This executor captures the shell environment once (after login scripts run)
130/// and reuses it for subsequent commands, avoiding the overhead of re-running
131/// login scripts for every command.
132pub struct SnapshotExecutor {
133    shell: String,
134    snapshot: Option<std::sync::Arc<ShellSnapshot>>,
135}
136
137impl SnapshotExecutor {
138    /// Create a new snapshot executor.
139    ///
140    /// The snapshot will be lazily captured on first command execution.
141    pub fn new() -> Self {
142        Self { shell: resolve_fallback_shell(), snapshot: None }
143    }
144
145    /// Create a snapshot executor with a pre-captured snapshot.
146    pub fn with_snapshot(snapshot: std::sync::Arc<ShellSnapshot>) -> Self {
147        Self {
148            shell: snapshot.shell_path.clone(),
149            snapshot: Some(snapshot),
150        }
151    }
152
153    /// Get or capture a shell snapshot.
154    async fn get_snapshot(&self) -> Result<std::sync::Arc<ShellSnapshot>> {
155        if let Some(ref snap) = self.snapshot {
156            return Ok(std::sync::Arc::clone(snap));
157        }
158        global_snapshot_manager().get_or_capture().await
159    }
160}
161
162impl Default for SnapshotExecutor {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[async_trait::async_trait]
169impl CommandExecutor for SnapshotExecutor {
170    async fn execute(&self, command_str: &str, cwd: &Path) -> Result<ShellOutput> {
171        let snapshot = self.get_snapshot().await?;
172
173        let mut tags = hashbrown::HashMap::new();
174        tags.insert("subsystem".to_string(), "shell_snapshot".to_string());
175        tags.insert("program".to_string(), self.shell.clone());
176        perf::record_value("vtcode.perf.spawn_count", 1.0, tags);
177
178        let mut cmd = Command::new(&self.shell);
179        cmd.arg("-c")
180            .arg(command_str)
181            .current_dir(cwd)
182            .stdout(Stdio::piped())
183            .stderr(Stdio::piped());
184
185        cmd.env_clear();
186        for (key, value) in &snapshot.env {
187            cmd.env(key, value);
188        }
189
190        debug!(
191            shell = %self.shell,
192            env_vars = snapshot.env.len(),
193            "Executing command with snapshot environment"
194        );
195
196        let output = cmd.output().await?;
197
198        Ok(ShellOutput {
199            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
200            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
201            exit_code: output.status.code().unwrap_or(-1),
202        })
203    }
204}
205
206impl ShellRunner<SystemExecutor> {
207    /// Create a new system shell runner anchored to the workspace root
208    pub fn new(workspace_root: PathBuf) -> Self {
209        let canonical_root = canonicalize_workspace(&workspace_root);
210        Self {
211            workspace_root: canonical_root.clone(),
212            working_dir: canonical_root,
213            executor: SystemExecutor::default(),
214        }
215    }
216}
217
218impl ShellRunner<SnapshotExecutor> {
219    /// Create a new shell runner that uses environment snapshots.
220    ///
221    /// This avoids re-running login scripts for every command by capturing
222    /// the shell environment once and reusing it.
223    pub fn with_snapshot(workspace_root: PathBuf) -> Self {
224        let canonical_root = canonicalize_workspace(&workspace_root);
225        Self {
226            workspace_root: canonical_root.clone(),
227            working_dir: canonical_root,
228            executor: SnapshotExecutor::new(),
229        }
230    }
231
232    /// Create a new shell runner with a pre-captured snapshot.
233    pub fn with_existing_snapshot(workspace_root: PathBuf, snapshot: std::sync::Arc<ShellSnapshot>) -> Self {
234        let canonical_root = canonicalize_workspace(&workspace_root);
235        Self {
236            workspace_root: canonical_root.clone(),
237            working_dir: canonical_root,
238            executor: SnapshotExecutor::with_snapshot(snapshot),
239        }
240    }
241}
242
243impl<E: CommandExecutor> ShellRunner<E> {
244    /// Create a new shell runner with a custom executor
245    pub fn with_executor(workspace_root: PathBuf, executor: E) -> Self {
246        let canonical_root = canonicalize_workspace(&workspace_root);
247        Self {
248            workspace_root: canonical_root.clone(),
249            working_dir: canonical_root,
250            executor,
251        }
252    }
253
254    /// Get current working directory relative to workspace root
255    pub fn cwd_relative(&self) -> String {
256        self.working_dir
257            .strip_prefix(&self.workspace_root)
258            .unwrap_or(&self.working_dir)
259            .to_string_lossy()
260            .into_owned()
261    }
262
263    /// Change directory workspace-safely
264    pub fn cd(&mut self, path: &str) -> Result<()> {
265        let target = self.resolve_path(path);
266
267        // Single metadata call avoids TOCTOU between exists() and is_dir()
268        let meta = target
269            .metadata()
270            .map_err(|e| anyhow::anyhow!("directory `{path}` does not exist or is not accessible: {e}"))?;
271        if !meta.is_dir() {
272            bail!("path `{path}` is not a directory");
273        }
274
275        let normalized = ensure_path_within_workspace(&target, &self.workspace_root)?;
276
277        self.working_dir = normalized;
278        Ok(())
279    }
280
281    /// List directory contents (simplified version of ListDirHandler)
282    pub async fn ls(&self, path: Option<&str>) -> Result<Vec<Value>> {
283        let target = match path {
284            Some(p) => self.resolve_path(p),
285            None => self.working_dir.clone(),
286        };
287
288        validate_path_exists(&target, "path")?;
289        ensure_path_within_workspace(&target, &self.workspace_root)?;
290
291        let mut entries = Vec::new();
292        let mut read_dir = tokio::fs::read_dir(&target).await?;
293
294        while let Some(entry) = read_dir.next_entry().await? {
295            let metadata = entry.metadata().await?;
296            entries.push(json!({
297                "name": entry.file_name().to_string_lossy(),
298                "is_dir": metadata.is_dir(),
299                "size": metadata.len(),
300            }));
301        }
302
303        Ok(entries)
304    }
305
306    /// Execute a shell command in the current working directory
307    pub async fn exec(&self, command_str: &str) -> Result<ShellOutput> {
308        if let Some(cached) = get_cached_output(command_str, &self.working_dir) {
309            return Ok(cached);
310        }
311
312        let mut inflight_token = None;
313        if let Some(inflight) = enter_inflight(command_str, &self.working_dir).await {
314            match inflight {
315                InFlightState::Wait(receiver) => {
316                    if let Ok(result) = receiver.await {
317                        return result.map_err(|msg| anyhow::anyhow!(msg));
318                    }
319                }
320                InFlightState::Owner(token) => {
321                    inflight_token = Some(token);
322                }
323            }
324        }
325
326        let output = self.executor.execute(command_str, &self.working_dir).await;
327
328        if let Some(token) = inflight_token {
329            let result = output.as_ref().map(|out| out.clone()).map_err(|err| err.to_string());
330            finish_inflight(token, result).await;
331        }
332
333        let output = output?;
334        cache_output(command_str, &self.working_dir, output.clone());
335        Ok(output)
336    }
337
338    /// Resolve a path relative to current working directory
339    fn resolve_path(&self, path: &str) -> PathBuf {
340        let path = Path::new(path);
341        if path.is_absolute() {
342            path.to_path_buf()
343        } else {
344            self.working_dir.join(path)
345        }
346    }
347}
348
349/// Output from a shell command
350#[derive(Clone, Debug)]
351pub struct ShellOutput {
352    pub stdout: String,
353    pub stderr: String,
354    pub exit_code: i32,
355}
356
357impl ShellOutput {
358    /// Sanitize output to redact any secrets that may have been printed
359    ///
360    /// This should be called before displaying output in UI or writing to logs
361    pub fn sanitize_secrets(&self) -> Self {
362        Self {
363            stdout: vtcode_commons::sanitizer::redact_secrets(self.stdout.clone()),
364            stderr: vtcode_commons::sanitizer::redact_secrets(self.stderr.clone()),
365            exit_code: self.exit_code,
366        }
367    }
368}
369
370/// Resolve the fallback shell for command execution when program is not found.
371pub fn resolve_fallback_shell() -> String {
372    if let Ok(shell) = std::env::var("SHELL") {
373        let trimmed = shell.trim();
374        if !trimmed.is_empty() && Path::new(trimmed).exists() {
375            return trimmed.to_string();
376        }
377    }
378
379    const SHELL_CANDIDATES: &[&str] = &["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"];
380
381    for shell_path in SHELL_CANDIDATES {
382        if Path::new(shell_path).exists() {
383            return shell_path.to_string();
384        }
385    }
386
387    "/bin/sh".to_string()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[tokio::test]
395    async fn default_system_executor_runs_with_a_sanitized_environment() {
396        let root = tempfile::tempdir().unwrap();
397        let runner = ShellRunner::new(root.path().to_path_buf());
398
399        // The child must still run (PATH survives the allowlist) but must see the
400        // rebuilt environment rather than an inherited one.
401        let output = runner.exec("echo $VTCODE_SANDBOX_ACTIVE").await.unwrap();
402
403        assert_eq!(output.exit_code, 0, "{output:?}");
404        assert_eq!(output.stdout.trim(), "1", "{output:?}");
405    }
406}