Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docker/agent-sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/cmd/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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
Expand Down
51 changes: 50 additions & 1 deletion src/handlers/agent/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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::*;
Expand Down
4 changes: 4 additions & 0 deletions src/handlers/entry/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) => {
Expand Down
61 changes: 61 additions & 0 deletions src/handlers/run/docker_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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 {
Expand Down
Loading