Skip to main content

a3s_code_core/
sandbox.rs

1//! Sandbox integration for bash tool execution.
2//!
3//! When [`SandboxConfig`] is provided via `SessionOptions::with_sandbox()`,
4//! the `bash` tool routes commands through an A3S Box MicroVM instead of
5//! `std::process::Command`. The workspace directory is mounted read-write
6//! at `/workspace` inside the sandbox.
7//!
8//! # Feature Flag
9//!
10//! The concrete [`BoxSandboxHandle`] implementation requires the `sandbox`
11//! Cargo feature and the `a3s-box-sdk` crate. Without the feature, setting
12//! a `SandboxConfig` emits a warning but has no effect.
13
14use async_trait::async_trait;
15use std::collections::HashMap;
16
17// ============================================================================
18// SandboxConfig
19// ============================================================================
20
21/// Configuration for routing `bash` tool execution through an A3S Box sandbox.
22///
23/// Use [`SessionOptions::with_sandbox()`](crate::SessionOptions::with_sandbox) to activate.
24/// Requires the `sandbox` Cargo feature.
25#[derive(Debug, Clone)]
26pub struct SandboxConfig {
27    /// OCI image reference (e.g., `"alpine:latest"`, `"ubuntu:22.04"`).
28    pub image: String,
29    /// Memory limit in megabytes (default: 512).
30    pub memory_mb: u32,
31    /// Number of vCPUs (default: 1).
32    pub cpus: u32,
33    /// Enable outbound networking (default: `false` — safer for agent workflows).
34    pub network: bool,
35    /// Additional environment variables to inject into the sandbox.
36    pub env: HashMap<String, String>,
37}
38
39impl Default for SandboxConfig {
40    fn default() -> Self {
41        Self {
42            image: "alpine:latest".into(),
43            memory_mb: 512,
44            cpus: 1,
45            network: false,
46            env: HashMap::new(),
47        }
48    }
49}
50
51// ============================================================================
52// SandboxOutput
53// ============================================================================
54
55/// Output from running a command inside a sandbox.
56pub struct SandboxOutput {
57    /// Standard output bytes decoded as UTF-8.
58    pub stdout: String,
59    /// Standard error bytes decoded as UTF-8.
60    pub stderr: String,
61    /// Process exit code (0 = success).
62    pub exit_code: i32,
63}
64
65// ============================================================================
66// BashSandbox trait
67// ============================================================================
68
69/// Abstraction over sandbox bash execution used by the `bash` built-in tool.
70///
71/// Implement this trait to provide a custom sandbox backend.
72/// The default implementation ([`BoxSandboxHandle`]) uses A3S Box.
73#[async_trait]
74pub trait BashSandbox: Send + Sync {
75    /// Execute a shell command inside the sandbox.
76    ///
77    /// * `command` — the shell command string (passed as `bash -c <command>`).
78    /// * `guest_workspace` — the guest path where the workspace is mounted
79    ///   (e.g., `"/workspace"`).
80    async fn exec_command(
81        &self,
82        command: &str,
83        guest_workspace: &str,
84    ) -> anyhow::Result<SandboxOutput>;
85
86    /// Shut down the sandbox (best-effort, infallible from caller's perspective).
87    async fn shutdown(&self);
88}
89
90// ============================================================================
91// BoxSandboxHandle — feature-gated concrete implementation
92// ============================================================================
93
94#[cfg(feature = "sandbox")]
95pub use box_impl::BoxSandboxHandle;
96
97#[cfg(feature = "sandbox")]
98mod box_impl {
99    use super::{BashSandbox, SandboxConfig, SandboxOutput};
100    use a3s_box_sdk::{BoxSdk, MountSpec, Sandbox, SandboxOptions};
101    use anyhow::Context;
102    use async_trait::async_trait;
103    use std::sync::Arc;
104    use tokio::sync::Mutex;
105
106    #[allow(clippy::large_enum_variant)]
107    enum SandboxState {
108        NotStarted,
109        Running(Sandbox),
110    }
111
112    /// A3S Box–backed sandbox handle.
113    ///
114    /// Lazily boots the sandbox on the first `exec_command()` call.
115    /// The session workspace is mounted read-write at `/workspace`.
116    pub struct BoxSandboxHandle {
117        state: Arc<Mutex<SandboxState>>,
118        config: SandboxConfig,
119        /// Absolute host path of the session workspace.
120        workspace_host: String,
121    }
122
123    impl BoxSandboxHandle {
124        /// Create a new handle. The sandbox is **not** started yet — it boots
125        /// on the first `exec_command()` call.
126        pub fn new(config: SandboxConfig, workspace_host: impl Into<String>) -> Self {
127            Self {
128                state: Arc::new(Mutex::new(SandboxState::NotStarted)),
129                config,
130                workspace_host: workspace_host.into(),
131            }
132        }
133
134        /// Ensure the sandbox is running, booting it if necessary.
135        async fn ensure_running(&self) -> anyhow::Result<()> {
136            let mut state = self.state.lock().await;
137            if matches!(*state, SandboxState::NotStarted) {
138                tracing::info!(image = %self.config.image, "Booting A3S Box sandbox");
139                let sdk = BoxSdk::new()
140                    .await
141                    .context("BoxSdk initialization failed")?;
142
143                let opts = SandboxOptions {
144                    image: self.config.image.clone(),
145                    memory_mb: self.config.memory_mb,
146                    cpus: self.config.cpus,
147                    network: self.config.network,
148                    env: self.config.env.clone(),
149                    mounts: vec![MountSpec {
150                        host_path: self.workspace_host.clone(),
151                        guest_path: "/workspace".into(),
152                        readonly: false,
153                    }],
154                    workdir: Some("/workspace".into()),
155                    ..SandboxOptions::default()
156                };
157
158                let sandbox = sdk
159                    .create(opts)
160                    .await
161                    .context("Failed to create A3S Box sandbox")?;
162
163                tracing::info!("A3S Box sandbox ready");
164                *state = SandboxState::Running(sandbox);
165            }
166            Ok(())
167        }
168    }
169
170    #[async_trait]
171    impl BashSandbox for BoxSandboxHandle {
172        async fn exec_command(
173            &self,
174            command: &str,
175            _guest_workspace: &str,
176        ) -> anyhow::Result<SandboxOutput> {
177            self.ensure_running().await?;
178
179            let state = self.state.lock().await;
180            let sandbox = match &*state {
181                SandboxState::Running(s) => s,
182                SandboxState::NotStarted => {
183                    unreachable!("ensure_running guarantees Running state")
184                }
185            };
186
187            let result = sandbox
188                .exec("bash", &["-c", command])
189                .await
190                .context("Sandbox exec failed")?;
191
192            Ok(SandboxOutput {
193                stdout: result.stdout,
194                stderr: result.stderr,
195                exit_code: result.exit_code,
196            })
197        }
198
199        async fn shutdown(&self) {
200            let mut state = self.state.lock().await;
201            // Take ownership of the sandbox to call stop() (which takes self).
202            let old = std::mem::replace(&mut *state, SandboxState::NotStarted);
203            if let SandboxState::Running(sandbox) = old {
204                tracing::info!("Stopping A3S Box sandbox");
205                if let Err(e) = sandbox.stop().await {
206                    tracing::warn!("Sandbox stop failed (non-fatal): {}", e);
207                }
208            }
209        }
210    }
211
212    impl Drop for BoxSandboxHandle {
213        fn drop(&mut self) {
214            // Best-effort async cleanup: spawn a task to stop the sandbox.
215            let state = Arc::clone(&self.state);
216            if let Ok(handle) = tokio::runtime::Handle::try_current() {
217                handle.spawn(async move {
218                    let mut s = state.lock().await;
219                    let old = std::mem::replace(&mut *s, SandboxState::NotStarted);
220                    if let SandboxState::Running(sandbox) = old {
221                        let _ = sandbox.stop().await;
222                    }
223                });
224            }
225        }
226    }
227}
228
229// ============================================================================
230// Tests
231// ============================================================================
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::sync::Arc;
237
238    #[test]
239    fn test_sandbox_config_default() {
240        let cfg = SandboxConfig::default();
241        assert_eq!(cfg.image, "alpine:latest");
242        assert_eq!(cfg.memory_mb, 512);
243        assert_eq!(cfg.cpus, 1);
244        assert!(!cfg.network);
245        assert!(cfg.env.is_empty());
246    }
247
248    #[test]
249    fn test_sandbox_config_custom() {
250        let cfg = SandboxConfig {
251            image: "ubuntu:22.04".into(),
252            memory_mb: 1024,
253            cpus: 2,
254            network: true,
255            env: [("FOO".into(), "bar".into())].into(),
256        };
257        assert_eq!(cfg.image, "ubuntu:22.04");
258        assert_eq!(cfg.memory_mb, 1024);
259        assert_eq!(cfg.cpus, 2);
260        assert!(cfg.network);
261        assert_eq!(cfg.env["FOO"], "bar");
262    }
263
264    #[test]
265    fn test_sandbox_config_clone() {
266        let cfg = SandboxConfig {
267            image: "python:3.12-slim".into(),
268            ..SandboxConfig::default()
269        };
270        let cloned = cfg.clone();
271        assert_eq!(cloned.image, "python:3.12-slim");
272        assert_eq!(cloned.memory_mb, cfg.memory_mb);
273    }
274
275    // -----------------------------------------------------------------------
276    // Mock BashSandbox for testing the trait contract
277    // -----------------------------------------------------------------------
278
279    struct MockSandbox {
280        output: String,
281        exit_code: i32,
282    }
283
284    #[async_trait]
285    impl BashSandbox for MockSandbox {
286        async fn exec_command(
287            &self,
288            _command: &str,
289            _guest_workspace: &str,
290        ) -> anyhow::Result<SandboxOutput> {
291            Ok(SandboxOutput {
292                stdout: self.output.clone(),
293                stderr: String::new(),
294                exit_code: self.exit_code,
295            })
296        }
297
298        async fn shutdown(&self) {}
299    }
300
301    #[tokio::test]
302    async fn test_mock_sandbox_success() {
303        let sandbox = MockSandbox {
304            output: "hello sandbox\n".into(),
305            exit_code: 0,
306        };
307        let result = sandbox
308            .exec_command("echo hello sandbox", "/workspace")
309            .await
310            .unwrap();
311        assert_eq!(result.stdout, "hello sandbox\n");
312        assert_eq!(result.exit_code, 0);
313        assert!(result.stderr.is_empty());
314    }
315
316    #[tokio::test]
317    async fn test_mock_sandbox_nonzero_exit() {
318        let sandbox = MockSandbox {
319            output: String::new(),
320            exit_code: 127,
321        };
322        let result = sandbox
323            .exec_command("nonexistent_cmd", "/workspace")
324            .await
325            .unwrap();
326        assert_eq!(result.exit_code, 127);
327    }
328
329    #[tokio::test]
330    async fn test_bash_sandbox_is_arc_send_sync() {
331        // Verify the trait object is Send + Sync so it can live in ToolContext.
332        let sandbox: Arc<dyn BashSandbox> = Arc::new(MockSandbox {
333            output: "ok".into(),
334            exit_code: 0,
335        });
336        let result = sandbox.exec_command("true", "/workspace").await.unwrap();
337        assert_eq!(result.exit_code, 0);
338    }
339}