1use crate::types::{Layer3Result, ToolRequest, ToolResponse};
12use async_trait::async_trait;
13use parking_lot::RwLock;
14use sh_layer1::generate_short_id;
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::process::Command;
18use std::time::{Duration, Instant};
19
20#[async_trait]
24pub trait SandboxRuntime: Send + Sync {
25 async fn create(&self, config: SandboxConfig) -> Layer3Result<SandboxId>;
27
28 async fn destroy(&self, id: &SandboxId) -> Layer3Result<bool>;
30
31 async fn execute(
33 &self,
34 id: &SandboxId,
35 code: &str,
36 language: &str,
37 ) -> Layer3Result<ExecutionResult>;
38
39 async fn execute_tool(
41 &self,
42 id: &SandboxId,
43 request: ToolRequest,
44 ) -> Layer3Result<ToolResponse>;
45
46 async fn status(&self, id: &SandboxId) -> Layer3Result<SandboxStatus>;
48
49 async fn info(&self, id: &SandboxId) -> Layer3Result<Option<SandboxInfo>>;
51
52 async fn list(&self) -> Layer3Result<Vec<SandboxInfo>>;
54
55 async fn reset(&self, id: &SandboxId) -> Layer3Result<bool>;
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct SandboxId(pub String);
62
63impl std::fmt::Display for SandboxId {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 write!(f, "{}", self.0)
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct SandboxConfig {
72 pub base_image: String,
74 pub limits: SandboxLimits,
76 pub network: NetworkPolicy,
78 pub filesystem: FsPolicy,
80 pub env_vars: HashMap<String, String>,
82 pub working_dir: PathBuf,
84 pub timeout_secs: u64,
86 pub interactive: bool,
88}
89
90impl Default for SandboxConfig {
91 fn default() -> Self {
92 Self {
93 base_image: "default".to_string(),
94 limits: SandboxLimits::default(),
95 network: NetworkPolicy::Disabled,
96 filesystem: FsPolicy::ReadOnly,
97 env_vars: HashMap::new(),
98 working_dir: PathBuf::from("/sandbox"),
99 timeout_secs: 30,
100 interactive: false,
101 }
102 }
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct SandboxLimits {
108 pub max_memory: Option<u64>,
110 pub max_cpu_percent: Option<u32>,
112 pub max_file_size: Option<u64>,
114 pub max_processes: Option<u32>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum NetworkPolicy {
121 Disabled,
123 OutboundOnly,
125 RestrictedPorts(Vec<u16>),
127 Full,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum FsPolicy {
134 ReadOnly,
136 RestrictedDirs(Vec<PathBuf>),
138 TempWritable,
140 FullWritable,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum SandboxStatus {
147 Creating,
148 Ready,
149 Running,
150 Paused,
151 Error,
152 Destroyed,
153}
154
155#[derive(Debug, Clone)]
157pub struct SandboxInfo {
158 pub id: SandboxId,
160 pub status: SandboxStatus,
162 pub created_at: chrono::DateTime<chrono::Utc>,
164 pub memory_used: u64,
166 pub cpu_used: f32,
168 pub executions: u32,
170 pub config: SandboxConfig,
172}
173
174#[derive(Debug, Clone)]
176pub struct ExecutionResult {
177 pub stdout: String,
179 pub stderr: String,
181 pub exit_code: i32,
183 pub duration_ms: u64,
185 pub timed_out: bool,
187 pub killed: bool,
189}
190
191impl ExecutionResult {
192 pub fn success(stdout: String) -> Self {
194 Self {
195 stdout,
196 stderr: String::new(),
197 exit_code: 0,
198 duration_ms: 0,
199 timed_out: false,
200 killed: false,
201 }
202 }
203
204 pub fn failure(stderr: String, exit_code: i32) -> Self {
206 Self {
207 stdout: String::new(),
208 stderr,
209 exit_code,
210 duration_ms: 0,
211 timed_out: false,
212 killed: false,
213 }
214 }
215
216 pub fn timeout(stdout: String, stderr: String) -> Self {
218 Self {
219 stdout,
220 stderr,
221 exit_code: -1,
222 duration_ms: 0,
223 timed_out: true,
224 killed: false,
225 }
226 }
227
228 pub fn is_success(&self) -> bool {
230 self.exit_code == 0 && !self.timed_out && !self.killed
231 }
232}
233
234pub struct DefaultSandboxRuntime {
242 sandboxes: RwLock<HashMap<String, SandboxInfo>>,
243 temp_dir: PathBuf,
244}
245
246impl DefaultSandboxRuntime {
247 pub fn new() -> Layer3Result<Self> {
249 let temp_dir = std::env::temp_dir().join("continuum_sandboxes");
250 std::fs::create_dir_all(&temp_dir)?;
251
252 Ok(Self {
253 sandboxes: RwLock::new(HashMap::new()),
254 temp_dir,
255 })
256 }
257
258 fn get_language_command(language: &str) -> Option<(&'static str, &'static str)> {
260 match language.to_lowercase().as_str() {
261 "python" | "python3" | "py" => Some(("python", "-c")),
262 "javascript" | "js" | "node" => Some(("node", "-e")),
263 "ruby" | "rb" => Some(("ruby", "-e")),
264 "perl" | "pl" => Some(("perl", "-e")),
265 "bash" | "sh" | "shell" => Some(("bash", "-c")),
266 "lua" => Some(("lua", "-e")),
267 _ => None,
268 }
269 }
270
271 fn command_exists(cmd: &str) -> bool {
273 #[cfg(target_os = "windows")]
274 {
275 Command::new("where")
276 .arg(cmd)
277 .output()
278 .map(|o| o.status.success())
279 .unwrap_or(false)
280 }
281 #[cfg(not(target_os = "windows"))]
282 {
283 Command::new("which")
284 .arg(cmd)
285 .output()
286 .map(|o| o.status.success())
287 .unwrap_or(false)
288 }
289 }
290
291 fn execute_with_timeout(
293 &self,
294 cmd: &str,
295 args: &[&str],
296 input: Option<&str>,
297 timeout_secs: u64,
298 ) -> ExecutionResult {
299 let start = Instant::now();
300
301 let mut command = Command::new(cmd);
302 command.args(args);
303
304 command.env_clear();
306 command.env("PATH", std::env::var("PATH").unwrap_or_default());
307
308 command.current_dir(&self.temp_dir);
310
311 command.stdout(std::process::Stdio::piped());
313 command.stderr(std::process::Stdio::piped());
314
315 if input.is_some() {
317 command.stdin(std::process::Stdio::piped());
318 }
319
320 let spawn_result = command.spawn();
321
322 match spawn_result {
323 Ok(mut child) => {
324 if let Some(input_data) = input {
326 use std::io::Write;
327 if let Some(mut stdin) = child.stdin.take() {
328 let _ = stdin.write_all(input_data.as_bytes());
329 }
330 }
331
332 let timeout = Duration::from_secs(timeout_secs);
334 let result = child.wait_timeout(timeout);
335
336 match result {
337 Ok(Some(status)) => {
338 let stdout = read_child_stdout(&mut child);
339 let stderr = read_child_stderr(&mut child);
340 let duration_ms = start.elapsed().as_millis() as u64;
341
342 ExecutionResult {
343 stdout,
344 stderr,
345 exit_code: status.code().unwrap_or(-1),
346 duration_ms,
347 timed_out: false,
348 killed: false,
349 }
350 }
351 Ok(None) => {
352 let _ = child.kill();
354 let _ = child.wait();
355 let stdout = read_child_stdout(&mut child);
356 let stderr = read_child_stderr(&mut child);
357
358 ExecutionResult::timeout(stdout, stderr)
359 }
360 Err(e) => ExecutionResult::failure(format!("Wait error: {}", e), -1),
361 }
362 }
363 Err(e) => ExecutionResult::failure(format!("Spawn error: {}", e), -1),
364 }
365 }
366}
367
368impl Default for DefaultSandboxRuntime {
369 fn default() -> Self {
370 Self::new().expect("Failed to create DefaultSandboxRuntime")
371 }
372}
373
374#[async_trait]
375impl SandboxRuntime for DefaultSandboxRuntime {
376 async fn create(&self, config: SandboxConfig) -> Layer3Result<SandboxId> {
377 let id = SandboxId(generate_short_id());
378
379 let info = SandboxInfo {
380 id: id.clone(),
381 status: SandboxStatus::Ready,
382 created_at: chrono::Utc::now(),
383 memory_used: 0,
384 cpu_used: 0.0,
385 executions: 0,
386 config,
387 };
388
389 self.sandboxes.write().insert(id.0.clone(), info);
390 tracing::info!("Created sandbox: {}", id);
391
392 Ok(id)
393 }
394
395 async fn destroy(&self, id: &SandboxId) -> Layer3Result<bool> {
396 let mut sandboxes = self.sandboxes.write();
397 if let Some(mut info) = sandboxes.remove(&id.0) {
398 info.status = SandboxStatus::Destroyed;
399 tracing::info!("Destroyed sandbox: {}", id);
400 Ok(true)
401 } else {
402 Ok(false)
403 }
404 }
405
406 async fn execute(
407 &self,
408 id: &SandboxId,
409 code: &str,
410 language: &str,
411 ) -> Layer3Result<ExecutionResult> {
412 {
414 let sandboxes = self.sandboxes.read();
415 let info = sandboxes
416 .get(&id.0)
417 .ok_or_else(|| anyhow::anyhow!("Sandbox not found: {}", id))?;
418
419 if info.status != SandboxStatus::Ready {
420 return Err(anyhow::anyhow!("Sandbox not ready: {:?}", info.status));
421 }
422 }
423
424 {
426 let mut sandboxes = self.sandboxes.write();
427 if let Some(info) = sandboxes.get_mut(&id.0) {
428 info.status = SandboxStatus::Running;
429 }
430 }
431
432 let (cmd, flag) = Self::get_language_command(language)
434 .ok_or_else(|| anyhow::anyhow!("Unsupported language: {}", language))?;
435
436 if !Self::command_exists(cmd) {
438 return Err(anyhow::anyhow!("Command not found: {}", cmd));
439 }
440
441 let timeout = {
443 let sandboxes = self.sandboxes.read();
444 sandboxes
445 .get(&id.0)
446 .map(|i| i.config.timeout_secs)
447 .unwrap_or(30)
448 };
449
450 let result = self.execute_with_timeout(cmd, &[flag, code], None, timeout);
451
452 {
454 let mut sandboxes = self.sandboxes.write();
455 if let Some(info) = sandboxes.get_mut(&id.0) {
456 info.status = SandboxStatus::Ready;
457 info.executions += 1;
458 }
459 }
460
461 Ok(result)
462 }
463
464 async fn execute_tool(
465 &self,
466 id: &SandboxId,
467 request: ToolRequest,
468 ) -> Layer3Result<ToolResponse> {
469 Err(anyhow::anyhow!(
470 "[experimental] Tool execution in sandbox is not yet implemented (sandbox_id: {}, tool: {})",
471 id, request.name
472 ))
473 }
474
475 async fn status(&self, id: &SandboxId) -> Layer3Result<SandboxStatus> {
476 let sandboxes = self.sandboxes.read();
477 let info = sandboxes
478 .get(&id.0)
479 .ok_or_else(|| anyhow::anyhow!("Sandbox not found: {}", id))?;
480 Ok(info.status)
481 }
482
483 async fn info(&self, id: &SandboxId) -> Layer3Result<Option<SandboxInfo>> {
484 let sandboxes = self.sandboxes.read();
485 Ok(sandboxes.get(&id.0).cloned())
486 }
487
488 async fn list(&self) -> Layer3Result<Vec<SandboxInfo>> {
489 Ok(self.sandboxes.read().values().cloned().collect())
490 }
491
492 async fn reset(&self, id: &SandboxId) -> Layer3Result<bool> {
493 let mut sandboxes = self.sandboxes.write();
494 if let Some(info) = sandboxes.get_mut(&id.0) {
495 info.status = SandboxStatus::Ready;
496 info.executions = 0;
497 info.memory_used = 0;
498 info.cpu_used = 0.0;
499 tracing::info!("Reset sandbox: {}", id);
500 Ok(true)
501 } else {
502 Ok(false)
503 }
504 }
505}
506
507fn read_child_stdout(child: &mut std::process::Child) -> String {
513 use std::io::Read;
514 if let Some(mut stdout) = child.stdout.take() {
515 let mut buf = String::new();
516 let _ = stdout.read_to_string(&mut buf);
517 buf
518 } else {
519 String::new()
520 }
521}
522
523fn read_child_stderr(child: &mut std::process::Child) -> String {
525 use std::io::Read;
526 if let Some(mut stderr) = child.stderr.take() {
527 let mut buf = String::new();
528 let _ = stderr.read_to_string(&mut buf);
529 buf
530 } else {
531 String::new()
532 }
533}
534
535trait ChildWaitTimeout {
537 fn wait_timeout(
538 &mut self,
539 timeout: Duration,
540 ) -> std::io::Result<Option<std::process::ExitStatus>>;
541}
542
543impl ChildWaitTimeout for std::process::Child {
544 fn wait_timeout(
545 &mut self,
546 timeout: Duration,
547 ) -> std::io::Result<Option<std::process::ExitStatus>> {
548 let start = Instant::now();
549
550 loop {
551 match self.try_wait()? {
552 Some(status) => return Ok(Some(status)),
553 None => {
554 if start.elapsed() >= timeout {
555 return Ok(None);
556 }
557 std::thread::sleep(Duration::from_millis(10));
558 }
559 }
560 }
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn test_sandbox_config_default() {
570 let config = SandboxConfig::default();
571 assert_eq!(config.timeout_secs, 30);
572 assert_eq!(config.network, NetworkPolicy::Disabled);
573 }
574
575 #[test]
576 fn test_execution_result_success() {
577 let result = ExecutionResult::success("hello".to_string());
578 assert!(result.is_success());
579 }
580
581 #[test]
582 fn test_execution_result_timeout() {
583 let result = ExecutionResult::timeout("out".to_string(), "err".to_string());
584 assert!(result.timed_out);
585 assert!(!result.is_success());
586 }
587
588 #[test]
589 fn test_sandbox_id_display() {
590 let id = SandboxId("abc123".to_string());
591 assert_eq!(format!("{}", id), "abc123");
592 }
593
594 #[test]
595 fn test_language_command_mapping() {
596 assert!(DefaultSandboxRuntime::get_language_command("python").is_some());
597 assert!(DefaultSandboxRuntime::get_language_command("javascript").is_some());
598 assert!(DefaultSandboxRuntime::get_language_command("bash").is_some());
599 assert!(DefaultSandboxRuntime::get_language_command("unknown").is_none());
600 }
601
602 #[tokio::test]
603 async fn test_sandbox_create_and_destroy() {
604 let runtime = DefaultSandboxRuntime::new().unwrap();
605 let config = SandboxConfig::default();
606
607 let id = runtime.create(config).await.unwrap();
608 assert!(!id.0.is_empty());
609
610 let status = runtime.status(&id).await.unwrap();
611 assert_eq!(status, SandboxStatus::Ready);
612
613 let destroyed = runtime.destroy(&id).await.unwrap();
614 assert!(destroyed);
615
616 let status = runtime.status(&id).await;
617 assert!(status.is_err());
618 }
619
620 #[tokio::test]
621 async fn test_sandbox_list() {
622 let runtime = DefaultSandboxRuntime::new().unwrap();
623
624 let id1 = runtime.create(SandboxConfig::default()).await.unwrap();
625 let id2 = runtime.create(SandboxConfig::default()).await.unwrap();
626
627 let list = runtime.list().await.unwrap();
628 assert_eq!(list.len(), 2);
629
630 runtime.destroy(&id1).await.unwrap();
631 runtime.destroy(&id2).await.unwrap();
632
633 let list = runtime.list().await.unwrap();
634 assert!(list.is_empty());
635 }
636
637 #[tokio::test]
638 async fn test_sandbox_reset() {
639 let runtime = DefaultSandboxRuntime::new().unwrap();
640 let id = runtime.create(SandboxConfig::default()).await.unwrap();
641
642 {
644 let mut sandboxes = runtime.sandboxes.write();
645 if let Some(info) = sandboxes.get_mut(&id.0) {
646 info.executions = 5;
647 }
648 }
649
650 let reset = runtime.reset(&id).await.unwrap();
651 assert!(reset);
652
653 let info = runtime.info(&id).await.unwrap().unwrap();
654 assert_eq!(info.executions, 0);
655
656 runtime.destroy(&id).await.unwrap();
657 }
658
659 #[test]
660 fn test_network_policy_equality() {
661 assert_eq!(NetworkPolicy::Disabled, NetworkPolicy::Disabled);
662 assert_ne!(NetworkPolicy::Disabled, NetworkPolicy::Full);
663 }
664
665 #[test]
666 fn test_fs_policy_equality() {
667 assert_eq!(FsPolicy::ReadOnly, FsPolicy::ReadOnly);
668 assert_ne!(FsPolicy::ReadOnly, FsPolicy::FullWritable);
669 }
670
671 #[test]
672 fn test_execution_result_failure() {
673 let result = ExecutionResult::failure("error".to_string(), 1);
674 assert!(!result.is_success());
675 assert_eq!(result.exit_code, 1);
676 }
677
678 #[tokio::test]
679 async fn test_execute_tool_returns_contextual_experimental_error() {
680 let runtime = DefaultSandboxRuntime::new().unwrap();
681 let sandbox_id = runtime.create(SandboxConfig::default()).await.unwrap();
682 let request = ToolRequest {
683 call_id: "call_1".to_string(),
684 name: "read_file".to_string(),
685 arguments: serde_json::json!({"path": "README.md"}),
686 };
687
688 let err = runtime
689 .execute_tool(&sandbox_id, request)
690 .await
691 .unwrap_err();
692 let message = err.to_string();
693
694 assert!(message.contains("[experimental]"));
695 assert!(message.contains(&sandbox_id.to_string()));
696 assert!(message.contains("read_file"));
697 }
698}