anvilsign in

collin/anvil

RenderedSource

1# Agent sessions
2
3An **agent session** is a long-lived container running tmux plus an agent CLI
4(Claude Code) against one repository, attached from a terminal in the browser.
5
6It is off by default. Turn it on with `agent.enabled` in `anvil.toml`, and read
7[untrusted-mode.md](untrusted-mode.md) §7 first — a session is a genuinely
8different exposure from CI.
9
10```
11 browser (wterm) <--websocket--> anvil-web
12 |
13 docker exec (tty)
14 v
15 supervisor ---- tail -F ----> [ container: tmux -> claude ]
16 | |
17 '--> transcript on disk pipe-pane
18```
19
20## Why tmux
21
22With a long-lived container you could just `docker exec -it` a shell. tmux earns
23its place for four reasons, in order of importance:
24
251. **A dropped websocket must not kill the agent.** An exec session is tied to
26 its client. The agent is a process *inside* tmux rather than a child of the
27 exec, so closing the tab detaches a client and nothing more. This is the
28 decisive one.
292. **`tmux pipe-pane`** gives a byte-exact transcript that keeps recording
30 whether or not anyone is watching — the storage story for autonomous runs.
313. **tmux is already the multiplexer.** Several browsers attach to the same
32 session and tmux repaints each one, so reconnect shows the current screen
33 rather than a blank one. anvil needs no fan-out channel of its own.
344. Multiple windows (agent / shell / `git log`) without more containers.
35
36(2) and (3) are the two hard problems in a web terminal, and tmux solves both.
37
38## The shared runner image
39
40`deploy/runner/Dockerfile` builds **one** image used by both CI jobs and agent
41sessions, so a session can reproduce a build by hand and there is one thing to
42keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed
43from Anthropic's signed apt repository, with the release key's fingerprint
44pinned in the Dockerfile).
45
46```
47./deploy/runner/build.sh # anvil-runner:latest and :rust
48./deploy/runner/build.sh latest # just the slim one
49```
50
51Two tags from one recipe on one base, differing only in whether the Rust
52toolchain is installed:
53
54| Tag | Base | For |
55| -------------------- | ------------------------ | ------------------------------- |
56| `anvil-runner:latest`| `ubuntu:26.04` | the default, general work |
57| `anvil-runner:rust` | `ubuntu:26.04` + rustup 1.98.0 | pipelines needing the toolchain |
58
59**There is no registry behind this image.** It is built straight into the
60Docker daemon's local store, so it must be built on whichever host owns the
61socket anvil talks to. Because of that, `anvil_ci::docker::ensure_image` treats
62a failed pull as non-fatal when the image is already present locally — an
63unconditional pull, which is what CI used to do, fails for exactly this image.
64
65A pipeline may still name any image it likes; `image:` in `.anvil/ci.yml` is now
66simply optional, and omitting it selects `ci.default_image`. The default image
67is always permitted regardless of `ci.allowed_images`, since a pipeline that
68omits `image:` never named anything for an operator to allow.
69
70## Lifecycle
71
72- **Start** (`POST /{owner}/{repo}/-/agent`, owner-only) creates the row, then
73 creates, seeds and starts the container. The workspace is the commit's files
74 uploaded as a tar, exactly as CI seeds a job — no `.git` yet, so no push
75 credential exists to leak.
76- **Credentials** are uploaded the same way: `agent.credentials_dir` on the host
77 (a `~/.claude`) is tarred into the container's home. Never bind-mounted, so
78 the no-mounts invariant in the threat model still holds. Note the corollary —
79 a token the CLI refreshes *inside* the container is refreshed on a copy, and
80 is lost when the session ends.
81- **Attach** (`GET …/-/agent/{id}/ws`) opens a `docker exec … tmux attach` per
82 browser and bridges it to the websocket.
83- **Autonomous** sessions are the same interactive agent with the opening prompt
84 typed at it via `tmux send-keys`, rather than a separate headless mode. A
85 human can take over mid-run just by attaching — there is nothing to bail out
86 of.
87- **End**: the container exits when its tmux session does, and the entrypoint
88 propagates the agent's exit code, so `docker wait` is a valid completion
89 signal. The sweep also ends sessions past `agent.idle_timeout_secs` (with no
90 viewer attached *and* no output) or `agent.max_lifetime_secs`.
91- **Restart**: containers outlive anvil, and the live-session registry is
92 in-memory. Startup therefore reaps every container labelled `anvil.session`
93 and marks every row that still claims to be live. A terminal session cannot
94 be resumed the way `ci::requeue_interrupted` re-queues a job, so it ends.
95
96## The websocket protocol
97
98wterm ships a `WebSocketTransport`, and anvil deliberately does not use it: it
99is a raw byte pass-through with no control channel — no way to carry a resize —
100and a blind reconnect that would reattach without a repaint. The protocol here
101is a few lines instead:
102
103| Frame | Direction | Meaning |
104| ------ | --------- | ---------------------------------------------- |
105| binary | both | raw terminal bytes |
106| text | browser→ | `{"t":"resize","cols":N,"rows":M}` → `resize_exec` |
107| text | →browser | `{"t":"exit","code":N}` / `{"t":"error","message":…}` |
108
109## The terminal
110
111[wterm](https://wterm.dev) (Apache-2.0), vendored as published ESM under
112`crates/anvil-web/assets/wterm/` and embedded in the binary exactly as htmx is.
113**No bundler**: the tree has a single bare specifier (`@wterm/core`), which the
114page resolves with a three-line import map. The built-in core's WASM is inlined
115as base64, so there is no second request and no `import.meta.url` resolution to
116get wrong.
117
118It is DOM-first, which is why it was chosen over xterm.js: real browser text
119selection and find work on an agent transcript. The built-in core handles the
120alternate screen buffer, mouse tracking, bracketed paste, synchronized output
121and OSC 8 links, which is the surface a Claude Code TUI uses. If a TUI ever
122glitches, `@wterm/ghostty` (libghostty's VT parser, ~400K) is a documented
123drop-in: `new WTerm(el, { core })`.
124
125The session page tears the socket and the WASM instance down on
126`htmx:beforeSwap`. The layout sets `hx-boost` on `<body>`, so without that
127teardown both would leak on every navigation.
128
129## Configuration
130
131See the `[agent]` block in `anvil.example.toml`. The knobs that matter:
132`enabled`, `credentials_dir`, `max_concurrent` (each session holds a container
133open, so this is the real resource bound), `idle_timeout_secs` and
134`max_lifetime_secs`.
135
136`memory_mb` defaults to 4096 rather than CI's 2048 because Claude Code asks for
1374 GB. Unlike CI there is no network opt-out: a session cannot work without
138reaching the model API.
139
140## Not yet done
141
142- **A real checkout.** M1 seeds files only. Giving the container a clone with
143 history and a working remote needs a push credential, which does not exist
144 today (tokens are read-only, and Bearer is accepted only on GET/HEAD).
145- **Ref scoping for that credential.** Restricting a session to write only
146 `refs/heads/agent/*` needs a ref filter in receive-pack. Until it exists, any
147 push credential handed to a session could write `main`.
148- **Trigger surfaces** from a TODO item, an issue, or a red CI run.
149- **Rate limiting.** Nothing stops automated pushes from queueing sessions
150 once triggers land; `max_concurrent` bounds concurrency, not churn.