1use std::collections::HashMap;
2use std::net::TcpListener;
3use std::process::Command;
4
5pub 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
14pub fn agent_knowledge_store_path() -> &'static str {
18 "/home/agent/.stakpak/knowledge"
19}
20
21pub fn volume_host_part(vol: &str) -> &str {
23 vol.split(':').next().unwrap_or(vol)
24}
25
26pub fn volume_container_part(vol: &str) -> &str {
29 vol.split(':').nth(1).unwrap_or(vol)
30}
31
32pub fn stakpak_agent_default_mounts() -> Vec<String> {
38 vec![
39 "~/.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 format!("~/.stakpak/knowledge:{}", agent_knowledge_store_path()),
45 "~/.agent-board/data.db:/home/agent/.agent-board/data.db".to_string(),
46 "./:/agent:ro".to_string(),
48 "./.stakpak:/agent/.stakpak".to_string(),
49 "~/.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 "~/.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:/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:/home/agent/.digitalocean:ro".to_string(),
71 "~/.kube:/home/agent/.kube:ro".to_string(),
72 "~/.ssh:/home/agent/.ssh:ro".to_string(),
74 "stakpak-aqua-cache:/home/agent/.local/share/aquaproj-aqua".to_string(),
76 ]
77}
78
79pub 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 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
134pub 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
144pub 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
155pub 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
173pub 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>, pub extra_hosts: Vec<String>, pub volumes: Vec<String>, }
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
206pub 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
215pub 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
231pub const WARDEN_PLATFORM: &str = "linux/amd64";
235
236pub 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
251pub 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 for port_mapping in &config.ports {
280 cmd.arg("-p").arg(port_mapping);
281 }
282
283 for (key, value) in &config.env_vars {
285 cmd.arg("-e").arg(format!("{}={}", key, value));
286 }
287
288 for host_mapping in &config.extra_hosts {
290 cmd.arg("--add-host").arg(host_mapping);
291 }
292
293 for volume_mapping in &config.volumes {
295 cmd.arg("-v").arg(volume_mapping);
296 }
297
298 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 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 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 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}