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