Files
clawmates/crates/cm-sandbox/src/docker.rs
T
Omar SobhandClaude Opus 4.8 3554a3aaf2
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 23s
ci / rust (push) Failing after 27s
ci / e2e (push) Has been skipped
CI: remove k8s stages, fix the Docker-level pipeline green
Survey + fixes so the pipeline passes at the Docker level (no k8s).

- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
  "Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
  - `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
  - clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
    fleet.rs doc list indentation, node_rules map_or→is_none_or).
  - Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
    query → offline compile failed). DB-backed tests use testcontainers at runtime.
  - Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
    the committed cache deterministically (no DB needed at compile time).
- Frontend job:
  - Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
    tag the slice with agentId + derive null on mismatch).
  - Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
    current APP_IDS + use a genuinely-unknown id for the reject case).

Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 18:15:31 -07:00

346 lines
13 KiB
Rust

//! 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<DockerDriver, SandboxError> {
// 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<DockerDriver, SandboxError> {
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<SandboxHandle, SandboxError> {
// 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<Vec<Mount>> = 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),
..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::<StartContainerOptions>)
.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<ExecResult, SandboxError> {
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<PtySession, SandboxError> {
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<bool, SandboxError> {
match self
.docker
.inspect_container(&handle.id, None::<InspectContainerOptions>)
.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<Vec<ManagedSandbox>, 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())
}
}