Skip to main content

vtcode_bash_runner/
executor.rs

1use anyhow::{Context, Result};
2#[cfg(any(not(feature = "powershell-process"), feature = "pure-rust"))]
3use anyhow::{anyhow, bail};
4#[cfg(feature = "pure-rust")]
5use std::path::Path;
6use std::path::PathBuf;
7
8#[cfg(feature = "exec-events")]
9use parking_lot::Mutex;
10#[cfg(feature = "serde-errors")]
11use serde::{Deserialize, Serialize};
12#[cfg(feature = "pure-rust")]
13use std::fs;
14#[cfg(feature = "exec-events")]
15use std::sync::atomic::{AtomicU64, Ordering};
16#[cfg(feature = "dry-run")]
17use std::sync::{Arc, Mutex as DryRunMutex};
18
19#[cfg(feature = "exec-events")]
20use vtcode_exec_events::{
21    CommandExecutionItem, CommandExecutionStatus, EventEmitter, ItemCompletedEvent, ItemStartedEvent, ThreadEvent,
22    ThreadItem, ThreadItemDetails,
23};
24
25/// Logical grouping for commands issued by the [`BashRunner`][crate::BashRunner].
26#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum CommandCategory {
29    ChangeDirectory,
30    ListDirectory,
31    PrintDirectory,
32    CreateDirectory,
33    Remove,
34    Copy,
35    Move,
36    Search,
37}
38
39/// Shell family used to execute commands.
40#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum ShellKind {
43    Unix,
44    Windows,
45}
46
47/// Describes a command that will be executed by a [`CommandExecutor`].
48#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
49#[derive(Debug, Clone)]
50pub struct CommandInvocation {
51    shell: ShellKind,
52    pub command: String,
53    form: CommandForm,
54    pub(crate) category: CommandCategory,
55    pub(crate) working_dir: PathBuf,
56    pub(crate) touched_paths: Vec<PathBuf>,
57    #[cfg_attr(feature = "serde-errors", serde(default))]
58    pub(crate) create_parents: bool,
59}
60
61#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
62#[derive(Debug, Clone)]
63enum CommandForm {
64    DirectArgv(Vec<String>),
65    ValidatedShellScript(String),
66    Invalid(String),
67}
68
69impl CommandInvocation {
70    pub(crate) fn new(shell: ShellKind, command: String, category: CommandCategory, working_dir: PathBuf) -> Self {
71        let form = match shell {
72            ShellKind::Unix => match shell_words::split(&command) {
73                Ok(argv) if !argv.is_empty() => CommandForm::DirectArgv(argv),
74                Ok(_) => CommandForm::Invalid("direct command is empty".to_owned()),
75                Err(error) => CommandForm::Invalid(format!("direct command is not valid argv: {error}")),
76            },
77            ShellKind::Windows => CommandForm::ValidatedShellScript(command.clone()),
78        };
79        Self {
80            shell,
81            command,
82            form,
83            category,
84            working_dir,
85            touched_paths: Vec::new(),
86            create_parents: false,
87        }
88    }
89
90    pub(crate) fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
91        self.touched_paths = paths;
92        self
93    }
94
95    pub(crate) fn with_create_parents(mut self, create_parents: bool) -> Self {
96        self.create_parents = create_parents;
97        self
98    }
99}
100
101/// Describes the exit status of a command execution.
102#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct CommandStatus {
105    success: bool,
106    code: Option<i32>,
107}
108
109impl CommandStatus {
110    pub(crate) fn new(success: bool, code: Option<i32>) -> Self {
111        Self { success, code }
112    }
113
114    pub(crate) fn success(&self) -> bool {
115        self.success
116    }
117
118    fn code(&self) -> Option<i32> {
119        self.code
120    }
121
122    #[cold]
123    pub fn failure(code: Option<i32>) -> Self {
124        Self { success: false, code }
125    }
126}
127
128impl From<std::process::ExitStatus> for CommandStatus {
129    fn from(status: std::process::ExitStatus) -> Self {
130        let code = status.code();
131        Self { success: status.success(), code }
132    }
133}
134
135/// Output produced by the executor for a command invocation.
136#[cfg_attr(feature = "serde-errors", derive(Serialize, Deserialize))]
137#[derive(Debug, Clone)]
138pub struct CommandOutput {
139    pub(crate) status: CommandStatus,
140    pub(crate) stdout: String,
141    pub(crate) stderr: String,
142}
143
144impl CommandOutput {
145    fn success(stdout: impl Into<String>) -> Self {
146        Self {
147            status: CommandStatus::new(true, Some(0)),
148            stdout: stdout.into(),
149            stderr: String::new(),
150        }
151    }
152
153    pub fn failure(code: Option<i32>, stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
154        Self {
155            status: CommandStatus::failure(code),
156            stdout: stdout.into(),
157            stderr: stderr.into(),
158        }
159    }
160}
161
162/// Trait implemented by concrete command execution strategies.
163pub trait CommandExecutor: Send + Sync {
164    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput>;
165}
166
167/// Executes commands by delegating to the system shell via [`std::process::Command`].
168#[cfg(feature = "std-process")]
169pub struct ProcessCommandExecutor;
170
171#[cfg(feature = "std-process")]
172impl ProcessCommandExecutor {
173    fn new() -> Self {
174        Self
175    }
176}
177
178#[cfg(feature = "std-process")]
179impl Default for ProcessCommandExecutor {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185#[cfg(feature = "std-process")]
186impl CommandExecutor for ProcessCommandExecutor {
187    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
188        use std::process::Command;
189
190        let mut cmd = match &invocation.form {
191            CommandForm::DirectArgv(argv) => {
192                let (program, args) = argv.split_first().context("direct command is missing executable")?;
193                let mut command = Command::new(program);
194                command.args(args);
195                command
196            }
197            CommandForm::ValidatedShellScript(script) if invocation.shell == ShellKind::Unix => {
198                let mut command = Command::new("sh");
199                command.arg("-c").arg(script);
200                command
201            }
202            CommandForm::ValidatedShellScript(script) => {
203                #[cfg(not(feature = "powershell-process"))]
204                {
205                    bail!("powershell-process feature disabled; enable it to execute Windows commands");
206                }
207                #[cfg(feature = "powershell-process")]
208                let mut command = Command::new("powershell");
209                command.arg("-NoProfile").arg("-NonInteractive").arg("-Command").arg(script);
210                #[cfg(feature = "powershell-process")]
211                {
212                    command
213                }
214            }
215            CommandForm::Invalid(message) => return Err(anyhow::Error::msg(message.clone())),
216        };
217
218        #[cfg(unix)]
219        {
220            let directory = vtcode_commons::fs::bound_file::open_directory_handle(&invocation.working_dir)
221                .with_context(|| format!("bind command working directory {}", invocation.working_dir.display()))?;
222            vtcode_commons::fs::bound_file::set_command_working_directory(&mut cmd, &directory)
223                .context("confine command working directory")?;
224            cmd.current_dir(&invocation.working_dir);
225        }
226        #[cfg(not(unix))]
227        cmd.current_dir(&invocation.working_dir);
228        let output = cmd
229            .output()
230            .with_context(|| format!("failed to execute command: {}", invocation.command))?;
231
232        Ok(CommandOutput {
233            status: CommandStatus::from(output.status),
234            stdout: String::from_utf8(output.stdout).unwrap_or_else(|e| e.to_string()),
235            stderr: String::from_utf8(output.stderr).unwrap_or_else(|e| e.to_string()),
236        })
237    }
238}
239
240#[cfg(feature = "dry-run")]
241#[derive(Clone, Default)]
242pub struct DryRunCommandExecutor {
243    log: Arc<DryRunMutex<Vec<CommandInvocation>>>,
244}
245
246#[cfg(feature = "dry-run")]
247impl DryRunCommandExecutor {
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    pub fn logged_invocations(&self) -> Vec<CommandInvocation> {
253        match self.log.lock() {
254            Ok(guard) => guard.clone(),
255            Err(poisoned) => poisoned.into_inner().clone(),
256        }
257    }
258}
259
260#[cfg(feature = "dry-run")]
261impl CommandExecutor for DryRunCommandExecutor {
262    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
263        let mut guard = match self.log.lock() {
264            Ok(guard) => guard,
265            Err(poisoned) => poisoned.into_inner(),
266        };
267        guard.push(invocation.clone());
268        Ok(match invocation.category {
269            CommandCategory::ListDirectory => CommandOutput::success("(dry-run listing)"),
270            _ => CommandOutput::success(String::new()),
271        })
272    }
273}
274
275#[cfg(feature = "pure-rust")]
276#[derive(Debug, Default, Clone, Copy)]
277pub struct PureRustCommandExecutor;
278
279#[cfg(feature = "pure-rust")]
280impl PureRustCommandExecutor {
281    fn resolve_primary_path(invocation: &CommandInvocation) -> Result<&PathBuf> {
282        invocation
283            .touched_paths
284            .first()
285            .ok_or_else(|| anyhow!("invocation missing target path"))
286    }
287
288    fn should_include_hidden(command: &str) -> bool {
289        command.contains("-a") || command.contains("-Force")
290    }
291
292    fn mkdir(path: &Path, create_parents: bool) -> Result<()> {
293        if create_parents {
294            fs::create_dir_all(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
295        } else {
296            fs::create_dir(path).with_context(|| format!("failed to create directory `{}`", path.display()))?
297        }
298        Ok(())
299    }
300
301    fn rm(path: &Path, command: &str) -> Result<()> {
302        if path.is_dir() {
303            if command.contains("-r") || command.contains("-Recurse") {
304                fs::remove_dir_all(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
305            } else {
306                fs::remove_dir(path).with_context(|| format!("failed to remove directory `{}`", path.display()))?
307            }
308        } else if path.exists() {
309            fs::remove_file(path).with_context(|| format!("failed to remove file `{}`", path.display()))?
310        }
311        Ok(())
312    }
313
314    fn copy_recursive(source: &Path, dest: &Path, recursive: bool) -> Result<()> {
315        let canonical_source = vtcode_commons::canonicalize(source)
316            .with_context(|| format!("failed to resolve copy source `{}`", source.display()))?;
317        if !canonical_source.is_dir() {
318            return Self::copy_single_file(&canonical_source, dest);
319        }
320        if !recursive {
321            bail!("copying directory `{}` requires recursive flag", source.display());
322        }
323        fs::create_dir_all(dest).with_context(|| format!("failed to create directory `{}`", dest.display()))?;
324        let canonical_dest = vtcode_commons::canonicalize(dest)
325            .with_context(|| format!("failed to resolve copy destination `{}`", dest.display()))?;
326        Self::copy_dir_anchored(&canonical_source, &canonical_source, &canonical_dest, &canonical_dest)
327    }
328
329    /// Copy directory contents with both sides anchored at their roots.
330    ///
331    /// Descendant symlinks are resolved and must stay beneath the root they
332    /// were discovered under; links escaping to the rest of the filesystem
333    /// fail the copy instead of leaking reads or writes outside the
334    /// authorized trees. Links that stay inside keep the previous
335    /// copy-through behavior.
336    ///
337    /// Residual limitation: validation is check-then-act per entry, so an
338    /// actor swapping links concurrently with the copy is not defeated —
339    /// only planted at-rest links are. Bound directory handles would be
340    /// needed against a live concurrent swapper.
341    fn copy_dir_anchored(source_dir: &Path, source_anchor: &Path, dest_dir: &Path, dest_anchor: &Path) -> Result<()> {
342        for entry in
343            fs::read_dir(source_dir).with_context(|| format!("failed to read directory `{}`", source_dir.display()))?
344        {
345            let entry = entry?;
346            let canonical_entry = vtcode_commons::canonicalize(entry.path())
347                .with_context(|| format!("failed to resolve `{}`", entry.path().display()))?;
348            if !canonical_entry.starts_with(source_anchor) {
349                bail!(
350                    "refusing to copy through symlink `{}` escaping `{}`",
351                    entry.path().display(),
352                    source_anchor.display()
353                );
354            }
355            let dest_path = dest_dir.join(entry.file_name());
356            Self::reject_escaping_destination_link(&dest_path, dest_anchor)?;
357            if canonical_entry.is_dir() {
358                fs::create_dir_all(&dest_path)
359                    .with_context(|| format!("failed to create directory `{}`", dest_path.display()))?;
360                // `create_dir_all` follows links, so re-anchor the real
361                // location before descending into it.
362                let canonical_dest_subdir = vtcode_commons::canonicalize(&dest_path)
363                    .with_context(|| format!("failed to resolve `{}`", dest_path.display()))?;
364                if !canonical_dest_subdir.starts_with(dest_anchor) {
365                    bail!("refusing to copy into `{}` escaping `{}`", dest_path.display(), dest_anchor.display());
366                }
367                Self::copy_dir_anchored(&canonical_entry, source_anchor, &canonical_dest_subdir, dest_anchor)?;
368            } else {
369                Self::copy_file(&canonical_entry, &dest_path)?;
370            }
371        }
372        Ok(())
373    }
374
375    /// Copy one file, refusing a symlinked destination that escapes the
376    /// destination's own directory.
377    fn copy_single_file(canonical_source: &Path, dest: &Path) -> Result<()> {
378        if let Some(parent) = dest.parent() {
379            fs::create_dir_all(parent)
380                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
381            let canonical_parent = vtcode_commons::canonicalize(parent)
382                .with_context(|| format!("failed to resolve `{}`", parent.display()))?;
383            Self::reject_escaping_destination_link(dest, &canonical_parent)?;
384        }
385        Self::copy_file(canonical_source, dest)
386    }
387
388    /// Fail when `dest` is a symlink resolving outside `dest_anchor`.
389    ///
390    /// `fs::copy` follows a symlinked destination, so a planted link would
391    /// otherwise redirect the write outside the authorized tree.
392    fn reject_escaping_destination_link(dest: &Path, dest_anchor: &Path) -> Result<()> {
393        if fs::symlink_metadata(dest).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
394            let canonical_dest = vtcode_commons::canonicalize(dest)
395                .with_context(|| format!("failed to resolve destination `{}`", dest.display()))?;
396            if !canonical_dest.starts_with(dest_anchor) {
397                bail!(
398                    "refusing to copy through destination symlink `{}` escaping `{}`",
399                    dest.display(),
400                    dest_anchor.display()
401                );
402            }
403        }
404        Ok(())
405    }
406
407    fn copy_file(source: &Path, dest: &Path) -> Result<()> {
408        if let Some(parent) = dest.parent() {
409            fs::create_dir_all(parent)
410                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
411        }
412        fs::copy(source, dest)
413            .with_context(|| format!("failed to copy `{}` to `{}`", source.display(), dest.display()))?;
414        Ok(())
415    }
416
417    fn move_path(source: &Path, dest: &Path) -> Result<()> {
418        if let Some(parent) = dest.parent() {
419            fs::create_dir_all(parent)
420                .with_context(|| format!("failed to prepare destination directory `{}`", parent.display()))?;
421        }
422
423        if let Err(rename_err) = fs::rename(source, dest) {
424            Self::copy_recursive(source, dest, true)
425                .and_then(|_| Self::rm(source, "-r -f"))
426                .with_context(|| {
427                    format!("failed to move `{}` to `{}` via rename: {rename_err}", source.display(), dest.display())
428                })?;
429        }
430        Ok(())
431    }
432}
433
434#[cfg(feature = "pure-rust")]
435impl CommandExecutor for PureRustCommandExecutor {
436    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
437        match invocation.category {
438            CommandCategory::ListDirectory => {
439                let path = Self::resolve_primary_path(invocation)?;
440                let mut entries = Vec::new();
441                for entry in
442                    fs::read_dir(path).with_context(|| format!("failed to read directory `{}`", path.display()))?
443                {
444                    let entry = entry?;
445                    let name = entry.file_name();
446                    let name = name.to_string_lossy();
447                    if !Self::should_include_hidden(&invocation.command) && name.starts_with('.') {
448                        continue;
449                    }
450                    entries.push(name.to_string());
451                }
452                entries.sort();
453                Ok(CommandOutput::success(entries.join("\n")))
454            }
455            CommandCategory::CreateDirectory => {
456                let path = Self::resolve_primary_path(invocation)?;
457                Self::mkdir(path, invocation.create_parents)?;
458                Ok(CommandOutput::success(String::new()))
459            }
460            CommandCategory::Remove => {
461                let path = Self::resolve_primary_path(invocation)?;
462                Self::rm(path, &invocation.command)?;
463                Ok(CommandOutput::success(String::new()))
464            }
465            CommandCategory::Copy => {
466                let source = invocation
467                    .touched_paths
468                    .first()
469                    .ok_or_else(|| anyhow!("copy missing source path"))?;
470                let dest = invocation
471                    .touched_paths
472                    .get(1)
473                    .ok_or_else(|| anyhow!("copy missing destination path"))?;
474                let recursive = invocation.command.contains("-r") || invocation.command.contains("-Recurse");
475                Self::copy_recursive(source.as_path(), dest.as_path(), recursive)?;
476                Ok(CommandOutput::success(String::new()))
477            }
478            CommandCategory::Move => {
479                let source = invocation
480                    .touched_paths
481                    .first()
482                    .ok_or_else(|| anyhow!("move missing source path"))?;
483                let dest = invocation
484                    .touched_paths
485                    .get(1)
486                    .ok_or_else(|| anyhow!("move missing destination path"))?;
487                Self::move_path(source.as_path(), dest.as_path())?;
488                Ok(CommandOutput::success(String::new()))
489            }
490            CommandCategory::Search => {
491                bail!("pure-rust executor does not implement search; enable std-process or provide a custom executor")
492            }
493            CommandCategory::ChangeDirectory | CommandCategory::PrintDirectory => {
494                Ok(CommandOutput::success(String::new()))
495            }
496        }
497    }
498}
499
500#[cfg(feature = "exec-events")]
501#[derive(Debug)]
502pub struct EventfulExecutor<E, T> {
503    inner: E,
504    emitter: Mutex<T>,
505    counter: AtomicU64,
506    id_prefix: String,
507}
508
509#[cfg(feature = "exec-events")]
510impl<E, T> EventfulExecutor<E, T>
511where
512    T: EventEmitter,
513{
514    pub fn new(inner: E, emitter: T) -> Self {
515        Self {
516            inner,
517            emitter: Mutex::new(emitter),
518            counter: AtomicU64::new(0),
519            id_prefix: "cmd-".to_string(),
520        }
521    }
522
523    pub fn with_id_prefix(inner: E, emitter: T, prefix: impl Into<String>) -> Self {
524        let mut executor = Self::new(inner, emitter);
525        executor.id_prefix = prefix.into();
526        executor
527    }
528
529    fn next_id(&self) -> String {
530        let value = self.counter.fetch_add(1, Ordering::Relaxed) + 1;
531        let mut id = String::with_capacity(self.id_prefix.len() + 10);
532        id.push_str(&self.id_prefix);
533        use std::fmt::Write;
534        let _ = write!(id, "{value}");
535        id
536    }
537
538    fn emit_event(&self, event: ThreadEvent) {
539        let mut emitter = self.emitter.lock();
540        EventEmitter::emit(&mut *emitter, &event);
541    }
542
543    fn command_details(
544        &self,
545        invocation: &CommandInvocation,
546        status: CommandExecutionStatus,
547        output: Option<&CommandOutput>,
548        error: Option<&anyhow::Error>,
549    ) -> CommandExecutionItem {
550        let aggregated_output = if let Some(output) = output {
551            aggregate_output(output)
552        } else if let Some(err) = error {
553            err.to_string()
554        } else {
555            String::new()
556        };
557
558        CommandExecutionItem {
559            command: invocation.command.clone(),
560            arguments: None,
561            aggregated_output,
562            exit_code: output.and_then(|out| out.status.code()),
563            status,
564        }
565    }
566}
567
568#[cfg(feature = "exec-events")]
569impl<E, T> CommandExecutor for EventfulExecutor<E, T>
570where
571    E: CommandExecutor,
572    T: EventEmitter + Send,
573{
574    fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
575        let item_id = self.next_id();
576        let starting_item = ThreadItem {
577            context: None,
578            id: item_id.clone(),
579            details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
580                invocation,
581                CommandExecutionStatus::InProgress,
582                None,
583                None,
584            ))),
585        };
586        self.emit_event(ThreadEvent::ItemStarted(ItemStartedEvent { item: starting_item }));
587
588        match self.inner.execute(invocation) {
589            Ok(output) => {
590                let status = if output.status.success() {
591                    CommandExecutionStatus::Completed
592                } else {
593                    CommandExecutionStatus::Failed
594                };
595
596                let completed_item = ThreadItem {
597                    context: None,
598                    id: item_id,
599                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
600                        invocation,
601                        status,
602                        Some(&output),
603                        None,
604                    ))),
605                };
606                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: completed_item }));
607                Ok(output)
608            }
609            Err(err) => {
610                let failure = ThreadItem {
611                    context: None,
612                    id: item_id,
613                    details: ThreadItemDetails::CommandExecution(Box::new(self.command_details(
614                        invocation,
615                        CommandExecutionStatus::Failed,
616                        None,
617                        Some(&err),
618                    ))),
619                };
620                self.emit_event(ThreadEvent::ItemCompleted(ItemCompletedEvent { item: failure }));
621                Err(err)
622            }
623        }
624    }
625}
626
627#[cfg(feature = "exec-events")]
628fn aggregate_output(output: &CommandOutput) -> String {
629    let mut combined = String::new();
630    if !output.stdout.trim().is_empty() {
631        combined.push_str(output.stdout.trim());
632    }
633    if !output.stderr.trim().is_empty() {
634        if !combined.is_empty() {
635            combined.push('\n');
636        }
637        combined.push_str(output.stderr.trim());
638    }
639    combined
640}
641
642#[cfg(all(test, feature = "serde-errors"))]
643mod serde_tests {
644    use super::{CommandForm, CommandInvocation, CommandOutput, CommandStatus};
645    use serde::Serialize;
646    use serde::de::DeserializeOwned;
647
648    /// Compile-time assertion that a type satisfies the `serde-errors`
649    /// contract; the oracle is that this module fails to compile when a derive
650    /// is missing, which is exactly how `--all-features` broke previously.
651    fn assert_serde<T: Serialize + DeserializeOwned>() {}
652
653    /// Every type reachable from a serialized payload must derive the same
654    /// features. `CommandInvocation` derives under `serde-errors`, so its
655    /// private `CommandForm` (and the output/status types) must as well, or
656    /// `cargo check --all-features` fails.
657    #[test]
658    fn serde_error_types_satisfy_serialize_and_deserialize() {
659        assert_serde::<CommandInvocation>();
660        assert_serde::<CommandForm>();
661        assert_serde::<CommandOutput>();
662        assert_serde::<CommandStatus>();
663    }
664}