Overview
The Sandbox trait provides an abstraction for where commands execute and files live. Each pipeline run creates its own Box<dyn Sandbox>, providing full isolation between concurrent runs.
Magpie includes two built-in implementations:
- LocalSandbox: Runs commands locally via
std::process::Command (current behavior)
- DaytonaSandbox: Runs commands in a remote Daytona sandbox via REST API (feature-flagged)
Trait Definition
use anyhow::Result;
use async_trait::async_trait;
#[async_trait]
pub trait Sandbox: Send + Sync {
fn name(&self) -> &str;
fn working_dir(&self) -> &str;
async fn exec(&self, command: &str, args: &[&str]) -> Result<ExecOutput>;
async fn exec_shell(&self, shell_cmd: &str) -> Result<ExecOutput> {
self.exec("sh", &["-c", shell_cmd]).await
}
async fn read_file(&self, path: &str) -> Result<Vec<u8>>;
async fn write_file(&self, path: &str, content: &[u8]) -> Result<()>;
async fn destroy(&self) -> Result<()>;
}
Types
ExecOutput
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl ExecOutput {
/// Combine stdout and stderr, preferring whichever is non-empty.
pub fn combined(&self) -> String {
if self.stderr.is_empty() {
self.stdout.clone()
} else if self.stdout.is_empty() {
self.stderr.clone()
} else {
format!("{}\n{}", self.stdout, self.stderr)
}
}
}
DaytonaConfig
#[derive(Debug, Clone)]
pub struct DaytonaConfig {
pub api_key: String,
pub base_url: String,
pub organization_id: Option<String>,
pub sandbox_class: String,
/// Daytona snapshot name to create sandboxes from (pre-built image).
pub snapshot_name: Option<String>,
/// Environment variables to inject into the sandbox at creation time.
pub env_vars: std::collections::HashMap<String, String>,
/// Persistent volume ID for build cache (e.g. cargo target dir).
pub volume_id: Option<String>,
/// Mount point for the persistent volume inside the sandbox.
pub volume_mount_path: Option<String>,
}
Methods
name
fn(&self) -> &str
required
Human-readable name for this sandbox type (e.g., “local”, “daytona”).Used for logging and telemetry.
working_dir
fn(&self) -> &str
required
The root working directory inside this sandbox.All relative paths in exec, read_file, and write_file are resolved relative to this directory.
exec
async fn(&self, command: &str, args: &[&str]) -> Result<ExecOutput>
required
Execute a command with arguments inside the sandbox.Parameters:
command - The command to run (e.g., “cargo”, “git”)
args - Array of command arguments
Returns:
Ok(ExecOutput) - Command output with stdout, stderr, and exit code
Err(_) - If command execution fails
Example:let output = sandbox.exec("cargo", &["test", "--all"]).await?;
if output.exit_code != 0 {
eprintln!("Tests failed: {}", output.stderr);
}
exec_shell
async fn(&self, shell_cmd: &str) -> Result<ExecOutput>
Execute a shell command string (passed to sh -c).Parameters:
shell_cmd - Shell command string (supports pipes, &&, etc.)
Returns:
Ok(ExecOutput) - Command output
Err(_) - If execution fails
Default Implementation: Calls self.exec("sh", &["-c", shell_cmd])Example:let output = sandbox.exec_shell("cargo build && cargo test").await?;
read_file
async fn(&self, path: &str) -> Result<Vec<u8>>
required
Read a file from the sandbox filesystem.Parameters:
path - File path (relative to working_dir or absolute)
Returns:
Ok(Vec<u8>) - File contents as bytes
Err(_) - If file doesn’t exist or read fails
write_file
async fn(&self, path: &str, content: &[u8]) -> Result<()>
required
Write a file to the sandbox filesystem.Parameters:
path - File path (relative to working_dir or absolute)
content - File contents as bytes
Returns:
Ok(()) - File written successfully
Err(_) - If write fails
Note: Creates parent directories automatically if they don’t exist.
destroy
async fn(&self) -> Result<()>
required
Destroy the sandbox and clean up resources.Returns:
Ok(()) - Cleanup successful
Err(_) - If cleanup fails
Note: For LocalSandbox with temp directories, this deletes the temp dir. For DaytonaSandbox, this calls the Daytona API to destroy the remote sandbox.
LocalSandbox Implementation
Construction
use std::path::PathBuf;
use magpie_core::sandbox::LocalSandbox;
// From existing directory (no cleanup on destroy)
let sandbox = LocalSandbox::from_path(PathBuf::from("/path/to/repo"));
// From cloned repo (temp dir cleaned up on destroy)
let sandbox = LocalSandbox::from_clone("api-service", "myorg")?;
Example
use magpie_core::sandbox::{LocalSandbox, Sandbox};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let sandbox = LocalSandbox::from_path(PathBuf::from("/tmp"));
// Execute command
let output = sandbox.exec("echo", &["hello"]).await?;
assert_eq!(output.exit_code, 0);
assert!(output.stdout.contains("hello"));
// Write and read file
sandbox.write_file("test.txt", b"hello world").await?;
let content = sandbox.read_file("test.txt").await?;
assert_eq!(content, b"hello world");
// Shell command with pipes
let output = sandbox.exec_shell("echo hello && echo world").await?;
assert!(output.stdout.contains("hello"));
assert!(output.stdout.contains("world"));
// Cleanup
sandbox.destroy().await?;
Ok(())
}
DaytonaSandbox Implementation
Construction
use magpie_core::sandbox::{DaytonaConfig, DaytonaSandbox};
use std::collections::HashMap;
let config = DaytonaConfig {
api_key: std::env::var("DAYTONA_API_KEY")?,
base_url: "https://app.daytona.io/api".to_string(),
organization_id: None,
sandbox_class: "small".to_string(),
snapshot_name: Some("magpie-self".to_string()),
env_vars: HashMap::new(),
volume_id: None,
volume_mount_path: None,
};
// Create and clone repo
let sandbox = DaytonaSandbox::create(&config, "myorg/api-service").await?;
// Or create from pre-built snapshot
let sandbox = DaytonaSandbox::create_from_snapshot(
&config,
"magpie-self",
"/workspace/magpie",
env_vars,
volumes,
).await?;
Example
use magpie_core::sandbox::{DaytonaConfig, DaytonaSandbox, Sandbox};
use std::collections::HashMap;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = DaytonaConfig {
api_key: std::env::var("DAYTONA_API_KEY")?,
base_url: "https://app.daytona.io/api".to_string(),
organization_id: None,
sandbox_class: "small".to_string(),
snapshot_name: None,
env_vars: HashMap::new(),
volume_id: None,
volume_mount_path: None,
};
// Create remote sandbox with cloned repo
let sandbox = DaytonaSandbox::create(&config, "block/goose").await?;
// Execute commands remotely
let output = sandbox.exec("cargo", &["test"]).await?;
println!("Exit code: {}", output.exit_code);
// Read/write files via API
sandbox.write_file("config.toml", b"key = 'value'").await?;
let content = sandbox.read_file("config.toml").await?;
// Destroy remote sandbox
sandbox.destroy().await?;
Ok(())
}
Design Notes
- The trait uses
async_trait for all I/O operations
- Requires
Send + Sync bounds for use across async task boundaries
ExecOutput combines stdout and stderr into a single stream for remote execution
- Remote sandboxes (Daytona) execute commands via
sh -c to support shell operators
- File paths can be relative (to working_dir) or absolute
- The
destroy method is idempotent and safe to call multiple times