diff --git a/docker/agent-sandbox/Dockerfile b/docker/agent-sandbox/Dockerfile index 7f2a5c86..642abe4c 100644 --- a/docker/agent-sandbox/Dockerfile +++ b/docker/agent-sandbox/Dockerfile @@ -72,6 +72,15 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ # root-owned and unwritable to that uid. RUN mkdir -p /home/agent && chmod 777 /home/agent +# Point user-level installs (`npm i -g`, `pip install --user`) into the +# persistent home volume so tools installed via `agent docker shell` survive +# across runs; npm's default global prefix (/usr/local) lives in the +# container's --rm'd layer and isn't writable by a non-root uid anyway. +# Appended (not prepended) to PATH so a stale install in the shared home can +# never shadow the image's own binaries (e.g. the native `claude`/`codex`). +ENV NPM_CONFIG_PREFIX=/home/agent/.npm-global +ENV PATH=$PATH:/home/agent/.npm-global/bin:/home/agent/.local/bin + # `iptables` above is used by the short-lived network-namespace-holder # container (see start_netns_holder in docker_sandbox.rs), which uses this # same image — the actual agent container never runs iptables itself and diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index a894d05e..43aa7149 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -71,11 +71,24 @@ pub enum AgentDockerSubcommand { Build(AgentDockerBuildCommand), /// Check whether the Docker sandbox backend can run on this machine Doctor(AgentDockerDoctorCommand), + /// Open an interactive shell in the sandbox image with the persistent agent home mounted (e.g. to log in or install tools under $HOME) + Shell(AgentDockerShellCommand), } #[derive(Debug, Args)] pub struct AgentDockerDoctorCommand {} +#[derive(Debug, Args)] +pub struct AgentDockerShellCommand { + /// Image to start the shell in instead of the built-in default sandbox image + #[arg(long)] + pub image: Option, + + /// Run the shell as root instead of the user agent runs use (only differs on Linux; files created in $HOME may become unwritable for agent runs) + #[arg(long)] + pub root: bool, +} + #[derive(Debug, Args)] pub struct AgentDockerCleanupCommand { /// Remove every leftover resource without prompting for confirmation diff --git a/src/handlers/agent/docker.rs b/src/handlers/agent/docker.rs index 23f92ecf..738b9623 100644 --- a/src/handlers/agent/docker.rs +++ b/src/handlers/agent/docker.rs @@ -4,7 +4,7 @@ use anyhow::Result; use crate::cmd::agent::{ AgentDockerBuildCommand, AgentDockerCleanupCommand, AgentDockerDoctorCommand, - AgentDockerStatusCommand, + AgentDockerShellCommand, AgentDockerStatusCommand, }; use crate::handlers::run::docker_sandbox::ExistingRunNetwork; use crate::utils::output::{get_formatted_json_string, ColorizeIfColoredOutput}; @@ -449,6 +449,55 @@ pub async fn handle_docker_doctor_command( Ok(!all_ok) } +pub async fn handle_docker_shell_command(command: AgentDockerShellCommand) -> Result<()> { + use std::io::IsTerminal; + + use crate::handlers::run::docker_sandbox::{ + docker_enforcement_error, docker_shell_command_args, sandbox_image_exists, AgentImageSource, + }; + + if let Some(error) = docker_enforcement_error() { + anyhow::bail!("Docker sandbox backend unavailable: {error}"); + } + + let source = match command.image { + Some(image) => AgentImageSource::Image(image), + None => AgentImageSource::Default, + }; + if !sandbox_image_exists(&source) { + match source { + AgentImageSource::Default => { + anyhow::bail!( + "Default sandbox image is not built yet. Run `agent docker build` first." + ) + } + _ => anyhow::bail!("Image '{}' was not found locally.", source.image_tag()), + } + } + + eprintln!( + "{}", + "Only $HOME (/home/agent) persists after exit — system packages (apt) belong in a custom sandbox Dockerfile." + .yellow_if_tty() + ); + + let args = docker_shell_command_args( + &source.image_tag(), + command.root, + std::io::stdin().is_terminal(), + ); + let status = std::process::Command::new("docker") + .args(&args) + .status() + .map_err(|error| anyhow::anyhow!("failed to start `docker`: {error}"))?; + // Mirror the shell's own exit code (e.g. `exit 3`, or 130 after Ctrl-C) + // rather than turning it into an error message. + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index d2107452..5040cb69 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -633,6 +633,10 @@ pub async fn handle_cli(args: Cli) { Err(error) => Err(error), } } + crate::cmd::agent::AgentDockerSubcommand::Shell(command) => { + crate::handlers::agent::docker::handle_docker_shell_command(command) + .await + } }, AgentSubcommand::Logs(mut agent_logs) => match agent_logs.subcommand.take() { Some(AgentLogsSubcommand::List(list)) => { diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 31ae56d5..96bd7bc3 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -665,6 +665,40 @@ pub(crate) fn read_persistent_home_file(image: &str, relative_path: &str) -> Opt String::from_utf8(output.stdout).ok() } +/// Builds the `docker run` args for `agent docker shell`: an interactive +/// admin shell in `image` with the persistent home volume mounted exactly +/// as agent runs mount it. Deliberately none of a run's sandboxing (no +/// per-run network/firewall, no working-directory mount) — it's for +/// maintaining the shared home (logins, config, tools installed under +/// $HOME), not for doing work. Runs as the same user agent runs do unless +/// `as_root`, so files it creates in the volume stay writable for them. +pub(crate) fn docker_shell_command_args( + image: &str, + as_root: bool, + stdin_is_terminal: bool, +) -> Vec { + let mut args = vec!["run".to_owned(), "--rm".to_owned(), "-i".to_owned()]; + if stdin_is_terminal { + args.push("-t".to_owned()); + } + args.push("--init".to_owned()); + if !as_root { + args.extend(docker_run_user_flag_args()); + } + args.extend([ + "-v".to_owned(), + format!("{PERSISTENT_HOME_VOLUME}:{CONTAINER_HOME}"), + "-e".to_owned(), + format!("HOME={CONTAINER_HOME}"), + "-w".to_owned(), + CONTAINER_HOME.to_owned(), + "--entrypoint".to_owned(), + "bash".to_owned(), + image.to_owned(), + ]); + args +} + /// Builds the image for `source` (`Default` or `Dockerfile` only — `Image` /// has nothing to build and is rejected). Writes the Dockerfile to a /// temporary build context directory (Docker needs a real directory to @@ -2002,6 +2036,33 @@ mod tests { assert!(args.contains(&format!("HOME={CONTAINER_HOME}"))); } + #[test] + fn docker_shell_command_mounts_home_volume_and_runs_bash_in_image() { + let args = docker_shell_command_args("my/image:tag", false, true); + let joined = args.join(" "); + assert!(joined.starts_with("run --rm -i -t ")); + assert!(joined.contains(&format!("-v {PERSISTENT_HOME_VOLUME}:{CONTAINER_HOME}"))); + assert!(joined.contains(&format!("-e HOME={CONTAINER_HOME}"))); + assert!(joined.contains(&format!("-w {CONTAINER_HOME}"))); + assert!(joined.ends_with("--entrypoint bash my/image:tag")); + } + + #[test] + fn docker_shell_command_omits_pty_flag_when_stdin_is_not_a_terminal() { + let args = docker_shell_command_args("img", false, false); + assert!(!args.contains(&"-t".to_owned())); + } + + #[test] + fn docker_shell_command_uses_agent_run_user_unless_root() { + let user_args = docker_run_user_flag_args(); + let args = docker_shell_command_args("img", false, false); + assert!(user_args.iter().all(|arg| args.contains(arg))); + + let root_args = docker_shell_command_args("img", true, false); + assert!(!root_args.contains(&"--user".to_owned())); + } + #[test] fn docker_run_command_drops_capabilities_and_denies_new_privileges() { let network = DockerRunNetwork {