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 #[allow(clippy::large_enum_variant)]
107 enum SandboxState {
108 NotStarted,
109 Running(Sandbox),
110 }
111
112 pub struct BoxSandboxHandle {
117 state: Arc<Mutex<SandboxState>>,
118 config: SandboxConfig,
119 workspace_host: String,
121 }
122
123 impl BoxSandboxHandle {
124 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 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 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 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#[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 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 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}