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