1use async_trait::async_trait;
15use std::collections::HashMap;
16
17#[derive(Debug, Clone)]
26pub struct SandboxConfig {
27 pub image: String,
29 pub memory_mb: u32,
31 pub cpus: u32,
33 pub network: bool,
35 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
51pub struct SandboxOutput {
57 pub stdout: String,
59 pub stderr: String,
61 pub exit_code: i32,
63}
64
65#[async_trait]
74pub trait BashSandbox: Send + Sync {
75 async fn exec_command(
81 &self,
82 command: &str,
83 guest_workspace: &str,
84 ) -> anyhow::Result<SandboxOutput>;
85
86 async fn shutdown(&self);
88}
89
90#[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 pub struct BoxSandboxHandle {
116 state: Arc<Mutex<SandboxState>>,
117 config: SandboxConfig,
118 workspace_host: String,
120 }
121
122 impl BoxSandboxHandle {
123 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 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 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 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#[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 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 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}