use crate::cgroups_stats::ContainerStats;
use crate::error::{AgentError, Result};
use crate::runtime::{
ContainerId, ContainerState, ImageInfo, ImageInspectInfo, LogChannel, LogChunk, LogsStream,
LogsStreamOptions, OverlayAttachKind, PruneResult, Runtime,
};
use crate::storage_manager::StorageManager;
use crate::MacSandboxConfig;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
use tokio::sync::mpsc;
use tokio::sync::RwLock;
use tokio_stream::wrappers::ReceiverStream;
use zlayer_observability::logs::{LogEntry, LogSource, LogStream};
use zlayer_registry::BlobCacheBackend;
use zlayer_spec::{RegistryAuth, ServiceSpec};
pub(crate) const DEFAULT_SEATBELT_PATH: &str =
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
fn ensure_default_path(spec: &mut ServiceSpec) {
match spec.env.get("PATH") {
Some(v) if !v.trim().is_empty() => {}
_ => {
spec.env
.insert("PATH".to_string(), DEFAULT_SEATBELT_PATH.to_string());
}
}
}
fn ensure_spawn_path(env_vars: &mut Vec<(String, String)>) {
match env_vars.iter_mut().find(|(k, _)| k == "PATH") {
Some((_, v)) if v.trim().is_empty() => *v = DEFAULT_SEATBELT_PATH.to_string(),
Some(_) => {}
None => env_vars.push(("PATH".to_string(), DEFAULT_SEATBELT_PATH.to_string())),
}
}
fn inject_toolchain_env(
env_vars: &mut Vec<(String, String)>,
handle: &zlayer_toolchain::ToolchainHandle,
) {
if !handle.path_dirs.is_empty() {
let prefix = handle.path_dirs.join(":");
match env_vars.iter_mut().find(|(k, _)| k == "PATH") {
Some((_, v)) if !v.trim().is_empty() => *v = format!("{prefix}:{v}"),
Some((_, v)) => *v = prefix,
None => env_vars.push(("PATH".to_string(), prefix)),
}
}
let mut extra: Vec<(&String, &String)> = handle.env.iter().collect();
extra.sort_by(|a, b| a.0.cmp(b.0));
for (k, val) in extra {
if !env_vars.iter().any(|(ek, _)| ek == k) {
env_vars.push((k.clone(), val.clone()));
}
}
}
const SANDBOX_CA_BUNDLE: &str = "/private/etc/ssl/cert.pem";
fn inject_ca_cert_env(env_vars: &mut Vec<(String, String)>) {
for key in ["SSL_CERT_FILE", "GIT_SSL_CAINFO"] {
if !env_vars.iter().any(|(k, _)| k == key) {
env_vars.push((key.to_string(), SANDBOX_CA_BUNDLE.to_string()));
}
}
}
fn inject_home_env(env_vars: &mut Vec<(String, String)>, home_dir: &Path) {
if !env_vars.iter().any(|(k, _)| k == "HOME") {
env_vars.push(("HOME".to_string(), home_dir.to_string_lossy().into_owned()));
}
}
fn host_developer_dir() -> Option<&'static str> {
static DEV_DIR: OnceLock<Option<String>> = OnceLock::new();
DEV_DIR
.get_or_init(|| {
let out = std::process::Command::new("/usr/bin/xcode-select")
.arg("-p")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!dir.is_empty()).then_some(dir)
})
.as_deref()
}
fn enclosing_app_bundle(path: &str) -> Option<&str> {
if let Some(idx) = path.find(".app/") {
return Some(&path[..idx + ".app".len()]);
}
Path::new(path)
.extension()
.is_some_and(|ext| ext == "app")
.then_some(path)
}
struct DarwinUserDirs {
temp: Option<PathBuf>,
cache: Option<PathBuf>,
}
fn host_darwin_user_dirs() -> &'static DarwinUserDirs {
static DIRS: OnceLock<DarwinUserDirs> = OnceLock::new();
DIRS.get_or_init(|| DarwinUserDirs {
temp: getconf_canonical("DARWIN_USER_TEMP_DIR"),
cache: getconf_canonical("DARWIN_USER_CACHE_DIR"),
})
}
fn getconf_canonical(name: &str) -> Option<PathBuf> {
let out = std::process::Command::new("/usr/bin/getconf")
.arg(name)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() {
return None;
}
std::fs::canonicalize(&raw).ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GpuAccess {
None,
MetalCompute,
MpsOnly,
}
#[derive(Debug, Clone)]
pub enum NetworkAccess {
None,
LocalhostOnly {
bind_ports: Vec<u16>,
connect_ports: Vec<u16>,
overlay_cidr: Option<String>,
loopback_api_port: Option<u16>,
},
Full,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeychainAccess {
None,
Enabled {
keychain_paths: Vec<PathBuf>,
},
}
#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub rootfs_dir: PathBuf,
pub workspace_dir: PathBuf,
pub gpu_access: GpuAccess,
pub keychain_access: KeychainAccess,
pub network_access: NetworkAccess,
pub writable_dirs: Vec<PathBuf>,
pub readonly_dirs: Vec<PathBuf>,
pub toolchain_cache: Option<PathBuf>,
pub max_files: u64,
pub cpu_time_limit: Option<u64>,
pub memory_limit: Option<u64>,
}
const METAL_COMPUTE_PROFILE_SECTION: &str = "\
; --- GPU: Full Metal Compute ---
; IOKit user clients for GPU hardware access
; Apple Silicon (M1/M2/M3/M4/M5): AGXDeviceUserClient is the actual user client
; class opened by Metal. AGXSharedUserClient handles multi-process GPU sharing.
; The IOAccel* classes are IOKit compatibility shims (still needed).
(allow iokit-open
(iokit-user-client-class \"AGXDeviceUserClient\")
(iokit-user-client-class \"AGXSharedUserClient\")
(iokit-user-client-class \"IOSurfaceRootUserClient\")
(iokit-user-client-class \"IOSurfaceAcceleratorClient\")
(iokit-user-client-class \"IOAccelDevice\")
(iokit-user-client-class \"IOAccelDevice2\")
(iokit-user-client-class \"IOAccelContext\")
(iokit-user-client-class \"IOAccelContext2\")
(iokit-user-client-class \"IOAccelSharedUserClient\")
(iokit-user-client-class \"IOAccelSharedUserClient2\")
(iokit-user-client-class \"IOAccelSubmitter2\")
(iokit-user-client-class \"RootDomainUserClient\"))
; IOKit service-level access (macOS 26+ fine-grained syntax)
; AGXAcceleratorG* prefix matches all Apple Silicon GPU generations.
(allow iokit-open-service
(iokit-user-client-class \"IOSurfaceRoot\")
(iokit-registry-entry-class-prefix \"AGXAcceleratorG\"))
; IOKit user-client-level access (macOS 26+ fine-grained syntax)
(allow iokit-open-user-client
(iokit-user-client-class \"AGXDeviceUserClient\")
(iokit-user-client-class \"AGXSharedUserClient\")
(iokit-user-client-class \"IOSurfaceRootUserClient\")
(iokit-user-client-class \"IOSurfaceAcceleratorClient\"))
; GPU IOKit properties (comprehensive set from Apple's safety-inference profile)
(allow iokit-get-properties
(iokit-property \"AGCInfo\")
(iokit-property \"AGXCliqueTracingDefaults\")
(iokit-property \"AGXInternalPerfCounterResourcesPath\")
(iokit-property \"AGXLimitersDirName\")
(iokit-property \"AGXParameterBufferMaxSize\")
(iokit-property \"AGXParameterBufferMaxSizeEverMemless\")
(iokit-property \"AGXParameterBufferMaxSizeNeverMemless\")
(iokit-property \"AGXTraceCodeVersion\")
(iokit-property \"CFBundleIdentifier\")
(iokit-property \"CFBundleIdentifierKernel\")
(iokit-property \"chip-id\")
(iokit-property \"CommandSubmissionEnabled\")
(iokit-property \"CompactVRAM\")
(iokit-property \"EnableBlitLib\")
(iokit-property \"gpu-core-count\")
(iokit-property \"GPUConfigurationVariable\")
(iokit-property \"GPUDCCDisplayable\")
(iokit-property \"GPUDebugNullClientMask\")
(iokit-property \"GpuDebugPolicy\")
(iokit-property \"GPURawCounterBundleName\")
(iokit-property \"GPURawCounterPluginClassName\")
(iokit-property \"IOClass\")
(iokit-property \"IOClassNameOverride\")
(iokit-property \"IOGeneralInterest\")
(iokit-property \"IOGLBundleName\")
(iokit-property \"IOGLESBundleName\")
(iokit-property \"IOGLESDefaultUseMetal\")
(iokit-property \"IOGLESMetalBundleName\")
(iokit-property \"IOMatchCategory\")
(iokit-property \"IOMatchedAtBoot\")
(iokit-property \"IONameMatch\")
(iokit-property \"IONameMatched\")
(iokit-property \"IOPCIMatch\")
(iokit-property \"IOPersonalityPublisher\")
(iokit-property \"IOPowerManagement\")
(iokit-property \"IOProbeScore\")
(iokit-property \"IOProviderClass\")
(iokit-property \"IORegistryEntryPropertyKeys\")
(iokit-property \"IOReportLegend\")
(iokit-property \"IOReportLegendPublic\")
(iokit-property \"IOSourceVersion\")
(iokit-property \"KDebugVersion\")
(iokit-property \"MetalCoalesce\")
(iokit-property \"MetalPluginClassName\")
(iokit-property \"MetalPluginName\")
(iokit-property \"MetalStatisticsName\")
(iokit-property \"MetalStatisticsScriptName\")
(iokit-property \"model\")
(iokit-property \"PerformanceStatistics\")
(iokit-property \"Removable\")
(iokit-property \"SafeEjectRequested\")
(iokit-property \"SchedulerState\")
(iokit-property \"SCMBuildTime\")
(iokit-property \"SCMVersionNumber\")
(iokit-property \"soc-generation\")
(iokit-property \"SurfaceList\")
(iokit-property \"vendor-id\")
(iokit-property \"device-id\")
(iokit-property \"class-code\"))
; Mach services for Metal shader compilation and GPU memory
(allow mach-lookup
(global-name \"com.apple.MTLCompilerService\")
(global-name \"com.apple.CARenderServer\")
(global-name \"com.apple.PowerManagement.control\")
(global-name \"com.apple.gpu.process\")
(global-name \"com.apple.gpumemd.source\")
(global-name \"com.apple.cvmsServ\"))
; XPC services for shader compilation (Apple Silicon)
(allow mach-lookup
(xpc-service-name \"com.apple.MTLCompilerService\")
(xpc-service-name-prefix \"com.apple.AGXCompilerService\"))
; User preferences for Metal/OpenGL
(allow user-preference-read
(preference-domain \"com.apple.opengl\")
(preference-domain \"com.apple.Metal\")
(preference-domain \"com.nvidia.OpenGL\"))
; GPU driver bundles and libraries
(allow file-read*
(subpath \"/Library/GPUBundles\")
(subpath \"/System/Library/Frameworks/Metal.framework\")
(subpath \"/System/Library/Frameworks/MetalPerformanceShaders.framework\")
(subpath \"/System/Library/Frameworks/MetalPerformanceShadersGraph.framework\")
(subpath \"/System/Library/PrivateFrameworks/GPUCompiler.framework\"))
";
const MPS_ONLY_PROFILE_SECTION: &str = "\
; --- GPU: MPS Only (pre-compiled kernels, no shader compilation) ---
; IOKit user clients for GPU hardware access (minimal set)
; AGXDeviceUserClient is required -- MTLCreateSystemDefaultDevice() opens this class.
(allow iokit-open
(iokit-user-client-class \"AGXDeviceUserClient\")
(iokit-user-client-class \"AGXSharedUserClient\")
(iokit-user-client-class \"IOSurfaceRootUserClient\")
(iokit-user-client-class \"IOAccelDevice2\")
(iokit-user-client-class \"IOAccelContext2\")
(iokit-user-client-class \"IOAccelSharedUserClient2\")
(iokit-user-client-class \"RootDomainUserClient\"))
; IOKit service-level access (macOS 26+ fine-grained syntax)
(allow iokit-open-service
(iokit-user-client-class \"IOSurfaceRoot\")
(iokit-registry-entry-class-prefix \"AGXAcceleratorG\"))
; IOKit user-client-level access (macOS 26+ fine-grained syntax)
(allow iokit-open-user-client
(iokit-user-client-class \"AGXDeviceUserClient\")
(iokit-user-client-class \"IOSurfaceRootUserClient\"))
; GPU IOKit properties (minimal set for MPS)
(allow iokit-get-properties
(iokit-property \"MetalPluginClassName\")
(iokit-property \"MetalPluginName\")
(iokit-property \"IOClass\")
(iokit-property \"IOGLESDefaultUseMetal\")
(iokit-property \"IORegistryEntryPropertyKeys\")
(iokit-property \"IOSourceVersion\")
(iokit-property \"GPUConfigurationVariable\")
(iokit-property \"GPURawCounterBundleName\")
(iokit-property \"gpu-core-count\")
(iokit-property \"model\")
(iokit-property \"vendor-id\")
(iokit-property \"device-id\")
(iokit-property \"soc-generation\"))
; Mach services for MPS
; MTLCompilerService is required because MPSGraph on macOS 26+ uses JIT
; compilation internally for kernel fusion, even for pre-compiled MPS kernels.
; gpumemd.source is needed for GPU memory management.
(allow mach-lookup
(global-name \"com.apple.MTLCompilerService\")
(global-name \"com.apple.PowerManagement.control\")
(global-name \"com.apple.gpumemd.source\"))
; XPC service for MTLCompilerService (required by MPSGraph JIT)
(allow mach-lookup
(xpc-service-name \"com.apple.MTLCompilerService\"))
; User preferences
(allow user-preference-read
(preference-domain \"com.apple.Metal\"))
; MPS framework access
(allow file-read*
(subpath \"/Library/GPUBundles\")
(subpath \"/System/Library/Frameworks/Metal.framework\")
(subpath \"/System/Library/Frameworks/MetalPerformanceShaders.framework\")
(subpath \"/System/Library/Frameworks/MetalPerformanceShadersGraph.framework\"))
";
fn write_ancestor_metadata_rules(profile: &mut String, writable_roots: &[PathBuf]) {
let mut seen: HashSet<String> = HashSet::new();
let mut rules = String::new();
for root in writable_roots {
let mut cur = root.parent();
while let Some(anc) = cur {
let s = anc.to_string_lossy().into_owned();
if seen.insert(s.clone()) {
let _ = writeln!(rules, "(allow file-read-metadata (literal \"{s}\"))");
}
if anc == Path::new("/") {
break;
}
cur = anc.parent();
}
}
if !rules.is_empty() {
profile.push_str(
"; --- Ancestor metadata (lstat/traverse parents of writable subpaths) ---\n",
);
profile.push_str(&rules);
profile.push('\n');
}
}
fn append_docker_socket_sbpl_rules(profile: &mut String, socket_path: &Path) {
let _ = writeln!(profile, "; --- Per-container Docker Engine API socket ---");
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
socket_path.display()
);
write_ancestor_metadata_rules(profile, std::slice::from_ref(&socket_path.to_path_buf()));
}
async fn sandbox_container_stopped(
containers: &Arc<RwLock<HashMap<String, SandboxContainer>>>,
dir_name: &str,
) -> bool {
let guard = containers.read().await;
match guard.get(dir_name) {
Some(c) => matches!(
c.state,
ContainerState::Exited { .. } | ContainerState::Failed { .. }
),
None => true,
}
}
async fn stream_sandbox_log_file(
path: PathBuf,
channel: LogChannel,
opts: LogsStreamOptions,
tx: mpsc::Sender<Result<LogChunk>>,
containers: Arc<RwLock<HashMap<String, SandboxContainer>>>,
dir_name: String,
) {
let Ok(file) = tokio::fs::File::open(&path).await else {
return;
};
let mut reader = BufReader::new(file);
if let Some(tail) = opts.tail {
if tail > 0 {
if let Ok(meta) = reader.get_ref().metadata().await {
let start = compute_tail_offset(reader.get_mut(), meta.len(), tail).await;
if reader.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return;
}
}
}
}
let mut line = String::new();
loop {
line.clear();
let Ok(bytes_read) = reader.read_line(&mut line).await else {
return;
};
if bytes_read == 0 {
if !opts.follow {
return;
}
if !sandbox_container_stopped(&containers, &dir_name).await {
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
tokio::time::sleep(Duration::from_millis(100)).await;
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
}
let now = chrono::Utc::now();
let now_secs = now.timestamp();
if let Some(since) = opts.since {
if now_secs < since {
continue;
}
}
if let Some(until) = opts.until {
if now_secs > until {
return;
}
}
let chunk = LogChunk {
stream: channel,
bytes: bytes::Bytes::copy_from_slice(line.as_bytes()),
timestamp: opts.timestamps.then_some(now),
};
if tx.send(Ok(chunk)).await.is_err() {
return;
}
}
}
async fn compute_tail_offset(file: &mut tokio::fs::File, file_len: u64, tail: u64) -> u64 {
const CHUNK_USIZE: usize = 4096;
if file_len == 0 || tail == 0 {
return 0;
}
let chunk: u64 = CHUNK_USIZE as u64;
let target = tail.saturating_add(1); let mut pos = file_len;
let mut newlines: u64 = 0;
let mut buf = vec![0u8; CHUNK_USIZE];
while pos > 0 {
let read_len = std::cmp::min(chunk, pos);
pos -= read_len;
if file.seek(std::io::SeekFrom::Start(pos)).await.is_err() {
return 0;
}
let slice_len = usize::try_from(read_len).unwrap_or(CHUNK_USIZE);
let buf_slice = &mut buf[..slice_len];
if tokio::io::AsyncReadExt::read_exact(file, buf_slice)
.await
.is_err()
{
return 0;
}
for (i, byte) in buf_slice.iter().enumerate().rev() {
if *byte == b'\n' {
newlines += 1;
if newlines == target {
let absolute = pos + (i as u64) + 1;
return absolute.min(file_len);
}
}
}
}
0
}
fn loopback_api_url(url: &str) -> String {
let Some((scheme, authority)) = url.split_once("://") else {
return url.to_string();
};
match authority.rsplit_once(':') {
Some((_host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => {
format!("{scheme}://127.0.0.1:{port}")
}
_ => url.to_string(),
}
}
fn api_url_port(url: &str) -> Option<u16> {
let (_scheme, authority) = url.split_once("://")?;
let (_host, port) = authority.rsplit_once(':')?;
port.parse::<u16>().ok()
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn generate_sandbox_profile(config: &SandboxConfig) -> String {
let mut profile = String::with_capacity(4096);
profile.push_str("(version 1)\n");
profile.push_str("(deny default)\n");
profile.push('\n');
profile.push_str("; --- Base process rules ---\n");
profile.push_str("(allow process-exec)\n");
profile.push_str("(allow process-fork)\n");
profile.push_str("(allow signal (target same-sandbox))\n");
profile.push_str("(allow process-info* (target self))\n");
profile.push_str("(allow process-info-pidinfo)\n");
profile.push_str("(allow process-info-rusage)\n");
profile.push('\n');
profile.push_str("; --- System libraries (required for any process to run) ---\n");
profile.push_str("(allow file-read*\n");
profile.push_str(" (subpath \"/usr/lib\")\n");
profile.push_str(" (subpath \"/System/Library/Frameworks\")\n");
profile.push_str(" (subpath \"/System/Library/PrivateFrameworks\")\n");
profile.push_str(" (subpath \"/System/Library/Extensions\")\n");
profile.push_str(" (subpath \"/System/Library/ColorSync\")\n");
profile.push_str(" (subpath \"/bin\")\n");
profile.push_str(" (subpath \"/sbin\")\n");
profile.push_str(" (subpath \"/usr/bin\")\n");
profile.push_str(" (subpath \"/usr/sbin\")\n");
profile.push_str(" (subpath \"/usr/libexec\")\n");
profile.push_str(" (subpath \"/usr/local/bin\")\n");
profile.push_str(" (subpath \"/usr/local/sbin\")\n");
profile.push_str(" (subpath \"/opt/homebrew\")\n");
profile.push_str(" (subpath \"/Library/Developer/CommandLineTools\")\n");
profile.push_str(" (subpath \"/private/etc\")\n");
profile.push_str(" (literal \"/\")\n");
profile.push_str(" (literal \"/dev/random\")\n");
profile.push_str(" (literal \"/dev/urandom\"))\n");
profile.push('\n');
profile.push_str("; --- Executable mapping (required for dyld) ---\n");
profile.push_str("(allow file-map-executable\n");
profile.push_str(" (subpath \"/usr/lib\")\n");
profile.push_str(" (subpath \"/System/Library/Frameworks\")\n");
profile.push_str(" (subpath \"/System/Library/PrivateFrameworks\")\n");
profile.push_str(" (subpath \"/System/Library/Extensions\")\n");
profile.push_str(" (subpath \"/bin\")\n");
profile.push_str(" (subpath \"/sbin\")\n");
profile.push_str(" (subpath \"/usr/bin\")\n");
profile.push_str(" (subpath \"/usr/sbin\")\n");
profile.push_str(" (subpath \"/usr/libexec\")\n");
profile.push_str(" (subpath \"/usr/local/bin\")\n");
profile.push_str(" (subpath \"/usr/local/sbin\")\n");
profile.push_str(" (subpath \"/opt/homebrew\")\n");
profile.push_str(" (subpath \"/Library/Developer/CommandLineTools\"))\n");
profile.push('\n');
profile.push_str("; --- System info (hw detection, etc.) ---\n");
profile.push_str("(allow sysctl-read)\n");
profile.push_str("(allow system-info)\n");
profile.push('\n');
profile.push_str("; --- Mach basics ---\n");
profile.push_str("(allow mach-lookup\n");
profile.push_str(" (global-name \"com.apple.system.opendirectoryd.libinfo\"))\n");
profile.push('\n');
profile.push_str("; --- Container rootfs ---\n");
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
config.rootfs_dir.display()
);
let _ = writeln!(
profile,
"(allow file-map-executable (subpath \"{}\"))",
config.rootfs_dir.display()
);
profile.push('\n');
profile.push_str("; --- Workspace directory ---\n");
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
config.workspace_dir.display()
);
profile.push('\n');
if !config.writable_dirs.is_empty() {
profile.push_str("; --- Volume mounts (writable) ---\n");
for dir in &config.writable_dirs {
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
dir.display()
);
}
profile.push('\n');
}
if !config.readonly_dirs.is_empty() {
profile.push_str("; --- Volume mounts (read-only) ---\n");
for dir in &config.readonly_dirs {
let _ = writeln!(
profile,
"(allow file-read* (subpath \"{}\"))",
dir.display()
);
}
profile.push('\n');
}
if let Some(tc) = &config.toolchain_cache {
let _ = writeln!(profile, "; --- Provisioned toolchain cache (git keg) ---");
let _ = writeln!(
profile,
"(allow file-read* file-map-executable (subpath \"{}\"))",
tc.display()
);
profile.push('\n');
}
let mut writable_roots: Vec<PathBuf> =
vec![config.rootfs_dir.clone(), config.workspace_dir.clone()];
writable_roots.extend(config.writable_dirs.iter().cloned());
if let KeychainAccess::Enabled { keychain_paths } = &config.keychain_access {
for path in keychain_paths {
if let Some(parent) = path.parent() {
writable_roots.push(parent.to_path_buf());
}
}
}
if let Some(tc) = &config.toolchain_cache {
writable_roots.push(tc.clone());
}
write_ancestor_metadata_rules(&mut profile, &writable_roots);
match config.gpu_access {
GpuAccess::MetalCompute => {
profile.push_str(METAL_COMPUTE_PROFILE_SECTION);
}
GpuAccess::MpsOnly => {
profile.push_str(MPS_ONLY_PROFILE_SECTION);
}
GpuAccess::None => {}
}
profile.push_str("; --- Apple toolchain (xcrun/clang) support ---\n");
profile.push_str("(allow mach-lookup (global-name \"com.apple.bsd.dirhelper\"))\n");
profile.push_str("(allow file-read* (literal \"/var\"))\n");
profile.push_str(
"(allow file-read* (literal \"/System/Library/CoreServices/SystemVersion.plist\"))\n",
);
profile.push_str(
"(allow file-read-metadata (literal \"/Library\") (literal \"/Library/Developer\"))\n",
);
profile.push_str(
"(allow file-read* (literal \"/Library/Preferences/com.apple.dt.Xcode.plist\"))\n",
);
if let Ok(home) = std::env::var("HOME") {
let _ = writeln!(
profile,
"(allow file-read* (literal \"{home}/Library/Preferences/com.apple.dt.Xcode.plist\"))"
);
}
if let Some(dev_dir) = host_developer_dir() {
if !Path::new(dev_dir).starts_with("/Library/Developer/CommandLineTools") {
let grant_root = enclosing_app_bundle(dev_dir).unwrap_or(dev_dir);
let _ = writeln!(
profile,
"(allow file-read* file-map-executable (subpath \"{grant_root}\"))"
);
write_ancestor_metadata_rules(&mut profile, &[PathBuf::from(grant_root)]);
}
}
let darwin = host_darwin_user_dirs();
if let Some(temp) = &darwin.temp {
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
temp.display()
);
}
if let Some(cache) = &darwin.cache {
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
cache.display()
);
}
profile.push('\n');
profile.push_str("; --- TLS trust evaluation (server certs) ---\n");
profile.push_str("(allow mach-lookup\n");
profile.push_str(" (global-name \"com.apple.trustd\")\n");
profile.push_str(" (global-name \"com.apple.trustd.agent\")\n");
profile.push_str(" (global-name \"com.apple.ocspd\"))\n");
profile.push('\n');
if let KeychainAccess::Enabled { keychain_paths } = &config.keychain_access {
profile.push_str("; --- Keychain / code signing ---\n");
profile.push_str("(allow mach-lookup\n");
profile.push_str(" (global-name \"com.apple.SecurityServer\")\n");
profile.push_str(" (global-name \"com.apple.ocspd\")\n");
profile.push_str(" (global-name \"com.apple.trustd\")\n");
profile.push_str(" (global-name \"com.apple.trustd.agent\")\n");
profile.push_str(" (global-name \"com.apple.SystemConfiguration.configd\"))\n");
profile.push_str("(allow file-read* file-map-executable\n");
profile.push_str(" (subpath \"/usr/bin\")\n");
profile.push_str(" (subpath \"/usr/libexec\"))\n");
profile.push_str("(allow file-read*\n");
profile.push_str(" (subpath \"/Library/Keychains\")\n");
profile.push_str(" (subpath \"/System/Library/Keychains\"))\n");
profile
.push_str("(allow user-preference-read (preference-domain \"com.apple.security\"))\n");
for path in keychain_paths {
if let Some(parent) = path.parent() {
let _ = writeln!(
profile,
"(allow file-read* file-write* (subpath \"{}\"))",
parent.display()
);
} else {
let _ = writeln!(
profile,
"(allow file-read* file-write* (literal \"{}\"))",
path.display()
);
}
}
profile.push('\n');
}
match &config.network_access {
NetworkAccess::None => {
profile.push_str("; --- Network: DENIED ---\n\n");
}
NetworkAccess::LocalhostOnly {
bind_ports,
connect_ports,
overlay_cidr,
loopback_api_port,
} => {
profile.push_str("; --- Network: localhost only ---\n");
for port in bind_ports {
let _ = writeln!(
profile,
"(allow network-bind (local ip \"localhost:{port}\"))",
);
}
for port in connect_ports {
let _ = writeln!(
profile,
"(allow network-outbound (remote ip \"localhost:{port}\"))",
);
}
if let Some(port) = loopback_api_port {
let _ = writeln!(
profile,
"(allow network-outbound (remote ip \"127.0.0.1:{port}\"))",
);
}
if !bind_ports.is_empty() {
profile.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
}
if let Some(cidr) = overlay_cidr {
let _ = writeln!(profile, "(allow network-outbound (remote ip \"{cidr}:*\"))");
let _ = writeln!(profile, "(allow network-inbound (remote ip \"{cidr}:*\"))");
}
profile.push('\n');
}
NetworkAccess::Full => {
profile.push_str("; --- Network: full access ---\n");
profile.push_str("(allow network-outbound)\n");
profile.push_str("(allow network-inbound)\n");
profile.push_str("(allow network-bind)\n");
profile.push_str("(allow system-socket)\n");
profile.push('\n');
}
}
profile.push_str("; --- I/O essentials ---\n");
profile.push_str("(allow file-write-data\n");
profile.push_str(" (require-all (literal \"/dev/null\") (vnode-type CHARACTER-DEVICE)))\n");
profile.push_str("(allow file-read-data\n");
profile.push_str(" (require-all (literal \"/dev/null\") (vnode-type CHARACTER-DEVICE)))\n");
profile.push_str("(allow pseudo-tty)\n");
profile.push_str("(allow file-read* file-write* file-ioctl (literal \"/dev/ptmx\"))\n");
profile.push('\n');
profile.push_str("; --- IPC ---\n");
profile.push_str("(allow ipc-posix-sem)\n");
profile.push_str("(allow ipc-posix-shm)\n");
profile.push('\n');
profile
}
mod seatbelt_ffi {
use std::os::raw::c_char;
#[link(name = "System", kind = "dylib")]
extern "C" {
pub fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32;
pub fn sandbox_free_error(errorbuf: *mut c_char);
}
}
#[allow(unsafe_code)]
fn apply_seatbelt_profile(sbpl: &str) -> std::io::Result<()> {
use std::ffi::CString;
use std::ptr;
let profile_cstr =
CString::new(sbpl).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let mut error_buf: *mut std::os::raw::c_char = ptr::null_mut();
let result = unsafe {
seatbelt_ffi::sandbox_init(
profile_cstr.as_ptr(),
0, &raw mut error_buf,
)
};
if result != 0 {
let error_msg = if error_buf.is_null() {
format!("sandbox_init returned error code {result}")
} else {
let msg = unsafe {
std::ffi::CStr::from_ptr(error_buf)
.to_string_lossy()
.into_owned()
};
unsafe { seatbelt_ffi::sandbox_free_error(error_buf) };
msg
};
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("Failed to initialize sandbox: {error_msg}"),
));
}
Ok(())
}
extern "C" {
fn clonefile(
src: *const libc::c_char,
dst: *const libc::c_char,
flags: libc::c_int,
) -> libc::c_int;
}
#[allow(unsafe_code)]
fn clone_file_apfs(src: &Path, dst: &Path) -> std::io::Result<bool> {
use std::ffi::CString;
let c_src = CString::new(src.to_str().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid src path")
})?)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let c_dst = CString::new(dst.to_str().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid dst path")
})?)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let ret = unsafe { clonefile(c_src.as_ptr(), c_dst.as_ptr(), 0) };
if ret == 0 {
Ok(true) } else {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ENOTSUP)
|| err.raw_os_error() == Some(libc::EXDEV)
|| err.raw_os_error() == Some(libc::EEXIST)
{
Ok(false) } else {
Err(err)
}
}
}
async fn clone_directory_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
tokio::fs::create_dir_all(dst).await?;
let mut entries = tokio::fs::read_dir(src).await?;
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
let file_name = entry.file_name();
let dest_path = dst.join(&file_name);
let file_type = entry.file_type().await?;
if file_type.is_dir() {
Box::pin(clone_directory_recursive(&entry_path, &dest_path)).await?;
} else if file_type.is_file() {
let src_clone = entry_path.clone();
let dst_clone = dest_path.clone();
let cloned =
tokio::task::spawn_blocking(move || clone_file_apfs(&src_clone, &dst_clone))
.await
.map_err(std::io::Error::other)??;
if !cloned {
tokio::fs::copy(&entry_path, &dest_path).await?;
}
} else if file_type.is_symlink() {
let link_target = tokio::fs::read_link(&entry_path).await?;
tokio::fs::symlink(&link_target, &dest_path).await?;
}
}
let src_meta = tokio::fs::metadata(src).await?;
tokio::fs::set_permissions(dst, src_meta.permissions()).await?;
Ok(())
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn get_process_rss(pid: u32) -> std::io::Result<u64> {
#[repr(C)]
#[allow(non_snake_case)]
#[allow(clippy::struct_field_names)]
struct ProcTaskInfo {
pti_virtual_size: u64,
pti_resident_size: u64,
pti_total_user: u64,
pti_total_system: u64,
pti_threads_user: u64,
pti_threads_system: u64,
pti_policy: i32,
pti_faults: i32,
pti_pageins: i32,
pti_cow_faults: i32,
pti_messages_sent: i32,
pti_messages_received: i32,
pti_syscalls_mach: i32,
pti_syscalls_unix: i32,
pti_csw: i32,
pti_threadnum: i32,
pti_numrunning: i32,
pti_priority: i32,
}
extern "C" {
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
const PROC_PIDTASKINFO: libc::c_int = 4;
let mut info: ProcTaskInfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<ProcTaskInfo>() as libc::c_int;
let ret = unsafe {
proc_pidinfo(
pid as libc::c_int,
PROC_PIDTASKINFO,
0,
(&raw mut info).cast::<libc::c_void>(),
size,
)
};
if ret <= 0 {
return Err(std::io::Error::last_os_error());
}
Ok(info.pti_resident_size)
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn get_process_stats(pid: u32) -> Result<(u64, u64)> {
#[repr(C)]
#[allow(non_snake_case)]
#[allow(clippy::struct_field_names)]
struct ProcTaskInfo {
pti_virtual_size: u64,
pti_resident_size: u64,
pti_total_user: u64,
pti_total_system: u64,
pti_threads_user: u64,
pti_threads_system: u64,
pti_policy: i32,
pti_faults: i32,
pti_pageins: i32,
pti_cow_faults: i32,
pti_messages_sent: i32,
pti_messages_received: i32,
pti_syscalls_mach: i32,
pti_syscalls_unix: i32,
pti_csw: i32,
pti_threadnum: i32,
pti_numrunning: i32,
pti_priority: i32,
}
extern "C" {
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
const PROC_PIDTASKINFO: libc::c_int = 4;
let mut info: ProcTaskInfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<ProcTaskInfo>() as libc::c_int;
let ret = unsafe {
proc_pidinfo(
pid as libc::c_int,
PROC_PIDTASKINFO,
0,
(&raw mut info).cast::<libc::c_void>(),
size,
)
};
if ret <= 0 {
return Err(AgentError::Internal(format!(
"proc_pidinfo failed for pid {pid}: {}",
std::io::Error::last_os_error()
)));
}
let cpu_usec = (info.pti_total_user + info.pti_total_system) / 1000;
let rss = info.pti_resident_size;
Ok((cpu_usec, rss))
}
#[allow(unsafe_code)]
fn set_resource_limits(max_files: u64, cpu_seconds: Option<u64>) -> std::io::Result<()> {
let file_limit = libc::rlimit {
rlim_cur: max_files,
rlim_max: max_files,
};
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const file_limit) } != 0 {
return Err(std::io::Error::last_os_error());
}
if let Some(seconds) = cpu_seconds {
let cpu_limit = libc::rlimit {
rlim_cur: seconds,
rlim_max: seconds,
};
if unsafe { libc::setrlimit(libc::RLIMIT_CPU, &raw const cpu_limit) } != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_wrap)]
async fn memory_watchdog(pid: u32, limit_bytes: u64) {
let check_interval = Duration::from_secs(2);
loop {
tokio::time::sleep(check_interval).await;
let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
if !alive {
tracing::debug!(pid = pid, "Memory watchdog: process exited");
return;
}
match get_process_rss(pid) {
Ok(rss_bytes) => {
if rss_bytes > limit_bytes {
tracing::warn!(
pid = pid,
rss_mb = rss_bytes / (1024 * 1024),
limit_mb = limit_bytes / (1024 * 1024),
"Memory limit exceeded, sending SIGKILL"
);
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
return;
}
}
Err(e) => {
tracing::debug!(pid = pid, error = %e, "Failed to read process RSS");
}
}
}
}
fn build_network_access(
spec: &ServiceSpec,
overlay_cidr: Option<String>,
iso: &zlayer_types::overlay::NetworkIsolation,
daemon_api_port: Option<u16>,
) -> NetworkAccess {
let mut bind_ports = Vec::new();
for endpoint in &spec.endpoints {
bind_ports.push(endpoint.target_port());
}
if iso.egress == zlayer_types::overlay::EgressPolicy::None {
return NetworkAccess::None;
}
if iso.fenced {
let mut connect_ports = vec![53, 80, 443];
connect_ports.extend_from_slice(&bind_ports);
if let Some(port) = daemon_api_port {
if !connect_ports.contains(&port) {
connect_ports.push(port);
}
}
return NetworkAccess::LocalhostOnly {
bind_ports,
connect_ports,
overlay_cidr: None,
loopback_api_port: daemon_api_port,
};
}
if bind_ports.is_empty() {
return NetworkAccess::Full;
}
let mut connect_ports = vec![53, 80, 443];
connect_ports.extend_from_slice(&bind_ports);
if let Some(port) = daemon_api_port {
if !connect_ports.contains(&port) {
connect_ports.push(port);
}
}
NetworkAccess::LocalhostOnly {
bind_ports,
connect_ports,
overlay_cidr,
loopback_api_port: daemon_api_port,
}
}
fn build_keychain_access(spec: &ServiceSpec, operator_default: Option<&str>) -> KeychainAccess {
let Some(raw) = spec
.labels
.get("zlayer.io/keychain")
.map(String::as_str)
.or(operator_default)
else {
return KeychainAccess::None;
};
let home = std::env::var("HOME").unwrap_or_default();
let keychain_dir = format!("{home}/Library/Keychains");
let paths = keychain_paths_from_label(raw, &keychain_dir);
if paths.is_empty() {
KeychainAccess::None
} else {
KeychainAccess::Enabled {
keychain_paths: paths,
}
}
}
#[must_use]
fn operator_keychain_default_from_env() -> Option<String> {
std::env::var("ZLAYER_KEYCHAIN_DEFAULT")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn keychain_paths_from_label(raw: &str, keychain_dir: &str) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let lower = entry.to_ascii_lowercase();
let path = match lower.as_str() {
"login" => PathBuf::from(format!("{keychain_dir}/login.keychain-db")),
"build" | "true" | "default" | "1" | "yes" | "on" => {
PathBuf::from(format!("{keychain_dir}/zlayer-build.keychain-db"))
}
_ if lower.starts_with("build:") => {
let name = &entry["build:".len()..];
PathBuf::from(format!("{keychain_dir}/{name}.keychain-db"))
}
_ if entry.starts_with('/') => PathBuf::from(entry),
_ => {
tracing::warn!(
label = %entry,
"Ignoring unrecognized zlayer.io/keychain value (expected login|build|build:<name>|/abs/path)"
);
continue;
}
};
if !paths.contains(&path) {
paths.push(path);
}
}
paths
}
fn storage_target(storage: &zlayer_spec::StorageSpec) -> &str {
match storage {
zlayer_spec::StorageSpec::Named { target, .. }
| zlayer_spec::StorageSpec::Anonymous { target, .. }
| zlayer_spec::StorageSpec::Bind { target, .. }
| zlayer_spec::StorageSpec::Tmpfs { target, .. }
| zlayer_spec::StorageSpec::S3 { target, .. } => target,
}
}
fn is_workspace_target(target: &str) -> bool {
matches!(target.trim_end_matches('/'), "/workspace" | "workspace")
}
struct SandboxStorage {
writable_dirs: Vec<PathBuf>,
workspace_host: PathBuf,
}
fn resolve_sandbox_storage(
spec: &ServiceSpec,
rootfs_dir: &Path,
container_dir: &Path,
volume_paths: &std::collections::HashMap<String, PathBuf>,
) -> SandboxStorage {
let mut writable_dirs = vec![
container_dir.join("tmp"), container_dir.join("home"), ];
let mut workspace_host: Option<PathBuf> = None;
for storage in &spec.storage {
let target = storage_target(storage);
let host = crate::runtimes::volume_prep::resolve_host_source(storage, volume_paths)
.map_or_else(
|| rootfs_dir.join(target.trim_start_matches('/')),
|rm| rm.host,
);
if is_workspace_target(target) {
workspace_host = Some(host.clone());
}
writable_dirs.push(host);
}
let workspace_host = workspace_host.unwrap_or_else(|| rootfs_dir.join("workspace"));
if !writable_dirs.contains(&workspace_host) {
writable_dirs.push(workspace_host.clone());
}
SandboxStorage {
writable_dirs,
workspace_host,
}
}
#[must_use]
fn parse_memory_string(s: &str) -> Option<u64> {
let s = s.trim();
if let Some(num) = s.strip_suffix("Gi") {
num.parse::<u64>().ok().map(|v| v * 1024 * 1024 * 1024)
} else if let Some(num) = s.strip_suffix("Mi") {
num.parse::<u64>().ok().map(|v| v * 1024 * 1024)
} else if let Some(num) = s.strip_suffix("Ki") {
num.parse::<u64>().ok().map(|v| v * 1024)
} else {
s.parse::<u64>().ok()
}
}
#[must_use]
fn sanitize_image_name(image: &str) -> String {
image.replace(['/', ':', '@'], "_")
}
fn read_sandbox_image_config(image_dir: &Path) -> Option<zlayer_registry::ImageConfig> {
let sidecar = image_dir.join("image-config.json");
let bytes = std::fs::read(&sidecar).ok()?;
match serde_json::from_slice::<zlayer_registry::ImageConfig>(&bytes) {
Ok(cfg) => Some(cfg),
Err(e) => {
tracing::debug!(
sidecar = %sidecar.display(),
error = %e,
"sandbox: failed to parse image-config.json sidecar; image Env will not layer",
);
None
}
}
}
fn read_local_image_metadata(
image_dir: &Path,
) -> Option<zlayer_types::local_image::LocalImageMetadata> {
let path = image_dir.join(zlayer_types::local_image::LOCAL_IMAGE_METADATA_FILE);
let bytes = std::fs::read(&path).ok()?;
match serde_json::from_slice(&bytes) {
Ok(meta) => Some(meta),
Err(e) => {
tracing::debug!(
sidecar = %path.display(),
error = %e,
"sandbox: failed to parse image metadata.json sidecar",
);
None
}
}
}
#[derive(Default, serde::Deserialize)]
struct SandboxBuildConfig {
#[serde(default)]
env: Vec<String>,
#[serde(default)]
working_dir: String,
#[serde(default)]
entrypoint: Option<Vec<String>>,
#[serde(default)]
cmd: Option<Vec<String>>,
#[serde(default)]
user: Option<String>,
#[serde(default)]
labels: HashMap<String, String>,
}
fn read_sandbox_build_config(image_dir: &Path) -> Option<SandboxBuildConfig> {
let path = image_dir.join("config.json");
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn resolve_entrypoint(spec: &ServiceSpec, rootfs: &Path) -> Result<(String, Vec<String>)> {
let resolve_program = |prog: &str| -> String {
if prog.starts_with('/') {
if std::path::Path::new(prog).exists() {
return prog.to_string();
}
let rootfs_path = rootfs.join(prog.trim_start_matches('/'));
if rootfs_path.exists() {
return rootfs_path.to_string_lossy().into_owned();
}
}
prog.to_string()
};
if let Some(ref entrypoint) = spec.command.entrypoint {
if !entrypoint.is_empty() {
let program = resolve_program(&entrypoint[0]);
let mut args: Vec<String> = entrypoint[1..].to_vec();
if let Some(ref extra_args) = spec.command.args {
args.extend(extra_args.iter().cloned());
}
return Ok((program, args));
}
}
if let Some(ref cmd_args) = spec.command.args {
if !cmd_args.is_empty() {
let program = resolve_program(&cmd_args[0]);
let args = cmd_args[1..].to_vec();
return Ok((program, args));
}
}
for shell in &["/bin/sh", "/bin/bash", "/usr/bin/sh"] {
if std::path::Path::new(shell).exists() {
return Ok(((*shell).to_string(), vec![]));
}
if rootfs.join(shell.trim_start_matches('/')).exists() {
let resolved = rootfs.join(shell.trim_start_matches('/'));
return Ok((resolved.to_string_lossy().into_owned(), vec![]));
}
}
Err(AgentError::InvalidSpec(
"No command specified and no shell found in rootfs".to_string(),
))
}
struct SandboxSpawnParams {
program: String,
args: Vec<String>,
sbpl_profile: String,
rootfs_dir: PathBuf,
workspace_host: PathBuf,
env_base: Vec<(String, String)>,
stdout_path: PathBuf,
stderr_path: PathBuf,
spec: ServiceSpec,
sandbox_config: SandboxConfig,
assigned_port: u16,
auth_env: Option<(String, String, String, bool)>, docker_host: Option<String>,
toolchains: Vec<zlayer_toolchain::ToolchainHandle>,
}
#[allow(unsafe_code, clippy::too_many_lines)]
fn spawn_sandboxed_process(params: &SandboxSpawnParams) -> Result<u32> {
use std::os::unix::process::CommandExt;
let profile = params.sbpl_profile.clone();
let max_files = params.sandbox_config.max_files;
let cpu_time_limit = params.sandbox_config.cpu_time_limit;
let mut env_vars: Vec<(String, String)> = params.env_base.clone();
let port_str = params.assigned_port.to_string();
if !env_vars.iter().any(|(k, _)| k == "PORT") {
env_vars.push(("PORT".to_string(), port_str.clone()));
}
env_vars.push(("ZLAYER_PORT".to_string(), port_str));
if !env_vars.iter().any(|(k, _)| k == "GITHUB_WORKSPACE") {
env_vars.push((
"GITHUB_WORKSPACE".to_string(),
params.workspace_host.to_string_lossy().into_owned(),
));
}
ensure_spawn_path(&mut env_vars);
for handle in ¶ms.toolchains {
inject_toolchain_env(&mut env_vars, handle);
}
inject_ca_cert_env(&mut env_vars);
inject_home_env(
&mut env_vars,
¶ms.sandbox_config.workspace_dir.join("home"),
);
if !env_vars.iter().any(|(k, _)| k == "TMPDIR") {
env_vars.push((
"TMPDIR".to_string(),
params
.sandbox_config
.workspace_dir
.join("tmp")
.to_string_lossy()
.into_owned(),
));
}
if !env_vars.iter().any(|(k, _)| k == "DEVELOPER_DIR") {
if let Some(dev_dir) = host_developer_dir() {
env_vars.push(("DEVELOPER_DIR".to_string(), dev_dir.to_string()));
}
}
let toolchain_clone = params
.rootfs_dir
.join("opt/zlayer/toolchains")
.to_string_lossy()
.into_owned();
if !env_vars.iter().any(|(k, _)| k == "RUNNER_TOOL_CACHE") {
env_vars.push(("RUNNER_TOOL_CACHE".to_string(), toolchain_clone.clone()));
}
if !env_vars.iter().any(|(k, _)| k == "AGENT_TOOLSDIRECTORY") {
env_vars.push(("AGENT_TOOLSDIRECTORY".to_string(), toolchain_clone));
}
if let Some((ref api_url, ref token, ref socket, mount_socket)) = params.auth_env {
env_vars.push(("ZLAYER_API_URL".to_string(), api_url.clone()));
env_vars.push(("ZLAYER_TOKEN".to_string(), token.clone()));
if mount_socket {
env_vars.push(("ZLAYER_SOCKET".to_string(), socket.clone()));
}
}
if let Some(ref docker_host) = params.docker_host {
if !env_vars.iter().any(|(k, _)| k == "DOCKER_HOST") {
env_vars.push(("DOCKER_HOST".to_string(), docker_host.clone()));
}
if !env_vars.iter().any(|(k, _)| k == "DOCKER_BUILDKIT") {
env_vars.push(("DOCKER_BUILDKIT".to_string(), "0".to_string()));
}
}
let stdout_file =
std::fs::File::create(¶ms.stdout_path).map_err(|e| AgentError::CreateFailed {
id: "sandbox-process".to_string(),
reason: format!("Failed to create stdout log: {e}"),
})?;
let stderr_file =
std::fs::File::create(¶ms.stderr_path).map_err(|e| AgentError::CreateFailed {
id: "sandbox-process".to_string(),
reason: format!("Failed to create stderr log: {e}"),
})?;
let cwd = params
.spec
.command
.workdir
.as_deref()
.filter(|w| !w.is_empty())
.map(|w| params.rootfs_dir.join(w.trim_start_matches('/')))
.filter(|p| p.is_dir())
.unwrap_or_else(|| params.rootfs_dir.clone());
let child = unsafe {
std::process::Command::new(¶ms.program)
.args(¶ms.args)
.current_dir(&cwd)
.stdout(stdout_file)
.stderr(stderr_file)
.env_clear()
.envs(env_vars)
.pre_exec(move || {
apply_seatbelt_profile(&profile)?;
set_resource_limits(max_files, cpu_time_limit)?;
Ok(())
})
.spawn()
}
.map_err(|e| AgentError::StartFailed {
id: "sandbox-process".to_string(),
reason: format!("Failed to spawn sandboxed process: {e}"),
})?;
Ok(child.id())
}
fn reserve_port() -> std::io::Result<(u16, std::net::TcpListener)> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok((port, listener))
}
#[derive(Debug)]
struct SandboxContainer {
pid: u32,
state: ContainerState,
state_dir: PathBuf,
rootfs_dir: PathBuf,
workspace_host: PathBuf,
stdout_path: PathBuf,
stderr_path: PathBuf,
started_at: Option<Instant>,
spec: ServiceSpec,
sandbox_config: SandboxConfig,
toolchain_handles: Vec<zlayer_toolchain::ToolchainHandle>,
memory_limit: Option<u64>,
watchdog_handle: Option<tokio::task::JoinHandle<()>>,
assigned_port: u16,
port_guard: Option<std::net::TcpListener>,
overlay_ip: Option<std::net::IpAddr>,
forwarders: Vec<tokio::task::AbortHandle>,
}
static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0);
const INIT_TOOLCHAINS: &[&str] = &["git", "node@lts"];
#[allow(clippy::type_complexity)]
fn init_toolchain_cells(
) -> &'static HashMap<&'static str, tokio::sync::OnceCell<Option<zlayer_toolchain::ToolchainHandle>>>
{
static CELLS: OnceLock<
HashMap<&'static str, tokio::sync::OnceCell<Option<zlayer_toolchain::ToolchainHandle>>>,
> = OnceLock::new();
CELLS.get_or_init(|| {
INIT_TOOLCHAINS
.iter()
.map(|&tool| (tool, tokio::sync::OnceCell::new()))
.collect()
})
}
static INIT_TOOLCHAINS_SPAWNED: AtomicBool = AtomicBool::new(false);
const INIT_TOOLCHAIN_PROVISION_TIMEOUT: Duration = Duration::from_secs(120);
const INIT_TOOLCHAIN_START_WAIT: Duration = Duration::from_secs(120);
async fn resolve_one_init_toolchain(
tool: &str,
host_tc_cache: &Path,
) -> Option<zlayer_toolchain::ToolchainHandle> {
if let Some(h) = init_toolchain_cells()
.get(tool)
.and_then(|cell| cell.get().cloned().flatten())
{
return Some(h);
}
zlayer_toolchain::probe_ready_toolchain(
tool,
zlayer_toolchain::ToolPlatform::MacOS,
host_tc_cache,
)
.await
}
async fn resolve_init_toolchain_handles(
host_tc_cache: &Path,
wait: Option<Duration>,
) -> Vec<zlayer_toolchain::ToolchainHandle> {
let mut handles = Vec::new();
let mut pending: Vec<&'static str> = Vec::new();
for &tool in INIT_TOOLCHAINS {
match resolve_one_init_toolchain(tool, host_tc_cache).await {
Some(h) => handles.push(h),
None => pending.push(tool),
}
}
let Some(budget) = wait else {
return handles;
};
let deadline = Instant::now() + budget;
while !pending.is_empty() && Instant::now() < deadline {
pending.retain(|&tool| {
init_toolchain_cells()
.get(tool)
.and_then(|cell| cell.get())
.is_none()
});
let mut still = Vec::new();
for &tool in &pending {
if let Some(h) = resolve_one_init_toolchain(tool, host_tc_cache).await {
handles.push(h);
} else {
still.push(tool);
}
}
pending = still;
if pending.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
handles
}
pub struct SandboxRuntime {
config: MacSandboxConfig,
containers: Arc<RwLock<HashMap<String, SandboxContainer>>>,
image_rootfs: Arc<RwLock<HashMap<String, PathBuf>>>,
storage_manager: Arc<RwLock<StorageManager>>,
auth_context: Option<crate::runtime::ContainerAuthContext>,
secrets_provider:
parking_lot::RwLock<Option<std::sync::Arc<dyn zlayer_secrets::SecretsProvider>>>,
}
impl std::fmt::Debug for SandboxRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SandboxRuntime")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl SandboxRuntime {
pub fn new(
config: MacSandboxConfig,
auth_context: Option<crate::runtime::ContainerAuthContext>,
) -> Result<Self> {
std::fs::create_dir_all(&config.data_dir).map_err(|e| {
AgentError::Configuration(format!(
"Failed to create data dir {}: {e}",
config.data_dir.display(),
))
})?;
std::fs::create_dir_all(&config.log_dir).map_err(|e| {
AgentError::Configuration(format!(
"Failed to create log dir {}: {e}",
config.log_dir.display(),
))
})?;
std::fs::create_dir_all(config.data_dir.join("containers")).map_err(|e| {
AgentError::Configuration(format!("Failed to create containers dir: {e}"))
})?;
std::fs::create_dir_all(config.data_dir.join("images"))
.map_err(|e| AgentError::Configuration(format!("Failed to create images dir: {e}")))?;
let volume_dir = std::env::var("ZLAYER_VOLUME_DIR").map_or_else(
|_| zlayer_paths::ZLayerDirs::new(&config.data_dir).volumes(),
PathBuf::from,
);
let storage_manager = StorageManager::new(&volume_dir).map_err(|e| {
AgentError::Configuration(format!(
"Failed to init storage manager at {}: {e}",
volume_dir.display(),
))
})?;
tracing::info!(
data_dir = %config.data_dir.display(),
log_dir = %config.log_dir.display(),
volume_dir = %volume_dir.display(),
gpu_access = config.gpu_access,
"macOS sandbox runtime initialized"
);
Ok(Self {
config,
containers: Arc::new(RwLock::new(HashMap::new())),
image_rootfs: Arc::new(RwLock::new(HashMap::new())),
storage_manager: Arc::new(RwLock::new(storage_manager)),
auth_context,
secrets_provider: parking_lot::RwLock::new(None),
})
}
async fn resolve_env(
&self,
spec: &ServiceSpec,
) -> Result<std::collections::HashMap<String, String>> {
let secrets_provider = self.secrets_provider.read().clone();
if let (Some(provider), Some(scope)) = (secrets_provider, spec.secret_scope.as_ref()) {
crate::env::resolve_env_with_secrets(
&spec.env,
provider.as_ref(),
&scope.to_storage_scope(),
)
.await
.map_err(|e| {
AgentError::InvalidSpec(format!("environment variable resolution failed: {e}"))
})
} else {
let resolved = crate::env::resolve_env_vars_with_warnings(&spec.env).map_err(|e| {
AgentError::InvalidSpec(format!("environment variable resolution failed: {e}"))
})?;
for w in &resolved.warnings {
tracing::warn!("{}", w);
}
let mut map = std::collections::HashMap::with_capacity(resolved.vars.len());
for var in resolved.vars {
if let Some((k, v)) = var.split_once('=') {
map.insert(k.to_string(), v.to_string());
}
}
Ok(map)
}
}
#[must_use]
pub fn config(&self) -> &MacSandboxConfig {
&self.config
}
fn container_dir_name(id: &ContainerId) -> String {
format!("{}-{}", id.service, id.replica)
}
fn container_dir(&self, id: &ContainerId) -> PathBuf {
self.config
.data_dir
.join("containers")
.join(Self::container_dir_name(id))
}
fn images_dir(&self) -> PathBuf {
self.config.data_dir.join("images")
}
#[allow(clippy::too_many_lines)]
pub async fn register_local_rootfs(&self, image: &str, source_dir: &Path) -> Result<()> {
let safe_name = sanitize_image_name(image);
let image_dir = self.images_dir().join(&safe_name);
let rootfs_dir = image_dir.join("rootfs");
if rootfs_dir.exists() {
let mut images = self.image_rootfs.write().await;
images.insert(safe_name, rootfs_dir);
return Ok(());
}
tokio::fs::create_dir_all(&image_dir)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("Failed to create image dir: {e}"),
})?;
let staging_name = format!(
".rootfs-staging-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
STAGING_COUNTER.fetch_add(1, Ordering::Relaxed)
);
let staging_dir = image_dir.join(&staging_name);
clone_directory_recursive(source_dir, &staging_dir)
.await
.map_err(|e| {
let _ = std::fs::remove_dir_all(&staging_dir);
AgentError::PullFailed {
image: image.to_string(),
reason: format!(
"Failed to clone local rootfs from {}: {e}",
source_dir.display(),
),
}
})?;
if tokio::fs::rename(&staging_dir, &rootfs_dir).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
if !rootfs_dir.exists() {
return Err(AgentError::PullFailed {
image: image.to_string(),
reason: "Failed to finalize rootfs and no other clone succeeded".into(),
});
}
}
let mut images = self.image_rootfs.write().await;
images.insert(safe_name, rootfs_dir.clone());
tracing::info!(
image = %image,
source = %source_dir.display(),
rootfs = %rootfs_dir.display(),
"Registered local rootfs as image"
);
Ok(())
}
async fn referenced_image_dirs(&self) -> HashSet<String> {
let mut referenced = HashSet::new();
{
let containers = self.containers.read().await;
for container in containers.values() {
referenced.insert(sanitize_image_name(&container.spec.image.name.to_string()));
}
}
let containers_dir = self.config.data_dir.join("containers");
if let Ok(mut entries) = tokio::fs::read_dir(&containers_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name();
let config_path = path.join("config.json");
let bytes = match tokio::fs::read(&config_path).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(
container = %dir_name.to_string_lossy(),
error = %e,
"prune: failed to read container config.json; \
not counting it as referencing any image"
);
continue;
}
};
match serde_json::from_slice::<ServiceSpec>(&bytes) {
Ok(spec) => {
referenced.insert(sanitize_image_name(&spec.image.name.to_string()));
}
Err(e) => {
tracing::warn!(
container = %dir_name.to_string_lossy(),
error = %e,
"prune: failed to parse container config.json; \
not counting it as referencing any image"
);
}
}
}
} else {
}
referenced
}
async fn dir_size_bytes(path: &Path) -> u64 {
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
continue;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(file_type) = entry.file_type().await else {
continue;
};
if file_type.is_dir() {
stack.push(entry.path());
} else if file_type.is_file() {
if let Ok(meta) = entry.metadata().await {
total = total.saturating_add(meta.len());
}
}
}
}
total
}
}
#[async_trait::async_trait]
impl Runtime for SandboxRuntime {
fn set_secrets_provider(&self, provider: std::sync::Arc<dyn zlayer_secrets::SecretsProvider>) {
*self.secrets_provider.write() = Some(provider);
}
async fn pull_image(&self, image: &str) -> Result<()> {
self.pull_image_with_policy(
image,
zlayer_spec::PullPolicy::IfNotPresent,
None,
zlayer_spec::SourcePolicy::default(),
)
.await
}
#[allow(clippy::too_many_lines)]
async fn pull_image_with_policy(
&self,
image: &str,
policy: zlayer_spec::PullPolicy,
_auth: Option<&RegistryAuth>,
_source: zlayer_spec::SourcePolicy,
) -> Result<()> {
let safe_name = sanitize_image_name(image);
let image_dir = self.images_dir().join(&safe_name);
let rootfs_dir = image_dir.join("rootfs");
match policy {
zlayer_spec::PullPolicy::Always | zlayer_spec::PullPolicy::Newer => {
}
zlayer_spec::PullPolicy::IfNotPresent => {
if rootfs_dir.exists() {
tracing::debug!(image = %image, "Image already present, skipping pull");
let ref_path = image_dir.join("ref");
if matches!(ref_path.try_exists(), Ok(false)) {
if let Err(e) = tokio::fs::write(&ref_path, image.as_bytes()).await {
tracing::debug!(
image = %image,
error = %e,
"sandbox: failed to backfill image ref file"
);
}
}
let mut images = self.image_rootfs.write().await;
images.insert(safe_name, rootfs_dir);
return Ok(());
}
}
zlayer_spec::PullPolicy::Never => {
if !rootfs_dir.exists() {
return Err(AgentError::PullFailed {
image: image.to_string(),
reason: "Image not present and pull policy is Never".to_string(),
});
}
let mut images = self.image_rootfs.write().await;
images.insert(safe_name, rootfs_dir);
return Ok(());
}
}
tracing::info!(
image = %image,
"Pulling image for macOS sandbox runtime \
(note: sandbox runtime expects macOS-native images)"
);
tokio::fs::create_dir_all(&rootfs_dir)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("Failed to create rootfs dir: {e}"),
})?;
let ref_path = image_dir.join("ref");
if let Err(e) = tokio::fs::write(&ref_path, image.as_bytes()).await {
tracing::debug!(
image = %image,
error = %e,
"sandbox: failed to write image ref file; list_images will fall back to dir name"
);
}
let cache_path = self.images_dir().join("blobs.redb");
let cache_type = zlayer_registry::CacheType::persistent_at(&cache_path);
let blob_cache = cache_type
.build()
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("Failed to open blob cache: {e}"),
})?;
let puller = zlayer_registry::ImagePuller::with_cache(blob_cache);
let auth =
zlayer_core::AuthResolver::new(zlayer_core::AuthConfig::default()).resolve(image);
let layers = puller
.pull_image(image, &auth)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("Failed to pull image layers: {e}"),
})?;
match puller.pull_image_config(image, &auth).await {
Ok(config) => {
match serde_json::to_string_pretty(&config) {
Ok(json) => {
let sidecar = image_dir.join("image-config.json");
if let Err(e) = tokio::fs::write(&sidecar, json).await {
tracing::warn!(
image = %image,
error = %e,
"sandbox: failed to write image-config.json sidecar; image Env will not layer on run",
);
}
}
Err(e) => tracing::warn!(
image = %image,
error = %e,
"sandbox: failed to serialize image config sidecar",
),
}
}
Err(e) => {
tracing::debug!(
image = %image,
error = %e,
"sandbox: failed to cache OCI config blob for local OS inspection; \
dispatch will rely on its fallthrough",
);
}
}
tracing::info!(
image = %image,
layer_count = layers.len(),
"Extracting layers to image rootfs"
);
let mut unpacker = zlayer_registry::LayerUnpacker::new(rootfs_dir.clone());
unpacker
.unpack_layers(&layers)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("Failed to extract rootfs: {e}"),
})?;
let mut images = self.image_rootfs.write().await;
images.insert(safe_name, rootfs_dir.clone());
tracing::info!(
image = %image,
rootfs = %rootfs_dir.display(),
"Image pulled successfully"
);
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn prune_images(&self) -> Result<PruneResult> {
let referenced = self.referenced_image_dirs().await;
let images_dir = self.images_dir();
let mut entries = match tokio::fs::read_dir(&images_dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(PruneResult::default());
}
Err(e) => {
return Err(AgentError::Internal(format!(
"failed to read images dir {}: {e}",
images_dir.display()
)));
}
};
let mut deleted: Vec<String> = Vec::new();
let mut space_reclaimed: u64 = 0;
let mut pruned_refs: Vec<String> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name == "blobs.redb" {
continue;
}
match entry.file_type().await {
Ok(ft) if ft.is_dir() => {}
_ => continue,
}
if referenced.contains(&name) {
continue;
}
let path = entry.path();
let size = Self::dir_size_bytes(&path).await;
let ref_path = path.join("ref");
match tokio::fs::read_to_string(&ref_path).await {
Ok(contents) => {
let r = contents.trim();
if r.is_empty() {
tracing::debug!(
image = %name,
"prune: empty ref file; manifest entries not reclaimable"
);
} else {
pruned_refs.push(r.to_string());
}
}
Err(e) => {
tracing::debug!(
image = %name,
error = %e,
"prune: no readable ref file; manifest entries not reclaimable"
);
}
}
if let Err(e) = tokio::fs::remove_dir_all(&path).await {
tracing::warn!(
image = %name,
error = %e,
"prune: failed to remove unused image rootfs; skipping"
);
continue;
}
tracing::info!(
image = %name,
bytes = size,
"prune: removed unused image rootfs"
);
deleted.push(name);
space_reclaimed = space_reclaimed.saturating_add(size);
}
if !deleted.is_empty() {
let mut images = self.image_rootfs.write().await;
for name in &deleted {
images.remove(name);
}
}
let cache_path = images_dir.join("blobs.redb");
if matches!(cache_path.try_exists(), Ok(true)) {
match zlayer_registry::CacheType::persistent_at(&cache_path)
.build()
.await
{
Ok(cache) => {
for r in &pruned_refs {
for key in [
zlayer_registry::manifest_cache_key(r),
zlayer_registry::manifest_digest_cache_key(r),
zlayer_registry::manifest_orig_cache_key(r),
] {
if let Err(e) = cache.delete(&key).await {
tracing::warn!(
image = %r,
key = %key,
error = %e,
"prune: failed to evict manifest cache entry; continuing"
);
}
}
}
match zlayer_registry::prune_dangling_blobs(cache.as_ref().as_ref()).await {
Ok((blob_deleted, blob_bytes)) => {
deleted.extend(blob_deleted);
space_reclaimed = space_reclaimed.saturating_add(blob_bytes);
}
Err(e) => {
tracing::warn!(
error = %e,
"prune: failed to garbage-collect dangling blobs; skipping"
);
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"prune: failed to open blob cache for blob GC; skipping"
);
}
}
}
Ok(PruneResult {
deleted,
space_reclaimed,
})
}
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
let images_dir = self.images_dir();
let mut entries = match tokio::fs::read_dir(&images_dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => {
return Err(AgentError::Internal(format!(
"failed to read images dir {}: {e}",
images_dir.display()
)));
}
};
let cache_path = images_dir.join("blobs.redb");
let cache: Option<Arc<Box<dyn BlobCacheBackend>>> =
if matches!(cache_path.try_exists(), Ok(true)) {
match zlayer_registry::CacheType::persistent_at(&cache_path)
.build()
.await
{
Ok(cache) => Some(cache),
Err(e) => {
tracing::debug!(
error = %e,
"list_images: failed to open blob cache; digests will be None"
);
None
}
}
} else {
None
};
let mut images = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if name == "blobs.redb" {
continue;
}
match entry.file_type().await {
Ok(ft) if ft.is_dir() => {}
_ => continue,
}
let dir = entry.path();
let reference = match tokio::fs::read_to_string(dir.join("ref")).await {
Ok(contents) => {
let r = contents.trim();
if r.is_empty() {
name.clone()
} else {
r.to_string()
}
}
Err(_) => name.clone(),
};
let size_bytes = Some(Self::dir_size_bytes(&dir.join("rootfs")).await);
let digest = match cache.as_ref() {
Some(cache) => cache
.get(&zlayer_registry::manifest_digest_cache_key(&reference))
.await
.ok()
.flatten()
.and_then(|bytes| String::from_utf8(bytes).ok()),
None => None,
};
images.push(ImageInfo {
reference,
digest,
size_bytes,
});
}
Ok(images)
}
async fn inspect_image_native(&self, image: &str) -> Result<ImageInspectInfo> {
let safe = sanitize_image_name(image);
let dir = self.images_dir().join(&safe);
let rootfs = dir.join("rootfs");
if !matches!(rootfs.try_exists(), Ok(true)) {
return Err(AgentError::NotFound {
container: image.to_string(),
reason: format!("image '{image}' not found"),
});
}
let reference = match tokio::fs::read_to_string(dir.join("ref")).await {
Ok(contents) if !contents.trim().is_empty() => contents.trim().to_string(),
_ => image.to_string(),
};
let size = Self::dir_size_bytes(&rootfs).await;
let mut info = ImageInspectInfo {
repo_tags: vec![reference],
size: Some(size),
..Default::default()
};
if let Some(meta) = read_local_image_metadata(&dir) {
info.os = Some(meta.os).filter(|s| !s.is_empty());
info.architecture = Some(meta.architecture).filter(|s| !s.is_empty());
info.created = meta.created;
info.id = meta.digest;
}
if let Some(cfg) = read_sandbox_build_config(&dir) {
info.env = cfg.env;
info.cmd = cfg.cmd.unwrap_or_default();
info.entrypoint = cfg.entrypoint.unwrap_or_default();
info.working_dir = Some(cfg.working_dir).filter(|s| !s.is_empty());
info.user = cfg.user;
info.labels = cfg.labels.into_iter().collect();
}
Ok(info)
}
async fn remove_image(&self, image: &str, force: bool) -> Result<()> {
let safe = sanitize_image_name(image);
let dir = self.images_dir().join(&safe);
if !matches!(dir.try_exists(), Ok(true)) {
return Err(AgentError::NotFound {
container: image.to_string(),
reason: format!("image '{image}' not found"),
});
}
if !force && self.referenced_image_dirs().await.contains(&safe) {
return Err(AgentError::InvalidSpec(format!(
"image '{image}' is in use by a container; pass --force to remove it"
)));
}
let ref_file = match tokio::fs::read_to_string(dir.join("ref")).await {
Ok(contents) => {
let r = contents.trim();
if r.is_empty() {
None
} else {
Some(r.to_string())
}
}
Err(_) => None,
};
tokio::fs::remove_dir_all(&dir).await.map_err(|e| {
AgentError::Internal(format!("failed to remove image dir {}: {e}", dir.display()))
})?;
{
let mut images = self.image_rootfs.write().await;
images.remove(&safe);
}
let cache_path = self.images_dir().join("blobs.redb");
if matches!(cache_path.try_exists(), Ok(true)) {
match zlayer_registry::CacheType::persistent_at(&cache_path)
.build()
.await
{
Ok(cache) => {
let mut candidates: Vec<String> = vec![image.to_string()];
if let Some(r) = ref_file {
if !candidates.contains(&r) {
candidates.push(r);
}
}
for r in &candidates {
for key in [
zlayer_registry::manifest_cache_key(r),
zlayer_registry::manifest_digest_cache_key(r),
zlayer_registry::manifest_orig_cache_key(r),
] {
if let Err(e) = cache.delete(&key).await {
tracing::warn!(
image = %r,
key = %key,
error = %e,
"remove_image: failed to evict manifest cache entry; continuing"
);
}
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"remove_image: failed to open blob cache for manifest eviction; skipping"
);
}
}
}
tracing::info!(image = %image, "Removed image (sandbox)");
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn create_container(&self, id: &ContainerId, spec: &ServiceSpec) -> Result<()> {
let spec = {
let mut s = spec.clone();
ensure_default_path(&mut s);
s
};
let spec = &spec;
let dir_name = Self::container_dir_name(id);
let container_dir = self.container_dir(id);
let rootfs_dir = container_dir.join("rootfs");
if container_dir.exists() {
tracing::warn!(
container = %dir_name,
"Stale container directory found, cleaning up"
);
if let Err(e) = tokio::fs::remove_dir_all(&container_dir).await {
tracing::warn!(
container = %dir_name,
error = %e,
"Failed to remove stale container directory"
);
}
}
tokio::fs::create_dir_all(&container_dir)
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!(
"Failed to create container dir {}: {e}",
container_dir.display(),
),
})?;
tokio::fs::create_dir_all(container_dir.join("tmp"))
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to create container tmp dir: {e}"),
})?;
tokio::fs::create_dir_all(container_dir.join("home"))
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to create container home dir: {e}"),
})?;
let image_name_str = spec.image.name.to_string();
let safe_image_name = sanitize_image_name(&image_name_str);
let image_rootfs = {
let images = self.image_rootfs.read().await;
images.get(&safe_image_name).cloned()
};
let image_rootfs =
image_rootfs.unwrap_or_else(|| self.images_dir().join(&safe_image_name).join("rootfs"));
if !image_rootfs.exists() {
return Err(AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!(
"Image rootfs not found at {}. Run pull_image first.",
image_rootfs.display()
),
});
}
tracing::debug!(
container = %dir_name,
src = %image_rootfs.display(),
dst = %rootfs_dir.display(),
"Cloning rootfs (APFS CoW)"
);
clone_directory_recursive(&image_rootfs, &rootfs_dir)
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!(
"Failed to clone rootfs from {} to {}: {e}",
image_rootfs.display(),
rootfs_dir.display(),
),
})?;
let tc_src = zlayer_paths::ZLayerDirs::new(&self.config.data_dir).toolchain_cache();
if tc_src.exists() {
let tc_dst = rootfs_dir.join("opt/zlayer/toolchains");
if let Some(parent) = tc_dst.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
clone_directory_recursive(&tc_src, &tc_dst)
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!(
"Failed to clone toolchain store from {} to {}: {e}",
tc_src.display(),
tc_dst.display(),
),
})?;
}
let host_tc_cache = self.config.data_dir.join("host-toolchains");
if !INIT_TOOLCHAINS_SPAWNED.swap(true, Ordering::AcqRel) {
for &tool in INIT_TOOLCHAINS {
let host_tc_cache_bg = host_tc_cache.clone();
tokio::spawn(async move {
let provision = zlayer_toolchain::ensure_toolchain(
tool,
zlayer_toolchain::ToolPlatform::MacOS,
&host_tc_cache_bg,
None,
);
let result = match tokio::time::timeout(
INIT_TOOLCHAIN_PROVISION_TIMEOUT,
provision,
)
.await
{
Ok(Ok(handle)) => Some(handle),
Ok(Err(e)) => {
tracing::warn!(
tool,
error = %e,
"init toolchain provisioning failed; sandboxed jobs fall back (keg not injected)"
);
None
}
Err(_elapsed) => {
tracing::warn!(
tool,
timeout_secs = INIT_TOOLCHAIN_PROVISION_TIMEOUT.as_secs(),
"init toolchain provisioning timed out; sandboxed jobs fall back (keg not injected)"
);
None
}
};
if let Some(h) = &result {
tracing::info!(
tool,
keg = %h.install_dir.display(),
"init toolchain provisioned; sandboxed jobs now use the keg",
);
} else {
tracing::debug!(
tool,
"init toolchain provision yielded no handle; tool not injected",
);
}
if let Some(cell) = init_toolchain_cells().get(tool) {
let _ = cell.set(result);
}
});
}
}
let toolchain_handles = resolve_init_toolchain_handles(&host_tc_cache, None).await;
let gpu_access = if self.config.gpu_access {
if let Some(ref gpu) = spec.resources.gpu {
if gpu.vendor == "apple" {
match gpu.mode.as_deref() {
Some("mps") => GpuAccess::MpsOnly,
_ => GpuAccess::MetalCompute,
}
} else {
GpuAccess::None
}
} else {
GpuAccess::None
}
} else {
GpuAccess::None
};
let operator_default = operator_keychain_default_from_env();
let keychain_access = if self.config.keychain_access_allowed || operator_default.is_some() {
build_keychain_access(spec, operator_default.as_deref())
} else {
KeychainAccess::None
};
let (assigned_port, port_guard) = reserve_port().map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to reserve a dynamic port for sandbox container: {e}"),
})?;
tracing::info!(
container = %dir_name,
assigned_port = assigned_port,
"Reserved dynamic port for sandbox container"
);
let mode = spec.overlay.as_ref().map(|o| o.mode).unwrap_or_default();
let nm_none = matches!(
spec.network_mode,
zlayer_types::spec::types::NetworkMode::None
);
let nm_host = matches!(
spec.network_mode,
zlayer_types::spec::types::NetworkMode::Host
);
let iso_label = spec
.labels
.get(zlayer_types::overlay::ISOLATION_NETWORK_LABEL)
.map(String::as_str);
let iso = zlayer_types::overlay::resolve_network_isolation(
mode, nm_none, nm_host, &dir_name, iso_label,
);
let daemon_api_port = self
.auth_context
.as_ref()
.map(|ctx| api_url_port(&ctx.api_url).unwrap_or(3669));
let mut network_access = build_network_access(
spec,
self.config.overlay_cidr.clone(),
&iso,
daemon_api_port,
);
match &mut network_access {
NetworkAccess::LocalhostOnly {
ref mut bind_ports,
ref mut connect_ports,
..
} => {
if !bind_ports.contains(&assigned_port) {
bind_ports.push(assigned_port);
}
if !connect_ports.contains(&assigned_port) {
connect_ports.push(assigned_port);
}
}
NetworkAccess::None => {
let mut connect_ports = vec![assigned_port, 53, 80, 443];
if let Some(port) = daemon_api_port {
if !connect_ports.contains(&port) {
connect_ports.push(port);
}
}
network_access = NetworkAccess::LocalhostOnly {
bind_ports: vec![assigned_port],
connect_ports,
overlay_cidr: self.config.overlay_cidr.clone(),
loopback_api_port: daemon_api_port,
};
}
NetworkAccess::Full => {
}
}
let volume_paths = {
let mut storage_manager = self.storage_manager.write().await;
crate::runtimes::volume_prep::prepare_storage_volumes(
&mut storage_manager,
&dir_name,
&spec.storage,
)
.await?
};
let storage = resolve_sandbox_storage(spec, &rootfs_dir, &container_dir, &volume_paths);
for dir in &storage.writable_dirs {
if let Err(e) = tokio::fs::create_dir_all(dir).await {
tracing::warn!(
container = %dir_name,
dir = %dir.display(),
error = %e,
"Failed to pre-create writable dir; workload may fail if it writes here"
);
}
}
let workspace_host = storage.workspace_host.clone();
let memory_limit = spec
.resources
.memory
.as_ref()
.and_then(|m| parse_memory_string(m));
let mut sandbox_config = SandboxConfig {
rootfs_dir: rootfs_dir.clone(),
workspace_dir: container_dir.clone(),
gpu_access,
keychain_access,
network_access,
writable_dirs: storage.writable_dirs,
readonly_dirs: vec![],
toolchain_cache: Some(host_tc_cache.clone()),
max_files: 4096,
cpu_time_limit: None,
memory_limit,
};
if let Some(ref auth_ctx) = self.auth_context {
let deployment = spec.deployment.as_deref().unwrap_or(&id.service);
if crate::auth::resolve_container_api_access(deployment, &spec.labels).mount_socket {
sandbox_config
.writable_dirs
.push(PathBuf::from(&auth_ctx.socket_path));
}
}
let profile = generate_sandbox_profile(&sandbox_config);
let profile_path = container_dir.join("sandbox.sb");
tokio::fs::write(&profile_path, &profile)
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to write Seatbelt profile: {e}"),
})?;
let config_json =
serde_json::to_string_pretty(spec).map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to serialize spec: {e}"),
})?;
tokio::fs::write(container_dir.join("config.json"), &config_json)
.await
.map_err(|e| AgentError::CreateFailed {
id: dir_name.clone(),
reason: format!("Failed to write config.json: {e}"),
})?;
let stdout_path = container_dir.join("stdout.log");
let stderr_path = container_dir.join("stderr.log");
let container = SandboxContainer {
pid: 0,
state: ContainerState::Pending,
state_dir: container_dir,
rootfs_dir,
workspace_host,
stdout_path,
stderr_path,
started_at: None,
spec: spec.clone(),
sandbox_config,
toolchain_handles,
memory_limit,
watchdog_handle: None,
assigned_port,
port_guard: Some(port_guard),
overlay_ip: None,
forwarders: Vec::new(),
};
let mut containers = self.containers.write().await;
containers.insert(dir_name.clone(), container);
tracing::info!(
container = %dir_name,
image = %spec.image.name,
port = assigned_port,
"Container created (sandbox)"
);
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn start_container(&self, id: &ContainerId) -> Result<()> {
let dir_name = Self::container_dir_name(id);
let (
rootfs_dir,
workspace_host,
stdout_path,
stderr_path,
spec,
sandbox_config,
toolchain_handles,
memory_limit,
assigned_port,
) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not created".to_string(),
})?;
(
container.rootfs_dir.clone(),
container.workspace_host.clone(),
container.stdout_path.clone(),
container.stderr_path.clone(),
container.spec.clone(),
container.sandbox_config.clone(),
container.toolchain_handles.clone(),
container.memory_limit,
container.assigned_port,
)
};
let host_tc_cache = self.config.data_dir.join("host-toolchains");
let toolchain_handles = {
let fresh =
resolve_init_toolchain_handles(&host_tc_cache, Some(INIT_TOOLCHAIN_START_WAIT))
.await;
if fresh.len() >= toolchain_handles.len() {
fresh
} else {
toolchain_handles
}
};
{
let mut containers = self.containers.write().await;
if let Some(container) = containers.get_mut(&dir_name) {
container.toolchain_handles.clone_from(&toolchain_handles);
}
}
let profile_path = self.container_dir(id).join("sandbox.sb");
let mut profile = tokio::fs::read_to_string(&profile_path)
.await
.map_err(|e| AgentError::StartFailed {
id: dir_name.clone(),
reason: format!("Failed to read Seatbelt profile: {e}"),
})?;
let (program, args) = resolve_entrypoint(&spec, &rootfs_dir)?;
tracing::info!(
container = %dir_name,
program = %program,
args = ?args,
port = assigned_port,
"Starting sandboxed process"
);
{
let mut containers = self.containers.write().await;
if let Some(container) = containers.get_mut(&dir_name) {
container.port_guard.take();
}
}
let mut docker_host: Option<String> = None;
let auth_env = if let Some(ctx) = self.auth_context.as_ref() {
let deployment = spec.deployment.as_deref().unwrap_or(&id.service);
let access = crate::auth::resolve_container_api_access(deployment, &spec.labels);
let container_id = format!("{}-{}", id.service, id.replica);
let jti = format!("container:{}:{}", id.service, container_id);
let token_jti = if let Some(sink) = ctx.token_sink.as_ref() {
let now = chrono::Utc::now();
let rec = zlayer_types::storage::StoredAccessToken {
id: jti.clone(),
name: id.service.clone(),
subject: jti.clone(),
roles: Vec::new(),
scopes: access.scopes.clone(),
expires_at: now
+ chrono::Duration::seconds(
i64::try_from(access.ttl.as_secs()).unwrap_or(i64::MAX),
),
created_at: now,
created_by: deployment.to_string(),
revoked_at: None,
};
if sink.persist(rec).await {
Some(jti)
} else {
None
}
} else {
None
};
let token = crate::auth::mint_container_token(
&ctx.jwt_secret,
&id.service,
&container_id,
access.scopes,
access.ttl,
token_jti,
)
.unwrap_or_default();
if let Some(spawner) = ctx.docker_socket_spawner.as_ref() {
if let Some(host_sock) = spawner.spawn(&container_id, token.clone()).await {
append_docker_socket_sbpl_rules(&mut profile, Path::new(&host_sock));
docker_host = Some(format!("unix://{host_sock}"));
} else {
tracing::warn!(
container = %container_id,
"the per-container Docker socket spawner failed; container \
starts without a docker socket"
);
}
}
Some((
loopback_api_url(&ctx.api_url),
token,
ctx.socket_path.clone(),
access.mount_socket,
))
} else {
None
};
let image_config = read_sandbox_image_config(
&self
.images_dir()
.join(sanitize_image_name(&spec.image.name.to_string())),
);
let env_base = {
let mut spec_for_env = spec.clone();
spec_for_env.env = self.resolve_env(&spec).await?;
crate::runtimes::macos_vz_shared::merge_env(&spec_for_env, image_config.as_ref())
};
let dir_name_for_err = dir_name.clone();
let child_pid = tokio::task::spawn_blocking(move || {
spawn_sandboxed_process(&SandboxSpawnParams {
program,
args,
sbpl_profile: profile,
rootfs_dir,
workspace_host,
env_base,
stdout_path,
stderr_path,
spec,
sandbox_config,
assigned_port,
auth_env,
docker_host,
toolchains: toolchain_handles,
})
})
.await
.map_err(|e| AgentError::StartFailed {
id: dir_name_for_err,
reason: format!("spawn task join error: {e}"),
})??;
let pid_path = self.container_dir(id).join("pid");
tokio::fs::write(&pid_path, child_pid.to_string())
.await
.map_err(|e| AgentError::StartFailed {
id: dir_name.clone(),
reason: format!("Failed to write PID file: {e}"),
})?;
let mut containers = self.containers.write().await;
if let Some(container) = containers.get_mut(&dir_name) {
container.pid = child_pid;
container.state = ContainerState::Running;
container.started_at = Some(Instant::now());
if let Some(limit) = memory_limit {
let pid = child_pid;
let handle = tokio::spawn(async move {
memory_watchdog(pid, limit).await;
});
container.watchdog_handle = Some(handle);
}
}
tracing::info!(
container = %dir_name,
pid = child_pid,
"Sandboxed process started"
);
Ok(())
}
#[allow(unsafe_code)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::cast_possible_wrap)]
async fn stop_container(&self, id: &ContainerId, timeout: Duration) -> Result<()> {
let dir_name = Self::container_dir_name(id);
let pid = {
let mut containers = self.containers.write().await;
let container = containers
.get_mut(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
if container.pid == 0 {
container.state = ContainerState::Exited { code: 0 };
return Ok(());
}
if matches!(
container.state,
ContainerState::Exited { .. } | ContainerState::Failed { .. }
) {
return Ok(());
}
container.state = ContainerState::Stopping;
container.pid
};
tracing::info!(
container = %dir_name,
pid = pid,
timeout = ?timeout,
"Stopping sandboxed process"
);
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let deadline = Instant::now() + timeout;
loop {
if Instant::now() >= deadline {
break;
}
let mut status: libc::c_int = 0;
let result = unsafe { libc::waitpid(pid as i32, &raw mut status, libc::WNOHANG) };
if result != 0 {
let exit_code = if result > 0 && libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else {
-1
};
let mut containers = self.containers.write().await;
if let Some(c) = containers.get_mut(&dir_name) {
c.state = ContainerState::Exited { code: exit_code };
if let Some(h) = c.watchdog_handle.take() {
h.abort();
}
}
tracing::info!(
container = %dir_name,
exit_code = exit_code,
"Container stopped gracefully"
);
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
tracing::warn!(
container = %dir_name,
pid = pid,
"SIGTERM timeout, sending SIGKILL"
);
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
let pid_for_wait = pid;
let exit_code = tokio::task::spawn_blocking(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
let mut status: libc::c_int = 0;
let result =
unsafe { libc::waitpid(pid_for_wait as i32, &raw mut status, libc::WNOHANG) };
if result > 0 || result == -1 {
break; }
if std::time::Instant::now() >= deadline {
break; }
std::thread::sleep(std::time::Duration::from_millis(50));
}
-9i32
})
.await
.unwrap_or(-9);
let mut containers = self.containers.write().await;
if let Some(c) = containers.get_mut(&dir_name) {
c.state = ContainerState::Exited { code: exit_code };
if let Some(h) = c.watchdog_handle.take() {
h.abort();
}
}
tracing::info!(container = %dir_name, "Container killed (SIGKILL)");
Ok(())
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_wrap)]
async fn remove_container(&self, id: &ContainerId) -> Result<()> {
let dir_name = Self::container_dir_name(id);
tracing::info!(container = %dir_name, "Removing container");
if let Some(auth_ctx) = self.auth_context.as_ref() {
let container_id = format!("{}-{}", id.service, id.replica);
if let Some(spawner) = auth_ctx.docker_socket_spawner.as_ref() {
spawner.teardown(&container_id).await;
}
if let Some(sink) = auth_ctx.token_sink.as_ref() {
let jti = format!("container:{}:{}", id.service, container_id);
sink.revoke(&jti).await;
}
}
let removed_spec = {
let mut containers = self.containers.write().await;
if let Some(mut c) = containers.remove(&dir_name) {
if let Some(h) = c.watchdog_handle.take() {
h.abort();
}
if c.pid > 0
&& matches!(c.state, ContainerState::Running | ContainerState::Stopping)
{
unsafe {
libc::kill(c.pid as i32, libc::SIGKILL);
}
let pid = c.pid;
let _ = tokio::task::spawn_blocking(move || {
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
let mut status: libc::c_int = 0;
let result = unsafe {
libc::waitpid(pid as i32, &raw mut status, libc::WNOHANG)
};
if result > 0 || result == -1 {
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
})
.await;
}
Some(c.spec)
} else {
None
}
};
if let Some(spec) = removed_spec {
let mut storage_manager = self.storage_manager.write().await;
crate::runtimes::volume_prep::cleanup_storage_volumes(
&mut storage_manager,
&dir_name,
&spec.storage,
);
}
let container_dir = self.container_dir(id);
if container_dir.exists() {
tokio::fs::remove_dir_all(&container_dir)
.await
.map_err(|e| {
AgentError::Internal(format!(
"Failed to remove container dir {}: {e}",
container_dir.display(),
))
})?;
}
tracing::info!(container = %dir_name, "Container removed");
Ok(())
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_wrap)]
async fn container_state(&self, id: &ContainerId) -> Result<ContainerState> {
let dir_name = Self::container_dir_name(id);
let mut containers = self.containers.write().await;
let container = containers
.get_mut(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
match &container.state {
ContainerState::Exited { .. } | ContainerState::Failed { .. } => {
return Ok(container.state.clone());
}
ContainerState::Pending => return Ok(ContainerState::Pending),
_ => {}
}
if container.pid > 0 {
let mut status: libc::c_int = 0;
let result =
unsafe { libc::waitpid(container.pid as i32, &raw mut status, libc::WNOHANG) };
match result.cmp(&0) {
std::cmp::Ordering::Greater => {
let exit_code = if libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else if libc::WIFSIGNALED(status) {
-(libc::WTERMSIG(status))
} else {
-1
};
container.state = ContainerState::Exited { code: exit_code };
if let Some(h) = container.watchdog_handle.take() {
h.abort();
}
}
std::cmp::Ordering::Equal => {
container.state = ContainerState::Running;
}
std::cmp::Ordering::Less => {
container.state = ContainerState::Failed {
reason: "Process disappeared".to_string(),
};
}
}
}
Ok(container.state.clone())
}
async fn container_logs(&self, id: &ContainerId, tail: usize) -> Result<Vec<LogEntry>> {
let dir_name = Self::container_dir_name(id);
let (stdout_path, stderr_path) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
(container.stdout_path.clone(), container.stderr_path.clone())
};
let now = chrono::Utc::now();
let source = LogSource::Container(id.to_string());
let mut entries = Vec::new();
if let Ok(stdout) = tokio::fs::read_to_string(&stdout_path).await {
for line in stdout.lines() {
entries.push(LogEntry {
timestamp: now,
stream: LogStream::Stdout,
message: line.to_string(),
source: source.clone(),
service: None,
deployment: None,
});
}
}
if let Ok(stderr) = tokio::fs::read_to_string(&stderr_path).await {
for line in stderr.lines() {
entries.push(LogEntry {
timestamp: now,
stream: LogStream::Stderr,
message: line.to_string(),
source: source.clone(),
service: None,
deployment: None,
});
}
}
if tail > 0 && entries.len() > tail {
let start = entries.len() - tail;
entries = entries.split_off(start);
}
Ok(entries)
}
async fn logs_stream(&self, id: &ContainerId, opts: LogsStreamOptions) -> Result<LogsStream> {
let dir_name = Self::container_dir_name(id);
let (stdout_path, stderr_path) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
(container.stdout_path.clone(), container.stderr_path.clone())
};
let none_specified = !opts.stdout && !opts.stderr;
let want_stdout = opts.stdout || none_specified;
let want_stderr = opts.stderr || none_specified;
let (tx, rx) = mpsc::channel::<Result<LogChunk>>(64);
if want_stdout {
tokio::spawn(stream_sandbox_log_file(
stdout_path,
LogChannel::Stdout,
opts.clone(),
tx.clone(),
Arc::clone(&self.containers),
dir_name.clone(),
));
}
if want_stderr {
tokio::spawn(stream_sandbox_log_file(
stderr_path,
LogChannel::Stderr,
opts.clone(),
tx.clone(),
Arc::clone(&self.containers),
dir_name.clone(),
));
}
drop(tx);
Ok(Box::pin(ReceiverStream::new(rx)))
}
async fn exec(&self, id: &ContainerId, cmd: &[String]) -> Result<(i32, String, String)> {
let dir_name = Self::container_dir_name(id);
if cmd.is_empty() {
return Err(AgentError::InvalidSpec(
"exec command cannot be empty".to_string(),
));
}
let (rootfs, profile_path, spec, toolchain_handles) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
(
container.rootfs_dir.clone(),
container.state_dir.join("sandbox.sb"),
container.spec.clone(),
container.toolchain_handles.clone(),
)
};
let profile = tokio::fs::read_to_string(&profile_path)
.await
.map_err(|e| AgentError::Internal(format!("Failed to read Seatbelt profile: {e}")))?;
tracing::debug!(
container = %dir_name,
cmd = ?cmd,
"Executing command in sandbox"
);
let image_config = read_sandbox_image_config(
&self
.images_dir()
.join(sanitize_image_name(&spec.image.name.to_string())),
);
let mut env_base = {
let mut spec_for_env = spec.clone();
spec_for_env.env = self.resolve_env(&spec).await?;
crate::runtimes::macos_vz_shared::merge_env(&spec_for_env, image_config.as_ref())
};
ensure_spawn_path(&mut env_base);
for handle in &toolchain_handles {
inject_toolchain_env(&mut env_base, handle);
}
inject_ca_cert_env(&mut env_base);
if let Some(container_dir) = rootfs.parent() {
inject_home_env(&mut env_base, &container_dir.join("home"));
}
let profile_clone = profile.clone();
let rootfs_clone = rootfs.clone();
let cmd_clone = cmd.to_vec();
let env_clone = env_base;
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("/usr/bin/sandbox-exec")
.arg("-p")
.arg(&profile_clone)
.arg("--")
.arg(&cmd_clone[0])
.args(&cmd_clone[1..])
.current_dir(&rootfs_clone)
.env_clear()
.envs(env_clone)
.output()
})
.await
.map_err(|e| AgentError::Internal(format!("exec task join error: {e}")))?
.map_err(|e| AgentError::Internal(format!("Failed to exec: {e}")))?;
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
tracing::debug!(
container = %dir_name,
exit_code = exit_code,
stdout_len = stdout.len(),
stderr_len = stderr.len(),
"exec completed"
);
Ok((exit_code, stdout, stderr))
}
async fn get_container_stats(&self, id: &ContainerId) -> Result<ContainerStats> {
let dir_name = Self::container_dir_name(id);
let (pid, memory_limit) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
if container.pid == 0 {
return Err(AgentError::Internal(
"Container not started -- no PID available for stats".to_string(),
));
}
(container.pid, container.memory_limit.unwrap_or(0))
};
let pid_for_stats = pid;
let (cpu_usec, memory_bytes) =
tokio::task::spawn_blocking(move || get_process_stats(pid_for_stats))
.await
.map_err(|e| AgentError::Internal(format!("stats task join error: {e}")))??;
Ok(ContainerStats {
cpu_usage_usec: cpu_usec,
memory_bytes,
memory_limit,
timestamp: Instant::now(),
})
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_wrap)]
async fn wait_container(&self, id: &ContainerId) -> Result<i32> {
let dir_name = Self::container_dir_name(id);
let pid = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
if let ContainerState::Exited { code } = &container.state {
return Ok(*code);
}
if container.pid == 0 {
return Err(AgentError::Internal(
"Container not started -- no PID to wait on".to_string(),
));
}
container.pid
};
tracing::debug!(container = %dir_name, pid = pid, "Waiting for container to exit");
let exit_code = tokio::task::spawn_blocking(move || {
let mut status: libc::c_int = 0;
let result = unsafe { libc::waitpid(pid as i32, &raw mut status, 0) };
if result < 0 {
return -1;
}
if libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else if libc::WIFSIGNALED(status) {
-(libc::WTERMSIG(status))
} else {
-1
}
})
.await
.map_err(|e| AgentError::Internal(format!("wait task join error: {e}")))?;
let mut containers = self.containers.write().await;
if let Some(c) = containers.get_mut(&dir_name) {
c.state = ContainerState::Exited { code: exit_code };
if let Some(h) = c.watchdog_handle.take() {
h.abort();
}
}
tracing::info!(
container = %dir_name,
exit_code = exit_code,
"Container exited"
);
Ok(exit_code)
}
async fn get_logs(&self, id: &ContainerId) -> Result<Vec<LogEntry>> {
let dir_name = Self::container_dir_name(id);
let (stdout_path, stderr_path) = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
(container.stdout_path.clone(), container.stderr_path.clone())
};
let now = chrono::Utc::now();
let source = LogSource::Container(id.to_string());
let mut entries = Vec::new();
if let Ok(content) = tokio::fs::read_to_string(&stdout_path).await {
for line in content.lines() {
entries.push(LogEntry {
timestamp: now,
stream: LogStream::Stdout,
message: line.to_string(),
source: source.clone(),
service: None,
deployment: None,
});
}
}
if let Ok(content) = tokio::fs::read_to_string(&stderr_path).await {
for line in content.lines() {
entries.push(LogEntry {
timestamp: now,
stream: LogStream::Stderr,
message: line.to_string(),
source: source.clone(),
service: None,
deployment: None,
});
}
}
Ok(entries)
}
async fn get_container_pid(&self, id: &ContainerId) -> Result<Option<u32>> {
let dir_name = Self::container_dir_name(id);
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
if container.pid > 0 {
Ok(Some(container.pid))
} else {
Ok(None)
}
}
fn overlay_attach_kind(&self) -> OverlayAttachKind {
OverlayAttachKind::HostProxy
}
async fn attach_overlay_ip(
&self,
id: &ContainerId,
overlay_ip: std::net::IpAddr,
) -> Result<()> {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let dir_name = Self::container_dir_name(id);
let mut guard = self.containers.write().await;
let Some(c) = guard.get_mut(&dir_name) else {
return Ok(());
};
c.overlay_ip = Some(overlay_ip);
let assigned = c.assigned_port;
let mut seen = std::collections::HashSet::new();
let mut first = true;
for pm in &c.spec.port_mappings {
if !seen.insert((pm.container_port, pm.protocol.as_str())) {
continue;
}
let bind = SocketAddr::new(overlay_ip, pm.container_port);
let deliver_port = if first { assigned } else { pm.container_port };
let target = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), deliver_port);
c.forwarders
.push(crate::runtimes::host_forward::spawn_overlay_forward(
bind,
target,
pm.protocol,
));
first = false;
}
Ok(())
}
async fn detach_overlay_ip(&self, id: &ContainerId) -> Result<()> {
let dir_name = Self::container_dir_name(id);
let mut guard = self.containers.write().await;
if let Some(c) = guard.get_mut(&dir_name) {
for h in c.forwarders.drain(..) {
h.abort();
}
c.overlay_ip = None;
}
Ok(())
}
async fn get_container_ip(&self, id: &ContainerId) -> Result<Option<IpAddr>> {
let dir_name = Self::container_dir_name(id);
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name,
reason: "Container not found".to_string(),
})?;
if let Some(ip) = container.overlay_ip {
return Ok(Some(ip));
}
Ok(Some(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)))
}
async fn get_container_port_override(&self, id: &ContainerId) -> Result<Option<u16>> {
let dir_name = Self::container_dir_name(id);
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name,
reason: "Container not found".to_string(),
})?;
Ok(Some(container.assigned_port))
}
#[allow(unsafe_code)]
#[allow(clippy::cast_possible_wrap)]
async fn kill_container(&self, id: &ContainerId, signal: Option<&str>) -> Result<()> {
let canonical = crate::runtime::validate_signal(signal.unwrap_or("SIGKILL"))?;
let dir_name = Self::container_dir_name(id);
let pid = {
let containers = self.containers.read().await;
let container = containers
.get(&dir_name)
.ok_or_else(|| AgentError::NotFound {
container: dir_name.clone(),
reason: "Container not found".to_string(),
})?;
container.pid
};
if pid == 0 {
return Err(AgentError::InvalidSpec(format!(
"container '{dir_name}' is not running (no pid)"
)));
}
let signum = match canonical.as_str() {
"SIGKILL" => libc::SIGKILL,
"SIGTERM" => libc::SIGTERM,
"SIGINT" => libc::SIGINT,
"SIGHUP" => libc::SIGHUP,
"SIGUSR1" => libc::SIGUSR1,
"SIGUSR2" => libc::SIGUSR2,
other => {
return Err(AgentError::InvalidSpec(format!(
"unsupported signal '{other}'"
)));
}
};
tracing::info!(container = %dir_name, pid = pid, signal = %canonical, "killing sandboxed process");
let ret = unsafe { libc::kill(pid as i32, signum) };
if ret != 0 {
let err = std::io::Error::last_os_error();
return Err(AgentError::Internal(format!(
"kill({pid}, {canonical}) failed: {err}"
)));
}
Ok(())
}
async fn tag_image(&self, _source: &str, _target: &str) -> Result<()> {
Err(AgentError::Unsupported(
"tag_image is not supported by the macOS sandbox runtime".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn seed_ready_keg(cache: &Path, formula: &str, version: &str) {
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
};
let keg = cache.join(format!("{formula}-{version}-{arch}"));
tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
tokio::fs::write(keg.join("bin").join("tool"), b"")
.await
.unwrap();
tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
}
#[tokio::test]
async fn resolve_init_toolchains_probes_ready_kegs_on_disk() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache = tmp.path();
seed_ready_keg(cache, "git", "2.53.0").await;
seed_ready_keg(cache, "node@lts", "24.18.0").await;
let handles = resolve_init_toolchain_handles(cache, None).await;
assert_eq!(handles.len(), 2, "both init kegs resolve from disk");
let path_dirs: Vec<String> = handles.iter().flat_map(|h| h.path_dirs.clone()).collect();
assert!(
path_dirs.iter().any(|d| d.contains("node@lts-24.18.0")),
"node keg bin must be on the injectable PATH set: {path_dirs:?}"
);
}
#[tokio::test]
async fn resolve_init_toolchains_waits_for_keg_becoming_ready() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache = tmp.path().to_path_buf();
seed_ready_keg(&cache, "git", "2.53.0").await;
let cache_bg = cache.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(400)).await;
seed_ready_keg(&cache_bg, "node@lts", "24.18.0").await;
});
let handles = resolve_init_toolchain_handles(&cache, Some(Duration::from_secs(5))).await;
assert_eq!(
handles.len(),
2,
"the Node keg that became ready during the wait must be picked up"
);
}
#[test]
fn resolve_sandbox_storage_rebases_workspace_volume_off_host_root() {
let rootfs = PathBuf::from("/data/containers/svc-0/rootfs");
let container_dir = PathBuf::from("/data/containers/svc-0");
let backing = PathBuf::from("/data/volumes/ws");
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
spec.storage = vec![zlayer_spec::StorageSpec::Named {
name: "ws".to_string(),
target: "/workspace".to_string(),
readonly: false,
tier: zlayer_spec::StorageTier::Local,
size: None,
}];
let mut volume_paths = std::collections::HashMap::new();
volume_paths.insert("ws".to_string(), backing.clone());
let storage = resolve_sandbox_storage(&spec, &rootfs, &container_dir, &volume_paths);
assert_eq!(storage.workspace_host, backing);
assert_ne!(storage.workspace_host, PathBuf::from("/workspace"));
assert!(storage.writable_dirs.contains(&backing));
assert!(!storage.writable_dirs.contains(&PathBuf::from("/workspace")));
}
#[test]
fn ensure_default_path_injects_when_absent_and_preserves_when_present() {
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
assert!(
spec.env.is_empty(),
"minimal spec should start with empty env"
);
ensure_default_path(&mut spec);
assert_eq!(
spec.env.get("PATH").map(String::as_str),
Some(DEFAULT_SEATBELT_PATH),
"default PATH must be persisted into the spec env"
);
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
spec.env.insert("PATH".to_string(), "/custom".to_string());
ensure_default_path(&mut spec);
assert_eq!(
spec.env.get("PATH").map(String::as_str),
Some("/custom"),
"a user/image PATH must win over the default"
);
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
spec.env.insert("PATH".to_string(), String::new());
ensure_default_path(&mut spec);
assert_eq!(
spec.env.get("PATH").map(String::as_str),
Some(DEFAULT_SEATBELT_PATH),
"an empty PATH must be replaced by the default"
);
}
#[test]
fn ensure_spawn_path_injects_when_absent_or_empty_and_preserves_otherwise() {
let mut env_vars: Vec<(String, String)> = Vec::new();
ensure_spawn_path(&mut env_vars);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str()),
Some(DEFAULT_SEATBELT_PATH),
"absent PATH must be seeded with the default"
);
let mut env_vars: Vec<(String, String)> = vec![
("FORGEJO_TASK_DATA".to_string(), "blob".to_string()),
("PATH".to_string(), String::new()),
("ZLAYER_ENDPOINT".to_string(), "unix:///x".to_string()),
];
ensure_spawn_path(&mut env_vars);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str()),
Some(DEFAULT_SEATBELT_PATH),
"empty PATH must be OVERWRITTEN with the default"
);
assert_eq!(
env_vars.iter().filter(|(k, _)| k == "PATH").count(),
1,
"exactly one PATH entry must remain"
);
let mut env_vars: Vec<(String, String)> = vec![("PATH".to_string(), "/custom".to_string())];
ensure_spawn_path(&mut env_vars);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str()),
Some("/custom"),
"a non-empty PATH must be left untouched"
);
}
#[test]
fn exec_env_build_honors_image_config_path_and_vars() {
let spec = ServiceSpec::minimal("svc", "scratch:latest");
let image_config = zlayer_registry::ImageConfig {
env: Some(vec![
"PATH=/opt/toolbin:/usr/bin".to_string(),
"IMAGE_ONLY=present".to_string(),
]),
..Default::default()
};
let mut env_base = crate::runtimes::macos_vz_shared::merge_env(&spec, Some(&image_config));
ensure_spawn_path(&mut env_base);
assert_eq!(
env_base
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str()),
Some("/opt/toolbin:/usr/bin"),
"the image's PATH must be preserved, not replaced by the default"
);
assert_eq!(
env_base.iter().filter(|(k, _)| k == "PATH").count(),
1,
"exactly one PATH entry must remain"
);
assert!(
env_base.contains(&("IMAGE_ONLY".to_string(), "present".to_string())),
"the image-only var must be carried into the exec env"
);
}
#[test]
fn inject_toolchain_env_prepends_path_and_sets_git_vars() {
let keg_bin = "/data/host-toolchains/git-2.55.0-arm64/bin";
let exec_path = "/data/host-toolchains/git-2.55.0-arm64/libexec/git-core";
let mut env = HashMap::new();
env.insert("GIT_EXEC_PATH".to_string(), exec_path.to_string());
let handle = zlayer_toolchain::ToolchainHandle {
install_dir: PathBuf::from("/data/host-toolchains/git-2.55.0-arm64"),
path_dirs: vec![keg_bin.to_string()],
env,
};
let mut env_vars: Vec<(String, String)> = vec![
("PATH".to_string(), DEFAULT_SEATBELT_PATH.to_string()),
(
"GIT_EXEC_PATH".to_string(),
"/job/pinned/git-core".to_string(),
),
];
inject_toolchain_env(&mut env_vars, &handle);
let path = env_vars
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str())
.expect("PATH present");
assert_eq!(
path,
format!("{keg_bin}:{DEFAULT_SEATBELT_PATH}"),
"keg bin must be PREPENDED so the toolchain git is searched first"
);
assert_eq!(
env_vars.iter().filter(|(k, _)| k == "PATH").count(),
1,
"exactly one PATH entry"
);
assert!(
!env_vars
.iter()
.any(|(k, _)| k == "DYLD_FALLBACK_LIBRARY_PATH"),
"source-built git must not inject DYLD_FALLBACK_LIBRARY_PATH"
);
assert!(
!env_vars.iter().any(|(k, _)| k == "GIT_CONFIG_SYSTEM"),
"source-built git must not inject GIT_CONFIG_SYSTEM"
);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "GIT_EXEC_PATH")
.map(|(_, v)| v.as_str()),
Some("/job/pinned/git-core"),
"a job-supplied GIT_EXEC_PATH must win over the toolchain's"
);
assert_eq!(
env_vars
.iter()
.filter(|(k, _)| k == "GIT_EXEC_PATH")
.count(),
1,
"no duplicate GIT_EXEC_PATH entry"
);
}
#[test]
fn inject_home_env_sets_when_absent_and_preserves_existing() {
let home_dir = Path::new("/data/containers/svc-0/home");
let mut env_vars: Vec<(String, String)> = Vec::new();
inject_home_env(&mut env_vars, home_dir);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "HOME")
.map(|(_, v)| v.as_str()),
Some("/data/containers/svc-0/home"),
"HOME must be seeded to the granted per-container home dir when absent"
);
assert_eq!(
env_vars.iter().filter(|(k, _)| k == "HOME").count(),
1,
"exactly one HOME entry"
);
let mut env_vars: Vec<(String, String)> =
vec![("HOME".to_string(), "/job/pinned/home".to_string())];
inject_home_env(&mut env_vars, home_dir);
assert_eq!(
env_vars
.iter()
.find(|(k, _)| k == "HOME")
.map(|(_, v)| v.as_str()),
Some("/job/pinned/home"),
"a job-supplied HOME must NOT be overwritten"
);
assert_eq!(
env_vars.iter().filter(|(k, _)| k == "HOME").count(),
1,
"no duplicate HOME entry"
);
}
struct FakeSecretsProvider {
scope: String,
name: String,
value: String,
}
#[async_trait::async_trait]
impl zlayer_secrets::SecretsProvider for FakeSecretsProvider {
async fn get_secret(
&self,
scope: &str,
name: &str,
) -> std::result::Result<zlayer_secrets::Secret, zlayer_secrets::SecretsError> {
if scope == self.scope && name == self.name {
Ok(zlayer_secrets::Secret::new(self.value.clone()))
} else {
Err(zlayer_secrets::SecretsError::NotFound {
name: name.to_string(),
})
}
}
async fn get_secrets(
&self,
scope: &str,
names: &[&str],
) -> std::result::Result<
std::collections::HashMap<String, zlayer_secrets::Secret>,
zlayer_secrets::SecretsError,
> {
let mut out = std::collections::HashMap::new();
for n in names {
if let Ok(s) = self.get_secret(scope, n).await {
out.insert((*n).to_string(), s);
}
}
Ok(out)
}
async fn list_secrets(
&self,
_scope: &str,
) -> std::result::Result<Vec<zlayer_secrets::SecretMetadata>, zlayer_secrets::SecretsError>
{
Ok(vec![])
}
async fn exists(
&self,
scope: &str,
name: &str,
) -> std::result::Result<bool, zlayer_secrets::SecretsError> {
Ok(scope == self.scope && name == self.name)
}
}
#[tokio::test]
async fn resolve_env_expands_secret_ref_when_provider_and_scope_present() {
let (rt, _tmp) = runtime();
rt.set_secrets_provider(std::sync::Arc::new(FakeSecretsProvider {
scope: "test-deployment".to_string(),
name: "mysecret".to_string(),
value: "hunter2".to_string(),
}));
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
spec.secret_scope = Some(zlayer_secrets::SecretScope::Deployment(
"test-deployment".to_string(),
));
spec.env
.insert("FOO".to_string(), "$S:mysecret".to_string());
let resolved = rt.resolve_env(&spec).await.expect("resolve env");
assert_eq!(
resolved.get("FOO").map(String::as_str),
Some("hunter2"),
"$S: ref must resolve to the provider's secret value"
);
}
#[tokio::test]
async fn resolve_env_resolves_host_ref_and_leaves_secret_ref_when_no_scope() {
let (rt, _tmp) = runtime();
let mut spec = ServiceSpec::minimal("svc", "scratch:latest");
assert!(spec.secret_scope.is_none());
let host_key = "ZL_SANDBOX_TEST_HOME_VAR";
std::env::set_var(host_key, "/home/zl-test");
spec.env.insert("BAR".to_string(), format!("$E:{host_key}"));
spec.env
.insert("BAZ".to_string(), "$S:untouched".to_string());
let resolved = rt.resolve_env(&spec).await.expect("resolve env");
std::env::remove_var(host_key);
assert_eq!(
resolved.get("BAR").map(String::as_str),
Some("/home/zl-test"),
"$E: ref must resolve to the host env value"
);
assert_eq!(
resolved.get("BAZ").map(String::as_str),
Some("$S:untouched"),
"$S: ref must be left untouched when no provider/scope is present"
);
}
#[test]
fn resolve_sandbox_storage_defaults_workspace_into_rootfs_clone() {
let rootfs = PathBuf::from("/data/containers/svc-0/rootfs");
let container_dir = PathBuf::from("/data/containers/svc-0");
let spec = ServiceSpec::minimal("svc", "scratch:latest");
let storage = resolve_sandbox_storage(
&spec,
&rootfs,
&container_dir,
&std::collections::HashMap::new(),
);
assert_eq!(storage.workspace_host, rootfs.join("workspace"));
assert!(storage.writable_dirs.contains(&rootfs.join("workspace")));
}
fn runtime() -> (SandboxRuntime, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("create tempdir");
let config = MacSandboxConfig {
data_dir: tmp.path().join("data"),
log_dir: tmp.path().join("logs"),
gpu_access: false,
keychain_access_allowed: false,
overlay_cidr: None,
};
let rt = SandboxRuntime::new(config, None).expect("construct SandboxRuntime");
(rt, tmp)
}
fn profile_config(keychain_access: KeychainAccess) -> SandboxConfig {
SandboxConfig {
rootfs_dir: PathBuf::from("/tmp/zl-test/rootfs"),
workspace_dir: PathBuf::from("/tmp/zl-test/workspace"),
gpu_access: GpuAccess::None,
keychain_access,
network_access: NetworkAccess::None,
writable_dirs: vec![],
readonly_dirs: vec![],
toolchain_cache: None,
max_files: 4096,
cpu_time_limit: None,
memory_limit: None,
}
}
#[test]
fn keychain_denied_by_default() {
let profile = generate_sandbox_profile(&profile_config(KeychainAccess::None));
assert!(
!profile.contains("com.apple.SecurityServer"),
"default profile must not allow securityd:\n{profile}"
);
assert!(
!profile.contains("Keychains"),
"default profile must not open any keychain path:\n{profile}"
);
}
#[test]
fn keychain_enabled_grants_securityd_and_paths() {
let kc = PathBuf::from("/Users/zachary/Library/Keychains/zlayer-build.keychain-db");
let profile = generate_sandbox_profile(&profile_config(KeychainAccess::Enabled {
keychain_paths: vec![kc],
}));
assert!(
profile.contains("(global-name \"com.apple.SecurityServer\")"),
"enabled profile must allow securityd:\n{profile}"
);
assert!(
profile.contains("(allow file-read* file-map-executable")
&& profile.contains("(subpath \"/usr/bin\")"),
"enabled profile must allow exec of host /usr/bin (codesign/security):\n{profile}"
);
assert!(
profile.contains("/Users/zachary/Library/Keychains"),
"enabled profile must open the keychain directory read/write:\n{profile}"
);
}
#[test]
fn allows_reading_system_config_dir_for_git() {
let profile = generate_sandbox_profile(&profile_config(KeychainAccess::None));
assert!(
profile.contains("(subpath \"/private/etc\")"),
"profile must allow file-read of /private/etc so git can read /etc/gitconfig:\n{profile}"
);
}
#[test]
fn grants_read_and_exec_on_toolchain_cache_when_provisioned() {
let none = generate_sandbox_profile(&profile_config(KeychainAccess::None));
assert!(
!none.contains("Provisioned toolchain cache"),
"no toolchain grant should appear when none was provisioned:\n{none}"
);
let mut config = profile_config(KeychainAccess::None);
let tc = PathBuf::from("/Users/zachary/.zlayer/toolchains");
config.toolchain_cache = Some(tc.clone());
let profile = generate_sandbox_profile(&config);
assert!(
profile.contains(&format!(
"(allow file-read* file-map-executable (subpath \"{}\"))",
tc.display()
)),
"the toolchain cache must be granted file-read* + file-map-executable:\n{profile}"
);
for ancestor in ["/", "/Users", "/Users/zachary", "/Users/zachary/.zlayer"] {
assert!(
profile.contains(&format!(
"(allow file-read-metadata (literal \"{ancestor}\"))"
)),
"ancestor `{ancestor}` of the toolchain cache must be lstat-able:\n{profile}"
);
}
}
#[tokio::test]
#[ignore = "live: builds git from source + runs it under a real Seatbelt profile (macOS + CLT)"]
async fn source_built_git_runs_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"git",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("git toolchain should build from source");
let git_bin = handle.install_dir.join("bin/git");
assert!(git_bin.exists(), "keg git must exist at <keg>/bin/git");
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-srcgit-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f")
.arg(&profile_path)
.arg(&git_bin)
.arg("--version");
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &handle.install_dir);
cmd.env("GIT_CONFIG_GLOBAL", handle.install_dir.join(".gitconfig"));
let out = cmd.output().expect("run sandbox-exec git --version");
let _ = std::fs::remove_file(&profile_path);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"keg git must EXECUTE under the real Seatbelt profile (NOT Abort trap:6).\n\
status={:?}\nstdout={stdout}\nstderr={stderr}",
out.status
);
assert!(
stdout.contains("git version"),
"expected a git version line, got stdout={stdout} stderr={stderr}"
);
}
#[tokio::test]
#[ignore = "live: builds jq from source + runs it under a real Seatbelt profile (macOS + CLT)"]
async fn source_built_jq_runs_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"jq",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("jq toolchain should build from source");
let jq_bin = handle.install_dir.join("bin/jq");
assert!(jq_bin.exists(), "keg jq must exist at <keg>/bin/jq");
let otool = std::process::Command::new("otool")
.arg("-L")
.arg(&jq_bin)
.output()
.expect("run otool -L on keg jq");
let libs = String::from_utf8_lossy(&otool.stdout);
assert!(
!libs.contains("@@HOMEBREW"),
"source-built jq must have NO @@HOMEBREW@@ load commands; otool -L:\n{libs}"
);
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-srcjq-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f")
.arg(&profile_path)
.arg(&jq_bin)
.arg("--version");
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &handle.install_dir);
let out = cmd.output().expect("run sandbox-exec jq --version");
let _ = std::fs::remove_file(&profile_path);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"keg jq must EXECUTE under the real Seatbelt profile (NOT Abort trap:6).\n\
status={:?}\nstdout={stdout}\nstderr={stderr}",
out.status
);
assert!(
stdout.contains("jq-") || stderr.contains("jq-"),
"expected a jq version line, got stdout={stdout} stderr={stderr}"
);
}
#[tokio::test]
#[ignore = "live: builds cmake from source + runs it under a real Seatbelt profile (macOS + CLT)"]
async fn source_built_cmake_runs_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"cmake",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("cmake toolchain should build from source");
let cmake_bin = handle.install_dir.join("bin/cmake");
assert!(
cmake_bin.exists(),
"keg cmake must exist at <keg>/bin/cmake"
);
let otool = std::process::Command::new("otool")
.arg("-L")
.arg(&cmake_bin)
.output()
.expect("run otool -L on keg cmake");
let libs = String::from_utf8_lossy(&otool.stdout);
assert!(
!libs.contains("@@HOMEBREW"),
"source-built cmake must have NO @@HOMEBREW@@ load commands; otool -L:\n{libs}"
);
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-srccmake-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f")
.arg(&profile_path)
.arg(&cmake_bin)
.arg("--version");
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &handle.install_dir);
cmd.current_dir(&handle.install_dir);
let out = cmd.output().expect("run sandbox-exec cmake --version");
let _ = std::fs::remove_file(&profile_path);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"keg cmake must EXECUTE under the real Seatbelt profile (NOT Abort trap:6).\n\
status={:?}\nstdout={stdout}\nstderr={stderr}",
out.status
);
assert!(
stdout.contains("cmake version") || stderr.contains("cmake version"),
"expected a cmake version line, got stdout={stdout} stderr={stderr}"
);
}
#[tokio::test]
#[ignore = "live: downloads the Node LTS keg + runs node under a real Seatbelt profile (macOS + network)"]
async fn node_lts_runs_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"node@lts",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("node@lts toolchain should provision via the prebuilt fetcher");
let node_bin = handle.install_dir.join("bin/node");
assert!(node_bin.exists(), "keg node must exist at <keg>/bin/node");
let keg_name = handle
.install_dir
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let expected_ver = keg_name
.strip_prefix("node@lts-")
.and_then(|s| s.rsplit_once('-'))
.map(|(v, _arch)| v.to_string())
.expect("keg name has node@lts-<ver>-<arch> shape");
let major: u64 = expected_ver
.split('.')
.next()
.and_then(|m| m.parse().ok())
.expect("version has a numeric major");
assert!(
major >= 20 && major % 2 == 0,
"resolved LTS major must be an even line >= 20 (LTS), got {major} ({expected_ver})"
);
let otool = std::process::Command::new("otool")
.arg("-L")
.arg(&node_bin)
.output()
.expect("run otool -L on keg node");
let libs = String::from_utf8_lossy(&otool.stdout);
assert!(
!libs.contains("@@HOMEBREW"),
"vendor node must have NO @@HOMEBREW@@ load commands; otool -L:\n{libs}"
);
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-node-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let run_node = |extra: &[&str]| {
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f").arg(&profile_path).arg(&node_bin);
for a in extra {
cmd.arg(a);
}
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &handle.install_dir);
cmd.current_dir(&handle.install_dir);
let out = cmd.output().expect("run sandbox-exec node");
(
out.status,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};
let (status, stdout, stderr) = run_node(&["--version"]);
assert!(
status.success(),
"keg node must EXECUTE under the real Seatbelt profile (NOT Abort trap:6).\n\
status={status:?}\nstdout={stdout}\nstderr={stderr}"
);
assert_eq!(
stdout.trim(),
format!("v{expected_ver}"),
"in-sandbox node --version must equal the resolved LTS version"
);
let (status2, stdout2, stderr2) = run_node(&["-e", "console.log(process.version)"]);
let _ = std::fs::remove_file(&profile_path);
assert!(
status2.success(),
"in-sandbox `node -e` must execute JS exit-0.\n\
status={status2:?}\nstdout={stdout2}\nstderr={stderr2}"
);
assert_eq!(
stdout2.trim(),
format!("v{expected_ver}"),
"in-sandbox node -e process.version must equal the resolved LTS version"
);
}
#[tokio::test]
#[ignore = "live: brew-emulate builds gron from source + runs it under a real Seatbelt profile (macOS + CLT + network)"]
async fn brew_emulate_gron_runs_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"gron",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("gron should provision via the brew-emulate fallback");
let brew_prefix = handle.install_dir.join("brew");
assert!(
brew_prefix.is_dir(),
"gron keg must be brew-emulate-built (expected a <keg>/brew prefix at {})",
brew_prefix.display()
);
let gron_bin = std::path::Path::new(&handle.path_dirs[0]).join("gron");
assert!(
gron_bin.exists(),
"keg gron must exist at {}",
gron_bin.display()
);
let otool = std::process::Command::new("otool")
.arg("-L")
.arg(&gron_bin)
.output()
.expect("run otool -L on keg gron");
let libs = String::from_utf8_lossy(&otool.stdout);
assert!(
!libs.contains("@@HOMEBREW"),
"brew-emulate gron must have NO @@HOMEBREW@@ load commands; otool -L:\n{libs}"
);
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-brewgron-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f")
.arg(&profile_path)
.arg(&gron_bin)
.arg("--version");
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &handle.install_dir);
let out = cmd.output().expect("run sandbox-exec gron --version");
let _ = std::fs::remove_file(&profile_path);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"brew-emulate gron must EXECUTE under the real Seatbelt profile (NOT Abort trap:6).\n\
status={:?}\nstdout={stdout}\nstderr={stderr}",
out.status
);
assert!(
!stdout.trim().is_empty() || !stderr.trim().is_empty(),
"expected gron --version to print something"
);
}
#[tokio::test]
#[ignore = "live: builds git from source + clones a repo under a real Seatbelt profile (macOS + CLT + network)"]
async fn keg_git_clones_small_repo_under_real_seatbelt_profile() {
let cache = std::path::PathBuf::from(std::env::var("HOME").unwrap())
.join(".zlayer/host-toolchains");
let handle = zlayer_toolchain::ensure_toolchain(
"git",
zlayer_toolchain::ToolPlatform::MacOS,
&cache,
None,
)
.await
.expect("git toolchain should build from source");
let git_bin = handle.install_dir.join("bin/git");
let scratch_raw = std::env::temp_dir().join(format!("zl-keg-clone-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch_raw);
std::fs::create_dir_all(&scratch_raw).expect("create scratch");
let scratch = std::fs::canonicalize(&scratch_raw).expect("canonicalize scratch");
let dest = scratch.join("Hello-World");
let mut config = profile_config(KeychainAccess::None);
config.toolchain_cache = Some(cache.clone());
config.network_access = NetworkAccess::Full;
config.writable_dirs = vec![scratch.clone()];
let profile = generate_sandbox_profile(&config);
let profile_path =
std::env::temp_dir().join(format!("zl-kegclone-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).expect("write profile");
let mut cmd = std::process::Command::new("/usr/bin/sandbox-exec");
cmd.arg("-f")
.arg(&profile_path)
.arg(&git_bin)
.arg("clone")
.arg("--depth")
.arg("1")
.arg("https://github.com/octocat/Hello-World.git")
.arg(&dest);
cmd.current_dir(&scratch);
for (k, v) in &handle.env {
cmd.env(k, v);
}
cmd.env("HOME", &scratch);
cmd.env("GIT_CONFIG_GLOBAL", scratch.join(".gitconfig"));
cmd.env("GIT_TERMINAL_PROMPT", "0");
let mut prod_env: Vec<(String, String)> = Vec::new();
inject_ca_cert_env(&mut prod_env);
for (k, v) in &prod_env {
cmd.env(k, v);
}
let out = cmd.output().expect("run sandbox-exec git clone");
let _ = std::fs::remove_file(&profile_path);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let cloned_ok = dest.join(".git").is_dir();
let _ = std::fs::remove_dir_all(&scratch);
assert!(
out.status.success() && cloned_ok,
"keg git must CLONE a repo under the real Seatbelt profile.\n\
status={:?}\ncloned_ok={cloned_ok}\nstdout={stdout}\nstderr={stderr}",
out.status
);
}
#[test]
fn writable_subpath_grants_ancestor_metadata() {
let mut config = profile_config(KeychainAccess::None);
let ws = PathBuf::from("/Users/zachary/.zlayer/volumes/ws-1");
config.writable_dirs = vec![ws.clone()];
let profile = generate_sandbox_profile(&config);
assert!(
profile.contains(&format!(
"(allow file-read* file-write* (subpath \"{}\"))",
ws.display()
)),
"writable volume dir must be granted read/write:\n{profile}"
);
for ancestor in [
"/",
"/Users",
"/Users/zachary",
"/Users/zachary/.zlayer",
"/Users/zachary/.zlayer/volumes",
] {
assert!(
profile.contains(&format!(
"(allow file-read-metadata (literal \"{ancestor}\"))"
)),
"ancestor `{ancestor}` of a writable subpath must be lstat-able:\n{profile}"
);
}
assert!(
!profile.contains(&format!(
"(allow file-read-metadata (literal \"{}\"))",
ws.display()
)),
"the writable dir itself should not get a redundant metadata literal:\n{profile}"
);
}
#[test]
fn keychain_label_grammar() {
let dir = "/Users/tester/Library/Keychains";
assert_eq!(
keychain_paths_from_label("login", dir),
vec![PathBuf::from(
"/Users/tester/Library/Keychains/login.keychain-db"
)]
);
assert_eq!(
keychain_paths_from_label("build:ci", dir),
vec![PathBuf::from(
"/Users/tester/Library/Keychains/ci.keychain-db"
)]
);
assert_eq!(
keychain_paths_from_label("true", dir),
vec![PathBuf::from(
"/Users/tester/Library/Keychains/zlayer-build.keychain-db"
)]
);
assert_eq!(
keychain_paths_from_label("/opt/keys/ci.keychain-db", dir),
vec![PathBuf::from("/opt/keys/ci.keychain-db")]
);
assert_eq!(
keychain_paths_from_label("login, build, login, bogus", dir),
vec![
PathBuf::from("/Users/tester/Library/Keychains/login.keychain-db"),
PathBuf::from("/Users/tester/Library/Keychains/zlayer-build.keychain-db"),
]
);
assert!(keychain_paths_from_label(" ", dir).is_empty());
assert!(keychain_paths_from_label("nope", dir).is_empty());
}
fn write_image_file(rt: &SandboxRuntime, dir: &str, file_name: &str, contents: &[u8]) {
let rootfs = rt.images_dir().join(dir).join("rootfs");
std::fs::create_dir_all(&rootfs).expect("create rootfs dir");
std::fs::write(rootfs.join(file_name), contents).expect("write image file");
}
fn write_ref_file(rt: &SandboxRuntime, dir: &str, reference: &str) {
let image_dir = rt.images_dir().join(dir);
std::fs::create_dir_all(&image_dir).expect("create image dir");
std::fs::write(image_dir.join("ref"), reference).expect("write ref file");
}
fn write_container_config(rt: &SandboxRuntime, name: &str, image: &str) {
let spec = ServiceSpec::minimal(name, image);
assert_eq!(
sanitize_image_name(&spec.image.name.to_string()),
image,
"test image must sanitize to its raw directory name"
);
let dir = rt.config.data_dir.join("containers").join(name);
std::fs::create_dir_all(&dir).expect("create container dir");
let json = serde_json::to_string_pretty(&spec).expect("serialize spec");
std::fs::write(dir.join("config.json"), json).expect("write config.json");
}
#[tokio::test]
async fn prune_removes_unreferenced_image_dirs() {
let (rt, _tmp) = runtime();
let imgb_contents = b"imgB-rootfs-bytes";
write_image_file(&rt, "imga", "file", b"imgA-rootfs-bytes");
write_image_file(&rt, "imgb", "file", imgb_contents);
write_container_config(&rt, "svc-0", "imga");
let result = rt.prune_images().await.expect("prune succeeds");
assert_eq!(result.deleted, vec!["imgb".to_string()]);
assert_eq!(result.space_reclaimed, imgb_contents.len() as u64);
assert!(
rt.images_dir().join("imga").is_dir(),
"referenced image must survive prune"
);
assert!(
!rt.images_dir().join("imgb").exists(),
"unreferenced image must be removed"
);
}
#[tokio::test]
async fn prune_missing_images_dir_returns_default() {
let (rt, _tmp) = runtime();
std::fs::remove_dir_all(rt.images_dir()).expect("remove images dir");
assert!(!rt.images_dir().exists());
let result = rt.prune_images().await.expect("prune succeeds");
assert!(result.deleted.is_empty());
assert_eq!(result.space_reclaimed, 0);
}
#[tokio::test]
async fn list_images_prefers_ref_file() {
let (rt, _tmp) = runtime();
write_image_file(&rt, "withref", "layer", b"abc");
write_ref_file(&rt, "withref", "alpine:latest\n");
write_image_file(&rt, "noref", "layer", b"de");
let mut images = rt.list_images().await.expect("list succeeds");
images.sort_by(|a, b| a.reference.cmp(&b.reference));
assert_eq!(images.len(), 2);
let by_ref = |r: &str| {
images
.iter()
.find(|i| i.reference == r)
.unwrap_or_else(|| panic!("missing image with reference {r}"))
.clone()
};
let withref = by_ref("alpine:latest");
assert_eq!(withref.reference, "alpine:latest");
assert_eq!(withref.size_bytes, Some(3));
let noref = by_ref("noref");
assert_eq!(noref.reference, "noref");
assert_eq!(noref.size_bytes, Some(2));
assert!(withref.digest.is_none());
assert!(noref.digest.is_none());
}
#[tokio::test]
async fn inspect_image_native_reports_darwin_from_sidecar() {
let (rt, _tmp) = runtime();
write_image_file(&rt, "myapp_latest", "bin", b"hello-rootfs");
write_ref_file(&rt, "myapp_latest", "myapp:latest");
let image_dir = rt.images_dir().join("myapp_latest");
let meta =
zlayer_types::local_image::LocalImageMetadata::new("myapp:latest", "darwin", "arm64");
std::fs::write(
image_dir.join(zlayer_types::local_image::LOCAL_IMAGE_METADATA_FILE),
serde_json::to_vec(&meta).expect("serialize sidecar"),
)
.expect("write sidecar");
std::fs::write(
image_dir.join("config.json"),
br#"{"env":["FOO=bar"],"working_dir":"/app","entrypoint":["/bin/app"],"cmd":["--serve"],"user":"nobody","labels":{"k":"v"}}"#,
)
.expect("write config.json");
let info = rt
.inspect_image_native("myapp:latest")
.await
.expect("inspect succeeds");
assert_eq!(info.os.as_deref(), Some("darwin"));
assert_eq!(info.architecture.as_deref(), Some("arm64"));
assert_eq!(info.repo_tags, vec!["myapp:latest".to_string()]);
assert_eq!(info.size, Some(12)); assert_eq!(info.env, vec!["FOO=bar".to_string()]);
assert_eq!(info.cmd, vec!["--serve".to_string()]);
assert_eq!(info.entrypoint, vec!["/bin/app".to_string()]);
assert_eq!(info.working_dir.as_deref(), Some("/app"));
assert_eq!(info.user.as_deref(), Some("nobody"));
assert_eq!(info.labels.get("k").map(String::as_str), Some("v"));
}
#[tokio::test]
async fn inspect_image_native_missing_is_not_found() {
let (rt, _tmp) = runtime();
let err = rt
.inspect_image_native("does-not-exist")
.await
.expect_err("missing image must error");
assert!(
matches!(err, AgentError::NotFound { .. }),
"expected NotFound, got {err:?}"
);
}
#[tokio::test]
async fn remove_image_not_found() {
let (rt, _tmp) = runtime();
let err = rt
.remove_image("does-not-exist", false)
.await
.expect_err("missing image must error");
assert!(
matches!(err, AgentError::NotFound { .. }),
"expected NotFound, got {err:?}"
);
}
#[tokio::test]
async fn remove_image_in_use_requires_force() {
let (rt, _tmp) = runtime();
write_image_file(&rt, "imga", "file", b"imgA-rootfs-bytes");
write_container_config(&rt, "svc-0", "imga");
let err = rt
.remove_image("imga", false)
.await
.expect_err("in-use image without force must error");
assert!(
matches!(err, AgentError::InvalidSpec(_)),
"expected InvalidSpec, got {err:?}"
);
assert!(
rt.images_dir().join("imga").is_dir(),
"image must remain after a refused removal"
);
rt.remove_image("imga", true)
.await
.expect("forced removal succeeds");
assert!(
!rt.images_dir().join("imga").exists(),
"forced removal must delete the image dir"
);
}
#[tokio::test]
async fn dir_size_bytes_sums_recursively() {
let (_rt, tmp) = runtime();
let root = tmp.path().join("tree");
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested dirs");
std::fs::write(root.join("top.bin"), vec![0u8; 10]).expect("write top file");
std::fs::write(root.join("a").join("mid.bin"), vec![0u8; 20]).expect("write mid file");
std::fs::write(nested.join("leaf.bin"), vec![0u8; 30]).expect("write leaf file");
let total = SandboxRuntime::dir_size_bytes(&root).await;
assert_eq!(total, 60);
}
#[test]
fn profile_grants_selected_developer_dir_outside_clt() {
let profile = generate_sandbox_profile(&profile_config(KeychainAccess::None));
match host_developer_dir() {
Some(dir) if !Path::new(dir).starts_with("/Library/Developer/CommandLineTools") => {
let grant_root = enclosing_app_bundle(dir).unwrap_or(dir);
assert!(
profile.contains(&format!(
"(allow file-read* file-map-executable (subpath \"{grant_root}\"))"
)),
"selected toolchain bundle must be granted read + exec-map: {grant_root}\n{profile}"
);
let parent = Path::new(grant_root)
.parent()
.expect("grant root has a parent");
assert!(
profile.contains(&format!(
"(allow file-read-metadata (literal \"{}\"))",
parent.display()
)),
"toolchain-bundle ancestors must be metadata-readable\n{profile}"
);
}
_ => {
assert!(
profile.contains("(subpath \"/Library/Developer/CommandLineTools\")"),
"CLT subpath must always be granted\n{profile}"
);
assert!(
!profile.contains("/Applications/Xcode.app"),
"no Xcode.app grant expected on a CLT-only host\n{profile}"
);
}
}
}
#[test]
fn enclosing_app_bundle_resolves_bundle_root() {
assert_eq!(
enclosing_app_bundle("/Applications/Xcode.app/Contents/Developer"),
Some("/Applications/Xcode.app")
);
assert_eq!(
enclosing_app_bundle("/Applications/Xcode-beta.app"),
Some("/Applications/Xcode-beta.app")
);
assert_eq!(
enclosing_app_bundle("/Library/Developer/CommandLineTools"),
None
);
}
#[tokio::test]
async fn compute_tail_offset_matches_tail_semantics() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("log.txt");
std::fs::write(&path, "line0\nline1\nline2\nline3\nline4\n").expect("write");
let len = std::fs::metadata(&path).expect("stat").len();
let mut f = tokio::fs::File::open(&path).await.expect("open");
let off = compute_tail_offset(&mut f, len, 2).await;
let rest = std::fs::read_to_string(&path).expect("read");
let off = usize::try_from(off).expect("offset fits usize");
assert_eq!(&rest[off..], "line3\nline4\n");
let mut f2 = tokio::fs::File::open(&path).await.expect("open");
assert_eq!(compute_tail_offset(&mut f2, len, 99).await, 0);
}
async fn insert_log_container(
rt: &SandboxRuntime,
id: &ContainerId,
state: ContainerState,
dir: &Path,
) {
let container = SandboxContainer {
pid: 0,
state,
state_dir: dir.to_path_buf(),
rootfs_dir: dir.join("rootfs"),
workspace_host: dir.join("ws"),
stdout_path: dir.join("stdout.log"),
stderr_path: dir.join("stderr.log"),
started_at: None,
spec: ServiceSpec::minimal("svc", "scratch:latest"),
sandbox_config: profile_config(KeychainAccess::None),
toolchain_handles: Vec::new(),
memory_limit: None,
watchdog_handle: None,
assigned_port: 0,
port_guard: None,
overlay_ip: None,
forwarders: Vec::new(),
};
rt.containers
.write()
.await
.insert(SandboxRuntime::container_dir_name(id), container);
}
#[tokio::test]
async fn logs_stream_follows_to_completion_on_exited_container() {
use futures_util::StreamExt as _;
let (rt, tmp) = runtime();
let id = ContainerId::new("svc", 0);
let dir = tmp.path().join("c0");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("stdout.log"), "hello\nworld\n").expect("stdout");
std::fs::write(dir.join("stderr.log"), "oops\n").expect("stderr");
insert_log_container(&rt, &id, ContainerState::Exited { code: 0 }, &dir).await;
let opts = LogsStreamOptions {
follow: true,
..Default::default()
};
let mut stream = rt.logs_stream(&id, opts).await.expect("logs_stream");
let mut out = String::new();
let mut err = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.expect("chunk ok");
let text = String::from_utf8_lossy(&chunk.bytes).into_owned();
match chunk.stream {
LogChannel::Stdout => out.push_str(&text),
LogChannel::Stderr => err.push_str(&text),
LogChannel::Stdin => {}
}
}
assert_eq!(out, "hello\nworld\n", "stdout must stream in full");
assert_eq!(err, "oops\n", "stderr must stream in full");
}
#[tokio::test]
async fn logs_stream_missing_container_is_not_found() {
let (rt, _tmp) = runtime();
let id = ContainerId::new("ghost", 3);
let res = rt.logs_stream(&id, LogsStreamOptions::default()).await;
assert!(
matches!(res, Err(AgentError::NotFound { .. })),
"missing container must surface NotFound"
);
}
}