1use 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
27pub struct ShellRunner<E: CommandExecutor = SystemExecutor> {
29 workspace_root: PathBuf,
30 working_dir: PathBuf,
31 executor: E,
32}
33
34#[async_trait::async_trait]
36pub trait CommandExecutor: Send + Sync {
37 async fn execute(&self, command: &str, cwd: &Path) -> Result<ShellOutput>;
39}
40
41pub struct SystemExecutor {
48 shell: String,
49 env: hashbrown::HashMap<String, String>,
50}
51
52impl SystemExecutor {
53 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
97pub 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
127pub struct SnapshotExecutor {
133 shell: String,
134 snapshot: Option<std::sync::Arc<ShellSnapshot>>,
135}
136
137impl SnapshotExecutor {
138 pub fn new() -> Self {
142 Self { shell: resolve_fallback_shell(), snapshot: None }
143 }
144
145 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 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 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 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 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 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 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 pub fn cd(&mut self, path: &str) -> Result<()> {
265 let target = self.resolve_path(path);
266
267 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 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 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 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#[derive(Clone, Debug)]
351pub struct ShellOutput {
352 pub stdout: String,
353 pub stderr: String,
354 pub exit_code: i32,
355}
356
357impl ShellOutput {
358 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
370pub 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 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}