Skip to main content

aether_cli/
sandbox.rs

1use std::env;
2use std::io;
3use std::io::IsTerminal;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6
7use llm::LlmModel;
8use thiserror::Error;
9
10/// Credentials and settings not covered by `LlmModel::ALL_REQUIRED_ENV_VARS`.
11///
12/// Bedrock resolves credentials through the AWS chain rather than a single
13/// required env var, so its variables have to be listed explicitly or a
14/// sandboxed run cannot authenticate at all.
15const EXTRA_FORWARDED_KEYS: &[&str] = &[
16    "OLLAMA_HOST",
17    "AWS_ACCESS_KEY_ID",
18    "AWS_BEARER_TOKEN_BEDROCK",
19    "AWS_DEFAULT_REGION",
20    "AWS_PROFILE",
21    "AWS_REGION",
22    "AWS_SECRET_ACCESS_KEY",
23    "AWS_SESSION_TOKEN",
24];
25
26const AWS_PATH_KEYS: &[&str] = &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE"];
27const AWS_SHARED_PATHS: &[&str] = &["config", "credentials", "sso/cache", "login/cache"];
28const AETHER_ENV_PREFIX: &str = "AETHER_";
29
30/// Host paths made available inside the container, and the env values that point at them.
31struct AwsBindings {
32    mounts: Vec<BindMount>,
33    env_overrides: Vec<(String, String)>,
34}
35
36struct BindMount {
37    host: PathBuf,
38    container: PathBuf,
39}
40
41/// Whether the sandboxed process runs attached to an interactive terminal.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum TerminalMode {
44    Interactive,
45    NonInteractive,
46}
47
48#[derive(Debug, Error)]
49pub enum SandboxError {
50    #[error("Docker is not installed or not in PATH")]
51    DockerNotFound,
52    #[error("Docker daemon is not running: {0}")]
53    DockerNotRunning(String),
54    #[error(
55        "Sandbox image '{0}' not found. Build it with:\n\
56             cargo build -p aether-agent-cli --bin aether\n\
57             docker build -t {0} -f crates/internal-evals/examples/Dockerfile ."
58    )]
59    ImageNotFound(String),
60    #[error("Failed to exec docker: {0}")]
61    ExecFailed(#[from] io::Error),
62    #[error("Could not determine home directory")]
63    HomeNotResolvable,
64}
65
66/// Entry point called from `main()` when `--sandbox-image` is present.
67pub fn exec_in_container(image: &str) -> ExitCode {
68    match try_exec_in_container(image) {
69        Ok(code) => code,
70        Err(err) => {
71            eprintln!("Sandbox error: {err}");
72            ExitCode::FAILURE
73        }
74    }
75}
76
77fn try_exec_in_container(image: &str) -> Result<ExitCode, SandboxError> {
78    check_docker()?;
79    check_image(image)?;
80
81    let cwd = env::current_dir().map_err(SandboxError::ExecFailed)?;
82    let home = dirs::home_dir().ok_or(SandboxError::HomeNotResolvable)?;
83    let aether_home = resolve_aether_home(&home);
84    let args: Vec<String> = env::args().collect();
85    let inner_args = filter_sandbox_arg(&args);
86    let mut env_vars = select_forwarded_vars(env::vars());
87    let aws = aws_bindings(&home, &env_vars);
88    apply_overrides(&mut env_vars, aws.env_overrides);
89
90    let terminal = if io::stdin().is_terminal() { TerminalMode::Interactive } else { TerminalMode::NonInteractive };
91    let docker_args = build_docker_args(image, &cwd, &aether_home, &aws.mounts, &env_vars, &inner_args, terminal);
92
93    exec_docker(&docker_args)
94}
95
96fn check_docker() -> Result<(), SandboxError> {
97    let output = Command::new("docker")
98        .arg("info")
99        .stdout(std::process::Stdio::null())
100        .stderr(std::process::Stdio::piped())
101        .output()
102        .map_err(|_| SandboxError::DockerNotFound)?;
103
104    if !output.status.success() {
105        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
106        return Err(SandboxError::DockerNotRunning(stderr));
107    }
108
109    Ok(())
110}
111
112fn check_image(image: &str) -> Result<(), SandboxError> {
113    let output = Command::new("docker")
114        .args(["image", "inspect", image])
115        .stdout(std::process::Stdio::null())
116        .stderr(std::process::Stdio::null())
117        .output()
118        .map_err(|_| SandboxError::DockerNotFound)?;
119
120    if !output.status.success() {
121        return Err(SandboxError::ImageNotFound(image.to_string()));
122    }
123
124    Ok(())
125}
126
127fn resolve_aether_home(home: &Path) -> PathBuf {
128    env::var("AETHER_HOME").map_or_else(|_| home.join(".aether"), PathBuf::from)
129}
130
131fn aws_bindings(home: &Path, env_vars: &[(String, String)]) -> AwsBindings {
132    let mut mounts = Vec::new();
133    let mut env_overrides = Vec::new();
134
135    let aws_home = home.join(".aws");
136    for relative in AWS_SHARED_PATHS {
137        let host = aws_home.join(relative);
138        if host.exists() {
139            mounts.push(BindMount { host, container: Path::new("/root/.aws").join(relative) });
140        }
141    }
142
143    for (key, value) in env_vars.iter().filter(|(key, _)| AWS_PATH_KEYS.contains(&key.as_str())) {
144        let host = PathBuf::from(value);
145        if !host.is_file() {
146            continue;
147        }
148        let container = PathBuf::from(format!("/run/aether-aws/{key}"));
149        env_overrides.push((key.clone(), container.to_string_lossy().into_owned()));
150        mounts.push(BindMount { host, container });
151    }
152
153    AwsBindings { mounts, env_overrides }
154}
155
156fn apply_overrides(env_vars: &mut [(String, String)], overrides: Vec<(String, String)>) {
157    for (key, replacement) in overrides {
158        if let Some((_, value)) = env_vars.iter_mut().find(|(existing, _)| *existing == key) {
159            *value = replacement;
160        }
161    }
162}
163
164fn filter_sandbox_arg(args: &[String]) -> Vec<String> {
165    let mut result = Vec::new();
166    let mut skip_next = false;
167    for arg in args {
168        if skip_next {
169            skip_next = false;
170            continue;
171        }
172        if arg == "--sandbox-image" {
173            skip_next = true;
174            continue;
175        }
176        if arg.starts_with("--sandbox-image=") {
177            continue;
178        }
179        result.push(arg.clone());
180    }
181    result
182}
183
184fn select_forwarded_vars(vars: impl Iterator<Item = (String, String)>) -> Vec<(String, String)> {
185    vars.filter(|(key, _)| {
186        LlmModel::ALL_REQUIRED_ENV_VARS.contains(&key.as_str())
187            || EXTRA_FORWARDED_KEYS.contains(&key.as_str())
188            || AWS_PATH_KEYS.contains(&key.as_str())
189            || key.starts_with(AETHER_ENV_PREFIX)
190    })
191    .collect()
192}
193
194fn build_docker_args(
195    image: &str,
196    cwd: &Path,
197    aether_home: &Path,
198    mounts: &[BindMount],
199    env_vars: &[(String, String)],
200    inner_args: &[String],
201    terminal: TerminalMode,
202) -> Vec<String> {
203    let mut args = vec!["run".to_string(), "--rm".to_string(), "-i".to_string()];
204    if terminal == TerminalMode::Interactive {
205        args.push("-t".to_string());
206    }
207    args.extend(
208        [
209            "--network",
210            "host",
211            "-w",
212            "/workspace",
213            "-v",
214            &format!("{}:/workspace", cwd.display()),
215            "-v",
216            &format!("{}:/root/.aether", aether_home.display()),
217            "-e",
218            "AETHER_HOME=/root/.aether",
219            "-e",
220            "AETHER_INSIDE_SANDBOX=1",
221        ]
222        .iter()
223        .map(ToString::to_string),
224    );
225
226    for mount in mounts {
227        args.push("-v".to_string());
228        args.push(format!("{}:{}:ro", mount.host.display(), mount.container.display()));
229    }
230
231    for (key, value) in env_vars {
232        args.push("-e".to_string());
233        args.push(format!("{key}={value}"));
234    }
235
236    args.push(image.to_string());
237
238    // Skip the binary name (first element) — the ENTRYPOINT already provides it
239    if inner_args.len() > 1 {
240        args.extend(inner_args[1..].iter().cloned());
241    }
242
243    args
244}
245
246#[cfg(unix)]
247fn exec_docker(args: &[String]) -> Result<ExitCode, SandboxError> {
248    use std::os::unix::process::CommandExt;
249
250    let err = Command::new("docker").args(args).exec();
251    Err(SandboxError::ExecFailed(err))
252}
253
254#[cfg(not(unix))]
255fn exec_docker(args: &[String]) -> Result<ExitCode, SandboxError> {
256    let status = Command::new("docker").args(args).status().map_err(SandboxError::ExecFailed)?;
257
258    Ok(match status.code() {
259        Some(0) => ExitCode::SUCCESS,
260        _ => ExitCode::FAILURE,
261    })
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn filter_sandbox_arg_strips_separate_value() {
270        let args = vec![
271            "aether".to_string(),
272            "--sandbox-image".to_string(),
273            "my-image:latest".to_string(),
274            "headless".to_string(),
275            "-m".to_string(),
276            "gpt-4".to_string(),
277        ];
278        let filtered = filter_sandbox_arg(&args);
279        assert_eq!(filtered, vec!["aether", "headless", "-m", "gpt-4"]);
280    }
281
282    #[test]
283    fn filter_sandbox_arg_strips_equals_form() {
284        let args = vec!["aether".to_string(), "--sandbox-image=my-image:latest".to_string(), "headless".to_string()];
285        let filtered = filter_sandbox_arg(&args);
286        assert_eq!(filtered, vec!["aether", "headless"]);
287    }
288
289    #[test]
290    fn filter_sandbox_arg_noop_when_absent() {
291        let args = vec!["aether".to_string(), "headless".to_string(), "-m".to_string()];
292        let filtered = filter_sandbox_arg(&args);
293        assert_eq!(filtered, args);
294    }
295
296    #[test]
297    fn filter_sandbox_arg_middle_position() {
298        let args = vec![
299            "aether".to_string(),
300            "headless".to_string(),
301            "--sandbox-image".to_string(),
302            "custom:v2".to_string(),
303            "-m".to_string(),
304        ];
305        let filtered = filter_sandbox_arg(&args);
306        assert_eq!(filtered, vec!["aether", "headless", "-m"]);
307    }
308
309    #[test]
310    fn select_forwarded_vars_includes_generated_provider_keys() {
311        let vars = vec![
312            ("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string()),
313            ("OPENROUTER_API_KEY".to_string(), "or-456".to_string()),
314            ("ZAI_API_KEY".to_string(), "zai-789".to_string()),
315            ("DEEPSEEK_API_KEY".to_string(), "ds-000".to_string()),
316            ("HOME".to_string(), "/root".to_string()),
317        ];
318        let forwarded = select_forwarded_vars(vars.into_iter());
319        assert_eq!(forwarded.len(), 4);
320        assert!(forwarded.iter().any(|(k, _)| k == "ANTHROPIC_API_KEY"));
321        assert!(forwarded.iter().any(|(k, _)| k == "OPENROUTER_API_KEY"));
322        assert!(forwarded.iter().any(|(k, _)| k == "ZAI_API_KEY"));
323        assert!(forwarded.iter().any(|(k, _)| k == "DEEPSEEK_API_KEY"));
324    }
325
326    #[test]
327    fn select_forwarded_vars_includes_extra_keys() {
328        let vars = vec![
329            ("OLLAMA_HOST".to_string(), "http://localhost:11434".to_string()),
330            ("HOME".to_string(), "/root".to_string()),
331        ];
332        let forwarded = select_forwarded_vars(vars.into_iter());
333        assert_eq!(forwarded.len(), 1);
334        assert!(forwarded.iter().any(|(k, _)| k == "OLLAMA_HOST"));
335    }
336
337    #[test]
338    fn select_forwarded_vars_includes_aether_prefix() {
339        let vars = vec![
340            ("AETHER_DEBUG".to_string(), "1".to_string()),
341            ("AETHER_LOG_LEVEL".to_string(), "trace".to_string()),
342            ("SOMETHING_ELSE".to_string(), "nope".to_string()),
343        ];
344        let forwarded = select_forwarded_vars(vars.into_iter());
345        assert_eq!(forwarded.len(), 2);
346        assert!(forwarded.iter().any(|(k, _)| k == "AETHER_DEBUG"));
347        assert!(forwarded.iter().any(|(k, _)| k == "AETHER_LOG_LEVEL"));
348    }
349
350    #[test]
351    fn select_forwarded_vars_excludes_unknown() {
352        let vars = vec![("HOME".to_string(), "/root".to_string()), ("EDITOR".to_string(), "vim".to_string())];
353        let forwarded = select_forwarded_vars(vars.into_iter());
354        assert!(forwarded.is_empty());
355    }
356
357    #[test]
358    fn all_required_env_vars_stays_in_sync() {
359        // If a new provider is added to codegen, this test reminds us it's auto-forwarded
360        assert!(LlmModel::ALL_REQUIRED_ENV_VARS.contains(&"ANTHROPIC_API_KEY"));
361        assert!(LlmModel::ALL_REQUIRED_ENV_VARS.contains(&"ZAI_API_KEY"));
362        assert!(LlmModel::ALL_REQUIRED_ENV_VARS.contains(&"DEEPSEEK_API_KEY"));
363    }
364
365    #[test]
366    fn build_docker_args_contains_expected_flags() {
367        let cwd = Path::new("/home/user/project");
368        let aether_home = Path::new("/home/user/.aether");
369        let env_vars = vec![("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())];
370        let inner_args = vec!["aether".to_string(), "headless".to_string(), "-m".to_string(), "gpt-4".to_string()];
371
372        let args = build_docker_args(
373            "test-image:latest",
374            cwd,
375            aether_home,
376            &[],
377            &env_vars,
378            &inner_args,
379            TerminalMode::NonInteractive,
380        );
381
382        assert!(args.contains(&"run".to_string()));
383        assert!(args.contains(&"--rm".to_string()));
384        assert!(args.contains(&"-i".to_string()));
385        assert!(!args.contains(&"-t".to_string()));
386        assert!(args.contains(&"--network".to_string()));
387        assert!(args.contains(&"host".to_string()));
388        assert!(args.contains(&"/workspace".to_string()));
389        assert!(args.contains(&format!("{}:/workspace", cwd.display())));
390        assert!(args.contains(&format!("{}:/root/.aether", aether_home.display())));
391        assert!(args.contains(&"AETHER_HOME=/root/.aether".to_string()));
392        assert!(args.contains(&"AETHER_INSIDE_SANDBOX=1".to_string()));
393        assert!(args.contains(&"ANTHROPIC_API_KEY=sk-123".to_string()));
394        assert!(args.contains(&"test-image:latest".to_string()));
395        // Inner args skip the binary name
396        assert!(args.contains(&"headless".to_string()));
397        assert!(args.contains(&"-m".to_string()));
398        assert!(args.contains(&"gpt-4".to_string()));
399        // Binary name must NOT appear after the image
400        let image_pos = args.iter().position(|a| a == "test-image:latest").unwrap();
401        assert!(!args[image_pos..].contains(&"aether".to_string()));
402    }
403
404    #[test]
405    fn build_docker_args_uses_custom_image() {
406        let cwd = Path::new("/tmp");
407        let aether_home = Path::new("/home/user/.aether");
408        let args = build_docker_args(
409            "my-go-sandbox:v2",
410            cwd,
411            aether_home,
412            &[],
413            &[],
414            &["aether".to_string(), "headless".to_string()],
415            TerminalMode::NonInteractive,
416        );
417
418        assert!(args.contains(&"my-go-sandbox:v2".to_string()));
419        assert!(!args.contains(&"test-image:latest".to_string()));
420    }
421
422    #[test]
423    fn build_docker_args_adds_tty_flag_when_requested() {
424        let cwd = Path::new("/tmp");
425        let aether_home = Path::new("/home/user/.aether");
426        let args = build_docker_args(
427            "test-image",
428            cwd,
429            aether_home,
430            &[],
431            &[],
432            &["aether".to_string()],
433            TerminalMode::Interactive,
434        );
435
436        assert!(args.contains(&"-t".to_string()));
437        assert!(args.contains(&"-i".to_string()));
438    }
439
440    #[test]
441    fn build_docker_args_skips_binary_name_only() {
442        let cwd = Path::new("/tmp");
443        let aether_home = Path::new("/home/user/.aether");
444        let args = build_docker_args(
445            "test-image:latest",
446            cwd,
447            aether_home,
448            &[],
449            &[],
450            &["aether".to_string()],
451            TerminalMode::NonInteractive,
452        );
453
454        // Only the binary name — nothing after image
455        assert_eq!(args.last().unwrap(), "test-image:latest");
456    }
457
458    /// A `~/.aws` holding one of everything: what the SDK reads, plus the AWS
459    /// CLI's own credential cache.
460    fn fake_aws_home(home: &Path) {
461        let aws = home.join(".aws");
462        std::fs::create_dir_all(aws.join("sso/cache")).unwrap();
463        std::fs::create_dir_all(aws.join("cli/cache")).unwrap();
464        std::fs::write(aws.join("config"), "[default]\n").unwrap();
465        std::fs::write(aws.join("credentials"), "[default]\n").unwrap();
466        std::fs::write(aws.join("sso/cache/token.json"), "{}").unwrap();
467        std::fs::write(aws.join("cli/cache/assumed-role.json"), "{}").unwrap();
468    }
469
470    #[test]
471    fn aws_bindings_mount_the_profile_and_rewrite_custom_files() {
472        let home = tempfile::tempdir().unwrap();
473        fake_aws_home(home.path());
474        let token = home.path().join("web-identity-token");
475        std::fs::write(&token, "token").unwrap();
476        let mut env_vars = vec![("AWS_WEB_IDENTITY_TOKEN_FILE".to_string(), token.to_string_lossy().into_owned())];
477
478        let aws = aws_bindings(home.path(), &env_vars);
479        apply_overrides(&mut env_vars, aws.env_overrides);
480
481        assert!(aws.mounts.iter().any(|mount| mount.container == Path::new("/root/.aws/config")));
482        assert!(aws.mounts.iter().any(|mount| mount.container == Path::new("/root/.aws/sso/cache")));
483        assert!(aws.mounts.iter().any(|mount| mount.host == token));
484        assert_eq!(env_vars[0].1, "/run/aether-aws/AWS_WEB_IDENTITY_TOKEN_FILE");
485
486        let args = build_docker_args(
487            "test-image",
488            Path::new("/workspace"),
489            &home.path().join(".aether"),
490            &aws.mounts,
491            &env_vars,
492            &["aether".to_string()],
493            TerminalMode::NonInteractive,
494        );
495        assert!(args.contains(&format!("{}:/root/.aws/config:ro", home.path().join(".aws/config").display())));
496        assert!(args.contains(&"AWS_WEB_IDENTITY_TOKEN_FILE=/run/aether-aws/AWS_WEB_IDENTITY_TOKEN_FILE".to_string()));
497    }
498
499    #[test]
500    fn aws_bindings_never_share_the_aws_cli_credential_cache() {
501        let home = tempfile::tempdir().unwrap();
502        fake_aws_home(home.path());
503
504        let aws = aws_bindings(home.path(), &[]);
505
506        assert!(
507            !aws.mounts.iter().any(|mount| mount.host.to_string_lossy().contains("/.aws/cli")),
508            "the AWS CLI's cache holds credentials the SDK never reads: {:?}",
509            aws.mounts.iter().map(|mount| mount.host.display().to_string()).collect::<Vec<_>>()
510        );
511        assert!(
512            !aws.mounts.iter().any(|mount| mount.container == Path::new("/root/.aws")),
513            "sharing the whole directory would expose everything under it"
514        );
515    }
516
517    #[test]
518    fn aws_bindings_mount_only_the_paths_that_exist() {
519        let home = tempfile::tempdir().unwrap();
520        std::fs::create_dir(home.path().join(".aws")).unwrap();
521        std::fs::write(home.path().join(".aws/config"), "[default]\n").unwrap();
522
523        let aws = aws_bindings(home.path(), &[]);
524
525        assert_eq!(aws.mounts.len(), 1);
526        assert_eq!(aws.mounts[0].container, Path::new("/root/.aws/config"));
527    }
528
529    #[test]
530    fn aws_bindings_skip_env_paths_that_do_not_exist_on_the_host() {
531        let home = tempfile::tempdir().unwrap();
532        let env_vars = vec![("AWS_CONFIG_FILE".to_string(), "/nowhere/config".to_string())];
533
534        let aws = aws_bindings(home.path(), &env_vars);
535
536        assert!(aws.mounts.is_empty(), "nothing to mount when neither ~/.aws nor the named file exists");
537        assert!(aws.env_overrides.is_empty(), "a missing host file must keep its original value");
538    }
539
540    #[test]
541    fn forwarded_vars_include_the_aws_path_variables() {
542        let vars = [
543            ("AWS_SHARED_CREDENTIALS_FILE".to_string(), "/home/user/creds".to_string()),
544            ("UNRELATED".to_string(), "x".to_string()),
545        ];
546
547        let forwarded = select_forwarded_vars(vars.into_iter());
548
549        assert_eq!(forwarded.len(), 1);
550        assert_eq!(forwarded[0].0, "AWS_SHARED_CREDENTIALS_FILE");
551    }
552
553    #[test]
554    fn sandbox_error_display_messages() {
555        assert_eq!(SandboxError::DockerNotFound.to_string(), "Docker is not installed or not in PATH");
556
557        assert!(SandboxError::DockerNotRunning("connection refused".into()).to_string().contains("connection refused"));
558
559        let img_err = SandboxError::ImageNotFound("aether-sandbox:latest".into());
560        assert!(img_err.to_string().contains("aether-sandbox:latest"));
561        assert!(img_err.to_string().contains("cargo build"));
562
563        assert!(SandboxError::HomeNotResolvable.to_string().contains("home directory"));
564
565        let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
566        assert!(SandboxError::ExecFailed(io_err).to_string().contains("not found"));
567    }
568}