use std::collections::HashMap;
use std::net::TcpListener;
use std::process::Command;
pub fn stakpak_agent_image() -> String {
std::env::var("STAKPAK_AGENT_IMAGE")
.unwrap_or_else(|_| format!("ghcr.io/stakpak/agent:v{}", env!("CARGO_PKG_VERSION")))
}
pub fn agent_knowledge_store_path() -> &'static str {
"/home/agent/.stakpak/knowledge"
}
pub fn volume_host_part(vol: &str) -> &str {
vol.split(':').next().unwrap_or(vol)
}
pub fn volume_container_part(vol: &str) -> &str {
vol.split(':').nth(1).unwrap_or(vol)
}
pub fn stakpak_agent_default_mounts() -> Vec<String> {
vec![
"~/.stakpak/config.toml:/home/agent/.stakpak/config.toml:ro".to_string(),
"~/.stakpak/auth.toml:/home/agent/.stakpak/auth.toml:ro".to_string(),
"~/.stakpak/data/local.db:/home/agent/.stakpak/data/local.db".to_string(),
format!("~/.stakpak/knowledge:{}", agent_knowledge_store_path()),
"~/.agent-board/data.db:/home/agent/.agent-board/data.db".to_string(),
"./:/agent:ro".to_string(),
"./.stakpak:/agent/.stakpak".to_string(),
"~/.aws/config:/home/agent/.aws/config:ro".to_string(),
"~/.aws/credentials:/home/agent/.aws/credentials:ro".to_string(),
"~/.aws/sso:/home/agent/.aws/sso".to_string(),
"~/.aws/cli:/home/agent/.aws/cli".to_string(),
"~/.config/gcloud/active_config:/home/agent/.config/gcloud/active_config:ro".to_string(),
"~/.config/gcloud/configurations:/home/agent/.config/gcloud/configurations:ro".to_string(),
"~/.config/gcloud/application_default_credentials.json:/home/agent/.config/gcloud/application_default_credentials.json:ro".to_string(),
"~/.config/gcloud/credentials.db:/home/agent/.config/gcloud/credentials.db:ro".to_string(),
"~/.config/gcloud/access_tokens.db:/home/agent/.config/gcloud/access_tokens.db:ro".to_string(),
"~/.config/gcloud/logs:/home/agent/.config/gcloud/logs".to_string(),
"~/.config/gcloud/cache:/home/agent/.config/gcloud/cache".to_string(),
"~/.azure/config:/home/agent/.azure/config:ro".to_string(),
"~/.azure/clouds.config:/home/agent/.azure/clouds.config:ro".to_string(),
"~/.azure/azureProfile.json:/home/agent/.azure/azureProfile.json:ro".to_string(),
"~/.azure/msal_token_cache.json:/home/agent/.azure/msal_token_cache.json".to_string(),
"~/.azure/msal_http_cache.bin:/home/agent/.azure/msal_http_cache.bin".to_string(),
"~/.azure/logs:/home/agent/.azure/logs".to_string(),
"~/.digitalocean:/home/agent/.digitalocean:ro".to_string(),
"~/.kube:/home/agent/.kube:ro".to_string(),
"~/.ssh:/home/agent/.ssh:ro".to_string(),
"stakpak-aqua-cache:/home/agent/.local/share/aquaproj-aqua".to_string(),
]
}
pub fn resolve_ak_store_for_sandbox() -> Result<Option<std::path::PathBuf>, String> {
let raw = match std::env::var_os("AK_STORE") {
Some(v) => v,
None => return Ok(None),
};
let raw_str = raw.to_string_lossy().to_string();
if raw_str.is_empty() {
return Ok(None);
}
let expanded = if let Some(rest) = raw_str.strip_prefix("~/") {
let home = std::env::var("HOME")
.map_err(|_| format!("AK_STORE='{raw_str}' uses '~' but $HOME is not set"))?;
std::path::PathBuf::from(home).join(rest)
} else if raw_str == "~" {
std::path::PathBuf::from(
std::env::var("HOME")
.map_err(|_| format!("AK_STORE='{raw_str}' uses '~' but $HOME is not set"))?,
)
} else {
std::path::PathBuf::from(&raw_str)
};
std::fs::create_dir_all(&expanded).map_err(|e| {
format!(
"AK_STORE='{raw_str}' could not be created at {}: {e}",
expanded.display()
)
})?;
let canonical = std::fs::canonicalize(&expanded).map_err(|e| {
format!(
"AK_STORE='{raw_str}' could not be resolved to an absolute path ({}): {e}",
expanded.display()
)
})?;
Ok(Some(canonical))
}
pub fn expand_volume_path(volume: &str) -> String {
if (volume.starts_with("~/") || volume.starts_with("~:"))
&& let Ok(home_dir) = std::env::var("HOME")
{
return volume.replacen("~", &home_dir, 1);
}
volume.to_string()
}
pub fn is_named_volume(host_part: &str) -> bool {
!host_part.starts_with('/')
&& !host_part.starts_with('.')
&& !host_part.starts_with('~')
&& !host_part.contains('/')
}
pub fn warden_ak_store_args(host_knowledge_root: Option<&std::path::Path>) -> Vec<String> {
match host_knowledge_root {
Some(host_path) => {
let target = agent_knowledge_store_path();
vec![
"--volume".to_string(),
format!("{}:{target}", host_path.display()),
"--env".to_string(),
format!("AK_STORE={target}"),
]
}
None => Vec::new(),
}
}
pub fn ensure_named_volumes_exist() {
for vol in stakpak_agent_default_mounts() {
let host_part = volume_host_part(&vol);
if is_named_volume(host_part) {
let _ = Command::new("docker")
.args(["volume", "create", host_part])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
}
#[derive(Debug, Clone)]
pub struct ContainerConfig {
pub image: String,
pub env_vars: HashMap<String, String>,
pub ports: Vec<String>, pub extra_hosts: Vec<String>, pub volumes: Vec<String>, }
pub fn find_available_port() -> Option<u16> {
match TcpListener::bind("0.0.0.0:0") {
Ok(listener) => listener.local_addr().ok().map(|addr| addr.port()),
Err(_) => None,
}
}
pub fn is_docker_available() -> bool {
Command::new("docker")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn image_exists_locally(image: &str) -> Result<bool, String> {
let output = Command::new("docker")
.args(["images", "-q", image])
.output()
.map_err(|e| format!("Failed to execute docker images command: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(!stdout.is_empty())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!("Docker images command failed: {}", stderr))
}
}
pub const WARDEN_PLATFORM: &str = "linux/amd64";
pub fn warden_image_exists_locally(image: &str) -> bool {
Command::new("docker")
.args(["image", "inspect", "--platform", WARDEN_PLATFORM, image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn pull_warden_image(image: &str) -> Result<(), String> {
let status = Command::new("docker")
.args(["pull", "--platform", WARDEN_PLATFORM, image])
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(|e| format!("Failed to run docker pull: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!(
"Failed to pull image '{image}' for platform {WARDEN_PLATFORM}. \
Check your network connection and that the image exists."
))
}
}
pub fn run_container_detached(config: ContainerConfig) -> Result<String, String> {
let mut cmd = Command::new("docker");
cmd.arg("run").arg("-d").arg("--rm");
for port_mapping in &config.ports {
cmd.arg("-p").arg(port_mapping);
}
for (key, value) in &config.env_vars {
cmd.arg("-e").arg(format!("{}={}", key, value));
}
for host_mapping in &config.extra_hosts {
cmd.arg("--add-host").arg(host_mapping);
}
for volume_mapping in &config.volumes {
cmd.arg("-v").arg(volume_mapping);
}
cmd.arg(&config.image);
let output = cmd
.output()
.map_err(|e| format!("Failed to execute docker command: {}", e))?;
if output.status.success() {
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(container_id)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!("Docker command failed: {}", stderr))
}
}
pub fn stop_container(container_id: &str) -> Result<(), String> {
let output = Command::new("docker")
.arg("stop")
.arg(container_id)
.output()
.map_err(|e| format!("Failed to execute docker stop: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such container") {
Ok(())
} else {
Err(format!("Failed to stop container: {}", stderr))
}
}
}
pub fn remove_container(
container_id: &str,
force: bool,
remove_volumes: bool,
) -> Result<(), String> {
let mut cmd = Command::new("docker");
cmd.arg("rm");
if force {
cmd.arg("-f");
}
if remove_volumes {
cmd.arg("-v");
}
cmd.arg(container_id);
let output = cmd
.output()
.map_err(|e| format!("Failed to execute docker rm: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such container") {
Ok(())
} else {
Err(format!("Failed to remove container: {}", stderr))
}
}
}
pub fn get_container_host_port(container_id: &str, container_port: u16) -> Result<u16, String> {
let output = Command::new("docker")
.arg("port")
.arg(container_id)
.arg(container_port.to_string())
.output()
.map_err(|e| format!("Failed to get container port: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let port = stdout.split(':').next_back().unwrap_or("");
Ok(port.parse().unwrap())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!("Failed to get container port: {}", stderr))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn warden_ak_store_args_empty_when_no_override() {
assert!(warden_ak_store_args(None).is_empty());
}
#[test]
fn warden_ak_store_args_emits_volume_and_env_when_override_set() {
let host = std::path::PathBuf::from("/tmp/custom-ak");
let args = warden_ak_store_args(Some(&host));
let target = agent_knowledge_store_path();
assert_eq!(
args,
vec![
"--volume".to_string(),
format!("/tmp/custom-ak:{target}"),
"--env".to_string(),
format!("AK_STORE={target}"),
]
);
}
#[test]
fn volume_part_helpers_split_at_first_colon() {
assert_eq!(volume_host_part("./:/agent:ro"), "./");
assert_eq!(volume_container_part("./:/agent:ro"), "/agent");
assert_eq!(volume_host_part("named-vol"), "named-vol");
assert_eq!(volume_container_part("named-vol"), "named-vol");
}
#[test]
fn knowledge_store_mount_present_and_rw() {
let mounts = stakpak_agent_default_mounts();
let suffix = format!(":{}", agent_knowledge_store_path());
let entry = mounts
.iter()
.find(|v| v.ends_with(&suffix))
.unwrap_or_else(|| panic!("knowledge store mount missing: {mounts:?}"));
assert!(
entry.starts_with("~/.stakpak/knowledge:"),
"host side should be ~/.stakpak/knowledge: {entry}"
);
assert!(
!entry.ends_with(":ro"),
"knowledge store mount must be RW (no :ro suffix): {entry}"
);
}
#[test]
fn resolve_ak_store_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var("AK_STORE");
}
assert_eq!(resolve_ak_store_for_sandbox().unwrap(), None);
}
#[test]
fn resolve_ak_store_expands_tilde() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let store_subdir = "ak-store-tilde-test";
let expected = tmp.path().join(store_subdir);
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("AK_STORE", format!("~/{store_subdir}"));
}
let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
let expected_canonical = std::fs::canonicalize(&expected).unwrap();
assert_eq!(resolved, expected_canonical);
unsafe {
std::env::remove_var("AK_STORE");
}
}
#[test]
fn resolve_ak_store_canonicalizes_relative_path() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let store_dir = tmp.path().join("relstore");
std::fs::create_dir_all(&store_dir).unwrap();
unsafe {
std::env::set_var("AK_STORE", store_dir.to_str().unwrap());
}
let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
assert!(
resolved.is_absolute(),
"resolved path must be absolute: {resolved:?}"
);
let expected_canonical = std::fs::canonicalize(&store_dir).unwrap();
assert_eq!(resolved, expected_canonical);
unsafe {
std::env::remove_var("AK_STORE");
}
}
#[test]
fn resolve_ak_store_creates_missing_directory() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let store_dir = tmp.path().join("does-not-exist-yet");
assert!(!store_dir.exists());
unsafe {
std::env::set_var("AK_STORE", store_dir.to_str().unwrap());
}
let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
assert!(
store_dir.exists(),
"AK_STORE target should be created on resolve"
);
assert_eq!(resolved, std::fs::canonicalize(&store_dir).unwrap());
unsafe {
std::env::remove_var("AK_STORE");
}
}
#[test]
fn resolve_ak_store_fails_when_parent_unreachable() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let bad = blocker.join("nested-store");
unsafe {
std::env::set_var("AK_STORE", bad.to_str().unwrap());
}
let err = resolve_ak_store_for_sandbox().unwrap_err();
assert!(
err.contains("AK_STORE="),
"error should name the offending env value: {err}"
);
unsafe {
std::env::remove_var("AK_STORE");
}
}
}