//! Docker implementation of the sandbox driver (bollard). Serves local //! development and the air-gapped compose target; podman works through the //! same API via `DOCKER_HOST`. use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, MountVolumeOptions}; use bollard::query_parameters::{ CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions, }; use bollard::Docker; use futures::StreamExt; use crate::spec::{ExecResult, ManagedSandbox, PtySession, SandboxHandle, SandboxSpec}; use crate::{SandboxDriver, SandboxError}; /// The seccomp deny profile, embedded so the Docker path needs no file /// distribution (single source of truth: images/seccomp/agent-profile.json). const SECCOMP_PROFILE: &str = include_str!("../../../images/seccomp/agent-profile.json"); pub struct DockerDriver { docker: Docker, } impl DockerDriver { pub fn connect() -> Result { // DOCKER_HOST (set in compose to the allow-listed socket proxy) // wins; otherwise the local socket. if let Ok(host) = std::env::var("DOCKER_HOST") { return DockerDriver::connect_to(&host); } let docker = Docker::connect_with_local_defaults() .map_err(|e| SandboxError::Engine(e.to_string()))?; Ok(DockerDriver { docker }) } /// Connects to a specific engine endpoint, e.g. the compose /// deployment's allow-listed socket proxy (`tcp://socket-proxy:2375`). pub fn connect_to(host: &str) -> Result { let docker = Docker::connect_with_http(host, 30, bollard::API_DEFAULT_VERSION) .map_err(|e| SandboxError::Engine(e.to_string()))?; Ok(DockerDriver { docker }) } /// Removes a container by name if it exists (test/restart hygiene). pub async fn destroy_by_name(&self, name: &str) -> Result<(), SandboxError> { self.docker .remove_container( name, Some(RemoveContainerOptions { force: true, ..Default::default() }), ) .await .map_err(|e| SandboxError::Engine(e.to_string())) } } #[async_trait::async_trait] impl SandboxDriver for DockerDriver { async fn provision(&self, spec: &SandboxSpec) -> Result { // Hardened tool sandboxes are read-only with a tmpfs home; the Terminal // flavour keeps a writable rootfs + home so its baked zsh/oh-my-zsh/p10k // config and shell history survive the session. Everything else (non-root, // cap-drop ALL, seccomp, no-new-privileges, limits) stays identical. let writable = spec.kind.writable_home(); let tmpfs = if writable { None } else { Some( [ ( "/tmp".to_owned(), "rw,noexec,nosuid,size=67108864".to_owned(), ), // Writable workspace; everything else is read-only. ( "/home/agent".to_owned(), "rw,nosuid,size=268435456,uid=10001,gid=10001".to_owned(), ), ] .into_iter() .collect(), ) }; // Per-agent volume-subpath mounts (the Terminal's Files drives). let mounts: Option> = if spec.mounts.is_empty() { None } else { Some( spec.mounts .iter() .map(|m| Mount { target: Some(m.target.clone()), source: Some(m.volume.clone()), typ: Some(MountTypeEnum::VOLUME), read_only: Some(m.read_only), // An empty subpath ⇒ mount the whole volume at the target // (the per-agent node-local drive volume, auto-created by // Docker). A non-empty subpath is per-agent isolation within // the shared gateway volume. volume_options: if m.subpath.is_empty() { None } else { Some(MountVolumeOptions { subpath: Some(m.subpath.clone()), ..Default::default() }) }, ..Default::default() }) .collect(), ) }; // The §15 controls, enforced unconditionally: let host_config = HostConfig { cap_drop: Some(vec!["ALL".into()]), security_opt: Some(vec![ "no-new-privileges:true".into(), format!("seccomp={SECCOMP_PROFILE}"), ]), readonly_rootfs: Some(!writable), tmpfs, mounts, network_mode: Some(if spec.egress { "bridge" } else { "none" }.into()), // Lets browser containers reach host-published test pages on // Linux engines; Docker Desktop resolves this name natively. extra_hosts: if spec.egress { Some(vec!["host.docker.internal:host-gateway".into()]) } else { None }, memory: Some(spec.memory_bytes), nano_cpus: Some(spec.nano_cpus), pids_limit: Some(spec.pids_limit), // Run tini as PID 1 so it reaps re-parented children (Chromium // spawns short-lived helper/crashpad processes). `sleep infinity` // never wait()s, so without this they accumulate as zombies and // eventually exhaust `pids_limit` in long-lived browser sandboxes. init: Some(true), ..Default::default() }; let body = ContainerCreateBody { image: Some(spec.image.clone()), user: Some(spec.kind.run_user().into()), cmd: Some(vec!["sleep".into(), "infinity".into()]), host_config: Some(host_config), // Mark every sandbox so the reaper can find orphans after a crash. labels: Some(std::collections::HashMap::from([( crate::SANDBOX_LABEL.to_string(), spec.kind.label().to_string(), )])), ..Default::default() }; let created = self .docker .create_container( Some(CreateContainerOptions { name: Some(spec.name.clone()), ..Default::default() }), body, ) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; self.docker .start_container(&created.id, None::) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; Ok(SandboxHandle { id: created.id, name: spec.name.clone(), }) } async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result { let exec = self .docker .create_exec( &handle.id, CreateExecOptions { cmd: Some(cmd.iter().map(|s| s.to_string()).collect()), attach_stdout: Some(true), attach_stderr: Some(true), ..Default::default() }, ) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; let mut stdout = String::new(); let mut stderr = String::new(); match self .docker .start_exec(&exec.id, None) .await .map_err(|e| SandboxError::Engine(e.to_string()))? { StartExecResults::Attached { mut output, .. } => { while let Some(chunk) = output.next().await { match chunk.map_err(|e| SandboxError::Engine(e.to_string()))? { bollard::container::LogOutput::StdOut { message } => { stdout.push_str(&String::from_utf8_lossy(&message)); } bollard::container::LogOutput::StdErr { message } => { stderr.push_str(&String::from_utf8_lossy(&message)); } _ => {} } } } StartExecResults::Detached => {} } let inspect = self .docker .inspect_exec(&exec.id) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; Ok(ExecResult { exit_code: inspect.exit_code.unwrap_or(-1), stdout, stderr, }) } async fn attach_pty( &self, handle: &SandboxHandle, cmd: &[&str], cols: u16, rows: u16, env: &[String], ) -> Result { let exec = self .docker .create_exec( &handle.id, CreateExecOptions { cmd: Some(cmd.iter().map(|s| s.to_string()).collect()), env: if env.is_empty() { None } else { Some(env.to_vec()) }, attach_stdin: Some(true), attach_stdout: Some(true), attach_stderr: Some(true), tty: Some(true), ..Default::default() }, ) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; match self .docker .start_exec(&exec.id, None) .await .map_err(|e| SandboxError::Engine(e.to_string()))? { StartExecResults::Attached { output, input } => { // Best-effort initial window size; resize_pty handles later changes. let _ = self .docker .resize_exec( &exec.id, ResizeExecOptions { height: rows, width: cols, }, ) .await; let bytes = output.map(|chunk| { chunk .map(|log| log.into_bytes().to_vec()) .map_err(|e| SandboxError::Engine(e.to_string())) }); Ok(PtySession { exec_id: exec.id, output: Box::pin(bytes), input, }) } StartExecResults::Detached => Err(SandboxError::Engine( "pty exec detached unexpectedly".into(), )), } } async fn resize_pty(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), SandboxError> { self.docker .resize_exec( exec_id, ResizeExecOptions { height: rows, width: cols, }, ) .await .map_err(|e| SandboxError::Engine(e.to_string())) } async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> { self.destroy_by_name(&handle.id).await } async fn health(&self, handle: &SandboxHandle) -> Result { match self .docker .inspect_container(&handle.id, None::) .await { Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)), Err(bollard::errors::Error::DockerResponseServerError { status_code: 404, .. }) => Ok(false), Err(e) => Err(SandboxError::Engine(e.to_string())), } } async fn list_managed(&self, kind: &str) -> Result, SandboxError> { use bollard::query_parameters::ListContainersOptions; let mut filters = std::collections::HashMap::new(); filters.insert( "label".to_string(), vec![format!("{}={}", crate::SANDBOX_LABEL, kind)], ); let opts = ListContainersOptions { all: true, // include stopped/exited orphans too filters: Some(filters), ..Default::default() }; let list = self .docker .list_containers(Some(opts)) .await .map_err(|e| SandboxError::Engine(e.to_string()))?; Ok(list .into_iter() .filter_map(|c| { c.id.map(|id| ManagedSandbox { id, created_unix: c.created.unwrap_or(0), }) }) .collect()) } }