use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use super::{BackendType, ExecResult, Sandbox, SandboxConfig};
static SYSTEM_VERIFIED: AtomicBool = AtomicBool::new(false);
pub fn apple_system_running() -> bool {
if SYSTEM_VERIFIED.load(Ordering::Relaxed) {
return true;
}
let running = Command::new("container")
.args(["system", "status"])
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("is running"))
.unwrap_or(false);
if running {
SYSTEM_VERIFIED.store(true, Ordering::Relaxed);
}
running
}
pub fn start_apple_system() -> Result<()> {
if SYSTEM_VERIFIED.load(Ordering::Relaxed) {
return Ok(());
}
if apple_system_running() {
return Ok(());
}
eprintln!("Starting Apple container system...");
let output = Command::new("sh")
.args(["-c", "echo 'Y' | container system start"])
.output()
.context("Failed to start Apple container system")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("already") {
bail!("Failed to start Apple container system: {}", stderr);
}
}
std::thread::sleep(std::time::Duration::from_millis(500));
SYSTEM_VERIFIED.store(true, Ordering::Relaxed);
Ok(())
}
pub fn apple_containers_available() -> bool {
Command::new("container")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn macos_version_supported() -> bool {
let output = Command::new("sw_vers").arg("-productVersion").output().ok();
if let Some(output) = output
&& let Ok(version) = String::from_utf8(output.stdout)
&& let Some(major) = version.trim().split('.').next()
&& let Ok(major_num) = major.parse::<u32>()
{
return major_num >= 26;
}
false
}
fn is_local_image(image: &str) -> bool {
image.starts_with("agentkernel-snap:")
|| (image.starts_with("agentkernel-")
&& !image.contains('/')
&& !image.contains(".io")
&& !image.contains(".com"))
}
fn apple_image_exists(image: &str) -> bool {
Command::new("container")
.args(["image", "inspect", image])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn import_image_from_docker(image: &str) -> Result<()> {
use std::process::Stdio;
let docker_check = Command::new("docker")
.args(["image", "inspect", image])
.output()
.context("Failed to check Docker for image")?;
if !docker_check.status.success() {
bail!(
"Image '{}' not found in Docker or Apple container stores. \
Was the snapshot created on this machine?",
image
);
}
eprintln!(
"Importing image '{}' from Docker into Apple containers...",
image
);
let docker_save = Command::new("docker")
.args(["save", image])
.stdout(Stdio::piped())
.spawn()
.context("Failed to run docker save")?;
let load_output = Command::new("container")
.args(["image", "load"])
.stdin(docker_save.stdout.unwrap())
.output()
.context("Failed to run container image load")?;
if !load_output.status.success() {
let stderr = String::from_utf8_lossy(&load_output.stderr);
bail!("Failed to import image into Apple containers: {}", stderr);
}
Ok(())
}
pub fn get_container_ip(container_name: &str) -> Option<String> {
let output = Command::new("container")
.args(["inspect", container_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let arr: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
let addr = arr
.get(0)?
.get("networks")?
.get(0)?
.get("address")?
.as_str()?;
Some(addr.split('/').next().unwrap_or(addr).to_string())
}
pub struct AppleSandbox {
name: String,
running: bool,
persistent: bool,
}
impl AppleSandbox {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
running: false,
persistent: false,
}
}
pub fn new_persistent(name: &str) -> Self {
Self {
name: name.to_string(),
running: false,
persistent: true,
}
}
fn container_name(&self) -> String {
format!("agentkernel-{}", self.name)
}
}
#[async_trait]
impl Sandbox for AppleSandbox {
async fn start(&mut self, config: &SandboxConfig) -> Result<()> {
start_apple_system()?;
let container_name = self.container_name();
if is_local_image(&config.image) && !apple_image_exists(&config.image) {
import_image_from_docker(&config.image)?;
}
let _ = Command::new("container")
.args(["delete", "-f", &container_name])
.output();
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
container_name.clone(),
];
args.push("--cpus".to_string());
args.push(config.vcpus.to_string());
args.push("--memory".to_string());
args.push(format!("{}M", config.memory_mb));
if config.mount_cwd
&& let Some(ref work_dir) = config.work_dir
{
args.push("-v".to_string());
args.push(format!("{}:/workspace", work_dir));
args.push("-w".to_string());
args.push("/workspace".to_string());
}
if config.mount_home
&& let Some(home) = std::env::var_os("HOME")
{
args.push("-v".to_string());
args.push(format!("{}:/home/user:ro", home.to_string_lossy()));
}
for (key, value) in &config.env {
args.push("-e".to_string());
args.push(format!("{}={}", key, value));
}
args.push(config.image.clone());
args.push("sleep".to_string());
args.push("infinity".to_string());
let output = Command::new("container")
.args(&args)
.output()
.context("Failed to start Apple container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to start container: {}", stderr);
}
self.running = true;
Ok(())
}
async fn exec(&mut self, cmd: &[&str]) -> Result<ExecResult> {
self.exec_with_env(cmd, &[]).await
}
async fn exec_with_env(&mut self, cmd: &[&str], env: &[String]) -> Result<ExecResult> {
let container_name = self.container_name();
let mut args = vec!["exec".to_string()];
for e in env {
args.push("-e".to_string());
args.push(e.clone());
}
args.push(container_name);
args.extend(cmd.iter().map(|s| s.to_string()));
let output = tokio::process::Command::new("container")
.args(&args)
.output()
.await
.context("Failed to run command in Apple container")?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
Ok(ExecResult {
exit_code,
stdout,
stderr,
})
}
async fn stop(&mut self) -> Result<()> {
let container_name = self.container_name();
let stop_timeout = std::time::Duration::from_secs(10);
let mut stop_child = tokio::process::Command::new("container")
.args(["stop", "-t", "1", &container_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
if let Some(ref mut child) = stop_child {
match tokio::time::timeout(stop_timeout, child.wait()).await {
Ok(_) => {}
Err(_) => {
let _ = child.kill().await;
}
}
}
let mut del_child = tokio::process::Command::new("container")
.args(["delete", "-f", &container_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
if let Some(ref mut child) = del_child {
match tokio::time::timeout(stop_timeout, child.wait()).await {
Ok(_) => {}
Err(_) => {
let _ = child.kill().await;
}
}
}
self.running = false;
Ok(())
}
fn name(&self) -> &str {
&self.name
}
fn backend_type(&self) -> BackendType {
BackendType::Apple
}
fn is_running(&self) -> bool {
let container_name = self.container_name();
Command::new("container")
.args(["ls"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&container_name))
.unwrap_or(false)
}
async fn write_file_unchecked(&mut self, path: &str, content: &[u8]) -> Result<()> {
let container_name = self.container_name();
let parent = std::path::Path::new(path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
let _ = Command::new("container")
.args(["exec", &container_name, "mkdir", "-p", &parent])
.output();
use base64::{Engine, engine::general_purpose::STANDARD};
let encoded = STANDARD.encode(content);
let decode_cmd = format!("echo '{}' | base64 -d > '{}'", encoded, path);
let output = Command::new("container")
.args(["exec", &container_name, "sh", "-c", &decode_cmd])
.output()
.context("Failed to write file in container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to write file: {}", stderr);
}
Ok(())
}
async fn read_file_unchecked(&mut self, path: &str) -> Result<Vec<u8>> {
let container_name = self.container_name();
let output = Command::new("container")
.args(["exec", &container_name, "base64", path])
.output()
.context("Failed to read file from container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to read file: {}", stderr);
}
use base64::{Engine, engine::general_purpose::STANDARD};
let decoded = STANDARD
.decode(String::from_utf8_lossy(&output.stdout).trim())
.context("Failed to decode base64 file content")?;
Ok(decoded)
}
async fn remove_file_unchecked(&mut self, path: &str) -> Result<()> {
let container_name = self.container_name();
let output = Command::new("container")
.args(["exec", &container_name, "rm", "-f", path])
.output()
.context("Failed to remove file in container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("rm failed: {}", stderr);
}
Ok(())
}
async fn mkdir_unchecked(&mut self, path: &str, recursive: bool) -> Result<()> {
let container_name = self.container_name();
let mut args = vec!["exec", &container_name, "mkdir"];
if recursive {
args.push("-p");
}
args.push(path);
let output = Command::new("container")
.args(&args)
.output()
.context("Failed to create directory in container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("mkdir failed: {}", stderr);
}
Ok(())
}
}
impl Drop for AppleSandbox {
fn drop(&mut self) {
if self.running && !self.persistent {
let container_name = self.container_name();
let _ = Command::new("container")
.args(["delete", "-f", &container_name])
.output();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_local_image_snapshot_tags() {
assert!(is_local_image("agentkernel-snap:test-snap"));
assert!(is_local_image("agentkernel-snap:my-snapshot"));
assert!(is_local_image("agentkernel-snap:v1"));
}
#[test]
fn test_is_local_image_other_agentkernel_tags() {
assert!(is_local_image("agentkernel-my-tools"));
assert!(is_local_image("agentkernel-custom:latest"));
}
#[test]
fn test_is_local_image_rejects_registry_images() {
assert!(!is_local_image("alpine:3.20"));
assert!(!is_local_image("python:3.12-alpine"));
assert!(!is_local_image("docker.io/library/alpine"));
assert!(!is_local_image("ghcr.io/user/image:latest"));
assert!(!is_local_image("registry.example.com/foo"));
}
}