use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use super::intent::Command;
use super::isolation::{
BackendCapabilities, ExecContext, ExecOutput, IsolationBackend, Sandbox, SandboxCapabilities,
SandboxSpec,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SandboxId(pub String);
impl SandboxId {
pub fn new() -> Self {
Self(format!("sbx-{}", Uuid::new_v4()))
}
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for SandboxId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for SandboxId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxSession {
pub session_id: String,
pub sandbox_id: SandboxId,
pub client_id: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_active_at: chrono::DateTime<chrono::Utc>,
pub state: SessionState,
pub metadata: HashMap<String, String>,
pub timeouts: SessionTimeouts,
}
impl SandboxSession {
pub fn new(sandbox_id: SandboxId, client_id: &str) -> Self {
let now = chrono::Utc::now();
Self {
session_id: format!("sess-{}", Uuid::new_v4()),
sandbox_id,
client_id: client_id.to_string(),
created_at: now,
last_active_at: now,
state: SessionState::Active,
metadata: HashMap::new(),
timeouts: SessionTimeouts::default(),
}
}
pub fn is_timed_out(&self) -> bool {
let now = chrono::Utc::now();
let idle_duration = now - self.last_active_at;
match self.state {
SessionState::Active => {
idle_duration > chrono::Duration::from_std(self.timeouts.idle_timeout).unwrap()
}
SessionState::Detached => {
idle_duration > chrono::Duration::from_std(self.timeouts.detach_timeout).unwrap()
}
SessionState::Suspended => {
idle_duration > chrono::Duration::from_std(self.timeouts.suspend_timeout).unwrap()
}
SessionState::Terminated => true,
}
}
pub fn touch(&mut self) {
self.last_active_at = chrono::Utc::now();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionState {
Active,
Detached,
Suspended,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTimeouts {
pub idle_timeout: Duration,
pub detach_timeout: Duration,
pub suspend_timeout: Duration,
}
impl Default for SessionTimeouts {
fn default() -> Self {
Self {
idle_timeout: Duration::from_secs(300), detach_timeout: Duration::from_secs(3600), suspend_timeout: Duration::from_secs(86400), }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachRequest {
pub sandbox_id: SandboxId,
pub client_id: String,
pub create_if_missing: bool,
pub create_spec: Option<SandboxSpec>,
}
#[derive(Debug, Clone)]
pub struct AttachResult {
pub session: SandboxSession,
pub newly_created: bool,
pub capabilities: SandboxCapabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SandboxSelectionOptions {
pub preferred_id: Option<SandboxId>,
pub require_fresh: bool,
pub required_capabilities: RequiredCapabilities,
pub preferred_backend: Option<String>,
pub required_labels: HashMap<String, String>,
pub use_pool: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RequiredCapabilities {
pub network: Option<bool>,
pub write_access: Option<bool>,
pub min_memory_bytes: Option<u64>,
pub readable_paths: Vec<String>,
pub writable_paths: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SandboxManagerStats {
pub sandboxes_created: u64,
pub active_sandboxes: u64,
pub active_sessions: u64,
pub detached_sessions: u64,
pub pool_size: u64,
pub pool_hit_rate: f64,
pub avg_creation_time_ms: f64,
pub timeout_destroys: u64,
}
#[async_trait]
pub trait SandboxManager: Send + Sync {
async fn acquire(
&self,
spec: &SandboxSpec,
options: &SandboxSelectionOptions,
client_id: &str,
) -> Result<(SandboxSession, Arc<dyn Sandbox>)>;
async fn release(&self, session: &SandboxSession, keep_alive: bool) -> Result<()>;
async fn attach(&self, request: AttachRequest) -> Result<AttachResult>;
async fn detach(&self, session: &SandboxSession) -> Result<()>;
async fn suspend(&self, session: &SandboxSession) -> Result<()>;
async fn resume(&self, session: &SandboxSession) -> Result<SandboxSession>;
async fn terminate(&self, session: &SandboxSession) -> Result<()>;
async fn list_sessions(&self) -> Result<Vec<SandboxSession>>;
async fn get_session(&self, session_id: &str) -> Result<Option<SandboxSession>>;
async fn get_session_by_sandbox(
&self,
sandbox_id: &SandboxId,
) -> Result<Option<SandboxSession>>;
async fn get_session_by_client(&self, client_id: &str) -> Result<Option<SandboxSession>>;
async fn get_sandbox(&self, sandbox_id: &SandboxId) -> Result<Option<Arc<dyn Sandbox>>>;
async fn set_default_sandbox(&self, sandbox_id: Option<SandboxId>) -> Result<()>;
async fn get_default_sandbox(&self) -> Option<SandboxId>;
async fn cleanup_expired(&self) -> Result<u64>;
async fn stats(&self) -> SandboxManagerStats;
async fn health(&self) -> Result<bool>;
}
pub struct DefaultSandboxManager {
backends: Vec<Arc<dyn IsolationBackend>>,
sessions: tokio::sync::RwLock<HashMap<String, SandboxSession>>,
sandboxes: tokio::sync::RwLock<HashMap<SandboxId, Arc<dyn Sandbox>>>,
pool: tokio::sync::RwLock<Vec<(SandboxSpec, Arc<dyn Sandbox>)>>,
config: SandboxManagerConfig,
stats: tokio::sync::RwLock<SandboxManagerStats>,
default_sandbox_id: tokio::sync::RwLock<Option<SandboxId>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxManagerConfig {
pub max_sandboxes: u32,
pub pool: PoolConfig,
pub default_timeouts: SessionTimeouts,
pub cleanup_interval: Duration,
}
impl Default for SandboxManagerConfig {
fn default() -> Self {
Self {
max_sandboxes: 100,
pool: PoolConfig::default(),
default_timeouts: SessionTimeouts::default(),
cleanup_interval: Duration::from_secs(60),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
pub enabled: bool,
pub min_warm: u32,
pub max_warm: u32,
pub warm_ttl: Duration,
pub warm_profiles: Vec<String>,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
enabled: true,
min_warm: 2,
max_warm: 10,
warm_ttl: Duration::from_secs(600), warm_profiles: vec!["default".to_string()],
}
}
}
impl DefaultSandboxManager {
pub fn new(backends: Vec<Arc<dyn IsolationBackend>>, config: SandboxManagerConfig) -> Self {
Self {
backends,
sessions: tokio::sync::RwLock::new(HashMap::new()),
sandboxes: tokio::sync::RwLock::new(HashMap::new()),
pool: tokio::sync::RwLock::new(Vec::new()),
config,
stats: tokio::sync::RwLock::new(SandboxManagerStats::default()),
default_sandbox_id: tokio::sync::RwLock::new(None),
}
}
async fn select_backend(
&self,
spec: &SandboxSpec,
preferred: Option<&str>,
) -> Result<Arc<dyn IsolationBackend>> {
if let Some(name) = preferred {
if let Some(backend) = self.backends.iter().find(|b| b.name() == name) {
return Ok(backend.clone());
}
}
for backend in &self.backends {
let caps = backend.probe().await?;
if self.backend_satisfies_spec(&caps, spec) {
return Ok(backend.clone());
}
}
anyhow::bail!("No suitable isolation backend found for spec")
}
fn backend_satisfies_spec(&self, caps: &BackendCapabilities, spec: &SandboxSpec) -> bool {
if spec.network_enabled && !caps.network_isolation {
}
if !spec.profile.is_empty() && spec.profile != "default" {
if !caps.available_profiles.contains(&spec.profile) {
return false;
}
}
true
}
}
#[async_trait]
impl SandboxManager for DefaultSandboxManager {
async fn acquire(
&self,
spec: &SandboxSpec,
options: &SandboxSelectionOptions,
client_id: &str,
) -> Result<(SandboxSession, Arc<dyn Sandbox>)> {
if let Some(ref id) = options.preferred_id {
if !options.require_fresh {
let sandboxes = self.sandboxes.read().await;
if let Some(sandbox) = sandboxes.get(id) {
let session = SandboxSession::new(id.clone(), client_id);
return Ok((session, sandbox.clone()));
}
}
}
if options.use_pool && !options.require_fresh && self.config.pool.enabled {
let mut pool = self.pool.write().await;
if let Some(idx) = pool.iter().position(|(s, _)| s.profile == spec.profile) {
let (_, sandbox) = pool.remove(idx);
let session = SandboxSession::new(sandbox.id().clone(), client_id);
{
let mut stats = self.stats.write().await;
stats.active_sandboxes += 1;
stats.active_sessions += 1;
}
return Ok((session, sandbox));
}
}
let backend = self
.select_backend(spec, options.preferred_backend.as_deref())
.await?;
let start = std::time::Instant::now();
let sandbox = backend.create_sandbox(spec).await?;
let creation_time = start.elapsed();
let sandbox_id = sandbox.id().clone();
let sandbox: Arc<dyn Sandbox> = Arc::from(sandbox);
{
let mut sandboxes = self.sandboxes.write().await;
sandboxes.insert(sandbox_id.clone(), sandbox.clone());
}
let session = SandboxSession::new(sandbox_id, client_id);
{
let mut sessions = self.sessions.write().await;
sessions.insert(session.session_id.clone(), session.clone());
}
{
let mut stats = self.stats.write().await;
stats.sandboxes_created += 1;
stats.active_sandboxes += 1;
stats.active_sessions += 1;
let total = stats.sandboxes_created as f64;
let current_avg = stats.avg_creation_time_ms;
stats.avg_creation_time_ms =
current_avg + (creation_time.as_millis() as f64 - current_avg) / total;
}
Ok((session, sandbox))
}
async fn release(&self, session: &SandboxSession, keep_alive: bool) -> Result<()> {
let sandbox = {
let sandboxes = self.sandboxes.read().await;
sandboxes.get(&session.sandbox_id).cloned()
};
if let Some(sandbox) = sandbox {
if keep_alive {
if self.config.pool.enabled {
let mut pool = self.pool.write().await;
if pool.len() < self.config.pool.max_warm as usize {
let spec = SandboxSpec {
profile: sandbox.capabilities().profile.clone(),
..Default::default()
};
pool.push((spec, sandbox));
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(&session.session_id) {
s.state = SessionState::Detached;
}
return Ok(());
}
}
}
sandbox.destroy().await?;
{
let mut sandboxes = self.sandboxes.write().await;
sandboxes.remove(&session.sandbox_id);
}
{
let mut sessions = self.sessions.write().await;
sessions.remove(&session.session_id);
}
{
let mut stats = self.stats.write().await;
stats.active_sandboxes = stats.active_sandboxes.saturating_sub(1);
stats.active_sessions = stats.active_sessions.saturating_sub(1);
}
}
Ok(())
}
async fn attach(&self, request: AttachRequest) -> Result<AttachResult> {
let sandboxes = self.sandboxes.read().await;
if let Some(sandbox) = sandboxes.get(&request.sandbox_id) {
let session = SandboxSession::new(request.sandbox_id.clone(), &request.client_id);
let capabilities = sandbox.capabilities().clone();
{
drop(sandboxes);
let mut sessions = self.sessions.write().await;
sessions.insert(session.session_id.clone(), session.clone());
}
return Ok(AttachResult {
session,
newly_created: false,
capabilities,
});
}
drop(sandboxes);
if request.create_if_missing {
if let Some(spec) = request.create_spec {
let (session, sandbox) = self
.acquire(
&spec,
&SandboxSelectionOptions::default(),
&request.client_id,
)
.await?;
let capabilities = sandbox.capabilities().clone();
return Ok(AttachResult {
session,
newly_created: true,
capabilities,
});
}
}
anyhow::bail!("Sandbox {} not found", request.sandbox_id)
}
async fn detach(&self, session: &SandboxSession) -> Result<()> {
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(&session.session_id) {
s.state = SessionState::Detached;
s.touch();
let mut stats = self.stats.write().await;
stats.active_sessions = stats.active_sessions.saturating_sub(1);
stats.detached_sessions += 1;
}
Ok(())
}
async fn suspend(&self, session: &SandboxSession) -> Result<()> {
let sandbox = {
let sandboxes = self.sandboxes.read().await;
sandboxes.get(&session.sandbox_id).cloned()
};
if let Some(sandbox) = sandbox {
sandbox.suspend().await?;
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(&session.session_id) {
s.state = SessionState::Suspended;
s.touch();
}
}
Ok(())
}
async fn resume(&self, session: &SandboxSession) -> Result<SandboxSession> {
let sandbox = {
let sandboxes = self.sandboxes.read().await;
sandboxes.get(&session.sandbox_id).cloned()
};
if let Some(sandbox) = sandbox {
sandbox.resume().await?;
let mut sessions = self.sessions.write().await;
if let Some(s) = sessions.get_mut(&session.session_id) {
s.state = SessionState::Active;
s.touch();
return Ok(s.clone());
}
}
anyhow::bail!("Session not found")
}
async fn terminate(&self, session: &SandboxSession) -> Result<()> {
self.release(session, false).await
}
async fn list_sessions(&self) -> Result<Vec<SandboxSession>> {
let sessions = self.sessions.read().await;
Ok(sessions.values().cloned().collect())
}
async fn get_session(&self, session_id: &str) -> Result<Option<SandboxSession>> {
let sessions = self.sessions.read().await;
Ok(sessions.get(session_id).cloned())
}
async fn get_session_by_sandbox(
&self,
sandbox_id: &SandboxId,
) -> Result<Option<SandboxSession>> {
let sessions = self.sessions.read().await;
Ok(sessions
.values()
.find(|s| s.sandbox_id == *sandbox_id)
.cloned())
}
async fn get_sandbox(&self, sandbox_id: &SandboxId) -> Result<Option<Arc<dyn Sandbox>>> {
let sandboxes = self.sandboxes.read().await;
Ok(sandboxes.get(sandbox_id).cloned())
}
async fn get_session_by_client(&self, client_id: &str) -> Result<Option<SandboxSession>> {
let sessions = self.sessions.read().await;
Ok(sessions
.values()
.find(|s| s.client_id == client_id && s.state == SessionState::Active)
.cloned())
}
async fn set_default_sandbox(&self, sandbox_id: Option<SandboxId>) -> Result<()> {
let mut default = self.default_sandbox_id.write().await;
*default = sandbox_id;
Ok(())
}
async fn get_default_sandbox(&self) -> Option<SandboxId> {
self.default_sandbox_id.read().await.clone()
}
async fn cleanup_expired(&self) -> Result<u64> {
let mut cleaned = 0u64;
let expired_sessions: Vec<SandboxSession> = {
let sessions = self.sessions.read().await;
sessions
.values()
.filter(|s| s.is_timed_out())
.cloned()
.collect()
};
for session in expired_sessions {
if let Err(e) = self.terminate(&session).await {
tracing::warn!(
session_id = %session.session_id,
error = %e,
"Failed to cleanup expired session"
);
} else {
cleaned += 1;
}
}
{
let mut stats = self.stats.write().await;
stats.timeout_destroys += cleaned;
}
Ok(cleaned)
}
async fn stats(&self) -> SandboxManagerStats {
self.stats.read().await.clone()
}
async fn health(&self) -> Result<bool> {
for backend in &self.backends {
let health = backend.health_check().await?;
if health.healthy {
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sandbox_id_new() {
let id = SandboxId::new();
assert!(id.as_str().starts_with("sbx-"));
assert!(!id.as_str().is_empty());
}
#[test]
fn test_sandbox_id_from_string() {
let id = SandboxId::from_string("custom-id-123");
assert_eq!(id.as_str(), "custom-id-123");
}
#[test]
fn test_sandbox_id_display() {
let id = SandboxId::from_string("test-sandbox");
assert_eq!(format!("{}", id), "test-sandbox");
}
#[test]
fn test_sandbox_id_default() {
let id = SandboxId::default();
assert!(id.as_str().starts_with("sbx-"));
}
#[test]
fn test_sandbox_id_equality() {
let id1 = SandboxId::from_string("same-id");
let id2 = SandboxId::from_string("same-id");
let id3 = SandboxId::from_string("different-id");
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_sandbox_session_new() {
let sandbox_id = SandboxId::new();
let session = SandboxSession::new(sandbox_id.clone(), "client-123");
assert!(session.session_id.starts_with("sess-"));
assert_eq!(session.sandbox_id, sandbox_id);
assert_eq!(session.client_id, "client-123");
assert_eq!(session.state, SessionState::Active);
assert!(session.metadata.is_empty());
}
#[test]
fn test_sandbox_session_touch() {
let sandbox_id = SandboxId::new();
let mut session = SandboxSession::new(sandbox_id, "client-123");
let original_time = session.last_active_at;
std::thread::sleep(std::time::Duration::from_millis(10));
session.touch();
assert!(session.last_active_at > original_time);
}
#[test]
fn test_session_state_equality() {
assert_eq!(SessionState::Active, SessionState::Active);
assert_eq!(SessionState::Detached, SessionState::Detached);
assert_eq!(SessionState::Suspended, SessionState::Suspended);
assert_eq!(SessionState::Terminated, SessionState::Terminated);
assert_ne!(SessionState::Active, SessionState::Detached);
}
#[test]
fn test_session_timeouts_default() {
let timeouts = SessionTimeouts::default();
assert_eq!(timeouts.idle_timeout, Duration::from_secs(300));
assert_eq!(timeouts.detach_timeout, Duration::from_secs(3600));
assert_eq!(timeouts.suspend_timeout, Duration::from_secs(86400));
}
#[test]
fn test_sandbox_selection_options_default() {
let options = SandboxSelectionOptions::default();
assert!(options.preferred_id.is_none());
assert!(!options.require_fresh);
assert!(options.preferred_backend.is_none());
assert!(options.required_labels.is_empty());
assert!(!options.use_pool);
}
#[test]
fn test_required_capabilities_default() {
let caps = RequiredCapabilities::default();
assert!(caps.network.is_none());
assert!(caps.write_access.is_none());
assert!(caps.min_memory_bytes.is_none());
assert!(caps.readable_paths.is_empty());
assert!(caps.writable_paths.is_empty());
}
#[test]
fn test_sandbox_manager_stats_default() {
let stats = SandboxManagerStats::default();
assert_eq!(stats.sandboxes_created, 0);
assert_eq!(stats.active_sandboxes, 0);
assert_eq!(stats.active_sessions, 0);
assert_eq!(stats.detached_sessions, 0);
assert_eq!(stats.pool_size, 0);
assert_eq!(stats.pool_hit_rate, 0.0);
assert_eq!(stats.avg_creation_time_ms, 0.0);
assert_eq!(stats.timeout_destroys, 0);
}
#[test]
fn test_sandbox_manager_config_default() {
let config = SandboxManagerConfig::default();
assert_eq!(config.max_sandboxes, 100);
assert!(config.pool.enabled);
assert_eq!(config.cleanup_interval, Duration::from_secs(60));
}
#[test]
fn test_pool_config_default() {
let config = PoolConfig::default();
assert!(config.enabled);
assert_eq!(config.min_warm, 2);
assert_eq!(config.max_warm, 10);
assert_eq!(config.warm_ttl, Duration::from_secs(600));
assert_eq!(config.warm_profiles, vec!["default".to_string()]);
}
#[test]
fn test_attach_request_creation() {
let request = AttachRequest {
sandbox_id: SandboxId::from_string("test-sandbox"),
client_id: "client-123".to_string(),
create_if_missing: true,
create_spec: None,
};
assert_eq!(request.sandbox_id.as_str(), "test-sandbox");
assert_eq!(request.client_id, "client-123");
assert!(request.create_if_missing);
}
#[test]
fn test_attach_result_creation() {
use super::super::isolation::ResourceLimits;
use std::path::PathBuf;
let session = SandboxSession::new(SandboxId::new(), "client");
let capabilities = SandboxCapabilities {
sandbox_id: "test-sandbox".to_string(),
backend: "test-backend".to_string(),
profile: "default".to_string(),
can_write_filesystem: false,
readable_paths: vec![],
writable_paths: vec![],
has_network: false,
allowed_destinations: vec![],
limits: ResourceLimits::default(),
syscall_filter_active: false,
blocked_syscall_categories: vec![],
is_persistent: false,
created_at: chrono::Utc::now(),
time_remaining_ms: None,
};
let result = AttachResult {
session: session.clone(),
newly_created: true,
capabilities: capabilities.clone(),
};
assert!(result.newly_created);
assert_eq!(result.session.session_id, session.session_id);
}
#[test]
fn test_sandbox_session_is_timed_out_active() {
let sandbox_id = SandboxId::new();
let session = SandboxSession::new(sandbox_id, "client");
assert!(!session.is_timed_out());
}
#[test]
fn test_sandbox_session_is_timed_out_terminated() {
let sandbox_id = SandboxId::new();
let mut session = SandboxSession::new(sandbox_id, "client");
session.state = SessionState::Terminated;
assert!(session.is_timed_out());
}
}