Isolated execution environments for local and remote command execution
All commands and file operations in Magpie route through the Sandbox trait, providing full isolation between pipeline runs and support for both local and remote execution environments.
pub struct LocalSandbox { working_dir: PathBuf, /// If we own a temp dir (from clone), keep it alive and clean up on destroy. _temp_dir: Mutex<Option<tempfile::TempDir>>,}impl LocalSandbox { /// Create a sandbox backed by an existing local directory. /// /// No cleanup is performed on `destroy()`. pub fn from_path(path: PathBuf) -> Self { Self { working_dir: path, _temp_dir: Mutex::new(None), } } /// Clone a repo into a temp directory and use it as the sandbox working dir. /// /// The temp directory is cleaned up on `destroy()` (or when the sandbox is dropped). pub fn from_clone(repo_name: &str, org: &str) -> Result<Self> { let full_name = format!("{org}/{repo_name}"); let temp_dir = tempfile::tempdir()?; let clone_target = temp_dir.path().join(repo_name); let output = std::process::Command::new("gh") .args(["repo", "clone", &full_name, clone_target.to_str().unwrap()]) .output() .context("failed to run gh repo clone")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("gh repo clone failed for '{full_name}': {stderr}"); } Ok(Self { working_dir: clone_target, _temp_dir: Mutex::new(Some(temp_dir)), }) }}
#[async_trait]impl Sandbox for LocalSandbox { fn name(&self) -> &str { "local" } fn working_dir(&self) -> &str { self.working_dir.to_str().unwrap_or(".") } async fn exec(&self, command: &str, args: &[&str]) -> Result<ExecOutput> { let output = std::process::Command::new(command) .args(args) .current_dir(&self.working_dir) .output() .with_context(|| format!("failed to run {command} {}", args.join(" ")))?; Ok(ExecOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), exit_code: output.status.code().unwrap_or(-1), }) } async fn read_file(&self, path: &str) -> Result<Vec<u8>> { let full_path = self.working_dir.join(path); std::fs::read(&full_path).with_context(|| format!("failed to read {}", full_path.display())) } async fn write_file(&self, path: &str, content: &[u8]) -> Result<()> { let full_path = self.working_dir.join(path); if let Some(parent) = full_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&full_path, content) .with_context(|| format!("failed to write {}", full_path.display())) } async fn destroy(&self) -> Result<()> { // Drop the temp dir if we own one — this deletes the directory. let mut guard = self._temp_dir.lock().unwrap(); *guard = None; Ok(()) }}
LocalSandbox::from_path() uses an existing directory (no cleanup on destroy). LocalSandbox::from_clone() clones a repo into a temp dir and cleans it up on destroy.
pub async fn create_from_snapshot( config: &DaytonaConfig, snapshot_name: &str, working_dir: &str, env: HashMap<String, String>, volumes: Vec<SandboxVolumeAttachment>,) -> Result<Self> { let daytona_config = daytona_client::DaytonaConfig::new(&config.api_key) .with_base_url(&config.base_url) .with_timeout(600); let daytona_config = if let Some(ref org_id) = config.organization_id { daytona_config.with_organization_id(org_id) } else { daytona_config }; let client = DaytonaClient::new(daytona_config) .context("failed to create Daytona client")?; info!(snapshot = snapshot_name, "creating sandbox from snapshot"); let sandbox = client .sandboxes() .create(CreateSandboxParams { snapshot: Some(snapshot_name.to_string()), class: Some(config.sandbox_class.clone()), env: if env.is_empty() { None } else { Some(env) }, volumes: if volumes.is_empty() { None } else { Some(volumes) }, ..Default::default() }) .await .context("failed to create sandbox from snapshot")?; let sandbox_id = sandbox.id; // Wait for the sandbox to reach Started state (large images can take 5 min) info!(sandbox_id = %sandbox_id, "waiting for sandbox to reach Started state"); client .sandboxes() .wait_for_state( &sandbox_id, daytona_client::SandboxState::Started, 300, // max 5 minutes ) .await .context("sandbox did not reach Started state")?; // Configure git and permissions let setup_cmd = format!( "sh -c '\ sudo git config --system --add safe.directory {} 2>/dev/null || true; \ sudo git config --system user.email magpie@bot 2>/dev/null || true; \ sudo git config --system user.name Magpie 2>/dev/null || true; \ sudo chmod -R 777 {wd} 2>/dev/null || true; \ cd {wd} && git checkout -- . 2>/dev/null || true; \ gh auth setup-git 2>/dev/null || true'", working_dir, wd = working_dir ); let setup_result = client .process() .execute_command(&sandbox_id, &setup_cmd) .await .context("failed to configure workspace")?; info!( sandbox_id = %sandbox_id, exit_code = setup_result.exit_code, "workspace setup completed" ); Ok(Self { client, sandbox_id, working_dir: working_dir.to_string(), })}
Snapshots are Docker images built ahead of time with the repo, toolchain (Rust/cargo), and dependencies pre-installed. This reduces sandbox creation time from ~5 minutes to ~30 seconds.