anvilsign in

collin/anvil

1//! Docker specifics for an agent session: creating the container, seeding it,
2//! attaching a terminal to the tmux session inside, and tearing it down.
3//!
4//! The containment story is deliberately the CI one (see
5//! `docs/untrusted-mode.md` §1): all capabilities dropped, `no-new-privileges`,
6//! pids/memory/cpu caps, **no Docker socket, no bind mounts, no volumes**.
7//! Everything the container needs — the checkout and the agent's credentials —
8//! is uploaded as a tar through the Docker API, so nothing on the anvil host is
9//! ever exposed to it.
10
11use anvil_core::config::AgentConfig;
12use bollard::{
13 Docker,
14 container::{
15 Config,
16 CreateContainerOptions,
17 RemoveContainerOptions,
18 StartContainerOptions,
19 UploadToContainerOptions,
20 },
21 exec::{
22 CreateExecOptions,
23 ResizeExecOptions,
24 StartExecOptions,
25 StartExecResults,
26 },
27 models::HostConfig,
28};
29
30/// Label carrying the session id, so containers can be found again after a
31/// restart — the registry is in-memory and does not survive one.
32pub const LABEL_SESSION: &str = "anvil.session";
33
34/// Working directory inside the container, matching the CI runner's.
35pub const WORKDIR: &str = "/workspace";
36
37/// The unprivileged user `deploy/runner/Dockerfile` creates. Sessions run as
38/// this rather than root; CI keeps the image's default (root) because plenty of
39/// pipelines expect to `apt-get`.
40pub const RUN_AS: &str = "agent";
41
42/// That user's uid/gid, needed when building tars so the uploaded files are
43/// owned by the account that has to write them.
44const RUN_AS_UID: u64 = 1000;
45
46/// Home directory of [`RUN_AS`]; the agent CLI's config lives under it.
47pub const HOME: &str = "/home/agent";
48
49/// tmux session name, matching `session-entrypoint.sh`.
50pub const TMUX_SESSION: &str = "agent";
51
52/// Create (but do not start) a session container.
53pub async fn create(
54 docker: &Docker,
55 cfg: &AgentConfig,
56 session_id: i64,
57 env: &[(String, String)],
58 command: &[String],
59) -> Result<String, String> {
60 // The same sandbox CI uses. Limits of 0 mean "unlimited" and omit the cap.
61 //
62 // Note there is no `network_mode` opt-out: unlike a CI job, a session is
63 // useless without network — it has to reach the model API, and from M2 it
64 // clones and pushes back to anvil.
65 let host_config = HostConfig {
66 cap_drop: Some(vec!["ALL".to_string()]),
67 security_opt: Some(vec!["no-new-privileges:true".to_string()]),
68 pids_limit: (cfg.pids_limit > 0).then_some(cfg.pids_limit),
69 memory: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
70 memory_swap: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
71 nano_cpus: (cfg.cpus > 0.0).then_some((cfg.cpus * 1e9) as i64),
72 ..Default::default()
73 };
74
75 let mut cmd = vec!["anvil-session".to_string()];
76 cmd.extend(command.iter().cloned());
77
78 let config = Config {
79 image: Some(cfg.image.clone()),
80 cmd: Some(cmd),
81 env: Some(
82 env.iter()
83 .map(|(k, v)| format!("{k}={v}"))
84 .chain([format!("HOME={HOME}"), "TERM=xterm-256color".to_string()])
85 .collect(),
86 ),
87 working_dir: Some(WORKDIR.to_string()),
88 user: Some(RUN_AS.to_string()),
89 labels: Some(
90 [(LABEL_SESSION.to_string(), session_id.to_string())]
91 .into_iter()
92 .collect(),
93 ),
94 // A terminal program needs a tty even before anyone attaches, or tmux
95 // starts with a 80x24 dumb terminal and never recovers.
96 tty: Some(true),
97 open_stdin: Some(true),
98 host_config: Some(host_config),
99 ..Default::default()
100 };
101
102 let created = docker
103 .create_container(None::<CreateContainerOptions<String>>, config)
104 .await
105 .map_err(|e| format!("create session container: {e}"))?;
106 Ok(created.id)
107}
108
109/// Upload a tar into the container, rooted at `/`.
110pub async fn upload(docker: &Docker, id: &str, tar: Vec<u8>) -> Result<(), String> {
111 docker
112 .upload_to_container(
113 id,
114 Some(UploadToContainerOptions {
115 path: "/".to_string(),
116 ..Default::default()
117 }),
118 tar.into(),
119 )
120 .await
121 .map_err(|e| format!("upload to session container: {e}"))
122}
123
124/// Build a tar of `(path, contents, executable)` entries, rooted at `prefix`
125/// (no leading slash) and owned by the session user.
126///
127/// Emits an explicit entry for every intermediate **directory**, which matters
128/// more than it looks: a tar of files alone makes Docker create the parent
129/// directories itself, owned by root. The session runs as `agent`, so `src/`
130/// would come out root-owned and the agent could edit existing files but never
131/// add one — which is most of what a coding agent does.
132pub fn build_tar(prefix: &str, files: &[(String, Vec<u8>, bool)]) -> Vec<u8> {
133 let mut builder = tar::Builder::new(Vec::new());
134 let mtime = anvil_core::agent::now_secs().max(0) as u64;
135
136 let header_for = |size: u64, mode: u32, entry_type: tar::EntryType| {
137 let mut header = tar::Header::new_gnu();
138 header.set_size(size);
139 header.set_mode(mode);
140 header.set_entry_type(entry_type);
141 // Ownership matters: the container runs as `agent`, and a checkout it
142 // cannot write is not a workspace.
143 header.set_uid(RUN_AS_UID);
144 header.set_gid(RUN_AS_UID);
145 // Without this every file lands in 1970, which upsets anything that
146 // compares timestamps (make, and incremental builds generally).
147 header.set_mtime(mtime);
148 header
149 };
150
151 // Every ancestor directory, deduplicated and shortest-first so parents are
152 // created before their children.
153 let mut dirs: Vec<String> = Vec::new();
154 for (path, _, _) in files {
155 let mut parts: Vec<&str> = path.split('/').collect();
156 parts.pop(); // the file itself
157 let mut acc = String::new();
158 for part in parts {
159 if !acc.is_empty() {
160 acc.push('/');
161 }
162 acc.push_str(part);
163 if !dirs.contains(&acc) {
164 dirs.push(acc.clone());
165 }
166 }
167 }
168 dirs.sort_by_key(|d| d.matches('/').count());
169
170 // The root itself, so `/workspace` is agent-owned even when empty.
171 let mut header = header_for(0, 0o755, tar::EntryType::Directory);
172 let _ = builder.append_data(&mut header, format!("{prefix}/"), std::io::empty());
173 for dir in &dirs {
174 let mut header = header_for(0, 0o755, tar::EntryType::Directory);
175 let _ = builder.append_data(&mut header, format!("{prefix}/{dir}/"), std::io::empty());
176 }
177
178 for (path, content, executable) in files {
179 let mode = if *executable { 0o755 } else { 0o644 };
180 let mut header = header_for(content.len() as u64, mode, tar::EntryType::Regular);
181 let full = format!("{prefix}/{path}");
182 if builder
183 .append_data(&mut header, &full, content.as_slice())
184 .is_err()
185 {
186 continue;
187 }
188 }
189 builder.into_inner().unwrap_or_default()
190}
191
192/// Read a host directory into `(relative path, bytes, executable)` entries.
193///
194/// Used for the agent CLI's credentials directory, which is uploaded rather
195/// than bind-mounted so the no-mounts invariant survives.
196pub fn read_dir_recursive(root: &std::path::Path) -> Result<Vec<(String, Vec<u8>, bool)>, String> {
197 fn walk(
198 base: &std::path::Path,
199 dir: &std::path::Path,
200 out: &mut Vec<(String, Vec<u8>, bool)>,
201 ) -> std::io::Result<()> {
202 for entry in std::fs::read_dir(dir)? {
203 let entry = entry?;
204 let path = entry.path();
205 let meta = entry.metadata()?;
206 if meta.is_dir() {
207 walk(base, &path, out)?;
208 } else if meta.is_file() {
209 let Ok(rel) = path.strip_prefix(base) else {
210 continue;
211 };
212 let executable = {
213 #[cfg(unix)]
214 {
215 use std::os::unix::fs::PermissionsExt;
216 meta.permissions().mode() & 0o111 != 0
217 }
218 #[cfg(not(unix))]
219 {
220 false
221 }
222 };
223 out.push((
224 rel.to_string_lossy().replace('\\', "/"),
225 std::fs::read(&path)?,
226 executable,
227 ));
228 }
229 }
230 Ok(())
231 }
232
233 let mut out = Vec::new();
234 walk(root, root, &mut out).map_err(|e| format!("reading {}: {e}", root.display()))?;
235 Ok(out)
236}
237
238/// Start a created container.
239pub async fn start(docker: &Docker, id: &str) -> Result<(), String> {
240 docker
241 .start_container(id, None::<StartContainerOptions<String>>)
242 .await
243 .map_err(|e| format!("start session container: {e}"))
244}
245
246/// A live terminal attached to the container's tmux session.
247pub struct Terminal {
248 /// Docker exec id, needed to resize the pty.
249 pub exec_id: String,
250 /// Bytes the terminal produces.
251 pub output: std::pin::Pin<
252 Box<
253 dyn futures_util::Stream<
254 Item = Result<bollard::container::LogOutput, bollard::errors::Error>,
255 > + Send,
256 >,
257 >,
258 /// Keystrokes go here.
259 pub input: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
260}
261
262/// Attach a new tmux client to the container's session.
263///
264/// Each caller gets its own `docker exec`, which is the point: a dropped
265/// websocket kills that client only, never the agent, because the agent is a
266/// process inside tmux rather than a child of the exec.
267pub async fn attach(docker: &Docker, id: &str, cols: u16, rows: u16) -> Result<Terminal, String> {
268 let exec = docker
269 .create_exec(
270 id,
271 CreateExecOptions {
272 attach_stdin: Some(true),
273 attach_stdout: Some(true),
274 attach_stderr: Some(true),
275 tty: Some(true),
276 user: Some(RUN_AS.to_string()),
277 env: Some(vec![
278 "TERM=xterm-256color".to_string(),
279 format!("HOME={HOME}"),
280 ]),
281 cmd: Some(vec![
282 "tmux".to_string(),
283 "-f".to_string(),
284 "/etc/anvil/tmux.conf".to_string(),
285 "attach-session".to_string(),
286 "-t".to_string(),
287 TMUX_SESSION.to_string(),
288 ]),
289 ..Default::default()
290 },
291 )
292 .await
293 .map_err(|e| format!("create attach exec: {e}"))?;
294
295 let started = docker
296 .start_exec(
297 &exec.id,
298 Some(StartExecOptions {
299 detach: false,
300 tty: true,
301 ..Default::default()
302 }),
303 )
304 .await
305 .map_err(|e| format!("start attach exec: {e}"))?;
306
307 let StartExecResults::Attached { output, input } = started else {
308 return Err("attach exec detached unexpectedly".to_string());
309 };
310
311 // Size the pty before the first byte, so the TUI lays out correctly rather
312 // than redrawing from an 80x24 assumption.
313 let terminal = Terminal {
314 exec_id: exec.id,
315 output,
316 input,
317 };
318 resize(docker, &terminal.exec_id, cols, rows).await;
319 Ok(terminal)
320}
321
322/// Resize an attached terminal. Best-effort: a resize racing the exec's exit is
323/// routine and not worth failing an attach over.
324pub async fn resize(docker: &Docker, exec_id: &str, cols: u16, rows: u16) {
325 if cols == 0 || rows == 0 {
326 return;
327 }
328 if let Err(e) = docker
329 .resize_exec(
330 exec_id,
331 ResizeExecOptions {
332 height: rows,
333 width: cols,
334 },
335 )
336 .await
337 {
338 tracing::debug!("resize exec {exec_id}: {e}");
339 }
340}
341
342/// Run a one-shot command in the container and collect its stdout.
343///
344/// This is the `capture-pane` / `send-keys` path: short, non-interactive tmux
345/// control commands rather than a terminal.
346pub async fn exec_capture(docker: &Docker, id: &str, cmd: &[&str]) -> Result<Vec<u8>, String> {
347 use futures_util::StreamExt;
348
349 let exec = docker
350 .create_exec(
351 id,
352 CreateExecOptions {
353 attach_stdout: Some(true),
354 attach_stderr: Some(true),
355 tty: Some(false),
356 user: Some(RUN_AS.to_string()),
357 env: Some(vec![format!("HOME={HOME}")]),
358 cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
359 ..Default::default()
360 },
361 )
362 .await
363 .map_err(|e| format!("create exec {cmd:?}: {e}"))?;
364
365 let started = docker
366 .start_exec(&exec.id, None)
367 .await
368 .map_err(|e| format!("start exec {cmd:?}: {e}"))?;
369
370 let StartExecResults::Attached { mut output, .. } = started else {
371 return Ok(Vec::new());
372 };
373
374 let mut buf = Vec::new();
375 while let Some(chunk) = output.next().await {
376 match chunk {
377 Ok(log) => buf.extend_from_slice(log.into_bytes().as_ref()),
378 Err(e) => return Err(format!("exec {cmd:?}: {e}")),
379 }
380 }
381 Ok(buf)
382}
383
384/// The tmux pane's current screen plus scrollback, with escape sequences kept.
385///
386/// This is what a reconnecting browser is sent before the live stream, so it
387/// resumes with the screen it left rather than a blank one.
388pub async fn capture_pane(docker: &Docker, id: &str) -> Result<Vec<u8>, String> {
389 exec_capture(
390 docker,
391 id,
392 &[
393 "tmux",
394 "capture-pane",
395 "-p",
396 "-e",
397 "-S",
398 "-",
399 "-t",
400 TMUX_SESSION,
401 ],
402 )
403 .await
404}
405
406/// Type a line into the tmux session, as if the user had.
407///
408/// This is how an autonomous session is driven: rather than a headless
409/// `-p` invocation, the same interactive agent gets its prompt typed at it, so
410/// a human can take over mid-run just by attaching.
411pub async fn send_keys(docker: &Docker, id: &str, text: &str) -> Result<(), String> {
412 exec_capture(
413 docker,
414 id,
415 &["tmux", "send-keys", "-t", TMUX_SESSION, text, "Enter"],
416 )
417 .await
418 .map(|_| ())
419}
420
421/// Force-remove the container. Best-effort; a container already gone is a
422/// success as far as callers are concerned.
423pub async fn remove(docker: &Docker, id: &str) {
424 if let Err(e) = docker
425 .remove_container(
426 id,
427 Some(RemoveContainerOptions {
428 force: true,
429 ..Default::default()
430 }),
431 )
432 .await
433 {
434 tracing::debug!("removing session container {id}: {e}");
435 }
436}
437
438/// Session containers Docker still knows about, as `(container id, session id)`.
439///
440/// Startup uses this to reconcile rows against reality: the registry is
441/// in-memory, so after a restart every one of these is an orphan.
442pub async fn list_sessions(docker: &Docker) -> Result<Vec<(String, i64)>, String> {
443 use bollard::container::ListContainersOptions;
444
445 let containers = docker
446 .list_containers(Some(ListContainersOptions::<String> {
447 all: true,
448 filters: [("label".to_string(), vec![LABEL_SESSION.to_string()])]
449 .into_iter()
450 .collect(),
451 ..Default::default()
452 }))
453 .await
454 .map_err(|e| format!("listing session containers: {e}"))?;
455
456 Ok(containers
457 .into_iter()
458 .filter_map(|c| {
459 let id = c.id?;
460 let session_id = c.labels?.get(LABEL_SESSION)?.parse().ok()?;
461 Some((id, session_id))
462 })
463 .collect())
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn entries(tar: &[u8]) -> Vec<(String, tar::EntryType, u64, u32)> {
471 let mut archive = tar::Archive::new(tar);
472 archive
473 .entries()
474 .unwrap()
475 .map(|e| {
476 let e = e.unwrap();
477 let header = e.header();
478 (
479 e.path().unwrap().to_string_lossy().into_owned(),
480 header.entry_type(),
481 header.uid().unwrap(),
482 header.mode().unwrap(),
483 )
484 })
485 .collect()
486 }
487
488 /// The bug this guards: a tar of files alone leaves Docker to create the
489 /// parent directories, owned by root. The session runs unprivileged, so
490 /// `src/` came out root-owned and the agent could edit `src/main.rs` but
491 /// never add `src/lib.rs` — most of what a coding agent does.
492 #[test]
493 fn intermediate_directories_are_present_and_agent_owned() {
494 let files = vec![
495 ("README.md".to_string(), b"hi".to_vec(), false),
496 ("src/main.rs".to_string(), b"fn main() {}".to_vec(), false),
497 ("a/b/c/deep.txt".to_string(), b"deep".to_vec(), false),
498 ];
499 let entries = entries(&build_tar("workspace", &files));
500
501 let dirs: Vec<_> = entries
502 .iter()
503 .filter(|(_, t, _, _)| *t == tar::EntryType::Directory)
504 .map(|(p, _, _, _)| p.trim_end_matches('/').to_string())
505 .collect();
506 for expected in [
507 "workspace",
508 "workspace/src",
509 "workspace/a",
510 "workspace/a/b",
511 "workspace/a/b/c",
512 ] {
513 assert!(
514 dirs.contains(&expected.to_string()),
515 "missing dir {expected}: {dirs:?}"
516 );
517 }
518
519 // Everything, files and directories alike, must belong to the session
520 // user or the workspace is read-only in practice.
521 for (path, _, uid, _) in &entries {
522 assert_eq!(*uid, RUN_AS_UID, "{path} is not owned by the session user");
523 }
524 }
525
526 /// Parents must precede their children, or the ownership set on a
527 /// directory entry is applied to one Docker already made as root.
528 #[test]
529 fn parents_are_written_before_their_children() {
530 let files = vec![("a/b/c/deep.txt".to_string(), b"x".to_vec(), false)];
531 let entries = entries(&build_tar("workspace", &files));
532 let order: Vec<_> = entries.iter().map(|(p, _, _, _)| p.as_str()).collect();
533
534 let index = |needle: &str| order.iter().position(|p| p.trim_end_matches('/') == needle);
535 let root = index("workspace").expect("root dir");
536 let a = index("workspace/a").expect("a");
537 let b = index("workspace/a/b").expect("b");
538 let c = index("workspace/a/b/c").expect("c");
539 let file = index("workspace/a/b/c/deep.txt").expect("file");
540 assert!(
541 root < a && a < b && b < c && c < file,
542 "wrong order: {order:?}"
543 );
544 }
545
546 #[test]
547 fn executables_keep_their_bit() {
548 let files = vec![
549 ("run.sh".to_string(), b"#!/bin/sh\n".to_vec(), true),
550 ("plain.txt".to_string(), b"x".to_vec(), false),
551 ];
552 let entries = entries(&build_tar("workspace", &files));
553 let mode = |name: &str| {
554 entries
555 .iter()
556 .find(|(p, _, _, _)| p == name)
557 .map(|(_, _, _, m)| *m)
558 .unwrap()
559 };
560 assert_eq!(mode("workspace/run.sh") & 0o111, 0o111);
561 assert_eq!(mode("workspace/plain.txt") & 0o111, 0);
562 }
563}