use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::IsTerminal;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::signal::unix::{SignalKind, signal};
const SAFE_ENV_ALLOWLIST: &[&str] = &["TERM", "COLORTERM", "COLUMNS", "LINES"];
const NATIVE_PLATFORM: &str = "linux/arm64";
#[derive(serde::Deserialize, Debug, Clone, Default)]
struct SandboxManifest {
image: Option<String>,
resources: Option<ResourceLimits>,
mounts: Option<HashMap<String, String>>,
security: Option<SecurityCaps>,
}
#[derive(serde::Deserialize, Debug, Clone, Default)]
struct ResourceLimits {
cpus: Option<u32>,
memory: Option<String>,
}
#[derive(serde::Deserialize, Debug, Clone, Default)]
struct SecurityCaps {
network: Option<String>,
workspace_mode: Option<String>,
}
#[derive(Debug, Clone)]
struct MountSpec {
host: String,
guest: String,
read_only: bool,
}
#[derive(Debug, Clone)]
struct ContainerProfileSettings {
image: String,
workspace_guest_path: String,
mounts: Vec<MountSpec>,
network_none: bool,
cpus: Option<u32>,
memory: Option<String>,
uses_golden_image: bool,
}
#[derive(Debug, Clone)]
struct AuditLogger {
path: PathBuf,
}
struct TerminalStateGuard {
fds: Vec<(i32, libc::termios)>,
}
#[derive(Clone, Copy)]
struct TerminalDimensions {
rows: u16,
cols: u16,
}
impl TerminalStateGuard {
fn capture() -> Self {
let mut fds = Vec::new();
for fd in [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO] {
let is_tty = unsafe { libc::isatty(fd) } == 1;
if !is_tty {
continue;
}
let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
let ok = unsafe { libc::tcgetattr(fd, &mut termios as *mut libc::termios) } == 0;
if ok {
fds.push((fd, termios));
}
}
Self { fds }
}
}
impl Drop for TerminalStateGuard {
fn drop(&mut self) {
for (fd, termios) in &self.fds {
let _ = unsafe { libc::tcsetattr(*fd, libc::TCSANOW, termios as *const libc::termios) };
}
}
}
fn sanitize_project_name(name: &str) -> Option<String> {
let sanitized: String = name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if sanitized.is_empty() {
return None;
}
Some(sanitized)
}
fn project_name_suffix(seed: &str) -> String {
let hash = seed
.as_bytes()
.iter()
.fold(0xcbf29ce484222325_u64, |acc, b| {
(acc ^ u64::from(*b)).wrapping_mul(0x100000001b3)
});
format!("{:08x}", (hash & 0xffff_ffff) as u32)
}
impl AuditLogger {
fn new(path: PathBuf) -> Self {
Self { path }
}
fn write_event(&self, event: serde_json::Value) -> Result<(), color_eyre::Report> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let serialized = serde_json::to_string(&event)?;
writeln!(file, "{}", serialized)?;
Ok(())
}
}
fn now_unix_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn default_audit_log_path(container_id: &str) -> Result<PathBuf, color_eyre::Report> {
let home = std::env::var("HOME")?;
let audit_dir = PathBuf::from(home).join(".cache/muthr/audit");
std::fs::create_dir_all(&audit_dir)?;
let ts = now_unix_seconds();
Ok(audit_dir.join(format!("{}-{}.ndjson", ts, container_id)))
}
fn resolve_audit_logger(
audit_log: Option<String>,
container_id: &str,
) -> Result<Option<AuditLogger>, color_eyre::Report> {
let Some(path_str) = audit_log else {
return Ok(None);
};
let path = if path_str.trim().is_empty() {
default_audit_log_path(container_id)?
} else {
PathBuf::from(path_str)
};
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Ok(Some(AuditLogger::new(path)))
}
fn runtime_env_summary(envs: &[(String, String)]) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (k, v) in envs {
if matches!(
k.as_str(),
"MUTHR_INFERENCE_URL"
| "MUTHR_MCP_BRIDGE_URL"
| "MUTHR_SEARXNG_URL"
| "MUTHR_MODEL_NAME"
| "MUTHR_ENGINE_RUNTIME"
) {
map.insert(k.clone(), serde_json::Value::String(v.clone()));
}
}
serde_json::Value::Object(map)
}
fn run_container_session(
container_id: &str,
guest_workdir: &str,
injected_envs: &[(String, String)],
target_args: &[&str],
requires_tty: bool,
audit: Option<&AuditLogger>,
) -> Result<(), color_eyre::Report> {
let use_tty = std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::io::stderr().is_terminal();
if requires_tty && !use_tty {
return Err(color_eyre::eyre::eyre!(
"interactive TTY is required for this profile; run from a local terminal"
));
}
let started_at = Instant::now();
if let Some(logger) = audit {
logger.write_event(serde_json::json!({
"event": "session_start",
"ts": now_unix_seconds(),
"container_id": container_id,
"workdir": guest_workdir,
"tty": use_tty,
"requires_tty": requires_tty,
"runtime_env": runtime_env_summary(injected_envs),
"target_args": target_args,
}))?;
}
let run_once = |tty: bool| -> Result<std::process::ExitStatus, color_eyre::Report> {
let _terminal_state_guard = tty.then(TerminalStateGuard::capture);
let mut args: Vec<String> = vec!["exec".to_string()];
if tty {
args.push("--interactive".to_string());
args.push("--tty".to_string());
}
args.push("--workdir".to_string());
args.push(guest_workdir.to_string());
args.push("--user".to_string());
args.push("muthr".to_string());
for (key, value) in injected_envs {
args.push("--env".to_string());
args.push(format!("{}={}", key, value));
}
args.push(container_id.to_string());
args.extend(target_args.iter().map(|s| s.to_string()));
if let Some(logger) = audit {
logger.write_event(serde_json::json!({
"event": "exec_invocation",
"ts": now_unix_seconds(),
"container_id": container_id,
"argv": args,
"tty": tty,
}))?;
}
let status = std::process::Command::new("container")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
Ok(status)
};
let status = match run_once(use_tty) {
Ok(status) => status,
Err(err) => {
if use_tty && !requires_tty {
eprintln!("warning: interactive TTY launch failed; retrying without TTY");
run_once(false)?
} else {
return Err(err);
}
}
};
if status.success() {
if let Some(logger) = audit {
logger.write_event(serde_json::json!({
"event": "session_exit",
"ts": now_unix_seconds(),
"container_id": container_id,
"exit_code": status.code(),
"duration_ms": started_at.elapsed().as_millis(),
}))?;
}
return Ok(());
}
if let Some(logger) = audit {
logger.write_event(serde_json::json!({
"event": "session_exit",
"ts": now_unix_seconds(),
"container_id": container_id,
"exit_code": status.code(),
"duration_ms": started_at.elapsed().as_millis(),
}))?;
}
Err(color_eyre::eyre::eyre!(
"application session exited with error"
))
}
fn parse_explicit_env(input: &str) -> Result<(String, String), color_eyre::Report> {
let Some((key, value)) = input.split_once('=') else {
return Err(color_eyre::eyre::eyre!(
"invalid --env value '{}': expected KEY=VALUE",
input
));
};
if key.is_empty() {
return Err(color_eyre::eyre::eyre!(
"invalid --env value '{}': key cannot be empty",
input
));
}
if key
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '_'))
{
return Err(color_eyre::eyre::eyre!(
"invalid --env key '{}': use [A-Za-z0-9_] only",
key
));
}
if value.contains('\0') || value.contains('\n') || value.contains('\r') {
return Err(color_eyre::eyre::eyre!(
"invalid --env value for '{}': contains control characters",
key
));
}
Ok((key.to_string(), value.to_string()))
}
fn expand_home_path(input: &str, home: &str) -> String {
input
.strip_prefix("~/")
.map(|tail| format!("{}/{}", home, tail))
.unwrap_or_else(|| input.to_string())
}
fn parse_guest_mount_target(value: &str) -> Result<(String, bool), color_eyre::Report> {
let raw = value.trim();
if raw.is_empty() {
return Err(color_eyre::eyre::eyre!("invalid guest mount target: empty"));
}
let mut read_only = false;
let mut guest = raw;
if let Some(stripped) = raw.strip_suffix(":ro") {
read_only = true;
guest = stripped;
}
if !guest.starts_with('/') {
return Err(color_eyre::eyre::eyre!(
"invalid guest mount target '{}' (must be absolute)",
value
));
}
Ok((guest.to_string(), read_only))
}
async fn load_profile_manifest(
profile_name: &str,
) -> Result<Option<SandboxManifest>, color_eyre::Report> {
let home = std::env::var("HOME")?;
let config_dir = PathBuf::from(&home).join(".config/muthr");
let manifest_path = crate::catalog::resolve_manifest(&config_dir, profile_name);
if !manifest_path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&manifest_path).await?;
let manifest: SandboxManifest = serde_yaml::from_str(&content)?;
Ok(Some(manifest))
}
async fn container_profile_settings(
profile_name: &str,
project_root: &Path,
) -> Result<ContainerProfileSettings, color_eyre::Report> {
let home = std::env::var("HOME")?;
let mount_src = project_root
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("workspace path contains invalid UTF-8"))?
.to_string();
let mut settings = ContainerProfileSettings {
image: "debian:13-slim".to_string(),
workspace_guest_path: "/workspace".to_string(),
mounts: Vec::new(),
network_none: false,
cpus: None,
memory: None,
uses_golden_image: false,
};
if let Some(manifest) = load_profile_manifest(profile_name).await? {
if let Some(image) = manifest.image
&& !image.trim().is_empty()
{
settings.image = image.trim().to_string();
}
if let Some(resources) = manifest.resources {
settings.cpus = resources.cpus;
settings.memory = resources.memory;
}
if let Some(security) = manifest.security {
if let Some(network) = security.network {
let mode = network.trim().to_ascii_lowercase();
if mode == "none" || mode == "restricted" {
settings.network_none = true;
}
}
if let Some(workspace_mode) = security.workspace_mode {
let mode = workspace_mode.trim().to_ascii_lowercase();
if mode == "overlay" {
eprintln!(
"warning: workspace_mode=overlay requested; overlay semantics are not implemented yet, using direct mount"
);
}
}
}
if let Some(mounts) = manifest.mounts {
for (host_key, guest_value) in mounts {
if host_key == "workspace" {
if guest_value.trim().starts_with('/') {
settings.workspace_guest_path = guest_value.trim().to_string();
}
continue;
}
let host = expand_home_path(host_key.trim(), &home);
if host.is_empty() {
continue;
}
let (guest, read_only) = parse_guest_mount_target(&guest_value)?;
settings.mounts.push(MountSpec {
host,
guest,
read_only,
});
}
}
}
settings.mounts.insert(
0,
MountSpec {
host: mount_src,
guest: settings.workspace_guest_path.clone(),
read_only: false,
},
);
let golden_tag = golden_image_tag(profile_name);
if image_exists(&golden_tag).await {
settings.image = golden_tag;
settings.uses_golden_image = true;
}
Ok(settings)
}
fn golden_image_tag(profile_name: &str) -> String {
format!("muthr-profile-{}:latest", profile_name)
}
async fn image_exists(image: &str) -> bool {
Command::new("container")
.args(["image", "inspect", image])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.is_ok_and(|s| s.success())
}
fn create_args_for_settings(
container_id: &str,
settings: &ContainerProfileSettings,
) -> Vec<String> {
let mut args = vec![
"create".to_string(),
"--name".to_string(),
container_id.to_string(),
"--platform".to_string(),
NATIVE_PLATFORM.to_string(),
"--detach".to_string(),
"--label".to_string(),
"muthr.managed=true".to_string(),
"--label".to_string(),
"muthr.owner=project".to_string(),
"--label".to_string(),
"muthr.profile.base=true".to_string(),
];
if settings.network_none {
args.push("--network".to_string());
args.push("none".to_string());
}
if let Some(cpus) = settings.cpus {
args.push(format!("--cpus={}", cpus));
}
if let Some(memory) = &settings.memory
&& !memory.trim().is_empty()
{
args.push(format!("--memory={}", memory.trim()));
}
for mount in &settings.mounts {
args.push("--volume".to_string());
if mount.read_only {
args.push(format!("{}:{}:ro", mount.host, mount.guest));
} else {
args.push(format!("{}:{}", mount.host, mount.guest));
}
}
args.push("--workdir".to_string());
args.push(settings.workspace_guest_path.clone());
args.push(settings.image.clone());
args.push("sh".to_string());
args.push("-lc".to_string());
args.push("while true; do sleep 3600; done".to_string());
args
}
fn host_uid_gid() -> (u32, u32) {
let uid = unsafe { libc::geteuid() };
let gid = unsafe { libc::getegid() };
(uid, gid)
}
fn get_current_tty_dimensions() -> Option<TerminalDimensions> {
let mut ws = unsafe { std::mem::zeroed::<libc::winsize>() };
let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0;
if !ok || ws.ws_row == 0 || ws.ws_col == 0 {
return None;
}
Some(TerminalDimensions {
rows: ws.ws_row,
cols: ws.ws_col,
})
}
async fn resize_container_pty(container_id: &str, dims: TerminalDimensions) {
let _ = Command::new("container")
.args([
"resize",
container_id,
&dims.rows.to_string(),
&dims.cols.to_string(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
async fn sync_container_user_ids(container_id: &str) {
let (uid, gid) = host_uid_gid();
let uid_str = uid.to_string();
let gid_str = gid.to_string();
let script = "UID_TARGET=\"$1\"; GID_TARGET=\"$2\"; \
if id -u muthr >/dev/null 2>&1; then \
CURRENT_UID=\"$(id -u muthr)\"; CURRENT_GID=\"$(id -g muthr)\"; \
if [ \"$CURRENT_GID\" != \"$GID_TARGET\" ]; then groupmod -o -g \"$GID_TARGET\" muthr >/dev/null 2>&1 || true; fi; \
if [ \"$CURRENT_UID\" != \"$UID_TARGET\" ]; then usermod -o -u \"$UID_TARGET\" -g \"$GID_TARGET\" muthr >/dev/null 2>&1 || true; fi; \
chown -h muthr:muthr /home/muthr >/dev/null 2>&1 || true; \
fi";
let status = Command::new("container")
.args(["exec", container_id, "sh", "-lc", script, "--"])
.arg(uid_str)
.arg(gid_str)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
if let Ok(exit) = status
&& !exit.success()
{
eprintln!(
"warning: failed to synchronize sandbox uid/gid mapping for '{}'",
container_id
);
}
}
async fn ensure_container_infrastructure(
container_id: &str,
project_root: &Path,
) -> Result<ContainerProfileSettings, color_eyre::Report> {
let settings = container_profile_settings("base", project_root).await?;
if !container_exists(container_id).await {
crate::ui::log_info(&format!("creating container {}", container_id));
let args = create_args_for_settings(container_id, &settings);
let status = Command::new("container").args(&args).status().await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to create container '{}' (run 'container system start' if the service is not running)",
container_id
));
}
}
if !container_is_running(container_id).await {
let status = Command::new("container")
.args(["start", container_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to start container '{}'",
container_id
));
}
}
ensure_container_runtime_baseline(container_id).await?;
sync_container_user_ids(container_id).await;
Ok(settings)
}
async fn update_container_network_mode(
container_id: &str,
mode: &str,
) -> Result<(), color_eyre::Report> {
let support_check = Command::new("container")
.args(["help", "update"])
.output()
.await?;
if !support_check.status.success() {
let stderr = String::from_utf8_lossy(&support_check.stderr).to_ascii_lowercase();
if stderr.contains("unknown command")
|| stderr.contains("not found")
|| stderr.contains("plugin")
{
eprintln!(
"warning: container CLI does not support 'update'; continuing without deferred network isolation for '{}' (mode '{}')",
container_id, mode
);
return Ok(());
}
return Err(color_eyre::eyre::eyre!(
"failed to verify container 'update' command support for '{}'",
container_id
));
}
let output = Command::new("container")
.args(["update", container_id, "--network", mode])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if stderr.contains("unknown command")
|| stderr.contains("not found")
|| stderr.contains("plugin")
{
eprintln!(
"warning: container update plugin unavailable; continuing without deferred network isolation for '{}' (mode '{}')",
container_id, mode
);
return Ok(());
}
return Err(color_eyre::eyre::eyre!(
"failed to set network mode '{}' for container '{}'",
mode,
container_id
));
}
Ok(())
}
pub async fn shell(
profile: Option<String>,
command: Option<String>,
no_tty: bool,
explicit_envs: Vec<String>,
audit_log: Option<String>,
) -> Result<(), color_eyre::Report> {
let use_tty = std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::io::stderr().is_terminal();
let requires_tty = !no_tty;
if requires_tty && !use_tty {
return Err(color_eyre::eyre::eyre!(
"interactive TTY is required; use --no-tty for non-interactive commands"
));
}
let parsed_envs: Vec<(String, String)> = explicit_envs
.iter()
.map(|entry| parse_explicit_env(entry))
.collect::<Result<Vec<_>, _>>()?;
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
let (container_id, project_root, workdir) = resolve_workspace_context()?;
if container_id == "muthr-config" {
return Err(color_eyre::eyre::eyre!(
"sandbox shell is only available inside a project directory"
));
}
let settings = ensure_container_infrastructure(&container_id, &project_root).await?;
let audit = resolve_audit_logger(audit_log, &container_id)?;
let home = std::env::var("HOME")?;
let cfg = crate::config::load()?;
let server_port = cfg.server_port.unwrap_or(8080);
let engine_name = cfg.default_engine_runtime.as_deref().unwrap_or("mlxcel");
let (active_model, ctx_window) =
resolve_active_model_and_ctx(&home, server_port, engine_name).await;
let runtime_envs =
runtime_env_contract(&container_id, server_port, engine_name, &active_model).await?;
if let Some(profile_name) = profile.as_deref() {
if profile_name != "base" {
let profile_settings = container_profile_settings(profile_name, &project_root).await?;
let deferred_network_isolation = profile_settings.network_none;
let cache_dir = PathBuf::from(&home)
.join(".cache/muthr")
.join(format!("{}-profiles", container_id));
crate::ui::log_info(&format!("applying profile: {}", profile_name));
let provision_result = run_provision_container(
&container_id,
profile_name,
engine_name,
&active_model,
ctx_window,
Path::new(&settings.workspace_guest_path),
server_port,
)
.await;
if deferred_network_isolation {
crate::ui::log_info("sealing sandbox boundary -> cutting off network access");
update_container_network_mode(&container_id, "none").await?;
}
provision_result?;
let existing_profiles = fs::read_to_string(&cache_dir).await.unwrap_or_default();
if !existing_profiles.lines().any(|l| l.trim() == profile_name) {
let mut existing = existing_profiles;
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(profile_name);
existing.push('\n');
let Some(cache_parent) = cache_dir.parent() else {
return Err(color_eyre::eyre::eyre!("invalid profile cache path"));
};
fs::create_dir_all(cache_parent).await?;
fs::write(&cache_dir, existing).await?;
}
}
mark_container_profile(&container_id, profile_name).await;
} else {
mark_container_profile(&container_id, "base").await;
}
let guest_workdir = match workdir.strip_prefix(&project_root) {
Ok(relative_workdir) => {
PathBuf::from(&settings.workspace_guest_path).join(relative_workdir)
}
Err(_) => PathBuf::from(&settings.workspace_guest_path),
};
let guest_workdir_str = guest_workdir
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("guest workdir contains invalid UTF-8"))?;
let mut args: Vec<String> = vec!["exec".to_string()];
if use_tty && requires_tty {
args.push("--interactive".to_string());
args.push("--tty".to_string());
}
args.push("--workdir".to_string());
args.push(guest_workdir_str.to_string());
args.push("--user".to_string());
args.push("muthr".to_string());
for key in SAFE_ENV_ALLOWLIST {
if let Ok(value) = std::env::var(key) {
args.push("--env".to_string());
args.push(format!("{}={}", key, value));
}
}
for (key, value) in &runtime_envs {
args.push("--env".to_string());
args.push(format!("{}={}", key, value));
}
for (key, value) in parsed_envs {
args.push("--env".to_string());
args.push(format!("{}={}", key, value));
}
args.push(container_id.clone());
match command {
Some(cmd) => {
args.push("bash".to_string());
args.push("-lc".to_string());
args.push(cmd);
}
None => {
args.push("bash".to_string());
args.push("-l".to_string());
}
}
let _terminal_state_guard = (use_tty && requires_tty).then(TerminalStateGuard::capture);
if let Some(logger) = &audit {
logger.write_event(serde_json::json!({
"event": "session_start",
"ts": now_unix_seconds(),
"container_id": container_id,
"workdir": guest_workdir_str,
"tty": use_tty && requires_tty,
"requires_tty": requires_tty,
"runtime_env": runtime_env_summary(&runtime_envs),
}))?;
logger.write_event(serde_json::json!({
"event": "exec_invocation",
"ts": now_unix_seconds(),
"container_id": container_id,
"argv": args,
"tty": use_tty && requires_tty,
"runtime_env": runtime_env_summary(&runtime_envs),
}))?;
}
let mut child_cmd = Command::new("container");
child_cmd.args(&args);
if use_tty && requires_tty {
child_cmd
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
} else {
child_cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
}
let mut child = child_cmd.spawn()?;
let resize_task = if use_tty && requires_tty {
let resize_container_id = container_id.clone();
if let Some(initial_dims) = get_current_tty_dimensions() {
resize_container_pty(&resize_container_id, initial_dims).await;
}
Some(tokio::spawn(async move {
let Ok(mut sigwinch) = signal(SignalKind::from_raw(libc::SIGWINCH)) else {
return;
};
while sigwinch.recv().await.is_some() {
if let Some(dims) = get_current_tty_dimensions() {
resize_container_pty(&resize_container_id, dims).await;
}
}
}))
} else {
None
};
let status = if use_tty && requires_tty {
child.wait().await?
} else {
let output = child.wait_with_output().await?;
if !output.stdout.is_empty() {
let mut stdout = tokio::io::stdout();
stdout.write_all(&output.stdout).await?;
stdout.flush().await?;
}
if !output.stderr.is_empty() {
let mut stderr = tokio::io::stderr();
stderr.write_all(&output.stderr).await?;
stderr.flush().await?;
}
output.status
};
if let Some(logger) = &audit {
logger.write_event(serde_json::json!({
"event": "session_exit",
"ts": now_unix_seconds(),
"container_id": container_id,
"exit_code": status.code(),
}))?;
}
if let Some(task) = resize_task {
task.abort();
}
if status.success() {
return Ok(());
}
std::process::exit(status.code().unwrap_or(1));
}
fn validate_workspace_root(workspace: &Path, home: &Path) -> Result<(), color_eyre::Report> {
if workspace == Path::new("/") {
return Err(color_eyre::eyre::eyre!("workspace root cannot be '/'"));
}
if workspace == Path::new("/Users") {
return Err(color_eyre::eyre::eyre!("workspace root cannot be '/Users'"));
}
if workspace == home {
return Err(color_eyre::eyre::eyre!(
"security violation: workspace root cannot be the home directory; use a dedicated subdirectory (for example, ~/src)"
));
}
if !workspace.starts_with(home) {
return Err(color_eyre::eyre::eyre!(
"workspace root must be inside '$HOME'"
));
}
Ok(())
}
pub fn resolve_workspace_context() -> Result<(String, PathBuf, PathBuf), color_eyre::Report> {
let current_dir = std::env::current_dir()?;
let home = std::env::var("HOME")?;
let canonical_current_dir = current_dir.canonicalize()?;
let raw_workspace_root = if let Ok(v) = std::env::var("MUTHR_WORKSPACE_ROOT") {
v
} else if let Ok(cfg) = crate::config::load() {
cfg.workspace_root
.unwrap_or_else(|| format!("{}/src", home))
} else {
format!("{}/src", home)
};
let workspace_root = raw_workspace_root
.strip_prefix("~/")
.map(|p| format!("{}/{}", home, p))
.unwrap_or(raw_workspace_root);
let muthr_config_dir = PathBuf::from(&home).join(".config/muthr");
if muthr_config_dir.exists()
&& let Ok(canonical_muthr_config_dir) = muthr_config_dir.canonicalize()
&& canonical_current_dir.starts_with(&canonical_muthr_config_dir)
{
return Ok(("muthr-config".to_string(), muthr_config_dir, current_dir));
}
let workspace_path = PathBuf::from(&workspace_root);
let canonical_workspace_path = match workspace_path.canonicalize() {
Ok(path) => path,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
eprintln!("error: workspace root '{}' does not exist", workspace_root);
eprintln!(
"info: create it with 'mkdir -p {}' or set MUTHR_WORKSPACE_ROOT",
workspace_root
);
std::process::exit(66);
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(color_eyre::eyre::eyre!(
"permission denied resolving workspace root '{}': {}",
workspace_root,
err
));
}
Err(err) => {
return Err(color_eyre::eyre::eyre!(
"failed to canonicalize workspace root '{}': {}",
workspace_root,
err
));
}
};
let canonical_home = PathBuf::from(&home)
.canonicalize()
.map_err(|e| color_eyre::eyre::eyre!("failed to canonicalize home directory: {}", e))?;
validate_workspace_root(&canonical_workspace_path, &canonical_home)?;
if canonical_current_dir == canonical_workspace_path {
return Err(color_eyre::eyre::eyre!(
"navigate into a project directory first"
));
}
let relative = canonical_current_dir
.strip_prefix(&canonical_workspace_path)
.map_err(|_| {
color_eyre::eyre::eyre!("current directory is outside the configured workspace root")
})?;
let project_component = relative
.components()
.next()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid workspace path"))?
.as_os_str();
let project_name = project_component
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid project name"))?;
let sanitized_project_name = sanitize_project_name(project_name)
.ok_or_else(|| color_eyre::eyre::eyre!("sanitized project name is empty"))?;
let project_token = if sanitized_project_name == project_name {
sanitized_project_name
} else {
format!(
"{}-{}",
sanitized_project_name,
project_name_suffix(project_name)
)
};
let project_folder = format!("muthr-{}", project_token);
let mount_point = canonical_workspace_path.join(project_component);
Ok((project_folder, mount_point, current_dir))
}
#[derive(serde::Deserialize, Debug, Clone)]
struct ContainerListItem {
#[serde(default)]
id: Option<String>,
#[serde(default, alias = "ID", alias = "Id")]
id_alias: Option<String>,
#[serde(default)]
status: Option<serde_json::Value>,
#[serde(default, alias = "Status")]
status_alias: Option<serde_json::Value>,
#[serde(default)]
state: Option<String>,
#[serde(default, alias = "State")]
state_alias: Option<String>,
#[serde(default)]
configuration: Option<ContainerConfiguration>,
#[serde(default, alias = "Configuration", alias = "config", alias = "Config")]
configuration_alias: Option<ContainerConfiguration>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct ContainerConfiguration {
#[serde(default)]
id: Option<String>,
#[serde(default, alias = "ID", alias = "Id")]
id_alias: Option<String>,
#[serde(default)]
labels: Option<HashMap<String, serde_json::Value>>,
#[serde(default, alias = "Labels")]
labels_alias: Option<HashMap<String, serde_json::Value>>,
}
impl ContainerListItem {
fn id(&self) -> Option<&str> {
self.id.as_deref().or(self.id_alias.as_deref()).or_else(|| {
self.configuration_ref()
.and_then(|c| c.id.as_deref().or(c.id_alias.as_deref()))
})
}
fn status_state(&self) -> Option<&str> {
self.status
.as_ref()
.or(self.status_alias.as_ref())
.and_then(|v| {
if let Some(s) = v.as_str() {
return Some(s);
}
v.get("state")
.or_else(|| v.get("State"))
.and_then(|s| s.as_str())
})
.or(self.state.as_deref())
.or(self.state_alias.as_deref())
}
fn label(&self, key: &str) -> Option<&str> {
self.labels_ref()
.and_then(|labels| labels.get(key))
.and_then(|v| v.as_str())
}
fn has_profile_label(&self) -> bool {
self.labels_ref()
.is_some_and(|labels| labels.keys().any(|k| k.starts_with("muthr.profile.")))
}
fn configuration_ref(&self) -> Option<&ContainerConfiguration> {
self.configuration
.as_ref()
.or(self.configuration_alias.as_ref())
}
fn labels_ref(&self) -> Option<&HashMap<String, serde_json::Value>> {
self.configuration_ref()
.and_then(|c| c.labels.as_ref().or(c.labels_alias.as_ref()))
}
}
async fn container_list_all() -> Option<Vec<ContainerListItem>> {
let output = Command::new("container")
.args(["list", "--all", "--format", "json"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
serde_json::from_slice::<Vec<ContainerListItem>>(&output.stdout).ok()
}
fn container_id_from_item(item: &ContainerListItem) -> Option<String> {
item.id().map(std::borrow::ToOwned::to_owned)
}
fn container_status_from_item(item: &ContainerListItem) -> Option<String> {
item.status_state().map(std::borrow::ToOwned::to_owned)
}
fn container_has_profile_label(item: &ContainerListItem) -> bool {
item.has_profile_label()
}
async fn mark_container_profile(container_id: &str, profile_name: &str) {
let label = format!("muthr.profile.{}=true", profile_name);
let result = Command::new("container")
.args(["update", container_id, "--label", &label])
.output()
.await;
match result {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Plugin 'container-update' not found") {
return;
}
eprintln!(
"warning: failed to persist profile label '{}' for container '{}'",
profile_name, container_id
);
}
Err(_) => {
eprintln!(
"warning: failed to persist profile label '{}' for container '{}'",
profile_name, container_id
);
}
}
}
async fn container_exists(container_id: &str) -> bool {
let Some(items) = container_list_all().await else {
return false;
};
items
.iter()
.filter_map(container_id_from_item)
.any(|id| id == container_id)
}
async fn container_is_running(container_id: &str) -> bool {
let Some(items) = container_list_all().await else {
return false;
};
items.iter().any(|item| {
container_id_from_item(item).is_some_and(|id| id == container_id)
&& container_status_from_item(item)
.map(|s| s.eq_ignore_ascii_case("running"))
.unwrap_or(false)
})
}
pub async fn sandbox_exists(container_id: &str) -> bool {
container_exists(container_id).await
}
pub async fn cleanup_untracked_vms(verbose: bool) -> Result<(), color_eyre::Report> {
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
let home = std::env::var("HOME")?;
let cache_dir = PathBuf::from(home).join(".cache/muthr");
if !cache_dir.exists() {
if verbose {
eprintln!(
"warning: sandbox cache directory is missing; skipping untracked cleanup to avoid accidental deletion"
);
}
return Ok(());
}
let Some(items) = container_list_all().await else {
if verbose {
eprintln!("warning: failed to list containers for cleanup");
}
return Ok(());
};
for item in items {
let Some(container_id) = container_id_from_item(&item) else {
continue;
};
if !container_id.starts_with("muthr-")
|| container_id == "muthr-services"
|| container_id == "muthr-searxng"
{
continue;
}
let is_managed_project = item.label("muthr.managed").is_some_and(|v| v == "true")
&& item.label("muthr.owner").is_some_and(|v| v == "project");
if !is_managed_project {
if verbose {
eprintln!(
"info: skipping unlabeled sandbox container {}",
container_id
);
}
continue;
}
let profile_cache = cache_dir.join(format!("{}-profiles", container_id));
if container_has_profile_label(&item) {
continue;
}
if profile_cache.exists() {
continue;
}
let running = container_status_from_item(&item)
.map(|s| s.eq_ignore_ascii_case("running"))
.unwrap_or(false);
if running {
if verbose {
eprintln!(
"info: skipping running untracked sandbox container {}",
container_id
);
}
continue;
}
if verbose {
eprintln!(
"warning: detected unlabeled sandbox container {} without profile cache; skipping auto-delete for safety",
container_id
);
}
}
Ok(())
}
pub async fn sandbox_is_running(container_id: &str) -> bool {
container_is_running(container_id).await
}
pub async fn stop_container_with_timeout(
container_id: &str,
timeout_secs: u64,
) -> Result<bool, color_eyre::Report> {
if !container_is_running(container_id).await {
return Ok(false);
}
let status = Command::new("container")
.args(["stop", "--time", &timeout_secs.to_string(), container_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to stop container '{}'",
container_id
));
}
Ok(true)
}
pub async fn stop_container_by_name(container_id: &str) -> Result<(), color_eyre::Report> {
let _ = stop_container_with_timeout(container_id, 30).await?;
Ok(())
}
async fn discover_managed_project_sandboxes() -> Vec<String> {
let Some(items) = container_list_all().await else {
return Vec::new();
};
let mut ids: Vec<String> = items
.iter()
.filter_map(|item| {
let id = container_id_from_item(item)?;
if !id.starts_with("muthr-") || id == "muthr-services" || id == "muthr-searxng" {
return None;
}
let managed = item.label("muthr.managed").is_some_and(|v| v == "true");
let owner_project = item.label("muthr.owner").is_some_and(|v| v == "project");
if managed && owner_project {
Some(id)
} else {
None
}
})
.collect();
ids.sort();
ids.dedup();
ids
}
fn validate_named_sandbox(container_id: &str) -> Result<(), color_eyre::Report> {
if !container_id.starts_with("muthr-") {
return Err(color_eyre::eyre::eyre!(
"invalid sandbox name '{}': must start with 'muthr-'",
container_id
));
}
if container_id == "muthr-services" || container_id == "muthr-searxng" {
return Err(color_eyre::eyre::eyre!(
"'{}' is a services container, not a project sandbox",
container_id
));
}
Ok(())
}
pub async fn protect_vm(vm_name: &str) -> Result<(), color_eyre::Report> {
let _ = vm_name;
Ok(())
}
pub async fn unprotect_vm(vm_name: &str) -> Result<(), color_eyre::Report> {
let _ = vm_name;
Ok(())
}
pub async fn delete_sandbox(container_id: &str, force: bool) -> Result<(), color_eyre::Report> {
delete_container(container_id, force).await
}
pub async fn run_provision_script(
container_id: &str,
script_name: &str,
engine_runtime: &str,
model_name: &str,
ctx_window: u32,
mount_point: &std::path::Path,
port: u16,
) -> Result<(), color_eyre::Report> {
run_provision_container(
container_id,
script_name,
engine_runtime,
model_name,
ctx_window,
mount_point,
port,
)
.await
}
async fn compute_specs_revision_hash(
script_path: &Path,
lib_dir: &Path,
) -> Result<String, color_eyre::Report> {
let mut files = vec![script_path.to_path_buf()];
files.extend(collect_regular_files_recursive(lib_dir)?);
files.sort();
let mut shasum_cmd = Command::new("shasum");
shasum_cmd.args(["-a", "256"]);
for file in &files {
shasum_cmd.arg(file);
}
let output = shasum_cmd.output().await?;
if !output.status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to compute provision hash for script and library"
));
}
let mut second_pass = Command::new("shasum")
.args(["-a", "256"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = second_pass.stdin.take() {
stdin.write_all(&output.stdout).await?;
stdin.shutdown().await?;
}
let second_output = second_pass.wait_with_output().await?;
if !second_output.status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to finalize provision hash digest"
));
}
let stdout = String::from_utf8_lossy(&second_output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid shasum output"))?;
Ok(hash.to_string())
}
fn collect_regular_files_recursive(root: &Path) -> Result<Vec<PathBuf>, color_eyre::Report> {
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
Ok(files)
}
async fn resolve_active_model_and_ctx(
home: &str,
server_port: u16,
engine_name: &str,
) -> (String, u32) {
let default_model = crate::config::load()
.ok()
.and_then(|cfg| cfg.default_engine_profile)
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| crate::engine::default_model_for_runtime(engine_name).to_string());
let preset_ctx_hint = crate::model::DEFAULT_CONTEXT_WINDOW;
let active_model_file = PathBuf::from(home).join(format!(
".cache/muthr/{}",
crate::engine::active_preset_file_for_runtime(engine_name)
));
let active_model_from_file = fs::read_to_string(&active_model_file)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let fallback_model = active_model_from_file.unwrap_or_else(|| default_model.clone());
let parsed_model = match crate::model::poll_loaded_model("127.0.0.1", server_port, 20, 1.0)
.await
{
Ok(model) => model,
Err(err) => {
eprintln!(
"warning: failed to poll loaded model from inference server, using fallback: {}",
err
);
fallback_model.clone()
}
};
let sanitized_model = if parsed_model.contains('/') || parsed_model.contains('\\') {
std::path::Path::new(&parsed_model)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&parsed_model)
.to_string()
} else {
parsed_model.clone()
};
let active_model = if sanitized_model.trim().is_empty() {
fallback_model
} else {
sanitized_model
};
let model_ctx_window = crate::model::get_ctx_window("127.0.0.1", server_port)
.await
.unwrap_or(preset_ctx_hint);
let ctx_window = std::cmp::max(model_ctx_window, preset_ctx_hint);
(active_model, ctx_window)
}
fn parse_gateway_from_route_output(output: &str) -> Option<String> {
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if let Some(idx) = tokens.iter().position(|t| *t == "via")
&& let Some(candidate) = tokens.get(idx + 1)
&& !candidate.trim().is_empty()
{
return Some(candidate.trim().to_string());
}
if let Some(idx) = tokens.iter().position(|t| *t == "gateway:")
&& let Some(candidate) = tokens.get(idx + 1)
&& !candidate.trim().is_empty()
{
return Some(candidate.trim().to_string());
}
}
None
}
async fn discover_container_gateway() -> Option<String> {
let output = Command::new("container")
.args(["network", "list", "--format", "json"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let entries = serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).ok()?;
for entry in entries {
let candidates = [
entry.get("gateway"),
entry.get("Gateway"),
entry.get("status").and_then(|v| v.get("ipv4Gateway")),
entry.get("status").and_then(|v| v.get("ipv6Gateway")),
entry.get("status").and_then(|v| v.get("gateway")),
entry.get("Status").and_then(|v| v.get("IPv4Gateway")),
entry.get("Status").and_then(|v| v.get("IPv6Gateway")),
entry.get("Status").and_then(|v| v.get("Gateway")),
entry.get("ipam").and_then(|v| v.get("gateway")),
entry.get("IPAM").and_then(|v| v.get("Gateway")),
entry
.get("subnets")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("gateway")),
entry
.get("Subnets")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("Gateway")),
];
for candidate in candidates.into_iter().flatten() {
if let Some(ip) = candidate.as_str()
&& !ip.trim().is_empty()
{
return Some(ip.trim().to_string());
}
}
}
None
}
async fn discover_gateway_from_container(container_id: &str) -> Option<String> {
let output = Command::new("container")
.args([
"exec",
container_id,
"sh",
"-lc",
"ip route show default 2>/dev/null || route -n get default 2>/dev/null || true",
])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_gateway_from_route_output(&stdout)
}
async fn resolve_container_host_gateway(container_id: &str) -> Result<String, color_eyre::Report> {
let cfg = crate::config::load()?;
let host = if let Some(configured) = cfg.container_host_gateway {
configured.trim().to_string()
} else if let Ok(env_host) = std::env::var("MUTHR_CONTAINER_HOST_GATEWAY") {
env_host.trim().to_string()
} else if let Some(discovered) = discover_container_gateway().await {
discovered
} else {
discover_gateway_from_container(container_id)
.await
.ok_or_else(|| {
color_eyre::eyre::eyre!(
"could not determine container host gateway; set MUTHR_CONTAINER_HOST_GATEWAY or container_host_gateway in config"
)
})?
};
if host.is_empty() {
return Err(color_eyre::eyre::eyre!(
"container host gateway resolved to an empty value"
));
}
Ok(host)
}
async fn backend_openai_url(container_id: &str, port: u16) -> Result<String, color_eyre::Report> {
let host = resolve_container_host_gateway(container_id).await?;
Ok(format!("http://{}:{}/v1", host, port))
}
async fn runtime_env_contract(
container_id: &str,
port: u16,
engine_runtime: &str,
model_name: &str,
) -> Result<Vec<(String, String)>, color_eyre::Report> {
let host_gateway = resolve_container_host_gateway(container_id).await?;
let inference_url = backend_openai_url(container_id, port).await?;
let mcp_bridge_url = format!("http://{}:18765", host_gateway);
let searxng_url = format!("http://{}:18766", host_gateway);
Ok(vec![
("MUTHR_INFERENCE_URL".to_string(), inference_url.clone()),
("MUTHR_OPENAI_URL".to_string(), inference_url),
("MUTHR_MCP_BRIDGE_URL".to_string(), mcp_bridge_url),
("MUTHR_SEARXNG_URL".to_string(), searxng_url),
("MUTHR_MODEL_NAME".to_string(), model_name.to_string()),
(
"MUTHR_ENGINE_RUNTIME".to_string(),
engine_runtime.to_string(),
),
])
}
pub async fn start(
profile_name: String,
audit_log: Option<String>,
) -> Result<(), color_eyre::Report> {
start_container(profile_name, audit_log).await
}
pub async fn build_golden_image(profile_name: String) -> Result<(), color_eyre::Report> {
if profile_name.trim().is_empty() {
return Err(color_eyre::eyre::eyre!("profile cannot be empty"));
}
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
let sanitized = sanitize_project_name(&profile_name)
.ok_or_else(|| color_eyre::eyre::eyre!("invalid profile name"))?;
let builder_id = format!("muthr-builder-{}", sanitized);
let image_tag = golden_image_tag(&profile_name);
let cfg = crate::config::load()?;
let server_port = cfg.server_port.unwrap_or(8080);
let engine_name = cfg.default_engine_runtime.as_deref().unwrap_or("mlxcel");
let model_name = cfg
.default_engine_profile
.unwrap_or_else(|| crate::engine::default_model_for_runtime(engine_name).to_string());
let ctx_window = 131072_u32;
let mut settings = container_profile_settings(&profile_name, Path::new("/tmp")).await?;
settings.mounts.clear();
settings.workspace_guest_path = "/workspace".to_string();
settings.network_none = false;
settings.uses_golden_image = false;
let create_args = create_args_for_settings(&builder_id, &settings);
if container_exists(&builder_id).await {
let _ = Command::new("container")
.args(["delete", "--force", &builder_id])
.status()
.await;
}
let mut cleanup_needed = false;
let temp_dir = std::env::temp_dir().join(format!("muthr-image-build-{}", sanitized));
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
std::fs::create_dir_all(&temp_dir)?;
let result: Result<(), color_eyre::Report> = async {
crate::ui::log_info(&format!("creating builder container: {}", builder_id));
let status = Command::new("container").args(&create_args).status().await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to create builder container '{}'",
builder_id
));
}
cleanup_needed = true;
let status = Command::new("container")
.args(["start", &builder_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to start builder container '{}'",
builder_id
));
}
ensure_container_runtime_baseline(&builder_id).await?;
sync_container_user_ids(&builder_id).await;
run_provision_container(
&builder_id,
&profile_name,
engine_name,
&model_name,
ctx_window,
Path::new("/workspace"),
server_port,
)
.await?;
let status = Command::new("container")
.args(["stop", &builder_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to stop builder container before export"
));
}
let rootfs_tar = temp_dir.join("rootfs.tar");
let status = Command::new("container")
.args([
"export",
"--output",
rootfs_tar.to_str().ok_or_else(|| {
color_eyre::eyre::eyre!("invalid temporary export path")
})?,
&builder_id,
])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to export builder container filesystem"
));
}
let containerfile = temp_dir.join("Containerfile");
std::fs::write(&containerfile, "FROM scratch\nADD rootfs.tar /\n")?;
let output = Command::new("container")
.args([
"build",
"--platform",
NATIVE_PLATFORM,
"--file",
containerfile
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid Containerfile path"))?,
"--tag",
&image_tag,
temp_dir
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid build context path"))?,
])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
if stderr.contains("rosetta") || stdout.contains("rosetta") {
return Err(color_eyre::eyre::eyre!(
"golden image build failed because the container backend buildkit requires Rosetta on this host, even with '{}'. This is a backend limitation; use prebuilt arm64 images or install Rosetta for buildkit",
NATIVE_PLATFORM
));
}
return Err(color_eyre::eyre::eyre!(
"failed to build golden image '{}'",
image_tag
));
}
crate::ui::log_info(&format!("built golden image {}", image_tag));
Ok(())
}
.await;
if cleanup_needed {
let _ = Command::new("container")
.args(["delete", "--force", &builder_id])
.status()
.await;
}
let _ = std::fs::remove_dir_all(&temp_dir);
result
}
pub async fn stop(names: Vec<String>, all: bool) -> Result<(), color_eyre::Report> {
if all {
let sandboxes = discover_managed_project_sandboxes().await;
if sandboxes.is_empty() {
crate::ui::log_info("no managed sandbox containers found");
return Ok(());
}
for id in sandboxes {
if !container_is_running(&id).await {
crate::ui::log_info(&format!("already stopped {}", id));
continue;
}
let status = Command::new("container")
.args(["stop", &id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!("failed to stop container '{}'", id));
}
crate::ui::log_info(&format!("stopped {}", id));
}
return Ok(());
}
if !names.is_empty() {
let mut unique = names;
unique.sort();
unique.dedup();
for id in unique {
validate_named_sandbox(&id)?;
if !container_exists(&id).await {
eprintln!("warning: sandbox '{}' does not exist", id);
continue;
}
if !container_is_running(&id).await {
crate::ui::log_info(&format!("already stopped {}", id));
continue;
}
let status = Command::new("container")
.args(["stop", &id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!("failed to stop container '{}'", id));
}
crate::ui::log_info(&format!("stopped {}", id));
}
return Ok(());
}
stop_container().await
}
pub async fn ls(out_fmt: crate::OutputFormat, quiet: bool) -> Result<(), color_eyre::Report> {
list_containers(out_fmt, quiet).await
}
async fn start_container(
profile_name: String,
audit_log: Option<String>,
) -> Result<(), color_eyre::Report> {
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
let (container_id, project_root, workdir) = resolve_workspace_context()?;
crate::ui::log_info(&format!("target: {}", container_id));
let audit = resolve_audit_logger(audit_log, &container_id)?;
let mut settings = container_profile_settings(&profile_name, &project_root).await?;
let needs_profile_provision = profile_name != "base" && !settings.uses_golden_image;
let deferred_network_isolation = settings.network_none && needs_profile_provision;
if deferred_network_isolation {
settings.network_none = false;
}
let exists = container_exists(&container_id).await;
if !exists {
crate::ui::log_info(&format!("creating container {}", container_id));
let args = create_args_for_settings(&container_id, &settings);
let status = Command::new("container").args(&args).status().await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to create container '{}' (run 'container system start' if the service is not running)",
container_id
));
}
}
if !container_is_running(&container_id).await {
let status = Command::new("container")
.args(["start", &container_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to start container '{}'",
container_id
));
}
}
ensure_container_runtime_baseline(&container_id).await?;
let guest_workdir = match workdir.strip_prefix(&project_root) {
Ok(relative_workdir) => {
PathBuf::from(&settings.workspace_guest_path).join(relative_workdir)
}
Err(_) => PathBuf::from(&settings.workspace_guest_path),
};
let guest_workdir_str = guest_workdir
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("guest workdir contains invalid UTF-8"))?;
let home = std::env::var("HOME")?;
let cfg = crate::config::load()?;
let server_port = cfg.server_port.unwrap_or(8080);
let engine_name = cfg.default_engine_runtime.as_deref().unwrap_or("mlxcel");
let (active_model, ctx_window) =
resolve_active_model_and_ctx(&home, server_port, engine_name).await;
let runtime_envs =
runtime_env_contract(&container_id, server_port, engine_name, &active_model).await?;
if profile_name != "base" {
let cache_dir = PathBuf::from(&home)
.join(".cache/muthr")
.join(format!("{}-profiles", container_id));
crate::ui::log_info(&format!("applying profile: {}", profile_name));
let provision_result = if needs_profile_provision {
run_provision_container(
&container_id,
&profile_name,
engine_name,
&active_model,
ctx_window,
Path::new(&settings.workspace_guest_path),
server_port,
)
.await
} else {
eprintln!(
"info: using pre-baked image {} for profile {}",
settings.image, profile_name
);
Ok(())
};
if deferred_network_isolation {
crate::ui::log_info("sealing sandbox boundary -> cutting off network access");
update_container_network_mode(&container_id, "none").await?;
}
provision_result?;
let existing_profiles = fs::read_to_string(&cache_dir).await.unwrap_or_default();
if !existing_profiles.lines().any(|l| l.trim() == profile_name) {
let mut existing = existing_profiles;
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&profile_name);
existing.push('\n');
let Some(cache_parent) = cache_dir.parent() else {
return Err(color_eyre::eyre::eyre!("invalid profile cache path"));
};
fs::create_dir_all(cache_parent).await?;
fs::write(&cache_dir, existing).await?;
}
mark_container_profile(&container_id, &profile_name).await;
crate::ui::log_info("launching workspace context");
let target_args = match profile_name.as_str() {
"opencode" => vec![
"bash",
"-lc",
"export PATH=\"$HOME/.opencode/bin:$HOME/.local/bin:$PATH\"; exec opencode",
],
_ => vec!["sh"],
};
let requires_tty = profile_name == "opencode";
run_container_session(
&container_id,
guest_workdir_str,
&runtime_envs,
&target_args,
requires_tty,
audit.as_ref(),
)?;
return Ok(());
}
let cache_dir = PathBuf::from(&home)
.join(".cache/muthr")
.join(format!("{}-profiles", container_id));
if !cache_dir.exists() {
let Some(cache_parent) = cache_dir.parent() else {
return Err(color_eyre::eyre::eyre!("invalid profile cache path"));
};
fs::create_dir_all(cache_parent).await?;
fs::write(&cache_dir, "base\n").await?;
}
mark_container_profile(&container_id, "base").await;
crate::ui::log_info("container ready, launching shell");
run_container_session(
&container_id,
guest_workdir_str,
&runtime_envs,
&["sh"],
false,
audit.as_ref(),
)
.map_err(|_| color_eyre::eyre::eyre!("shell session exited with error"))?;
Ok(())
}
async fn run_provision_container(
container_id: &str,
script_name: &str,
engine_runtime: &str,
model_name: &str,
ctx_window: u32,
mount_point: &std::path::Path,
port: u16,
) -> Result<(), color_eyre::Report> {
fn validate_script_name(script_name: &str) -> Result<(), color_eyre::Report> {
if script_name.is_empty() {
return Err(color_eyre::eyre::eyre!("invalid profile name: empty"));
}
if script_name
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
{
return Err(color_eyre::eyre::eyre!(
"invalid profile name: unsupported characters"
));
}
Ok(())
}
fn validate_engine_runtime(engine_runtime: &str) -> Result<(), color_eyre::Report> {
if engine_runtime.is_empty() {
return Err(color_eyre::eyre::eyre!("invalid runtime: empty"));
}
if engine_runtime
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
{
return Err(color_eyre::eyre::eyre!(
"invalid runtime: unsupported characters"
));
}
Ok(())
}
validate_script_name(script_name)?;
validate_engine_runtime(engine_runtime)?;
let home = std::env::var("HOME")?;
let host_script = PathBuf::from(&home).join(format!(
".config/muthr/sandbox.d/container/provision.d/{}.sh",
script_name
));
if !host_script.exists() {
return Err(color_eyre::eyre::eyre!(
"provision script not found: {:?}",
host_script
));
}
let mount_str = mount_point
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("workspace mount path contains invalid UTF-8"))?;
fn validate_env_value(value: &str, field: &str) -> Result<(), color_eyre::Report> {
if value.contains('\0') || value.contains('\n') || value.contains('\r') {
return Err(color_eyre::eyre::eyre!(
"invalid value for {}: contains control characters",
field
));
}
Ok(())
}
fn validate_model_name(model_name: &str) -> Result<(), color_eyre::Report> {
if model_name.is_empty() {
return Err(color_eyre::eyre::eyre!("invalid model name: empty"));
}
if model_name
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/')))
{
return Err(color_eyre::eyre::eyre!(
"invalid model name: unsupported characters"
));
}
Ok(())
}
crate::ui::log_info(&format!("provisioning: {}", script_name));
let runtime_envs = runtime_env_contract(container_id, port, engine_runtime, model_name).await?;
let mut env_map = std::collections::HashMap::new();
for (k, v) in &runtime_envs {
env_map.insert(k.as_str(), v.as_str());
}
let openai_url = env_map
.get("MUTHR_OPENAI_URL")
.copied()
.ok_or_else(|| color_eyre::eyre::eyre!("missing MUTHR_OPENAI_URL env value"))?;
let host_gateway = resolve_container_host_gateway(container_id).await?;
let searxng_url = env_map
.get("MUTHR_SEARXNG_URL")
.copied()
.ok_or_else(|| color_eyre::eyre::eyre!("missing MUTHR_SEARXNG_URL env value"))?;
let inference_url = env_map
.get("MUTHR_INFERENCE_URL")
.copied()
.ok_or_else(|| color_eyre::eyre::eyre!("missing MUTHR_INFERENCE_URL env value"))?;
let mcp_bridge_url = env_map
.get("MUTHR_MCP_BRIDGE_URL")
.copied()
.ok_or_else(|| color_eyre::eyre::eyre!("missing MUTHR_MCP_BRIDGE_URL env value"))?;
validate_env_value(openai_url, "MUTHR_OPENAI_URL")?;
validate_env_value(inference_url, "MUTHR_INFERENCE_URL")?;
validate_env_value(mcp_bridge_url, "MUTHR_MCP_BRIDGE_URL")?;
validate_env_value(&host_gateway, "MUTHR_CONTAINER_HOST_GATEWAY")?;
validate_env_value(searxng_url, "MUTHR_SEARXNG_URL")?;
validate_env_value(model_name, "MUTHR_MODEL_NAME")?;
validate_model_name(model_name)?;
validate_env_value(mount_str, "MUTHR_WORKSPACE_MOUNT")?;
validate_env_value(engine_runtime, "MUTHR_ENGINE_RUNTIME")?;
let host_lib_dir = host_script
.parent()
.ok_or_else(|| color_eyre::eyre::eyre!("invalid provision script path"))?
.join("lib");
if !host_lib_dir.is_dir() {
return Err(color_eyre::eyre::eyre!(
"provision library directory not found: {:?}",
host_lib_dir
));
}
let specs_rev = compute_specs_revision_hash(&host_script, &host_lib_dir).await?;
let guest_provision_dir = format!("/tmp/muthr-provision-{}", script_name);
let guest_script_path = format!("{}/{}.sh", guest_provision_dir, script_name);
let mkdir_status = Command::new("container")
.args(["exec", container_id, "mkdir", "-p", &guest_provision_dir])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !mkdir_status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to prepare guest provision directory"
));
}
let copy_script_status = Command::new("container")
.args([
"copy",
host_script.to_str().ok_or_else(|| {
color_eyre::eyre::eyre!("provision script path contains invalid UTF-8")
})?,
&format!("{}:{}", container_id, guest_script_path),
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !copy_script_status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to copy provision script into container"
));
}
let copy_lib_status = Command::new("container")
.args([
"copy",
host_lib_dir.to_str().ok_or_else(|| {
color_eyre::eyre::eyre!("provision lib path contains invalid UTF-8")
})?,
&format!("{}:{}", container_id, guest_provision_dir),
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !copy_lib_status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to copy provision library into container"
));
}
let mut child = Command::new("container")
.args(["exec", "--workdir", "/tmp"])
.arg("--user")
.arg("muthr")
.arg("--env")
.arg(format!("MUTHR_OPENAI_URL={}", openai_url))
.arg("--env")
.arg(format!("MUTHR_INFERENCE_URL={}", inference_url))
.arg("--env")
.arg(format!("MUTHR_MCP_BRIDGE_URL={}", mcp_bridge_url))
.arg("--env")
.arg(format!("MUTHR_MODEL_NAME={}", model_name))
.arg("--env")
.arg(format!("MUTHR_CTX_WINDOW={}", ctx_window))
.arg("--env")
.arg(format!("MUTHR_WORKSPACE_MOUNT={}", mount_str))
.arg("--env")
.arg(format!("MUTHR_SPECS_REV={}", specs_rev))
.arg("--env")
.arg(format!("MUTHR_CONTAINER_HOST_GATEWAY={}", host_gateway))
.arg("--env")
.arg(format!("MUTHR_SEARXNG_URL={}", searxng_url))
.arg("--env")
.arg(format!("MUTHR_ENGINE_RUNTIME={}", engine_runtime))
.arg(container_id)
.arg("bash")
.arg(&guest_script_path)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
let status = child.wait().await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!("provision failed: {}", script_name));
}
crate::ui::log_info(&format!("provisioned: {}", script_name));
Ok(())
}
async fn ensure_container_runtime_baseline(container_id: &str) -> Result<(), color_eyre::Report> {
let marker = "/var/lib/muthr/container-baseline-v2";
let has_marker = Command::new("container")
.args([
"exec",
container_id,
"sh",
"-lc",
&format!("test -f {}", marker),
])
.status()
.await?;
if has_marker.success() {
return Ok(());
}
let install_status = Command::new("container")
.args([
"exec",
container_id,
"sh",
"-lc",
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bash curl ca-certificates sudo git nodejs npm && if ! id -u muthr >/dev/null 2>&1; then useradd -m -s /bin/bash muthr; fi && usermod -aG sudo muthr && install -d -m 755 /etc/sudoers.d && printf 'muthr ALL=(ALL) NOPASSWD:ALL\\n' >/etc/sudoers.d/muthr && chmod 0440 /etc/sudoers.d/muthr",
])
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !install_status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to install container baseline dependencies"
));
}
let marker_status = Command::new("container")
.args([
"exec",
container_id,
"sh",
"-lc",
"mkdir -p /var/lib/muthr && touch /var/lib/muthr/container-baseline-v2",
])
.status()
.await?;
if !marker_status.success() {
eprintln!("warning: failed to persist container baseline marker");
}
Ok(())
}
async fn stop_container() -> Result<(), color_eyre::Report> {
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
let (container_id, _, _) = resolve_workspace_context()?;
if !container_exists(&container_id).await {
return Ok(());
}
if !container_is_running(&container_id).await {
crate::ui::log_info(&format!("already stopped {}", container_id));
return Ok(());
}
let status = Command::new("container")
.args(["stop", &container_id])
.status()
.await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to stop container '{}'",
container_id
));
}
crate::ui::log_info(&format!("stopped {}", container_id));
Ok(())
}
async fn delete_container(container_id: &str, force: bool) -> Result<(), color_eyre::Report> {
let _lock =
crate::lifecycle::acquire("container-lifecycle", std::time::Duration::from_secs(20))
.await?;
delete_container_impl(container_id, force).await
}
async fn delete_container_impl(container_id: &str, force: bool) -> Result<(), color_eyre::Report> {
if !force && !std::io::stdout().is_terminal() {
eprintln!("error: terminal required for deletion, use --force to skip");
std::process::exit(77);
}
if !container_exists(container_id).await {
return Ok(());
}
if container_is_running(container_id).await && !force {
let stop_status = Command::new("container")
.args(["stop", container_id])
.status()
.await?;
if !stop_status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to stop container '{}' before deletion",
container_id
));
}
}
let mut args: Vec<&str> = vec!["delete"];
if force {
args.push("--force");
}
args.push(container_id);
let status = Command::new("container").args(args).status().await?;
if !status.success() {
return Err(color_eyre::eyre::eyre!(
"failed to delete container '{}'",
container_id
));
}
crate::ui::log_info(&format!("deleted {}", container_id));
Ok(())
}
async fn list_containers(
out_fmt: crate::OutputFormat,
quiet: bool,
) -> Result<(), color_eyre::Report> {
let Some(items) = container_list_all().await else {
return Err(color_eyre::eyre::eyre!(
"failed to list containers (run 'container system start' if the service is not running)"
));
};
let mut entries: Vec<(String, String, String)> = items
.iter()
.filter_map(|item| {
let id = container_id_from_item(item)?;
if !id.starts_with("muthr-") || id == "muthr-services" || id == "muthr-searxng" {
return None;
}
let status = container_status_from_item(item).unwrap_or_else(|| "unknown".to_string());
Some((id.clone(), status, "/workspace".to_string()))
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
if entries.is_empty() {
if out_fmt == crate::OutputFormat::Json {
println!("[]");
}
return Ok(());
}
if quiet {
for (name, _, _) in &entries {
println!("{}", name);
}
return Ok(());
}
if out_fmt == crate::OutputFormat::Json {
let payload: Vec<serde_json::Value> = entries
.iter()
.map(|(name, status, mount)| {
serde_json::json!({"name": name, "status": status, "mount": mount})
})
.collect();
println!("{}", serde_json::to_string(&payload)?);
return Ok(());
}
if out_fmt == crate::OutputFormat::Ndjson {
for (name, status, mount) in &entries {
let payload = serde_json::json!({"name": name, "status": status, "mount": mount});
println!("{}", serde_json::to_string(&payload)?);
}
return Ok(());
}
for (name, status, mount) in &entries {
println!("{:<30} {} mount: {}", name, status, mount);
}
Ok(())
}