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    enum SandboxState {
107        NotStarted,
108        Running(Sandbox),
109    }
110
111    /// A3S Box–backed sandbox handle.
112    ///
113    /// Lazily boots the sandbox on the first `exec_command()` call.
114    /// The session workspace is mounted read-write at `/workspace`.
115    pub struct BoxSandboxHandle {
116        state: Arc<Mutex<SandboxState>>,
117        config: SandboxConfig,
118        /// Absolute host path of the session workspace.
119        workspace_host: String,
120    }
121
122    impl BoxSandboxHandle {
123        /// Create a new handle. The sandbox is **not** started yet — it boots
124        /// on the first `exec_command()` call.
125        pub fn new(config: SandboxConfig, workspace_host: impl Into<String>) -> Self {
126            Self {
127                state: Arc::new(Mutex::new(SandboxState::NotStarted)),
128                config,
129                workspace_host: workspace_host.into(),
130            }
131        }
132
133        /// Ensure the sandbox is running, booting it if necessary.
134        async fn ensure_running(&self) -> anyhow::Result<()> {
135            let mut state = self.state.lock().await;
136            if matches!(*state, SandboxState::NotStarted) {
137                tracing::info!(image = %self.config.image, "Booting A3S Box sandbox");
138                let sdk = BoxSdk::new()
139                    .await
140                    .context("BoxSdk initialization failed")?;
141
142                let opts = SandboxOptions {
143                    image: self.config.image.clone(),
144                    memory_mb: self.config.memory_mb,
145                    cpus: self.config.cpus,
146                    network: self.config.network,
147                    env: self.config.env.clone(),
148                    mounts: vec![MountSpec {
149                        host_path: self.workspace_host.clone(),
150                        guest_path: "/workspace".into(),
151                        readonly: false,
152                    }],
153                    workdir: Some("/workspace".into()),
154                    ..SandboxOptions::default()
155                };
156
157                let sandbox = sdk
158                    .create(opts)
159                    .await
160                    .context("Failed to create A3S Box sandbox")?;
161
162                tracing::info!("A3S Box sandbox ready");
163                *state = SandboxState::Running(sandbox);
164            }
165            Ok(())
166        }
167    }
168
169    #[async_trait]
170    impl BashSandbox for BoxSandboxHandle {
171        async fn exec_command(
172            &self,
173            command: &str,
174            _guest_workspace: &str,
175        ) -> anyhow::Result<SandboxOutput> {
176            self.ensure_running().await?;
177
178            let state = self.state.lock().await;
179            let sandbox = match &*state {
180                SandboxState::Running(s) => s,
181                SandboxState::NotStarted => {
182                    unreachable!("ensure_running guarantees Running state")
183                }
184            };
185
186            let result = sandbox
187                .exec("bash", &["-c", command])
188                .await
189                .context("Sandbox exec failed")?;
190
191            Ok(SandboxOutput {
192                stdout: result.stdout,
193                stderr: result.stderr,
194                exit_code: result.exit_code,
195            })
196        }
197
198        async fn shutdown(&self) {
199            let mut state = self.state.lock().await;
200            // Take ownership of the sandbox to call stop() (which takes self).
201            let old = std::mem::replace(&mut *state, SandboxState::NotStarted);
202            if let SandboxState::Running(sandbox) = old {
203                tracing::info!("Stopping A3S Box sandbox");
204                if let Err(e) = sandbox.stop().await {
205                    tracing::warn!("Sandbox stop failed (non-fatal): {}", e);
206                }
207            }
208        }
209    }
210
211    impl Drop for BoxSandboxHandle {
212        fn drop(&mut self) {
213            // Best-effort async cleanup: spawn a task to stop the sandbox.
214            let state = Arc::clone(&self.state);
215            if let Ok(handle) = tokio::runtime::Handle::try_current() {
216                handle.spawn(async move {
217                    let mut s = state.lock().await;
218                    let old = std::mem::replace(&mut *s, SandboxState::NotStarted);
219                    if let SandboxState::Running(sandbox) = old {
220                        let _ = sandbox.stop().await;
221                    }
222                });
223            }
224        }
225    }
226}
227
228// ============================================================================
229// Tests
230// ============================================================================
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::sync::Arc;
236
237    #[test]
238    fn test_sandbox_config_default() {
239        let cfg = SandboxConfig::default();
240        assert_eq!(cfg.image, "alpine:latest");
241        assert_eq!(cfg.memory_mb, 512);
242        assert_eq!(cfg.cpus, 1);
243        assert!(!cfg.network);
244        assert!(cfg.env.is_empty());
245    }
246
247    #[test]
248    fn test_sandbox_config_custom() {
249        let cfg = SandboxConfig {
250            image: "ubuntu:22.04".into(),
251            memory_mb: 1024,
252            cpus: 2,
253            network: true,
254            env: [("FOO".into(), "bar".into())].into(),
255        };
256        assert_eq!(cfg.image, "ubuntu:22.04");
257        assert_eq!(cfg.memory_mb, 1024);
258        assert_eq!(cfg.cpus, 2);
259        assert!(cfg.network);
260        assert_eq!(cfg.env["FOO"], "bar");
261    }
262
263    #[test]
264    fn test_sandbox_config_clone() {
265        let cfg = SandboxConfig {
266            image: "python:3.12-slim".into(),
267            ..SandboxConfig::default()
268        };
269        let cloned = cfg.clone();
270        assert_eq!(cloned.image, "python:3.12-slim");
271        assert_eq!(cloned.memory_mb, cfg.memory_mb);
272    }
273
274    // -----------------------------------------------------------------------
275    // Mock BashSandbox for testing the trait contract
276    // -----------------------------------------------------------------------
277
278    struct MockSandbox {
279        output: String,
280        exit_code: i32,
281    }
282
283    #[async_trait]
284    impl BashSandbox for MockSandbox {
285        async fn exec_command(
286            &self,
287            _command: &str,
288            _guest_workspace: &str,
289        ) -> anyhow::Result<SandboxOutput> {
290            Ok(SandboxOutput {
291                stdout: self.output.clone(),
292                stderr: String::new(),
293                exit_code: self.exit_code,
294            })
295        }
296
297        async fn shutdown(&self) {}
298    }
299
300    #[tokio::test]
301    async fn test_mock_sandbox_success() {
302        let sandbox = MockSandbox {
303            output: "hello sandbox\n".into(),
304            exit_code: 0,
305        };
306        let result = sandbox
307            .exec_command("echo hello sandbox", "/workspace")
308            .await
309            .unwrap();
310        assert_eq!(result.stdout, "hello sandbox\n");
311        assert_eq!(result.exit_code, 0);
312        assert!(result.stderr.is_empty());
313    }
314
315    #[tokio::test]
316    async fn test_mock_sandbox_nonzero_exit() {
317        let sandbox = MockSandbox {
318            output: String::new(),
319            exit_code: 127,
320        };
321        let result = sandbox
322            .exec_command("nonexistent_cmd", "/workspace")
323            .await
324            .unwrap();
325        assert_eq!(result.exit_code, 127);
326    }
327
328    #[tokio::test]
329    async fn test_bash_sandbox_is_arc_send_sync() {
330        // Verify the trait object is Send + Sync so it can live in ToolContext.
331        let sandbox: Arc<dyn BashSandbox> = Arc::new(MockSandbox {
332            output: "ok".into(),
333            exit_code: 0,
334        });
335        let result = sandbox.exec_command("true", "/workspace").await.unwrap();
336        assert_eq!(result.exit_code, 0);
337    }
338}