Skip to main content

stakpak_shared/
container.rs

1use std::collections::HashMap;
2use std::net::TcpListener;
3use std::process::Command;
4
5// ── Stakpak agent container constants ──────────────────────────────────────
6
7/// The container image used for sandboxed agent sessions.
8/// Override at runtime with STAKPAK_AGENT_IMAGE env var for local testing.
9pub fn stakpak_agent_image() -> String {
10    std::env::var("STAKPAK_AGENT_IMAGE")
11        .unwrap_or_else(|_| format!("ghcr.io/stakpak/agent:v{}", env!("CARGO_PKG_VERSION")))
12}
13
14/// Canonical path of the AK knowledge store inside the agent container.
15/// Both the default mount and the `AK_STORE`-override mount land here so the
16/// in-container `ak` resolves to the same path regardless of host layout.
17pub fn agent_knowledge_store_path() -> &'static str {
18    "/home/agent/.stakpak/knowledge"
19}
20
21/// Host-side part of a volume mount (everything before the first `:`).
22pub fn volume_host_part(vol: &str) -> &str {
23    vol.split(':').next().unwrap_or(vol)
24}
25
26/// Container-side path of a volume mount (segment after the first `:`).
27/// Falls back to the whole string when the format is unexpected.
28pub fn volume_container_part(vol: &str) -> &str {
29    vol.split(':').nth(1).unwrap_or(vol)
30}
31
32/// Default volume mounts for the stakpak agent container.
33///
34/// Single source of truth for every path the container needs.
35/// Used by `WardenConfig::readonly_profile()`, `prepare_volumes()`,
36/// and `build_dynamic_subagent_command()`.
37pub fn stakpak_agent_default_mounts() -> Vec<String> {
38    vec![
39        // Stakpak config & credentials
40        "~/.stakpak/config.toml:/home/agent/.stakpak/config.toml:ro".to_string(),
41        "~/.stakpak/auth.toml:/home/agent/.stakpak/auth.toml:ro".to_string(),
42        "~/.stakpak/data/local.db:/home/agent/.stakpak/data/local.db".to_string(),
43        // AK knowledge store — RW so sandboxed subagents can persist entries to the host.
44        format!("~/.stakpak/knowledge:{}", agent_knowledge_store_path()),
45        "~/.agent-board/data.db:/home/agent/.agent-board/data.db".to_string(),
46        // Working directory
47        "./:/agent:ro".to_string(),
48        "./.stakpak:/agent/.stakpak".to_string(),
49        // AWS — config read-only, SSO/STS cache writable for token refresh
50        "~/.aws/config:/home/agent/.aws/config:ro".to_string(),
51        "~/.aws/credentials:/home/agent/.aws/credentials:ro".to_string(),
52        "~/.aws/sso:/home/agent/.aws/sso".to_string(),
53        "~/.aws/cli:/home/agent/.aws/cli".to_string(),
54        // GCP — credential files read-only, cache/logs/db writable for gcloud to function
55        "~/.config/gcloud/active_config:/home/agent/.config/gcloud/active_config:ro".to_string(),
56        "~/.config/gcloud/configurations:/home/agent/.config/gcloud/configurations:ro".to_string(),
57        "~/.config/gcloud/application_default_credentials.json:/home/agent/.config/gcloud/application_default_credentials.json:ro".to_string(),
58        "~/.config/gcloud/credentials.db:/home/agent/.config/gcloud/credentials.db:ro".to_string(),
59        "~/.config/gcloud/access_tokens.db:/home/agent/.config/gcloud/access_tokens.db:ro".to_string(),
60        "~/.config/gcloud/logs:/home/agent/.config/gcloud/logs".to_string(),
61        "~/.config/gcloud/cache:/home/agent/.config/gcloud/cache".to_string(),
62        // Azure — config read-only, MSAL token cache and session writable
63        "~/.azure/config:/home/agent/.azure/config:ro".to_string(),
64        "~/.azure/clouds.config:/home/agent/.azure/clouds.config:ro".to_string(),
65        "~/.azure/azureProfile.json:/home/agent/.azure/azureProfile.json:ro".to_string(),
66        "~/.azure/msal_token_cache.json:/home/agent/.azure/msal_token_cache.json".to_string(),
67        "~/.azure/msal_http_cache.bin:/home/agent/.azure/msal_http_cache.bin".to_string(),
68        "~/.azure/logs:/home/agent/.azure/logs".to_string(),
69        // DigitalOcean & Kubernetes
70        "~/.digitalocean:/home/agent/.digitalocean:ro".to_string(),
71        "~/.kube:/home/agent/.kube:ro".to_string(),
72        // SSH — config and keys read-only (useful for host aliases and remote connections)
73        "~/.ssh:/home/agent/.ssh:ro".to_string(),
74        // Aqua tool cache (named volume — persists downloaded CLIs across runs)
75        "stakpak-aqua-cache:/home/agent/.local/share/aquaproj-aqua".to_string(),
76    ]
77}
78
79/// Resolve the host-side AK knowledge store directory for a sandboxed subagent.
80///
81/// Returns:
82/// - `Ok(None)` when `AK_STORE` is unset — the caller falls back to the default
83///   mount entry from [`stakpak_agent_default_mounts`].
84/// - `Ok(Some(path))` with an absolute, canonicalized host path when `AK_STORE`
85///   is set and resolves cleanly.
86/// - `Err(_)` with a message naming the offending path when `AK_STORE` is set but
87///   cannot be canonicalized (e.g. broken symlink, missing parent).
88///
89/// Tilde-prefixed paths are expanded against `$HOME` before canonicalization so
90/// values like `AK_STORE=~/my-store` work as users expect.
91pub fn resolve_ak_store_for_sandbox() -> Result<Option<std::path::PathBuf>, String> {
92    let raw = match std::env::var_os("AK_STORE") {
93        Some(v) => v,
94        None => return Ok(None),
95    };
96
97    let raw_str = raw.to_string_lossy().to_string();
98    if raw_str.is_empty() {
99        return Ok(None);
100    }
101
102    let expanded = if let Some(rest) = raw_str.strip_prefix("~/") {
103        let home = std::env::var("HOME")
104            .map_err(|_| format!("AK_STORE='{raw_str}' uses '~' but $HOME is not set"))?;
105        std::path::PathBuf::from(home).join(rest)
106    } else if raw_str == "~" {
107        std::path::PathBuf::from(
108            std::env::var("HOME")
109                .map_err(|_| format!("AK_STORE='{raw_str}' uses '~' but $HOME is not set"))?,
110        )
111    } else {
112        std::path::PathBuf::from(&raw_str)
113    };
114
115    // create_dir_all so a fresh AK_STORE path can be canonicalized without a
116    // confusing NotFound, matching the host store's "create on first write".
117    std::fs::create_dir_all(&expanded).map_err(|e| {
118        format!(
119            "AK_STORE='{raw_str}' could not be created at {}: {e}",
120            expanded.display()
121        )
122    })?;
123
124    let canonical = std::fs::canonicalize(&expanded).map_err(|e| {
125        format!(
126            "AK_STORE='{raw_str}' could not be resolved to an absolute path ({}): {e}",
127            expanded.display()
128        )
129    })?;
130
131    Ok(Some(canonical))
132}
133
134/// Expand `~` to `$HOME` in a volume mount string.
135pub fn expand_volume_path(volume: &str) -> String {
136    if (volume.starts_with("~/") || volume.starts_with("~:"))
137        && let Ok(home_dir) = std::env::var("HOME")
138    {
139        return volume.replacen("~", &home_dir, 1);
140    }
141    volume.to_string()
142}
143
144/// Check whether the host-side part of a volume mount is a Docker named volume
145/// (as opposed to a bind mount path).
146///
147/// Named volumes don't start with `/`, `.`, or `~` and contain no `/`.
148pub fn is_named_volume(host_part: &str) -> bool {
149    !host_part.starts_with('/')
150        && !host_part.starts_with('.')
151        && !host_part.starts_with('~')
152        && !host_part.contains('/')
153}
154
155/// Warden CLI flags that pin the AK knowledge store inside the sandboxed
156/// container. Returns an empty Vec when no host override is supplied — the
157/// caller then relies on the default mount in [`stakpak_agent_default_mounts`].
158pub fn warden_ak_store_args(host_knowledge_root: Option<&std::path::Path>) -> Vec<String> {
159    match host_knowledge_root {
160        Some(host_path) => {
161            let target = agent_knowledge_store_path();
162            vec![
163                "--volume".to_string(),
164                format!("{}:{target}", host_path.display()),
165                "--env".to_string(),
166                format!("AK_STORE={target}"),
167            ]
168        }
169        None => Vec::new(),
170    }
171}
172
173/// Pre-create any Docker named volumes found in [`stakpak_agent_default_mounts`].
174///
175/// Running `docker volume create` is idempotent and prevents a race condition
176/// when multiple sandbox containers first-use the same named volume in parallel.
177pub fn ensure_named_volumes_exist() {
178    for vol in stakpak_agent_default_mounts() {
179        let host_part = volume_host_part(&vol);
180        if is_named_volume(host_part) {
181            let _ = Command::new("docker")
182                .args(["volume", "create", host_part])
183                .stdout(std::process::Stdio::null())
184                .stderr(std::process::Stdio::null())
185                .status();
186        }
187    }
188}
189
190#[derive(Debug, Clone)]
191pub struct ContainerConfig {
192    pub image: String,
193    pub env_vars: HashMap<String, String>,
194    pub ports: Vec<String>,       // Format: "host_port:container_port"
195    pub extra_hosts: Vec<String>, // Format: "host:ip"
196    pub volumes: Vec<String>,     // Format: "host_path:container_path"
197}
198
199pub fn find_available_port() -> Option<u16> {
200    match TcpListener::bind("0.0.0.0:0") {
201        Ok(listener) => listener.local_addr().ok().map(|addr| addr.port()),
202        Err(_) => None,
203    }
204}
205
206/// Checks if Docker is installed and accessible
207pub fn is_docker_available() -> bool {
208    Command::new("docker")
209        .arg("--version")
210        .output()
211        .map(|output| output.status.success())
212        .unwrap_or(false)
213}
214
215/// Checks if a Docker image exists locally
216pub fn image_exists_locally(image: &str) -> Result<bool, String> {
217    let output = Command::new("docker")
218        .args(["images", "-q", image])
219        .output()
220        .map_err(|e| format!("Failed to execute docker images command: {}", e))?;
221
222    if output.status.success() {
223        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
224        Ok(!stdout.is_empty())
225    } else {
226        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
227        Err(format!("Docker images command failed: {}", stderr))
228    }
229}
230
231/// The platform warden uses for all containers.
232/// Warden hardcodes `--platform linux/amd64` on every `docker run` / `docker create`,
233/// so all image operations must target the same platform for consistency.
234pub const WARDEN_PLATFORM: &str = "linux/amd64";
235
236/// Checks if a Docker image for the warden platform (linux/amd64) exists locally.
237///
238/// Unlike [`image_exists_locally`], this uses `docker image inspect --platform`
239/// to verify the correct architecture variant is cached. On Apple Silicon a plain
240/// `docker images -q` would match a cached arm64 image, giving a false positive.
241pub fn warden_image_exists_locally(image: &str) -> bool {
242    Command::new("docker")
243        .args(["image", "inspect", "--platform", WARDEN_PLATFORM, image])
244        .stdout(std::process::Stdio::null())
245        .stderr(std::process::Stdio::null())
246        .status()
247        .map(|s| s.success())
248        .unwrap_or(false)
249}
250
251/// Pull a Docker image for the warden platform (linux/amd64) with visible progress.
252///
253/// Inherits stdout/stderr so the caller sees Docker's native progress bars
254/// (layer downloads, extraction, etc.). Returns an error if the pull fails.
255pub fn pull_warden_image(image: &str) -> Result<(), String> {
256    let status = Command::new("docker")
257        .args(["pull", "--platform", WARDEN_PLATFORM, image])
258        .stdout(std::process::Stdio::inherit())
259        .stderr(std::process::Stdio::inherit())
260        .status()
261        .map_err(|e| format!("Failed to run docker pull: {e}"))?;
262
263    if status.success() {
264        Ok(())
265    } else {
266        Err(format!(
267            "Failed to pull image '{image}' for platform {WARDEN_PLATFORM}. \
268             Check your network connection and that the image exists."
269        ))
270    }
271}
272
273pub fn run_container_detached(config: ContainerConfig) -> Result<String, String> {
274    let mut cmd = Command::new("docker");
275
276    cmd.arg("run").arg("-d").arg("--rm");
277
278    // Add ports
279    for port_mapping in &config.ports {
280        cmd.arg("-p").arg(port_mapping);
281    }
282
283    // Add environment variables
284    for (key, value) in &config.env_vars {
285        cmd.arg("-e").arg(format!("{}={}", key, value));
286    }
287
288    // Add extra hosts
289    for host_mapping in &config.extra_hosts {
290        cmd.arg("--add-host").arg(host_mapping);
291    }
292
293    // Add volumes
294    for volume_mapping in &config.volumes {
295        cmd.arg("-v").arg(volume_mapping);
296    }
297
298    // Add image
299    cmd.arg(&config.image);
300
301    let output = cmd
302        .output()
303        .map_err(|e| format!("Failed to execute docker command: {}", e))?;
304
305    if output.status.success() {
306        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
307        Ok(container_id)
308    } else {
309        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
310        Err(format!("Docker command failed: {}", stderr))
311    }
312}
313
314pub fn stop_container(container_id: &str) -> Result<(), String> {
315    let output = Command::new("docker")
316        .arg("stop")
317        .arg(container_id)
318        .output()
319        .map_err(|e| format!("Failed to execute docker stop: {}", e))?;
320
321    if output.status.success() {
322        Ok(())
323    } else {
324        let stderr = String::from_utf8_lossy(&output.stderr);
325        if stderr.contains("No such container") {
326            Ok(())
327        } else {
328            Err(format!("Failed to stop container: {}", stderr))
329        }
330    }
331}
332
333pub fn remove_container(
334    container_id: &str,
335    force: bool,
336    remove_volumes: bool,
337) -> Result<(), String> {
338    let mut cmd = Command::new("docker");
339
340    cmd.arg("rm");
341
342    if force {
343        cmd.arg("-f");
344    }
345
346    if remove_volumes {
347        cmd.arg("-v");
348    }
349
350    cmd.arg(container_id);
351
352    let output = cmd
353        .output()
354        .map_err(|e| format!("Failed to execute docker rm: {}", e))?;
355
356    if output.status.success() {
357        Ok(())
358    } else {
359        let stderr = String::from_utf8_lossy(&output.stderr);
360        if stderr.contains("No such container") {
361            Ok(())
362        } else {
363            Err(format!("Failed to remove container: {}", stderr))
364        }
365    }
366}
367
368pub fn get_container_host_port(container_id: &str, container_port: u16) -> Result<u16, String> {
369    let output = Command::new("docker")
370        .arg("port")
371        .arg(container_id)
372        .arg(container_port.to_string())
373        .output()
374        .map_err(|e| format!("Failed to get container port: {}", e))?;
375
376    if output.status.success() {
377        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
378        let port = stdout.split(':').next_back().unwrap_or("");
379        Ok(port.parse().unwrap())
380    } else {
381        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
382        Err(format!("Failed to get container port: {}", stderr))
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use std::sync::Mutex;
390
391    // ENV_LOCK serializes tests that mutate process-wide env vars (AK_STORE,
392    // HOME). All `unsafe { std::env::set_var / remove_var }` calls below are
393    // sound because the lock guarantees no concurrent reader exists in this
394    // suite.
395    static ENV_LOCK: Mutex<()> = Mutex::new(());
396
397    #[test]
398    fn warden_ak_store_args_empty_when_no_override() {
399        assert!(warden_ak_store_args(None).is_empty());
400    }
401
402    #[test]
403    fn warden_ak_store_args_emits_volume_and_env_when_override_set() {
404        let host = std::path::PathBuf::from("/tmp/custom-ak");
405        let args = warden_ak_store_args(Some(&host));
406        let target = agent_knowledge_store_path();
407        assert_eq!(
408            args,
409            vec![
410                "--volume".to_string(),
411                format!("/tmp/custom-ak:{target}"),
412                "--env".to_string(),
413                format!("AK_STORE={target}"),
414            ]
415        );
416    }
417
418    #[test]
419    fn volume_part_helpers_split_at_first_colon() {
420        assert_eq!(volume_host_part("./:/agent:ro"), "./");
421        assert_eq!(volume_container_part("./:/agent:ro"), "/agent");
422        assert_eq!(volume_host_part("named-vol"), "named-vol");
423        assert_eq!(volume_container_part("named-vol"), "named-vol");
424    }
425
426    #[test]
427    fn knowledge_store_mount_present_and_rw() {
428        let mounts = stakpak_agent_default_mounts();
429        let suffix = format!(":{}", agent_knowledge_store_path());
430        let entry = mounts
431            .iter()
432            .find(|v| v.ends_with(&suffix))
433            .unwrap_or_else(|| panic!("knowledge store mount missing: {mounts:?}"));
434        assert!(
435            entry.starts_with("~/.stakpak/knowledge:"),
436            "host side should be ~/.stakpak/knowledge: {entry}"
437        );
438        assert!(
439            !entry.ends_with(":ro"),
440            "knowledge store mount must be RW (no :ro suffix): {entry}"
441        );
442    }
443
444    #[test]
445    fn resolve_ak_store_returns_none_when_unset() {
446        let _guard = ENV_LOCK.lock().unwrap();
447        unsafe {
448            std::env::remove_var("AK_STORE");
449        }
450        assert_eq!(resolve_ak_store_for_sandbox().unwrap(), None);
451    }
452
453    #[test]
454    fn resolve_ak_store_expands_tilde() {
455        let _guard = ENV_LOCK.lock().unwrap();
456        let tmp = tempfile::tempdir().unwrap();
457        let store_subdir = "ak-store-tilde-test";
458        let expected = tmp.path().join(store_subdir);
459        unsafe {
460            std::env::set_var("HOME", tmp.path());
461            std::env::set_var("AK_STORE", format!("~/{store_subdir}"));
462        }
463        let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
464        // canonicalize: macOS /var → /private/var.
465        let expected_canonical = std::fs::canonicalize(&expected).unwrap();
466        assert_eq!(resolved, expected_canonical);
467        unsafe {
468            std::env::remove_var("AK_STORE");
469        }
470    }
471
472    #[test]
473    fn resolve_ak_store_canonicalizes_relative_path() {
474        let _guard = ENV_LOCK.lock().unwrap();
475        let tmp = tempfile::tempdir().unwrap();
476        let store_dir = tmp.path().join("relstore");
477        std::fs::create_dir_all(&store_dir).unwrap();
478        unsafe {
479            std::env::set_var("AK_STORE", store_dir.to_str().unwrap());
480        }
481        let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
482        assert!(
483            resolved.is_absolute(),
484            "resolved path must be absolute: {resolved:?}"
485        );
486        let expected_canonical = std::fs::canonicalize(&store_dir).unwrap();
487        assert_eq!(resolved, expected_canonical);
488        unsafe {
489            std::env::remove_var("AK_STORE");
490        }
491    }
492
493    #[test]
494    fn resolve_ak_store_creates_missing_directory() {
495        let _guard = ENV_LOCK.lock().unwrap();
496        let tmp = tempfile::tempdir().unwrap();
497        let store_dir = tmp.path().join("does-not-exist-yet");
498        assert!(!store_dir.exists());
499        unsafe {
500            std::env::set_var("AK_STORE", store_dir.to_str().unwrap());
501        }
502        let resolved = resolve_ak_store_for_sandbox().unwrap().unwrap();
503        assert!(
504            store_dir.exists(),
505            "AK_STORE target should be created on resolve"
506        );
507        assert_eq!(resolved, std::fs::canonicalize(&store_dir).unwrap());
508        unsafe {
509            std::env::remove_var("AK_STORE");
510        }
511    }
512
513    #[test]
514    fn resolve_ak_store_fails_when_parent_unreachable() {
515        let _guard = ENV_LOCK.lock().unwrap();
516        // A non-directory file as parent → both mkdir and canonicalize fail,
517        // so the resolver hits its error path.
518        let tmp = tempfile::tempdir().unwrap();
519        let blocker = tmp.path().join("blocker");
520        std::fs::write(&blocker, b"x").unwrap();
521        let bad = blocker.join("nested-store");
522        unsafe {
523            std::env::set_var("AK_STORE", bad.to_str().unwrap());
524        }
525        let err = resolve_ak_store_for_sandbox().unwrap_err();
526        assert!(
527            err.contains("AK_STORE="),
528            "error should name the offending env value: {err}"
529        );
530        unsafe {
531            std::env::remove_var("AK_STORE");
532        }
533    }
534}