Terminal app (xterm ⇄ WebSocket ⇄ per-agent themed container):
- zsh + oh-my-zsh + powerlevel10k image (agent-terminal), runs as uid 65532 to
share read-write ownership of the file-drive volume with the server.
- Interactive PTY in cm-sandbox (bollard exec tty/attach + resize) + a
TerminalManager; ticket-authed WS bridge routed straight to the backend via a
Traefik PathRegexp(/ws) rule. MOTD greets the user by name.
- tmux resumable sessions; multi-tab (one tmux session per tab, same container),
drag-to-reorder, rename, and a Save that persists named tabs to the server
(terminal_tabs, migration 0014) so they survive logout / a new device.
- Files drives mounted per-agent (subpath) at ~/drives/{documents,received,
shared}; a reconciler keeps the Files app's index in sync with terminal writes.
Storage moved to a shared `filedata` volume (CLAWMATES_STORAGE__DATA_DIR).
Obsidian vault (a markdown "second brain" per agent):
- New `vault` FileDrive (migration 0015) mounted into the terminal at ~/obsidian;
a file-content read route; a purple Obsidian tile + a vault viewer app.
Computer UI:
- Draggable computer-panel width (min = phone preset) keeping the size presets.
- Green Terminal glyph, "Claw Chat" → "Chat", colored gradient-outline app icons.
- Agent page: avatar↔activity-grid spacing + larger, uniform section fonts with
colored section-tinted tag chips.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
150 lines
4.6 KiB
Rust
150 lines
4.6 KiB
Rust
//! The compose deployment never hands the raw Docker socket to the
|
|
//! server: an allow-listed socket proxy (tecnativa/docker-socket-proxy)
|
|
//! sits between them. This suite runs the REAL proxy with the production
|
|
//! allowlist and proves (a) the full sandbox lifecycle works through it
|
|
//! and (b) operations outside the allowlist are refused.
|
|
|
|
use std::process::Command;
|
|
|
|
use cm_sandbox::{DockerDriver, SandboxDriver, SandboxSpec};
|
|
|
|
const PROXY_IMAGE: &str = "tecnativa/docker-socket-proxy:0.3";
|
|
const IMAGE: &str = "clawmates/agent-base:dev";
|
|
|
|
fn ensure_agent_image() {
|
|
let exists = Command::new("docker")
|
|
.args(["image", "inspect", IMAGE])
|
|
.output()
|
|
.expect("docker available")
|
|
.status
|
|
.success();
|
|
if !exists {
|
|
let root = env!("CARGO_MANIFEST_DIR");
|
|
let status = Command::new("docker")
|
|
.args([
|
|
"build",
|
|
"-t",
|
|
IMAGE,
|
|
"-f",
|
|
&format!("{root}/../../images/agent-base/Dockerfile"),
|
|
&format!("{root}/../../images/agent-base"),
|
|
])
|
|
.status()
|
|
.expect("docker build runs");
|
|
assert!(status.success());
|
|
}
|
|
}
|
|
|
|
/// Starts the proxy with EXACTLY the allowlist the compose file ships.
|
|
fn spawn_proxy() -> (String, String) {
|
|
let name = format!("tc-sockproxy-{}", std::process::id());
|
|
Command::new("docker")
|
|
.args(["rm", "-f", &name])
|
|
.output()
|
|
.ok();
|
|
let output = Command::new("docker")
|
|
.args([
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
&name,
|
|
"-p",
|
|
"0:2375",
|
|
"-v",
|
|
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
|
// The production allowlist (deploy/compose/docker-compose.yml):
|
|
// container lifecycle + exec, nothing else.
|
|
"-e",
|
|
"CONTAINERS=1",
|
|
"-e",
|
|
"POST=1",
|
|
"-e",
|
|
"EXEC=1",
|
|
"-e",
|
|
"DELETE=1",
|
|
"-e",
|
|
"VERSION=1",
|
|
PROXY_IMAGE,
|
|
])
|
|
.output()
|
|
.expect("docker run");
|
|
assert!(
|
|
output.status.success(),
|
|
"proxy start: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let port = Command::new("docker")
|
|
.args(["port", &name, "2375"])
|
|
.output()
|
|
.expect("docker port");
|
|
let mapping = String::from_utf8_lossy(&port.stdout);
|
|
let port = mapping
|
|
.lines()
|
|
.next()
|
|
.and_then(|line| line.rsplit(':').next())
|
|
.expect("mapped port")
|
|
.trim()
|
|
.to_owned();
|
|
(name, format!("tcp://127.0.0.1:{port}"))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_sandbox_lifecycle_works_through_the_allowlisted_proxy() {
|
|
ensure_agent_image();
|
|
let (proxy_name, docker_host) = spawn_proxy();
|
|
|
|
// The driver honors DOCKER_HOST — exactly how the compose server
|
|
// reaches the proxy.
|
|
let driver = DockerDriver::connect_to(&docker_host).expect("proxy reachable");
|
|
// The proxy needs a moment to come up.
|
|
let mut handle = None;
|
|
let spec = SandboxSpec {
|
|
name: format!("tc-proxy-test-{}", std::process::id()),
|
|
image: IMAGE.into(),
|
|
memory_bytes: 256 * 1024 * 1024,
|
|
nano_cpus: 500_000_000,
|
|
pids_limit: 64,
|
|
egress: false,
|
|
kind: cm_sandbox::SandboxKind::Agent,
|
|
mounts: Vec::new(),
|
|
};
|
|
for _ in 0..20 {
|
|
match driver.provision(&spec).await {
|
|
Ok(h) => {
|
|
handle = Some(h);
|
|
break;
|
|
}
|
|
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
|
|
}
|
|
}
|
|
let handle = handle.expect("provision through proxy");
|
|
|
|
let result = driver.exec(&handle, &["id", "-u"]).await.unwrap();
|
|
assert_eq!(result.stdout.trim(), "10001");
|
|
assert!(driver.health(&handle).await.unwrap());
|
|
driver.destroy(&handle).await.unwrap();
|
|
assert!(!driver.health(&handle).await.unwrap());
|
|
|
|
// Outside the allowlist: building images, listing networks, reading
|
|
// swarm secrets — the §15 blast-radius cap if the server is owned.
|
|
let client = reqwest::Client::new();
|
|
let base = docker_host.replace("tcp://", "http://");
|
|
for forbidden in ["/v1.43/networks", "/v1.43/secrets", "/v1.43/images/json"] {
|
|
let status = client
|
|
.get(format!("{base}{forbidden}"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status();
|
|
assert_eq!(
|
|
status, 403,
|
|
"{forbidden} must be refused by the proxy allowlist"
|
|
);
|
|
}
|
|
|
|
Command::new("docker")
|
|
.args(["rm", "-f", &proxy_name])
|
|
.output()
|
|
.ok();
|
|
}
|