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 {
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
77pub 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
107pub struct SnapshotExecutor {
113 shell: String,
114 snapshot: Option<std::sync::Arc<ShellSnapshot>>,
115}
116
117impl SnapshotExecutor {
118 pub fn new() -> Self {
122 Self { shell: resolve_fallback_shell(), snapshot: None }
123 }
124
125 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 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 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 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 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 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 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 pub fn cd(&mut self, path: &str) -> Result<()> {
245 let target = self.resolve_path(path);
246
247 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 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 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 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#[derive(Clone, Debug)]
331pub struct ShellOutput {
332 pub stdout: String,
333 pub stderr: String,
334 pub exit_code: i32,
335}
336
337impl ShellOutput {
338 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
350pub 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}