use crate::audit::{AuditEvent, log_event};
use crate::backend::{
BackendType, FileInjection, PortMapping, Sandbox, SandboxConfig, create_sandbox,
detect_best_backend,
};
use crate::config::Config;
use crate::docker_backend::detect_container_runtime;
use crate::languages::docker_image_to_firecracker_runtime;
use crate::permissions::Permissions;
use crate::pool::ContainerPool;
use crate::proxy::{ProxyConfig, ProxyHandle, SecretBinding};
use crate::secrets::{SecretBackend, SecretVault};
use crate::validation;
use crate::volume::{VolumeManager, VolumeMount};
use anyhow::{Result, bail};
#[derive(Debug, thiserror::Error)]
#[error("Command exited with code {exit_code}: {output}")]
pub struct CommandFailed {
pub exit_code: i32,
pub output: String,
}
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
static PROXY_HANDLES: std::sync::LazyLock<RwLock<HashMap<String, ProxyHandle>>> =
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
use tokio::sync::OnceCell;
static CONTAINER_POOL: OnceCell<Arc<ContainerPool>> = OnceCell::const_new();
async fn get_pool() -> Result<Arc<ContainerPool>> {
CONTAINER_POOL
.get_or_try_init(|| async {
let pool = ContainerPool::with_config(5, 20, "alpine:3.20")?;
pool.start().await?;
Ok(Arc::new(pool))
})
.await
.cloned()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SandboxLifecyclePolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_stop_after_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_archive_after_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_delete_after_seconds: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleAction {
pub sandbox: String,
pub action: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LifecycleReconcileResult {
pub dry_run: bool,
pub stopped: Vec<String>,
pub archived: Vec<String>,
pub removed: Vec<String>,
pub actions: Vec<LifecycleAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxState {
pub name: String,
#[serde(default)]
pub uuid: String,
pub image: String,
pub vcpus: u32,
pub memory_mb: u64,
pub vsock_cid: u32,
pub created_at: String,
#[serde(default)]
pub backend: Option<BackendType>,
#[serde(default)]
pub remote_id: Option<String>,
#[serde(default)]
pub remote_namespace: Option<String>,
#[serde(default)]
pub ttl_seconds: Option<u64>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub ports: Vec<PortMapping>,
#[serde(default)]
pub ssh_enabled: bool,
#[serde(default)]
pub ssh_host_port: Option<u16>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub secret_bindings: Vec<String>,
#[serde(default)]
pub secret_files: Vec<String>,
#[serde(default)]
pub placeholder_secrets: bool,
#[serde(default)]
pub proxy_port: Option<u16>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub secret_mappings: HashMap<String, String>,
#[serde(default)]
pub init_script: Option<String>,
#[serde(default)]
pub created_from_template: Option<String>,
#[serde(default)]
pub template_help_text: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub last_activity_at: Option<String>,
#[serde(default)]
pub archived_at: Option<String>,
#[serde(default)]
pub archived_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle_policy: Option<SandboxLifecyclePolicy>,
}
impl SandboxState {
pub fn status(&self, running: bool) -> &'static str {
if self.archived_at.is_some() {
"archived"
} else if running {
"running"
} else {
"stopped"
}
}
fn parse_rfc3339(ts: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc))
}
fn last_activity_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.last_activity_at
.as_deref()
.and_then(Self::parse_rfc3339)
.or_else(|| Self::parse_rfc3339(&self.created_at))
}
fn archived_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.archived_at.as_deref().and_then(Self::parse_rfc3339)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DetachedStatus {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetachedCommand {
pub id: String,
pub sandbox: String,
pub command: Vec<String>,
pub pid: u32,
pub status: DetachedStatus,
pub exit_code: Option<i32>,
pub started_at: String,
}
pub struct VmManager {
backend: BackendType,
running: HashMap<String, Box<dyn Sandbox>>,
sandboxes: HashMap<String, SandboxState>,
data_dir: PathBuf,
rootfs_dir: Option<PathBuf>,
next_cid: u32,
detached: HashMap<String, DetachedCommand>,
#[cfg(feature = "enterprise")]
policy_engine: Option<crate::policy::PolicyEngine>,
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn batch_cmd(names: &[String], cmd: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
if names.is_empty() {
return Err(std::io::Error::other("empty"));
}
std::process::Command::new(cmd).args(args).output()
}
impl VmManager {
pub fn new() -> Result<Self> {
Self::with_backend(None)
}
pub fn with_backend(explicit_backend: Option<BackendType>) -> Result<Self> {
let data_dir = Self::data_dir();
let sandboxes_dir = data_dir.join("sandboxes");
std::fs::create_dir_all(&sandboxes_dir)?;
let backend = if let Some(b) = explicit_backend {
if !crate::backend::backend_available(b) {
bail!("Backend '{}' is not available on this system", b);
}
b
} else {
detect_best_backend().ok_or_else(|| {
anyhow::anyhow!(
"No sandbox backend available. Need one of: KVM (Linux), Apple containers (macOS 26+), or Docker/Podman."
)
})?
};
let rootfs_dir = if backend == BackendType::Firecracker {
Self::find_images_dir().ok().map(|d| d.join("rootfs"))
} else {
None
};
let sandboxes = Self::load_sandboxes(&sandboxes_dir)?;
let max_cid = sandboxes.values().map(|s| s.vsock_cid).max().unwrap_or(2);
#[cfg(feature = "enterprise")]
let policy_engine = {
let default_config = PathBuf::from("agentkernel.toml");
if default_config.exists() {
if let Ok(cfg) = Config::from_file(&default_config) {
if cfg.enterprise.enabled {
match crate::policy::PolicyEngine::new(&cfg.enterprise) {
Ok(engine) => {
eprintln!("[enterprise] Policy engine initialized");
Some(engine)
}
Err(e) => {
eprintln!("[enterprise] Failed to initialize policy engine: {}", e);
None
}
}
} else {
None
}
} else {
None
}
} else {
None
}
};
let mut manager = Self {
backend,
running: HashMap::new(),
sandboxes,
data_dir,
rootfs_dir,
next_cid: max_cid + 1,
detached: HashMap::new(),
#[cfg(feature = "enterprise")]
policy_engine,
};
manager.detect_running_sandboxes();
crate::metrics::set_active_sandboxes(manager.sandboxes.len() as i64);
Ok(manager)
}
fn detect_running_sandboxes(&mut self) {
use std::collections::HashSet;
let mut docker_names: Vec<String> = Vec::new();
let mut podman_names: Vec<String> = Vec::new();
let mut k8s_names: Vec<String> = Vec::new();
let mut nomad_names: Vec<String> = Vec::new();
let mut apple_names: Vec<String> = Vec::new();
for (name, state) in &self.sandboxes {
match state.backend.unwrap_or(self.backend) {
BackendType::Docker => docker_names.push(name.clone()),
BackendType::Podman => podman_names.push(name.clone()),
BackendType::Kubernetes => k8s_names.push(name.clone()),
BackendType::Nomad => nomad_names.push(name.clone()),
BackendType::Apple => apple_names.push(name.clone()),
_ => {}
}
}
let mut running_set: HashSet<String> = HashSet::new();
let match_active =
|names: &[String], active: &HashSet<&str>, running: &mut HashSet<String>| {
for name in names {
if active.contains(format!("agentkernel-{}", name).as_str()) {
running.insert(name.clone());
}
}
};
if let Ok(output) = batch_cmd(&docker_names, "docker", &["ps", "--format", "{{.Names}}"]) {
let stdout = String::from_utf8_lossy(&output.stdout);
let active: HashSet<&str> = stdout.lines().collect();
match_active(&docker_names, &active, &mut running_set);
}
if let Ok(output) = batch_cmd(&podman_names, "podman", &["ps", "--format", "{{.Names}}"]) {
let stdout = String::from_utf8_lossy(&output.stdout);
let active: HashSet<&str> = stdout.lines().collect();
match_active(&podman_names, &active, &mut running_set);
}
if let Ok(output) = batch_cmd(
&k8s_names,
"kubectl",
&[
"get",
"pods",
"-n",
"agentkernel",
"--field-selector=status.phase=Running",
"-o",
"jsonpath={.items[*].metadata.name}",
],
) {
let stdout = String::from_utf8_lossy(&output.stdout);
let active: HashSet<&str> = stdout.split_whitespace().collect();
match_active(&k8s_names, &active, &mut running_set);
}
if let Ok(output) = batch_cmd(&nomad_names, "nomad", &["job", "status", "-short"]) {
let stdout = String::from_utf8_lossy(&output.stdout);
let active: HashSet<&str> = stdout
.lines()
.filter(|line| line.contains("running"))
.filter_map(|line| line.split_whitespace().next())
.collect();
match_active(&nomad_names, &active, &mut running_set);
}
if let Ok(output) = batch_cmd(&apple_names, "container", &["ls"]) {
let stdout = String::from_utf8_lossy(&output.stdout);
for name in &apple_names {
if stdout.contains(&format!("agentkernel-{}", name)) {
running_set.insert(name.clone());
}
}
}
for name in running_set {
let backend = self
.sandboxes
.get(&name)
.and_then(|s| s.backend)
.unwrap_or(self.backend);
if let Ok(sandbox) = create_sandbox(backend, &name) {
self.running.insert(name, sandbox);
}
}
}
fn data_dir() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".local/share/agentkernel")
} else {
PathBuf::from("/tmp/agentkernel")
}
}
fn load_sandboxes(sandboxes_dir: &Path) -> Result<HashMap<String, SandboxState>> {
let mut sandboxes = HashMap::new();
if sandboxes_dir.exists() {
for entry in std::fs::read_dir(sandboxes_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Ok(content) = std::fs::read_to_string(&path)
&& let Ok(mut state) = serde_json::from_str::<SandboxState>(&content)
{
if state.uuid.is_empty() {
state.uuid = uuid::Uuid::now_v7().to_string();
match serde_json::to_string_pretty(&state)
.map_err(anyhow::Error::from)
.and_then(|updated| {
std::fs::write(&path, updated).map_err(anyhow::Error::from)
}) {
Ok(()) => {}
Err(e) => {
eprintln!(
"[vmm] warning: failed to backfill UUID for {}: {}",
path.display(),
e
);
}
}
}
sandboxes.insert(state.name.clone(), state);
}
}
}
Ok(sandboxes)
}
fn save_sandbox(&self, state: &SandboxState) -> Result<()> {
let path = self
.data_dir
.join("sandboxes")
.join(format!("{}.json", state.name));
let content = serde_json::to_string_pretty(state)?;
std::fs::write(path, content)?;
Ok(())
}
fn delete_sandbox(&self, name: &str) -> Result<()> {
let path = self
.data_dir
.join("sandboxes")
.join(format!("{}.json", name));
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
fn find_images_dir() -> Result<PathBuf> {
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home).join(".local/share/agentkernel/images");
if home_path.join("kernel").exists() || home_path.join("rootfs").exists() {
return Ok(home_path);
}
}
let paths = [PathBuf::from("images"), PathBuf::from("../images")];
for path in &paths {
if path.join("kernel").exists() || path.join("rootfs").exists() {
return Ok(path.clone());
}
}
bail!("Images directory not found. Run 'agentkernel setup' first.")
}
pub fn rootfs_path(&self, runtime: &str) -> Result<PathBuf> {
validation::validate_runtime(runtime)?;
let rootfs_dir = self
.rootfs_dir
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Rootfs directory not configured"))?;
let path = rootfs_dir.join(format!("{}.ext4", runtime));
if !path.exists() {
bail!(
"Rootfs not found: {}. Run 'agentkernel setup' first.",
path.display()
);
}
Ok(path)
}
#[cfg(feature = "enterprise")]
async fn check_enterprise_policy(
&self,
action: crate::policy::Action,
sandbox_name: &str,
agent_type: &str,
runtime: &str,
) -> Result<()> {
if let Some(ref engine) = self.policy_engine {
let principal = crate::policy::Principal {
id: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
email: std::env::var("USER")
.map(|u| format!("{}@local", u))
.unwrap_or_else(|_| "unknown@local".to_string()),
org_id: "local".to_string(),
roles: vec!["developer".to_string()],
mfa_verified: false,
};
let resource = crate::policy::Resource {
name: sandbox_name.to_string(),
agent_type: agent_type.to_string(),
runtime: runtime.to_string(),
};
let decision = engine.evaluate(&principal, action, &resource).await;
if !decision.is_permit() {
bail!(
"Enterprise policy denied action '{}' on sandbox '{}': {}",
action,
sandbox_name,
decision.reason
);
}
}
Ok(())
}
pub async fn create(
&mut self,
name: &str,
image: &str,
vcpus: u32,
memory_mb: u64,
) -> Result<()> {
self.create_with_options(name, image, vcpus, memory_mb, None, Vec::new())
.await
}
pub async fn create_with_ttl(
&mut self,
name: &str,
image: &str,
vcpus: u32,
memory_mb: u64,
ttl_seconds: Option<u64>,
) -> Result<()> {
self.create_with_options(name, image, vcpus, memory_mb, ttl_seconds, Vec::new())
.await
}
pub async fn create_with_options(
&mut self,
name: &str,
image: &str,
vcpus: u32,
memory_mb: u64,
ttl_seconds: Option<u64>,
ports: Vec<PortMapping>,
) -> Result<()> {
self.create_with_agent(name, image, vcpus, memory_mb, ttl_seconds, ports, None)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_with_agent(
&mut self,
name: &str,
image: &str,
vcpus: u32,
memory_mb: u64,
ttl_seconds: Option<u64>,
ports: Vec<PortMapping>,
agent: Option<String>,
) -> Result<()> {
let create_start = std::time::Instant::now();
if self.sandboxes.contains_key(name) {
bail!("Sandbox '{}' already exists", name);
}
#[cfg(feature = "enterprise")]
self.check_enterprise_policy(
crate::policy::Action::Create,
name,
"unknown",
crate::languages::docker_image_to_firecracker_runtime(image),
)
.await?;
let effective_image = if self.backend == BackendType::Firecracker {
let runtime = docker_image_to_firecracker_runtime(image);
self.rootfs_path(runtime)?;
runtime.to_string()
} else {
image.to_string()
};
let vsock_cid = self.next_cid;
self.next_cid += 1;
let created = chrono::Utc::now();
let created_at = created.to_rfc3339();
let expires_at =
ttl_seconds.map(|ttl| (created + chrono::Duration::seconds(ttl as i64)).to_rfc3339());
let state = SandboxState {
name: name.to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: effective_image.clone(),
vcpus,
memory_mb,
vsock_cid,
created_at: created_at.clone(),
backend: Some(self.backend),
remote_id: None,
remote_namespace: None,
ttl_seconds,
expires_at,
ports,
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: Some(created_at),
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
self.save_sandbox(&state)?;
self.sandboxes.insert(name.to_string(), state);
log_event(AuditEvent::SandboxCreated {
name: name.to_string(),
image: effective_image,
backend: self.backend.to_string(),
labels: self
.sandboxes
.get(name)
.map(|s| s.labels.clone())
.unwrap_or_default(),
});
crate::metrics::record_sandbox_lifecycle(
"created",
&self.backend.to_string(),
create_start.elapsed().as_secs_f64(),
);
crate::metrics::inc_active_sandboxes();
Ok(())
}
pub fn set_ssh_enabled(&mut self, name: &str, enabled: bool) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.ssh_enabled = enabled;
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn set_secret_mappings(
&mut self,
name: &str,
mappings: &HashMap<String, String>,
) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.secret_mappings = mappings.clone();
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn set_labels(&mut self, name: &str, labels: &HashMap<String, String>) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.labels = labels.clone();
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn set_description(&mut self, name: &str, description: Option<&str>) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.description = description.map(String::from);
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn set_identity_metadata(
&mut self,
name: &str,
uuid: &str,
created_at: &str,
expires_at: Option<&str>,
) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.uuid = uuid.to_string();
state.created_at = created_at.to_string();
state.expires_at = expires_at.map(ToString::to_string);
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn set_volumes(&mut self, name: &str, volumes: &[String]) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.volumes = volumes.to_vec();
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn set_lifecycle_policy(
&mut self,
name: &str,
policy: Option<SandboxLifecyclePolicy>,
) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.lifecycle_policy = policy;
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn touch_activity(&mut self, name: &str) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.last_activity_at = Some(chrono::Utc::now().to_rfc3339());
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub fn set_secret_bindings(&mut self, name: &str, bindings: &[String]) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.secret_bindings = bindings.to_vec();
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn set_secret_files(&mut self, name: &str, keys: &[String]) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.secret_files = keys.to_vec();
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn set_placeholder_secrets(&mut self, name: &str, enabled: bool) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.placeholder_secrets = enabled;
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
async fn inject_placeholder_secrets(
&mut self,
sandbox: &mut dyn crate::backend::Sandbox,
name: &str,
resolved: &HashMap<String, String>,
backend: BackendType,
) -> Result<()> {
match crate::vsock_secrets::inject_secrets_as_placeholders(
sandbox,
crate::vsock_secrets::DEFAULT_SECRETS_PATH,
resolved,
)
.await
{
Ok((injected, placeholder_map)) => {
eprintln!(
"Injected {} placeholder secret file(s) at {} (real values never enter VM)",
injected.len(),
crate::vsock_secrets::DEFAULT_SECRETS_PATH,
);
if !placeholder_map.is_empty() {
let proxy_config = crate::proxy::ProxyConfig {
listen_addr: "0.0.0.0:0".parse().unwrap(),
bindings: Vec::new(),
allowed_hosts: Vec::new(),
blocked_hosts: Vec::new(),
allowlist_only: false,
sandbox_name: name.to_string(),
hooks: Vec::new(),
llm_intercept: true,
llm_domains: Vec::new(),
org_managed_domains: Vec::new(),
};
match crate::proxy::start_proxy(proxy_config, HashMap::new(), placeholder_map)
.await
{
Ok(handle) => {
let proxy_addr = handle.addr;
let proxy_host = match backend {
BackendType::Apple => {
format!("192.168.64.1:{}", proxy_addr.port())
}
BackendType::Docker | BackendType::Podman => {
if cfg!(target_os = "macos") {
format!("host.docker.internal:{}", proxy_addr.port())
} else {
format!("172.17.0.1:{}", proxy_addr.port())
}
}
_ => format!("127.0.0.1:{}", proxy_addr.port()),
};
let ca_pem = handle.ca_cert_pem.clone();
sandbox
.inject_files(&[crate::backend::FileInjection {
dest: "/usr/local/share/ca-certificates/agentkernel-proxy.crt"
.to_string(),
content: ca_pem.into_bytes(),
}])
.await
.ok();
let proxy_script = format!(
"export HTTP_PROXY=http://{h}\nexport HTTPS_PROXY=http://{h}\nexport http_proxy=http://{h}\nexport https_proxy=http://{h}\nexport NO_PROXY=localhost,127.0.0.1\n",
h = proxy_host
);
sandbox
.inject_files(&[crate::backend::FileInjection {
dest: "/etc/profile.d/agentkernel-proxy.sh".to_string(),
content: proxy_script.into_bytes(),
}])
.await
.ok();
if let Some(s) = self.sandboxes.get_mut(name) {
s.proxy_port = Some(proxy_addr.port());
}
self.save_sandbox(self.sandboxes.get(name).unwrap())?;
eprintln!(
"Placeholder proxy started on port {} for secret substitution",
proxy_addr.port()
);
}
Err(e) => {
eprintln!("Warning: Failed to start placeholder proxy: {}", e);
}
}
}
}
Err(e) => {
eprintln!("Warning: Failed to inject placeholder secret files: {}", e);
}
}
Ok(())
}
pub fn set_init_script(&mut self, name: &str, script: &str) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.init_script = Some(script.to_string());
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn set_template_metadata(
&mut self,
name: &str,
created_from_template: Option<&str>,
template_help_text: Option<&str>,
) -> Result<()> {
{
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.created_from_template = created_from_template
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
state.template_help_text = template_help_text
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
}
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(())
}
pub fn extend_ttl(&mut self, name: &str, additional_secs: u64) -> Result<Option<String>> {
use chrono::{DateTime, Duration, Utc};
let new_expiry = {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
let now = Utc::now();
let base_time = if let Some(ref expires_at) = state.expires_at {
expires_at
.parse::<DateTime<Utc>>()
.ok()
.filter(|exp| *exp > now)
.unwrap_or(now)
} else {
now
};
let new_exp = base_time + Duration::seconds(additional_secs as i64);
let new_expiry_str = new_exp.to_rfc3339();
state.expires_at = Some(new_expiry_str.clone());
if let Ok(created) = state.created_at.parse::<DateTime<Utc>>() {
let total_secs = (new_exp - created).num_seconds();
if total_secs > 0 {
state.ttl_seconds = Some(total_secs as u64);
}
}
Some(new_expiry_str)
};
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
self.save_sandbox(state)?;
Ok(new_expiry)
}
pub fn recover(&mut self, name: &str) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.archived_at = None;
state.archived_reason = None;
state.last_activity_at = Some(chrono::Utc::now().to_rfc3339());
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub async fn start(&mut self, name: &str) -> Result<()> {
self.start_with_permissions(name, &Permissions::default())
.await
}
pub async fn start_with_permissions(&mut self, name: &str, perms: &Permissions) -> Result<()> {
self.start_with_permissions_and_files(name, perms, &[])
.await
}
pub async fn start_with_permissions_and_files(
&mut self,
name: &str,
perms: &Permissions,
files: &[FileInjection],
) -> Result<()> {
let start_time = std::time::Instant::now();
let state = self
.sandboxes
.get(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?
.clone();
if state.archived_at.is_some() {
bail!(
"Sandbox '{}' is archived. Recover it before starting (POST /sandboxes/{}/recover).",
name,
name
);
}
if self.running.contains_key(name) {
bail!("Sandbox '{}' is already running", name);
}
#[cfg(feature = "enterprise")]
self.check_enterprise_policy(crate::policy::Action::Run, name, "unknown", &state.image)
.await?;
let backend = state.backend.unwrap_or(self.backend);
let mut sandbox = create_sandbox(backend, name)?;
let work_dir = if perms.mount_cwd {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
} else {
None
};
let mut env: Vec<(String, String)> = if perms.pass_env {
["PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM"]
.iter()
.filter_map(|&var| std::env::var(var).ok().map(|val| (var.to_string(), val)))
.collect()
} else {
Vec::new()
};
if let Some(ref agent) = state.agent {
let key_vars: &[&str] = match agent.as_str() {
"claude" | "amp" => &["ANTHROPIC_API_KEY"],
"copilot" => &["GITHUB_TOKEN"],
"gemini" => &["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"codex" => &["OPENAI_API_KEY"],
"opencode" => &["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"],
"pi" => &["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
_ => &[],
};
for &var in key_vars {
if let Ok(val) = std::env::var(var) {
env.push((var.to_string(), val));
}
}
}
if !state.secret_bindings.is_empty() {
let vault = SecretVault::new(SecretBackend::default());
let mut bindings = Vec::new();
let mut resolved_secrets = HashMap::new();
for raw in &state.secret_bindings {
let (binding, inline_value) = SecretBinding::parse_cli(raw)?;
if let Some(val) = inline_value {
vault.set(&binding.secret_key, &val)?;
}
if let Ok(Some(secret_val)) = vault.get(&binding.secret_key) {
let header_value = format!("{}{}", binding.header_prefix, secret_val);
resolved_secrets.insert(
binding.target_host.clone(),
(binding.header_name.clone(), header_value),
);
env.push((binding.secret_key.clone(), "ak-proxy-managed".to_string()));
} else {
eprintln!(
"Warning: Secret '{}' not found in vault, skipping binding",
binding.secret_key
);
}
bindings.push(binding);
}
if !resolved_secrets.is_empty() {
let allowed_hosts: Vec<String> =
bindings.iter().map(|b| b.target_host.clone()).collect();
let proxy_config = ProxyConfig {
listen_addr: "0.0.0.0:0".parse().unwrap(),
bindings,
allowed_hosts,
blocked_hosts: Vec::new(),
allowlist_only: false,
sandbox_name: name.to_string(),
hooks: Vec::new(),
llm_intercept: true,
llm_domains: Vec::new(),
org_managed_domains: Vec::new(),
};
match crate::proxy::start_proxy(
proxy_config,
resolved_secrets,
crate::vsock_secrets::PlaceholderMap::new(),
)
.await
{
Ok(handle) => {
let proxy_addr = handle.addr;
let proxy_host = match backend {
BackendType::Apple => {
format!("192.168.64.1:{}", proxy_addr.port())
}
BackendType::Docker | BackendType::Podman => {
if cfg!(target_os = "macos") {
format!("host.docker.internal:{}", proxy_addr.port())
} else {
format!("172.17.0.1:{}", proxy_addr.port())
}
}
_ => {
format!("127.0.0.1:{}", proxy_addr.port())
}
};
env.push(("HTTP_PROXY".to_string(), format!("http://{}", proxy_host)));
env.push(("HTTPS_PROXY".to_string(), format!("http://{}", proxy_host)));
env.push(("http_proxy".to_string(), format!("http://{}", proxy_host)));
env.push(("https_proxy".to_string(), format!("http://{}", proxy_host)));
env.push(("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()));
env.push((
"NODE_EXTRA_CA_CERTS".to_string(),
"/usr/local/share/ca-certificates/agentkernel-proxy.crt".to_string(),
));
env.push((
"REQUESTS_CA_BUNDLE".to_string(),
"/etc/ssl/certs/agentkernel-combined.crt".to_string(),
));
env.push((
"SSL_CERT_FILE".to_string(),
"/etc/ssl/certs/agentkernel-combined.crt".to_string(),
));
if let Some(s) = self.sandboxes.get_mut(name) {
s.proxy_port = Some(proxy_addr.port());
}
self.save_sandbox(self.sandboxes.get(name).unwrap())?;
eprintln!(
"Secret proxy started on port {} ({} binding(s))",
proxy_addr.port(),
state.secret_bindings.len()
);
PROXY_HANDLES.write().await.insert(name.to_string(), handle);
}
Err(e) => {
eprintln!("Warning: Failed to start secret proxy: {}", e);
}
}
}
}
{
let config_path = std::path::PathBuf::from("agentkernel.toml");
if config_path.exists()
&& let Ok(toml_cfg) = Config::from_file(&config_path)
&& !toml_cfg.llm_keys.is_empty()
{
let vault = SecretVault::new(SecretBackend::default());
let llm_registry = crate::llm_intercept::LlmDomainRegistry::default_registry();
let already_bound: std::collections::HashSet<String> = state
.secret_bindings
.iter()
.filter_map(|raw| {
crate::proxy::SecretBinding::parse_cli(raw)
.ok()
.map(|(b, _)| b.target_host.clone())
})
.collect();
let mut org_bindings = Vec::new();
let mut org_resolved = HashMap::new();
for (domain, vault_key_name) in &toml_cfg.llm_keys {
if already_bound.contains(domain) {
continue; }
if let Ok(Some(secret_val)) = vault.get(vault_key_name) {
let (header_name, header_prefix) =
if let Some(provider) = llm_registry.lookup(domain) {
if provider.name == "anthropic" {
("x-api-key".to_string(), String::new())
} else {
("Authorization".to_string(), "Bearer ".to_string())
}
} else {
("Authorization".to_string(), "Bearer ".to_string())
};
let header_value = format!("{}{}", header_prefix, secret_val);
org_resolved.insert(domain.clone(), (header_name.clone(), header_value));
org_bindings.push(SecretBinding {
secret_key: vault_key_name.clone(),
target_host: domain.clone(),
header_name,
header_prefix,
});
}
}
if !org_resolved.is_empty() {
let has_proxy = PROXY_HANDLES.read().await.contains_key(name);
if !has_proxy {
let org_domains: Vec<String> =
org_bindings.iter().map(|b| b.target_host.clone()).collect();
let allowed_hosts = org_domains.clone();
let proxy_config = ProxyConfig {
listen_addr: "0.0.0.0:0".parse().unwrap(),
bindings: org_bindings,
allowed_hosts,
blocked_hosts: Vec::new(),
allowlist_only: false,
sandbox_name: name.to_string(),
hooks: Vec::new(),
llm_intercept: true,
llm_domains: Vec::new(),
org_managed_domains: org_domains,
};
match crate::proxy::start_proxy(
proxy_config,
org_resolved,
crate::vsock_secrets::PlaceholderMap::new(),
)
.await
{
Ok(handle) => {
let proxy_addr = handle.addr;
let proxy_host = match backend {
BackendType::Apple => {
format!("192.168.64.1:{}", proxy_addr.port())
}
BackendType::Docker | BackendType::Podman => {
if cfg!(target_os = "macos") {
format!("host.docker.internal:{}", proxy_addr.port())
} else {
format!("172.17.0.1:{}", proxy_addr.port())
}
}
_ => format!("127.0.0.1:{}", proxy_addr.port()),
};
env.push((
"HTTP_PROXY".to_string(),
format!("http://{}", proxy_host),
));
env.push((
"HTTPS_PROXY".to_string(),
format!("http://{}", proxy_host),
));
env.push((
"http_proxy".to_string(),
format!("http://{}", proxy_host),
));
env.push((
"https_proxy".to_string(),
format!("http://{}", proxy_host),
));
env.push((
"NO_PROXY".to_string(),
"localhost,127.0.0.1".to_string(),
));
env.push((
"NODE_EXTRA_CA_CERTS".to_string(),
"/usr/local/share/ca-certificates/agentkernel-proxy.crt"
.to_string(),
));
env.push((
"REQUESTS_CA_BUNDLE".to_string(),
"/etc/ssl/certs/agentkernel-combined.crt".to_string(),
));
env.push((
"SSL_CERT_FILE".to_string(),
"/etc/ssl/certs/agentkernel-combined.crt".to_string(),
));
if let Some(s) = self.sandboxes.get_mut(name) {
s.proxy_port = Some(proxy_addr.port());
}
self.save_sandbox(self.sandboxes.get(name).unwrap())?;
eprintln!(
"Org LLM key proxy started on port {} ({} key(s))",
proxy_addr.port(),
toml_cfg.llm_keys.len()
);
PROXY_HANDLES.write().await.insert(name.to_string(), handle);
}
Err(e) => {
eprintln!("Warning: Failed to start org LLM key proxy: {}", e);
}
}
}
}
}
}
if !state.secret_files.is_empty() {
env.push((
"AGENTKERNEL_SECRETS_PATH".to_string(),
crate::vsock_secrets::DEFAULT_SECRETS_PATH.to_string(),
));
}
let ssh_config = if state.ssh_enabled {
let mut ssh_cfg = crate::ssh::SshConfig {
enabled: true,
..Default::default()
};
if let Ok(vault_addr) = std::env::var("VAULT_ADDR") {
ssh_cfg.vault_addr = Some(vault_addr);
}
Some(ssh_cfg)
} else {
None
};
let volume_args = if !state.volumes.is_empty() {
let volume_manager = VolumeManager::new()?;
let mut args = Vec::new();
for spec in &state.volumes {
let mount = VolumeMount::parse(spec)?;
if !volume_manager.exists(&mount.slug) {
bail!(
"Volume '{}' not found. Create it with: agentkernel volume create {}",
mount.slug,
mount.slug
);
}
args.push(mount.to_docker_arg(volume_manager.volumes_dir()));
}
args
} else {
Vec::new()
};
let config = SandboxConfig {
image: state.image.clone(),
vcpus: state.vcpus,
memory_mb: perms.max_memory_mb.unwrap_or(state.memory_mb),
mount_cwd: perms.mount_cwd,
work_dir,
env,
network: perms.network,
read_only: perms.read_only_root,
mount_home: perms.mount_home,
files: files.to_vec(),
ports: state.ports.clone(),
ssh: ssh_config.clone(),
volumes: volume_args,
};
sandbox.start(&config).await?;
if !files.is_empty() {
sandbox.inject_files(files).await?;
}
{
let handles = PROXY_HANDLES.read().await;
if let Some(handle) = handles.get(name) {
let ca_files = vec![FileInjection {
dest: "/usr/local/share/ca-certificates/agentkernel-proxy.crt".to_string(),
content: handle.ca_cert_pem.as_bytes().to_vec(),
}];
sandbox.inject_files(&ca_files).await?;
let _ = sandbox
.exec(&[
"sh",
"-c",
"{ cat /etc/ssl/certs/ca-certificates.crt /usr/local/share/ca-certificates/agentkernel-proxy.crt > /etc/ssl/certs/agentkernel-combined.crt && [ -s /etc/ssl/certs/agentkernel-combined.crt ]; } 2>/dev/null || \
{ cat /etc/pki/tls/certs/ca-bundle.crt /usr/local/share/ca-certificates/agentkernel-proxy.crt > /etc/ssl/certs/agentkernel-combined.crt && [ -s /etc/ssl/certs/agentkernel-combined.crt ]; } 2>/dev/null || \
cp /usr/local/share/ca-certificates/agentkernel-proxy.crt /etc/ssl/certs/agentkernel-combined.crt",
])
.await;
let _ = sandbox
.exec(&[
"sh",
"-c",
"update-ca-certificates 2>/dev/null || update-ca-trust 2>/dev/null || true",
])
.await;
}
}
if !state.secret_files.is_empty() {
let vault = SecretVault::new(SecretBackend::default());
let mut resolved = HashMap::new();
for key in &state.secret_files {
if let Ok(Some(val)) = vault.get(key) {
resolved.insert(key.clone(), val);
} else {
eprintln!(
"Warning: Secret '{}' not found in vault, skipping file injection",
key
);
}
}
if !resolved.is_empty() {
if state.placeholder_secrets {
self.inject_placeholder_secrets(sandbox.as_mut(), name, &resolved, backend)
.await?;
} else {
match crate::vsock_secrets::inject_secrets_as_files(
sandbox.as_mut(),
crate::vsock_secrets::DEFAULT_SECRETS_PATH,
&resolved,
)
.await
{
Ok(injected) => {
eprintln!(
"Injected {} secret file(s) at {}",
injected.len(),
crate::vsock_secrets::DEFAULT_SECRETS_PATH,
);
}
Err(e) => {
eprintln!("Warning: Failed to inject secret files: {}", e);
}
}
}
}
}
if let Some(ref ssh_cfg) = ssh_config {
let install_result = sandbox
.exec(&[
"sh",
"-c",
"apk add --no-cache openssh-server 2>/dev/null || \
apt-get update -qq && apt-get install -y -qq openssh-server 2>/dev/null || \
yum install -y openssh-server 2>/dev/null || true",
])
.await;
if let Err(e) = install_result {
eprintln!("Warning: Failed to install sshd: {}", e);
}
let (ca_priv, ca_pub) = crate::ssh::generate_ca_keypair()?;
let ssh_files = crate::ssh::sshd_file_injections(&ca_pub, ssh_cfg)?;
sandbox.inject_files(&ssh_files).await?;
let ca_key_path = self.data_dir.join(format!("{}-ssh-ca.key", name));
std::fs::write(&ca_key_path, ca_priv)?;
let _ = sandbox.exec(&["chmod", "+x", "/tmp/start-sshd.sh"]).await;
let start_result = sandbox.exec(&["sh", "/tmp/start-sshd.sh"]).await;
if let Err(e) = start_result {
eprintln!("Warning: Failed to start sshd: {}", e);
} else if let Ok(ref result) = start_result
&& !result.stderr.is_empty()
{
eprintln!("sshd: {}", result.stderr.trim());
}
let mut ssh_port = state
.ports
.iter()
.find(|p| p.container_port == 22)
.and_then(|p| p.host_port);
if ssh_port.is_none() {
let container_name = format!("agentkernel-{}", name);
if let Ok(output) = std::process::Command::new("docker")
.args(["port", &container_name, "22"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(port_str) = stdout.trim().rsplit(':').next()
&& let Ok(port) = port_str.parse::<u16>()
{
ssh_port = Some(port);
}
}
}
if let Some(port) = ssh_port {
if let Some(s) = self.sandboxes.get_mut(name) {
s.ssh_host_port = Some(port);
if let Some(pm) = s.ports.iter_mut().find(|p| p.container_port == 22) {
pm.host_port = Some(port);
}
}
self.save_sandbox(self.sandboxes.get(name).unwrap())?;
eprintln!("SSH access: agentkernel ssh {}", name);
eprintln!(" or: ssh -p {} sandbox@localhost", port);
} else {
eprintln!("SSH access: enabled on port 22 inside sandbox");
eprintln!(" (host port could not be resolved — try explicit: -p 2222:22)");
}
}
if let Some(ref agent) = state.agent {
let install_cmd = match agent.as_str() {
"claude" => Some("npm install -g @anthropic-ai/claude-code"),
"gemini" => Some("npm install -g @google/gemini-cli"),
"codex" => Some("npm install -g @openai/codex"),
"opencode" => Some("npm install -g opencode"),
"amp" => Some("npm install -g @sourcegraph/amp"),
"pi" => Some("npm install -g @mariozechner/pi-coding-agent"),
"copilot" => Some("npm install -g @githubnext/github-copilot-cli"),
_ => None,
};
if let Some(cmd) = install_cmd {
eprintln!("Installing {} agent CLI...", agent);
match sandbox.exec(&["sh", "-c", cmd]).await {
Ok(result) if result.exit_code == 0 => {
eprintln!("{} agent CLI installed successfully", agent);
}
Ok(result) => {
eprintln!(
"Warning: {} agent install exited with code {}: {}",
agent,
result.exit_code,
result.stderr.trim()
);
}
Err(e) => {
eprintln!("Warning: Failed to install {} agent CLI: {}", agent, e);
}
}
}
}
if let Some(ref script) = state.init_script {
eprintln!("Running init script...");
match sandbox.exec(&["sh", "-c", script]).await {
Ok(result) if result.exit_code == 0 => {
eprintln!("Init script completed successfully");
}
Ok(result) => {
let stderr = result.stderr.trim().to_string();
eprintln!(
"Error: init script exited with code {}: {}",
result.exit_code, stderr
);
log_event(AuditEvent::SandboxError {
name: name.to_string(),
error: format!(
"init script exited with code {}: {}",
result.exit_code, stderr
),
});
let _ = sandbox.stop().await;
anyhow::bail!(
"init script failed (exit code {}): {}",
result.exit_code,
stderr
);
}
Err(e) => {
eprintln!("Error: Failed to run init script: {}", e);
log_event(AuditEvent::SandboxError {
name: name.to_string(),
error: format!("failed to run init script: {}", e),
});
let _ = sandbox.stop().await;
anyhow::bail!("failed to run init script: {}", e);
}
}
}
self.running.insert(name.to_string(), sandbox);
self.touch_activity(name)?;
log_event(AuditEvent::SandboxStarted {
name: name.to_string(),
profile: Some(format!("{:?}", perms)),
});
crate::metrics::record_sandbox_lifecycle(
"started",
&backend.to_string(),
start_time.elapsed().as_secs_f64(),
);
Ok(())
}
fn enforce_command_policy(cmd: &[String]) -> Result<()> {
if let Some(binary) = cmd.first()
&& let Ok(cfg) = Config::from_file(&PathBuf::from("agentkernel.toml"))
&& !cfg.security.commands.is_allowed(binary)
{
log_event(AuditEvent::PolicyViolation {
sandbox: "ephemeral".to_string(),
policy: "commands".to_string(),
details: format!("blocked command: {}", binary),
});
bail!(
"Command '{}' blocked by security policy. Check [security.commands] in agentkernel.toml",
binary
);
}
Ok(())
}
pub async fn exec_cmd(&mut self, name: &str, cmd: &[String]) -> Result<String> {
self.exec_cmd_with_env(name, cmd, &[]).await
}
pub async fn exec_cmd_with_env(
&mut self,
name: &str,
cmd: &[String],
env: &[String],
) -> Result<String> {
self.exec_cmd_full(
name,
cmd,
&crate::backend::ExecOptions {
env: env.to_vec(),
..Default::default()
},
)
.await
}
pub async fn exec_cmd_full(
&mut self,
name: &str,
cmd: &[String],
opts: &crate::backend::ExecOptions,
) -> Result<String> {
Self::enforce_command_policy(cmd)?;
#[cfg(feature = "enterprise")]
{
let image = self
.sandboxes
.get(name)
.map(|s| s.image.clone())
.unwrap_or_default();
self.check_enterprise_policy(crate::policy::Action::Exec, name, "unknown", &image)
.await?;
}
let sandbox = self.running.get_mut(name).ok_or_else(|| {
anyhow::anyhow!(
"Sandbox '{}' is not running. Start it with: agentkernel start {}",
name,
name
)
})?;
let cmd_refs: Vec<&str> = cmd.iter().map(|s| s.as_str()).collect();
let exec_start = std::time::Instant::now();
let result = sandbox.exec_with_options(&cmd_refs, opts).await?;
crate::metrics::record_command(
&self.backend.to_string(),
exec_start.elapsed().as_secs_f64(),
);
log_event(AuditEvent::CommandExecuted {
sandbox: name.to_string(),
command: cmd.to_vec(),
exit_code: Some(result.exit_code),
});
let _ = self.touch_activity(name);
if result.exit_code != 0 {
return Err(CommandFailed {
exit_code: result.exit_code,
output: result.output(),
}
.into());
}
Ok(result.output())
}
pub async fn exec_detached(
&mut self,
name: &str,
cmd: &[String],
opts: &crate::backend::ExecOptions,
) -> Result<DetachedCommand> {
Self::enforce_command_policy(cmd)?;
#[cfg(feature = "enterprise")]
{
let image = self
.sandboxes
.get(name)
.map(|s| s.image.clone())
.unwrap_or_default();
self.check_enterprise_policy(crate::policy::Action::Exec, name, "unknown", &image)
.await?;
}
let sandbox = self.running.get_mut(name).ok_or_else(|| {
anyhow::anyhow!(
"Sandbox '{}' is not running. Start it with: agentkernel start {}",
name,
name
)
})?;
let id = format!("{:08x}", rand::thread_rng().r#gen::<u32>());
let stdout_path = format!("/tmp/ak-{id}.out");
let stderr_path = format!("/tmp/ak-{id}.err");
let exit_path = format!("/tmp/ak-{id}.exit");
let escaped_cmd: Vec<String> = cmd.iter().map(|c| shell_escape(c)).collect();
let wrapped = format!(
"nohup sh -c '{}' > {} 2> {} & pid=$!; (wait $pid; echo $? > {}) >/dev/null 2>&1 & echo $pid",
escaped_cmd.join(" "),
stdout_path,
stderr_path,
exit_path,
);
let wrapper_cmd: Vec<&str> = vec!["sh", "-c", &wrapped];
let result = sandbox.exec_with_options(&wrapper_cmd, opts).await?;
if result.exit_code != 0 {
bail!("Failed to start detached command: {}", result.output());
}
let pid: u32 = result.stdout.trim().parse().map_err(|_| {
anyhow::anyhow!(
"Failed to parse PID from detached command output: '{}'",
result.stdout.trim()
)
})?;
let now = chrono::Utc::now().to_rfc3339();
let detached_cmd = DetachedCommand {
id: id.clone(),
sandbox: name.to_string(),
command: cmd.to_vec(),
pid,
status: DetachedStatus::Running,
exit_code: None,
started_at: now,
};
log_event(AuditEvent::CommandExecuted {
sandbox: name.to_string(),
command: cmd.to_vec(),
exit_code: None,
});
self.detached.insert(id, detached_cmd.clone());
let _ = self.touch_activity(name);
Ok(detached_cmd)
}
pub async fn detached_status(&mut self, cmd_id: &str) -> Result<DetachedCommand> {
let cmd = self
.detached
.get(cmd_id)
.ok_or_else(|| anyhow::anyhow!("Detached command '{}' not found", cmd_id))?
.clone();
if cmd.status != DetachedStatus::Running {
return Ok(cmd);
}
let exit_path = format!("/tmp/ak-{}.exit", cmd_id);
let pid_str = cmd.pid.to_string();
let sandbox = self
.running
.get_mut(&cmd.sandbox)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' is not running", cmd.sandbox))?;
let probe = sandbox
.exec_with_options(
&["kill", "-0", &pid_str],
&crate::backend::ExecOptions::default(),
)
.await?;
if probe.exit_code == 0 {
return Ok(cmd);
}
let read_exit = sandbox
.exec_with_options(
&["cat", &exit_path],
&crate::backend::ExecOptions::default(),
)
.await;
if let Ok(exit_result) = read_exit
&& exit_result.exit_code == 0
{
let exit_code = exit_result.stdout.trim().parse::<i32>().unwrap_or(1);
let status = if exit_code == 0 {
DetachedStatus::Completed
} else {
DetachedStatus::Failed
};
if let Some(tracked) = self.detached.get_mut(cmd_id) {
tracked.status = status;
tracked.exit_code = Some(exit_code);
return Ok(tracked.clone());
}
return Ok(cmd);
}
if let Some(tracked) = self.detached.get_mut(cmd_id) {
tracked.status = DetachedStatus::Failed;
tracked.exit_code = None;
return Ok(tracked.clone());
}
Ok(cmd)
}
pub async fn detached_logs(&mut self, cmd_id: &str, stream: Option<&str>) -> Result<String> {
let cmd = self
.detached
.get(cmd_id)
.ok_or_else(|| anyhow::anyhow!("Detached command '{}' not found", cmd_id))?
.clone();
let sandbox = self
.running
.get_mut(&cmd.sandbox)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' is not running", cmd.sandbox))?;
let file_path = match stream {
Some("stderr") => format!("/tmp/ak-{}.err", cmd_id),
_ => format!("/tmp/ak-{}.out", cmd_id),
};
let result = sandbox
.exec_with_options(
&["cat", &file_path],
&crate::backend::ExecOptions::default(),
)
.await?;
Ok(result.stdout)
}
pub async fn detached_kill(&mut self, cmd_id: &str) -> Result<()> {
let cmd = self
.detached
.get(cmd_id)
.ok_or_else(|| anyhow::anyhow!("Detached command '{}' not found", cmd_id))?
.clone();
if cmd.status != DetachedStatus::Running {
return Ok(());
}
let sandbox = self
.running
.get_mut(&cmd.sandbox)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' is not running", cmd.sandbox))?;
let pid_str = cmd.pid.to_string();
let _ = sandbox
.exec_with_options(&["kill", &pid_str], &crate::backend::ExecOptions::default())
.await;
if let Some(tracked) = self.detached.get_mut(cmd_id) {
tracked.status = DetachedStatus::Failed;
tracked.exit_code = Some(137);
}
Ok(())
}
pub fn detached_list(&self, sandbox: Option<&str>) -> Vec<DetachedCommand> {
self.detached
.values()
.filter(|c| sandbox.is_none() || Some(c.sandbox.as_str()) == sandbox)
.cloned()
.collect()
}
pub async fn attach_with_env(&mut self, name: &str, env: &[String]) -> Result<i32> {
log_event(AuditEvent::SessionAttached {
sandbox: name.to_string(),
});
let result = {
let sandbox = self.running.get_mut(name).ok_or_else(|| {
anyhow::anyhow!(
"Sandbox '{}' is not running. Start it with: agentkernel start {}",
name,
name
)
})?;
sandbox.attach_with_env(None, env).await
};
if result.is_ok() {
let _ = self.touch_activity(name);
}
result
}
pub async fn stop(&mut self, name: &str) -> Result<()> {
let stop_start = std::time::Instant::now();
if let Some(handle) = PROXY_HANDLES.write().await.remove(name) {
let _ = handle.shutdown_tx.send(());
}
if let Some(mut sandbox) = self.running.remove(name) {
sandbox.stop().await?;
log_event(AuditEvent::SandboxStopped {
name: name.to_string(),
});
crate::metrics::record_sandbox_lifecycle(
"stopped",
&self.backend.to_string(),
stop_start.elapsed().as_secs_f64(),
);
}
Ok(())
}
pub async fn remove(&mut self, name: &str) -> Result<()> {
let remove_start = std::time::Instant::now();
if let Some(handle) = PROXY_HANDLES.write().await.remove(name) {
let _ = handle.shutdown_tx.send(());
}
if let Some(mut sandbox) = self.running.remove(name) {
let _ = sandbox.stop().await;
}
self.delete_sandbox(name)?;
self.sandboxes.remove(name);
log_event(AuditEvent::SandboxRemoved {
name: name.to_string(),
});
crate::metrics::record_sandbox_lifecycle(
"removed",
&self.backend.to_string(),
remove_start.elapsed().as_secs_f64(),
);
crate::metrics::dec_active_sandboxes();
crate::llm_intercept::LLM_USAGE
.write()
.await
.clear_sandbox(name);
Ok(())
}
pub async fn reconcile_lifecycle(&mut self, dry_run: bool) -> Result<LifecycleReconcileResult> {
#[derive(Debug, Clone, Copy)]
enum DecisionKind {
Stop,
Archive,
Delete,
}
#[derive(Debug, Clone)]
struct Decision {
sandbox: String,
kind: DecisionKind,
reason: String,
}
let now = chrono::Utc::now();
let mut decisions: Vec<Decision> = Vec::new();
for (name, state) in &self.sandboxes {
let Some(policy) = state.lifecycle_policy.as_ref() else {
continue;
};
if let Some(archived_time) = state.archived_time() {
if let Some(delete_after) = policy.auto_delete_after_seconds {
let archived_secs = now.signed_duration_since(archived_time).num_seconds();
if archived_secs >= delete_after as i64 {
decisions.push(Decision {
sandbox: name.clone(),
kind: DecisionKind::Delete,
reason: format!(
"archived for {}s (threshold={}s)",
archived_secs, delete_after
),
});
}
}
continue;
}
let inactivity_secs = state
.last_activity_time()
.map(|ts| now.signed_duration_since(ts).num_seconds().max(0) as u64)
.unwrap_or(0);
if let Some(archive_after) = policy.auto_archive_after_seconds
&& inactivity_secs >= archive_after
{
decisions.push(Decision {
sandbox: name.clone(),
kind: DecisionKind::Archive,
reason: format!(
"inactive for {}s (threshold={}s)",
inactivity_secs, archive_after
),
});
continue;
}
if let Some(stop_after) = policy.auto_stop_after_seconds
&& inactivity_secs >= stop_after
&& self.is_running(name)
{
decisions.push(Decision {
sandbox: name.clone(),
kind: DecisionKind::Stop,
reason: format!(
"inactive for {}s (threshold={}s)",
inactivity_secs, stop_after
),
});
}
}
decisions.sort_by(|a, b| {
a.sandbox
.cmp(&b.sandbox)
.then_with(|| a.reason.cmp(&b.reason))
});
let mut result = LifecycleReconcileResult {
dry_run,
..Default::default()
};
for decision in decisions {
let action_name = match decision.kind {
DecisionKind::Stop => "stop",
DecisionKind::Archive => "archive",
DecisionKind::Delete => "delete",
}
.to_string();
result.actions.push(LifecycleAction {
sandbox: decision.sandbox.clone(),
action: action_name,
reason: decision.reason.clone(),
});
match decision.kind {
DecisionKind::Stop => {
if !dry_run && self.is_running(&decision.sandbox) {
self.stop(&decision.sandbox).await?;
}
result.stopped.push(decision.sandbox);
}
DecisionKind::Archive => {
if !dry_run {
if self.is_running(&decision.sandbox) {
self.stop(&decision.sandbox).await?;
}
if let Some(state) = self.sandboxes.get_mut(&decision.sandbox) {
state.archived_at = Some(now.to_rfc3339());
state.archived_reason = Some(decision.reason.clone());
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
}
}
result.archived.push(decision.sandbox);
}
DecisionKind::Delete => {
if !dry_run {
self.remove(&decision.sandbox).await?;
}
result.removed.push(decision.sandbox);
}
}
}
Ok(result)
}
pub fn expired(&self) -> Vec<String> {
let now = chrono::Utc::now();
self.sandboxes
.iter()
.filter_map(|(name, state)| {
if let Some(ref exp) = state.expires_at
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(exp)
&& dt < now
{
return Some(name.clone());
}
None
})
.collect()
}
pub fn list_matching_labels(&self, filters: &[(String, String)]) -> Vec<String> {
self.sandboxes
.iter()
.filter(|(_, state)| {
filters
.iter()
.all(|(k, v)| state.labels.get(k).map(|lv| lv == v).unwrap_or(false))
})
.map(|(name, _)| name.clone())
.collect()
}
pub async fn gc(&mut self) -> Result<Vec<String>> {
let expired = self.expired();
let mut removed = Vec::new();
for name in expired {
self.remove(&name).await?;
removed.push(name);
}
Ok(removed)
}
pub fn list(&self) -> Vec<(&str, bool, Option<BackendType>)> {
self.sandboxes
.iter()
.map(|(name, state)| {
let running = self
.running
.get(name)
.map(|s| s.is_running())
.unwrap_or(false);
(name.as_str(), running, state.backend)
})
.collect()
}
pub fn exists(&self, name: &str) -> bool {
self.sandboxes.contains_key(name)
}
pub fn is_running(&self, name: &str) -> bool {
self.running
.get(name)
.map(|s| s.is_running())
.unwrap_or(false)
}
pub fn update_resources(&mut self, name: &str, vcpus: u32, memory_mb: u64) -> Result<()> {
let state = self
.sandboxes
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Sandbox '{}' not found", name))?;
state.vcpus = vcpus;
state.memory_mb = memory_mb;
let snapshot = state.clone();
self.save_sandbox(&snapshot)?;
Ok(())
}
pub async fn try_resize_in_place(
&mut self,
name: &str,
vcpus: u32,
memory_mb: u64,
) -> Result<bool> {
let Some(sandbox) = self.running.get_mut(name) else {
return Ok(false);
};
if sandbox.resize(vcpus, memory_mb).await? {
self.update_resources(name, vcpus, memory_mb)?;
let _ = self.touch_activity(name);
return Ok(true);
}
Ok(false)
}
#[allow(dead_code)]
pub fn backend(&self) -> BackendType {
self.backend
}
pub fn proxy_handles_registry() -> &'static RwLock<HashMap<String, ProxyHandle>> {
&PROXY_HANDLES
}
pub async fn run_pooled(cmd: &[String]) -> Result<String> {
Self::enforce_command_policy(cmd)?;
let pool = get_pool().await?;
let container = pool.acquire().await?;
let exec_start = std::time::Instant::now();
let result = container.run_command(cmd).await;
pool.release(container).await;
crate::metrics::record_command("pool", exec_start.elapsed().as_secs_f64());
result
}
#[allow(dead_code)]
pub fn pool_available() -> bool {
detect_container_runtime().is_some()
}
#[allow(dead_code)]
pub async fn run_ephemeral(
&mut self,
image: &str,
cmd: &[String],
perms: &Permissions,
) -> Result<String> {
self.run_ephemeral_with_files(image, cmd, perms, &[]).await
}
pub async fn run_ephemeral_with_files(
&mut self,
image: &str,
cmd: &[String],
perms: &Permissions,
files: &[FileInjection],
) -> Result<String> {
Self::enforce_command_policy(cmd)?;
let work_dir = if perms.mount_cwd {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
} else {
None
};
let env = if perms.pass_env {
["PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM"]
.iter()
.filter_map(|&var| std::env::var(var).ok().map(|val| (var.to_string(), val)))
.collect()
} else {
Vec::new()
};
let config = SandboxConfig {
image: image.to_string(),
vcpus: 1,
memory_mb: perms.max_memory_mb.unwrap_or(512),
mount_cwd: perms.mount_cwd,
work_dir,
env,
network: perms.network,
read_only: perms.read_only_root,
mount_home: perms.mount_home,
files: files.to_vec(),
ports: Vec::new(),
ssh: None,
volumes: Vec::new(),
};
if files.is_empty() {
match self.backend {
BackendType::Docker => {
use crate::docker_backend::{ContainerRuntime, ContainerSandbox};
let (exit_code, stdout, stderr) = ContainerSandbox::run_ephemeral_cmd(
ContainerRuntime::Docker,
image,
cmd,
perms,
)?;
if exit_code != 0 {
bail!("Command failed (exit {}): {}{}", exit_code, stdout, stderr);
}
return Ok(format!("{}{}", stdout, stderr));
}
BackendType::Podman => {
use crate::docker_backend::{ContainerRuntime, ContainerSandbox};
let (exit_code, stdout, stderr) = ContainerSandbox::run_ephemeral_cmd(
ContainerRuntime::Podman,
image,
cmd,
perms,
)?;
if exit_code != 0 {
bail!("Command failed (exit {}): {}{}", exit_code, stdout, stderr);
}
return Ok(format!("{}{}", stdout, stderr));
}
_ => {
}
}
}
let name = format!("ephemeral-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut sandbox = create_sandbox(self.backend, &name)?;
sandbox.start(&config).await?;
if !files.is_empty() {
sandbox.inject_files(files).await?;
}
let cmd_refs: Vec<&str> = cmd.iter().map(|s| s.as_str()).collect();
let result = sandbox.exec(&cmd_refs).await;
let _ = sandbox.stop().await;
let result = result?;
if !result.is_success() {
bail!("Command failed: {}", result.output());
}
Ok(result.output())
}
#[allow(dead_code)]
pub async fn pool_stats() -> Option<crate::pool::PoolStats> {
CONTAINER_POOL.get().map(|pool| {
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(pool.stats()))
})
}
pub async fn write_file(&mut self, name: &str, path: &str, content: &[u8]) -> Result<()> {
let sandbox = self.running.get_mut(name).ok_or_else(|| {
anyhow::anyhow!(
"Sandbox '{}' is not running. Start it with: agentkernel start {}",
name,
name
)
})?;
sandbox.write_file(path, content).await?;
log_event(AuditEvent::FileWritten {
sandbox: name.to_string(),
path: path.to_string(),
});
let _ = self.touch_activity(name);
Ok(())
}
pub fn get_state(&self, name: &str) -> Option<&SandboxState> {
self.sandboxes.get(name)
}
pub fn get_state_by_uuid(&self, uuid: &str) -> Option<&SandboxState> {
self.sandboxes.values().find(|state| state.uuid == uuid)
}
#[allow(dead_code)]
pub fn get_sandbox_state(&self, name: &str) -> Option<&SandboxState> {
self.sandboxes.get(name)
}
pub fn get_container_ip(&self, name: &str) -> Option<String> {
let container_name = format!("agentkernel-{}", name);
let backend = self.sandboxes.get(name).and_then(|s| s.backend);
match backend {
#[cfg(target_os = "macos")]
Some(BackendType::Apple) => crate::backend::apple::get_container_ip(&container_name),
_ => crate::backend::docker::get_container_ip(&container_name),
}
}
#[allow(dead_code)]
pub fn get_data_dir(&self) -> &Path {
&self.data_dir
}
pub async fn delete_file(&mut self, name: &str, path: &str) -> Result<()> {
let cmd = vec!["rm".to_string(), "-f".to_string(), path.to_string()];
self.exec_cmd(name, &cmd).await?;
Ok(())
}
pub async fn read_file(&mut self, name: &str, path: &str) -> Result<Vec<u8>> {
let sandbox = self.running.get_mut(name).ok_or_else(|| {
anyhow::anyhow!(
"Sandbox '{}' is not running. Start it with: agentkernel start {}",
name,
name
)
})?;
let content = sandbox.read_file(path).await?;
log_event(AuditEvent::FileRead {
sandbox: name.to_string(),
path: path.to_string(),
});
let _ = self.touch_activity(name);
Ok(content)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ExecResult;
use async_trait::async_trait;
use tempfile::TempDir;
#[test]
fn test_sandbox_state_serialize() {
let state = SandboxState {
name: "test-sandbox".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 2,
memory_mb: 1024,
vsock_cid: 5,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("test-sandbox"));
assert!(json.contains("alpine:3.20"));
assert!(json.contains("1024"));
}
#[test]
fn test_sandbox_state_deserialize() {
let json = r#"{
"name": "my-sandbox",
"image": "python:3.12-alpine",
"vcpus": 4,
"memory_mb": 2048,
"vsock_cid": 10,
"created_at": "2024-01-01T00:00:00Z"
}"#;
let state: SandboxState = serde_json::from_str(json).unwrap();
assert_eq!(state.name, "my-sandbox");
assert_eq!(state.image, "python:3.12-alpine");
assert_eq!(state.vcpus, 4);
assert_eq!(state.memory_mb, 2048);
assert_eq!(state.vsock_cid, 10);
}
#[test]
fn test_sandbox_state_roundtrip() {
let original = SandboxState {
name: "roundtrip-test".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "node:20-alpine".to_string(),
vcpus: 1,
memory_mb: 512,
vsock_cid: 3,
created_at: "2024-06-15T12:30:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
let json = serde_json::to_string(&original).unwrap();
let restored: SandboxState = serde_json::from_str(&json).unwrap();
assert_eq!(original.name, restored.name);
assert_eq!(original.uuid, restored.uuid);
assert_eq!(original.image, restored.image);
assert_eq!(original.vcpus, restored.vcpus);
assert_eq!(original.memory_mb, restored.memory_mb);
assert_eq!(original.vsock_cid, restored.vsock_cid);
assert_eq!(original.created_at, restored.created_at);
}
#[test]
fn test_data_dir_uses_home() {
let data_dir = VmManager::data_dir();
if std::env::var_os("HOME").is_some() {
assert!(
data_dir
.to_string_lossy()
.contains(".local/share/agentkernel")
);
}
}
#[test]
fn test_load_sandboxes_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let sandboxes = VmManager::load_sandboxes(temp_dir.path()).unwrap();
assert!(sandboxes.is_empty());
}
#[test]
fn test_load_sandboxes_with_files() {
let temp_dir = TempDir::new().unwrap();
let state = SandboxState {
name: "loaded-sandbox".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 256,
vsock_cid: 4,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
let json = serde_json::to_string(&state).unwrap();
std::fs::write(temp_dir.path().join("loaded-sandbox.json"), &json).unwrap();
std::fs::write(temp_dir.path().join("invalid.json"), "not valid json").unwrap();
std::fs::write(temp_dir.path().join("readme.txt"), "hello").unwrap();
let sandboxes = VmManager::load_sandboxes(temp_dir.path()).unwrap();
assert_eq!(sandboxes.len(), 1);
assert!(sandboxes.contains_key("loaded-sandbox"));
let loaded = &sandboxes["loaded-sandbox"];
assert_eq!(loaded.image, "alpine:3.20");
assert_eq!(loaded.memory_mb, 256);
}
#[test]
fn test_load_sandboxes_nonexistent_dir() {
let sandboxes = VmManager::load_sandboxes(Path::new("/nonexistent/path")).unwrap();
assert!(sandboxes.is_empty());
}
#[test]
fn test_load_sandboxes_backfills_uuid() {
let temp_dir = TempDir::new().unwrap();
let legacy = r#"{
"name": "legacy-box",
"image": "alpine:3.20",
"vcpus": 1,
"memory_mb": 256,
"vsock_cid": 4,
"created_at": "2026-02-16T00:00:00Z"
}"#;
let file = temp_dir.path().join("legacy-box.json");
std::fs::write(&file, legacy).unwrap();
let sandboxes = VmManager::load_sandboxes(temp_dir.path()).unwrap();
let loaded = sandboxes.get("legacy-box").unwrap();
assert!(!loaded.uuid.is_empty());
let file_state = std::fs::read_to_string(&file).unwrap();
assert!(file_state.contains("\"uuid\""));
}
#[test]
fn test_next_cid_calculation() {
let temp_dir = TempDir::new().unwrap();
for (name, cid) in [("sb1", 5), ("sb2", 10), ("sb3", 3)] {
let state = SandboxState {
name: name.to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine".to_string(),
vcpus: 1,
memory_mb: 256,
vsock_cid: cid,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
let json = serde_json::to_string(&state).unwrap();
std::fs::write(temp_dir.path().join(format!("{}.json", name)), &json).unwrap();
}
let sandboxes = VmManager::load_sandboxes(temp_dir.path()).unwrap();
let max_cid = sandboxes.values().map(|s| s.vsock_cid).max().unwrap_or(2);
assert_eq!(max_cid, 10);
}
#[test]
fn test_sandbox_state_default_values() {
let incomplete_json = r#"{"name": "test"}"#;
let result: Result<SandboxState, _> = serde_json::from_str(incomplete_json);
assert!(result.is_err());
}
#[test]
fn test_set_labels_and_retrieve() {
let temp_dir = TempDir::new().unwrap();
let mut manager = VmManager {
sandboxes: HashMap::new(),
data_dir: temp_dir.path().to_path_buf(),
backend: BackendType::Docker,
running: HashMap::new(),
rootfs_dir: None,
next_cid: 3,
detached: HashMap::new(),
#[cfg(feature = "enterprise")]
policy_engine: None,
};
let state = SandboxState {
name: "label-test".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 512,
vsock_cid: 3,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
std::fs::create_dir_all(temp_dir.path().join("sandboxes")).unwrap();
manager.sandboxes.insert("label-test".to_string(), state);
let mut labels = HashMap::new();
labels.insert("env".to_string(), "prod".to_string());
labels.insert("team".to_string(), "ml".to_string());
manager.set_labels("label-test", &labels).unwrap();
let state = manager.get_state("label-test").unwrap();
assert_eq!(state.labels.get("env").unwrap(), "prod");
assert_eq!(state.labels.get("team").unwrap(), "ml");
}
#[test]
fn test_list_matching_labels() {
let temp_dir = TempDir::new().unwrap();
let mut manager = VmManager {
sandboxes: HashMap::new(),
data_dir: temp_dir.path().to_path_buf(),
backend: BackendType::Docker,
running: HashMap::new(),
rootfs_dir: None,
next_cid: 3,
detached: HashMap::new(),
#[cfg(feature = "enterprise")]
policy_engine: None,
};
std::fs::create_dir_all(temp_dir.path().join("sandboxes")).unwrap();
for (name, env) in [("s1", "prod"), ("s2", "staging"), ("s3", "prod")] {
let mut labels = HashMap::new();
labels.insert("env".to_string(), env.to_string());
let state = SandboxState {
name: name.to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 512,
vsock_cid: 3,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels,
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
manager.sandboxes.insert(name.to_string(), state);
}
let filters = vec![("env".to_string(), "prod".to_string())];
let mut matched = manager.list_matching_labels(&filters);
matched.sort();
assert_eq!(matched, vec!["s1", "s3"]);
let filters = vec![("env".to_string(), "staging".to_string())];
let matched = manager.list_matching_labels(&filters);
assert_eq!(matched, vec!["s2"]);
let filters = vec![("team".to_string(), "ml".to_string())];
let matched = manager.list_matching_labels(&filters);
assert!(matched.is_empty());
}
#[test]
fn test_set_description() {
let temp_dir = TempDir::new().unwrap();
let mut manager = VmManager {
sandboxes: HashMap::new(),
data_dir: temp_dir.path().to_path_buf(),
backend: BackendType::Docker,
running: HashMap::new(),
rootfs_dir: None,
next_cid: 3,
detached: HashMap::new(),
#[cfg(feature = "enterprise")]
policy_engine: None,
};
std::fs::create_dir_all(temp_dir.path().join("sandboxes")).unwrap();
let state = SandboxState {
name: "desc-test".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 512,
vsock_cid: 3,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
manager.sandboxes.insert("desc-test".to_string(), state);
manager
.set_description("desc-test", Some("My sandbox"))
.unwrap();
assert_eq!(
manager
.get_state("desc-test")
.unwrap()
.description
.as_deref(),
Some("My sandbox")
);
manager.set_description("desc-test", None).unwrap();
assert!(
manager
.get_state("desc-test")
.unwrap()
.description
.is_none()
);
}
#[test]
fn test_labels_persist_across_reload() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir_all(temp_dir.path().join("sandboxes")).unwrap();
let mut labels = HashMap::new();
labels.insert("env".to_string(), "prod".to_string());
let state = SandboxState {
name: "persist-test".to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 512,
vsock_cid: 3,
created_at: "2024-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: None,
expires_at: None,
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: labels.clone(),
description: Some("Test sandbox".to_string()),
last_activity_at: None,
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
};
let path = temp_dir.path().join("sandboxes").join("persist-test.json");
std::fs::write(&path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
let loaded = VmManager::load_sandboxes(&temp_dir.path().join("sandboxes")).unwrap();
let loaded_state = loaded.get("persist-test").unwrap();
assert_eq!(loaded_state.labels.get("env").unwrap(), "prod");
assert_eq!(loaded_state.description.as_deref(), Some("Test sandbox"));
}
fn new_test_manager(temp_dir: &TempDir) -> VmManager {
std::fs::create_dir_all(temp_dir.path().join("sandboxes")).unwrap();
VmManager {
sandboxes: HashMap::new(),
data_dir: temp_dir.path().to_path_buf(),
backend: BackendType::Docker,
running: HashMap::new(),
rootfs_dir: None,
next_cid: 3,
detached: HashMap::new(),
#[cfg(feature = "enterprise")]
policy_engine: None,
}
}
fn lifecycle_state(name: &str) -> SandboxState {
SandboxState {
name: name.to_string(),
uuid: uuid::Uuid::now_v7().to_string(),
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 256,
vsock_cid: 3,
created_at: "2026-01-01T00:00:00Z".to_string(),
backend: None,
remote_id: None,
remote_namespace: None,
ttl_seconds: Some(3600),
expires_at: Some("2026-01-01T01:00:00Z".to_string()),
ports: Vec::new(),
ssh_enabled: false,
ssh_host_port: None,
volumes: Vec::new(),
agent: None,
secret_bindings: Vec::new(),
secret_mappings: HashMap::new(),
secret_files: Vec::new(),
placeholder_secrets: false,
proxy_port: None,
init_script: None,
created_from_template: None,
template_help_text: None,
labels: HashMap::new(),
description: None,
last_activity_at: Some("2026-01-01T00:00:00Z".to_string()),
archived_at: None,
archived_reason: None,
lifecycle_policy: None,
}
}
#[allow(dead_code)]
struct TestSandbox {
name: String,
running: bool,
}
#[async_trait]
impl Sandbox for TestSandbox {
async fn start(&mut self, _config: &SandboxConfig) -> Result<()> {
self.running = true;
Ok(())
}
async fn exec(&mut self, _cmd: &[&str]) -> Result<ExecResult> {
Ok(ExecResult::success(String::new()))
}
async fn stop(&mut self) -> Result<()> {
self.running = false;
Ok(())
}
fn name(&self) -> &str {
&self.name
}
fn backend_type(&self) -> BackendType {
BackendType::Docker
}
fn is_running(&self) -> bool {
self.running
}
async fn write_file_unchecked(&mut self, _path: &str, _content: &[u8]) -> Result<()> {
Ok(())
}
async fn read_file_unchecked(&mut self, _path: &str) -> Result<Vec<u8>> {
Ok(Vec::new())
}
async fn remove_file_unchecked(&mut self, _path: &str) -> Result<()> {
Ok(())
}
async fn mkdir_unchecked(&mut self, _path: &str, _recursive: bool) -> Result<()> {
Ok(())
}
}
#[test]
fn test_touch_activity_updates_timestamp() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("touch-test");
state.last_activity_at = Some("2026-01-01T00:00:00Z".to_string());
manager.sandboxes.insert("touch-test".to_string(), state);
manager.touch_activity("touch-test").unwrap();
let updated = manager
.get_state("touch-test")
.unwrap()
.last_activity_at
.clone()
.unwrap();
assert_ne!(updated, "2026-01-01T00:00:00Z");
}
#[test]
fn test_recover_clears_archive_metadata() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("recover-test");
state.archived_at = Some("2026-01-01T02:00:00Z".to_string());
state.archived_reason = Some("manual archive".to_string());
manager.sandboxes.insert("recover-test".to_string(), state);
manager.recover("recover-test").unwrap();
let recovered = manager.get_state("recover-test").unwrap();
assert!(recovered.archived_at.is_none());
assert!(recovered.archived_reason.is_none());
assert!(recovered.last_activity_at.is_some());
}
#[tokio::test]
async fn test_reconcile_lifecycle_dry_run_archive_does_not_mutate() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("archive-dry-run");
state.last_activity_at =
Some((chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339());
state.lifecycle_policy = Some(SandboxLifecyclePolicy {
auto_stop_after_seconds: None,
auto_archive_after_seconds: Some(60),
auto_delete_after_seconds: None,
});
manager
.sandboxes
.insert("archive-dry-run".to_string(), state.clone());
let result = manager.reconcile_lifecycle(true).await.unwrap();
assert!(result.archived.contains(&"archive-dry-run".to_string()));
assert!(
manager
.get_state("archive-dry-run")
.unwrap()
.archived_at
.is_none()
);
}
#[tokio::test]
async fn test_reconcile_lifecycle_archives_when_threshold_hit() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("archive-now");
state.last_activity_at =
Some((chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339());
state.lifecycle_policy = Some(SandboxLifecyclePolicy {
auto_stop_after_seconds: None,
auto_archive_after_seconds: Some(0),
auto_delete_after_seconds: None,
});
manager.sandboxes.insert("archive-now".to_string(), state);
let result = manager.reconcile_lifecycle(false).await.unwrap();
assert!(result.archived.contains(&"archive-now".to_string()));
let archived = manager.get_state("archive-now").unwrap();
assert!(archived.archived_at.is_some());
assert!(archived.archived_reason.is_some());
}
#[tokio::test]
async fn test_reconcile_lifecycle_stops_running_sandbox() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("stop-now");
state.last_activity_at =
Some((chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339());
state.lifecycle_policy = Some(SandboxLifecyclePolicy {
auto_stop_after_seconds: Some(10),
auto_archive_after_seconds: None,
auto_delete_after_seconds: None,
});
manager.sandboxes.insert("stop-now".to_string(), state);
manager.running.insert(
"stop-now".to_string(),
Box::new(TestSandbox {
name: "stop-now".to_string(),
running: true,
}),
);
let result = manager.reconcile_lifecycle(false).await.unwrap();
assert!(result.stopped.contains(&"stop-now".to_string()));
assert!(!manager.is_running("stop-now"));
}
#[tokio::test]
async fn test_reconcile_lifecycle_deletes_archived_sandbox() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
let mut state = lifecycle_state("delete-now");
state.archived_at = Some((chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339());
state.archived_reason = Some("stale".to_string());
state.lifecycle_policy = Some(SandboxLifecyclePolicy {
auto_stop_after_seconds: None,
auto_archive_after_seconds: None,
auto_delete_after_seconds: Some(60),
});
manager.sandboxes.insert("delete-now".to_string(), state);
let result = manager.reconcile_lifecycle(false).await.unwrap();
assert!(result.removed.contains(&"delete-now".to_string()));
assert!(!manager.exists("delete-now"));
}
#[test]
fn test_set_identity_metadata_preserves_uuid_and_timestamps() {
let temp_dir = TempDir::new().unwrap();
let mut manager = new_test_manager(&temp_dir);
manager
.sandboxes
.insert("id-test".to_string(), lifecycle_state("id-test"));
manager
.set_identity_metadata(
"id-test",
"fixed-uuid",
"2020-01-01T00:00:00Z",
Some("2020-01-01T01:00:00Z"),
)
.unwrap();
let state = manager.get_state("id-test").unwrap();
assert_eq!(state.uuid, "fixed-uuid");
assert_eq!(state.created_at, "2020-01-01T00:00:00Z");
assert_eq!(state.expires_at.as_deref(), Some("2020-01-01T01:00:00Z"));
}
}