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