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::{
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
29pub struct ShellRunner<E: CommandExecutor = SystemExecutor> {
31 workspace_root: PathBuf,
32 working_dir: PathBuf,
33 executor: E,
34}
35
36#[async_trait::async_trait]
38pub trait CommandExecutor: Send + Sync {
39 async fn execute(&self, command: &str, cwd: &Path) -> Result<ShellOutput>;
41}
42
43pub 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
81pub 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
111pub struct SnapshotExecutor {
117 shell: String,
118 snapshot: Option<std::sync::Arc<ShellSnapshot>>,
119}
120
121impl SnapshotExecutor {
122 pub fn new() -> Self {
126 Self {
127 shell: resolve_fallback_shell(),
128 snapshot: None,
129 }
130 }
131
132 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 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 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 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 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 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 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 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 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 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 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#[derive(Clone, Debug)]
343pub struct ShellOutput {
344 pub stdout: String,
345 pub stderr: String,
346 pub exit_code: i32,
347}
348
349impl ShellOutput {
350 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
362pub 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}