Skip to main content

synwire_agent/sandbox/
shell.rs

1//! Shell sandbox — extends `LocalProvider` with command execution.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use synwire_core::BoxFuture;
7use synwire_core::vfs::error::VfsError;
8use synwire_core::vfs::grep_options::GrepOptions;
9use synwire_core::vfs::protocol::Vfs;
10use synwire_core::vfs::types::{
11    CpOptions, DirEntry, EditResult, ExecuteResponse, FileContent, GlobEntry, GrepMatch, LsOptions,
12    RmOptions, TransferResult, VfsCapabilities, WriteResult,
13};
14use tokio::process::Command;
15use tokio::time::timeout;
16
17use crate::vfs::local::LocalProvider;
18
19/// Maximum output bytes captured per stream before truncation.
20const MAX_OUTPUT_BYTES: usize = 1024 * 1024; // 1 MiB
21
22/// Shell that wraps `LocalProvider` and adds command execution.
23pub struct Shell {
24    fs: LocalProvider,
25    env: HashMap<String, String>,
26    default_timeout: Duration,
27}
28
29impl std::fmt::Debug for Shell {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("Shell").finish()
32    }
33}
34
35impl Shell {
36    /// Create a new shell rooted at `root`.
37    ///
38    /// # Errors
39    ///
40    /// Propagates errors from [`LocalProvider::new`].
41    pub fn new(
42        root: impl Into<std::path::PathBuf>,
43        env: HashMap<String, String>,
44        timeout_secs: u64,
45    ) -> Result<Self, VfsError> {
46        Ok(Self {
47            fs: LocalProvider::new(root)?,
48            env,
49            default_timeout: Duration::from_secs(timeout_secs),
50        })
51    }
52
53    /// Execute a command with optional timeout, returning truncated output.
54    pub fn execute_cmd<'a>(
55        &'a self,
56        cmd: &'a str,
57        args: &'a [String],
58        timeout_override: Option<Duration>,
59    ) -> BoxFuture<'a, Result<ExecuteResponse, VfsError>> {
60        Box::pin(async move {
61            let deadline = timeout_override.unwrap_or(self.default_timeout);
62            let cwd = self.fs.pwd().await?;
63
64            let child = Command::new(cmd)
65                .args(args)
66                .envs(&self.env)
67                .current_dir(&cwd)
68                .output();
69
70            let output = timeout(deadline, child)
71                .await
72                .map_err(|_| VfsError::Timeout(format!("{cmd} timed out after {deadline:?}")))?
73                .map_err(VfsError::Io)?;
74
75            let stdout = truncate_string(
76                String::from_utf8_lossy(&output.stdout).into_owned(),
77                MAX_OUTPUT_BYTES,
78            );
79            let stderr = truncate_string(
80                String::from_utf8_lossy(&output.stderr).into_owned(),
81                MAX_OUTPUT_BYTES,
82            );
83
84            Ok(ExecuteResponse {
85                exit_code: output.status.code().unwrap_or(-1),
86                stdout,
87                stderr,
88            })
89        })
90    }
91}
92
93fn truncate_string(mut s: String, max: usize) -> String {
94    const SUFFIX: &str = "\n[truncated]";
95    if s.len() > max {
96        let keep = max.saturating_sub(SUFFIX.len());
97        // Walk back to a valid UTF-8 boundary.
98        let mut boundary = keep;
99        while boundary > 0 && !s.is_char_boundary(boundary) {
100            boundary -= 1;
101        }
102        s.truncate(boundary);
103        s.push_str(SUFFIX);
104    }
105    s
106}
107
108// Delegate all filesystem operations to the inner `LocalProvider`.
109impl Vfs for Shell {
110    fn ls(&self, path: &str, opts: LsOptions) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>> {
111        self.fs.ls(path, opts)
112    }
113
114    fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>> {
115        self.fs.read(path)
116    }
117
118    fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
119        self.fs.write(path, content)
120    }
121
122    fn edit(
123        &self,
124        path: &str,
125        old: &str,
126        new: &str,
127    ) -> BoxFuture<'_, Result<EditResult, VfsError>> {
128        self.fs.edit(path, old, new)
129    }
130
131    fn grep(
132        &self,
133        pattern: &str,
134        opts: GrepOptions,
135    ) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>> {
136        self.fs.grep(pattern, opts)
137    }
138
139    fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>> {
140        self.fs.glob(pattern)
141    }
142
143    fn upload(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
144        self.fs.upload(from, to)
145    }
146
147    fn download(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
148        self.fs.download(from, to)
149    }
150
151    fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>> {
152        self.fs.pwd()
153    }
154
155    fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
156        self.fs.cd(path)
157    }
158
159    fn rm(&self, path: &str, opts: RmOptions) -> BoxFuture<'_, Result<(), VfsError>> {
160        self.fs.rm(path, opts)
161    }
162
163    fn cp(
164        &self,
165        from: &str,
166        to: &str,
167        opts: CpOptions,
168    ) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
169        self.fs.cp(from, to, opts)
170    }
171
172    fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
173        self.fs.mv_file(from, to)
174    }
175
176    fn capabilities(&self) -> VfsCapabilities {
177        VfsCapabilities::all()
178    }
179
180    fn provider_name(&self) -> &'static str {
181        "Shell"
182    }
183}