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, differing only in base:
52
53| Tag | Base | For |
54| -------------------- | --------------------- | -------------------------- |
55| `anvil-runner:latest`| `ubuntu:26.04` | the default, general work |
56| `anvil-runner:rust` | `rust:1.95-bookworm` | pipelines needing the toolchain |
57
58**There is no registry behind this image.** It is built straight into the
59Docker daemon's local store, so it must be built on whichever host owns the
60socket anvil talks to. Because of that, `anvil_ci::docker::ensure_image` treats
61a failed pull as non-fatal when the image is already present locally — an
62unconditional pull, which is what CI used to do, fails for exactly this image.
63
64A pipeline may still name any image it likes; `image:` in `.anvil/ci.yml` is now
65simply optional, and omitting it selects `ci.default_image`. The default image
66is always permitted regardless of `ci.allowed_images`, since a pipeline that
67omits `image:` never named anything for an operator to allow.
68
69## Lifecycle
70
71- **Start** (`POST /{owner}/{repo}/-/agent`, owner-only) creates the row, then
72 creates, seeds and starts the container. The workspace is the commit's files
73 uploaded as a tar, exactly as CI seeds a job — no `.git` yet, so no push
74 credential exists to leak.
75- **Credentials** are uploaded the same way: `agent.credentials_dir` on the host
76 (a `~/.claude`) is tarred into the container's home. Never bind-mounted, so
77 the no-mounts invariant in the threat model still holds. Note the corollary —
78 a token the CLI refreshes *inside* the container is refreshed on a copy, and
79 is lost when the session ends.
80- **Attach** (`GET …/-/agent/{id}/ws`) opens a `docker exec … tmux attach` per
81 browser and bridges it to the websocket.
82- **Autonomous** sessions are the same interactive agent with the opening prompt
83 typed at it via `tmux send-keys`, rather than a separate headless mode. A
84 human can take over mid-run just by attaching — there is nothing to bail out
85 of.
86- **End**: the container exits when its tmux session does, and the entrypoint
87 propagates the agent's exit code, so `docker wait` is a valid completion
88 signal. The sweep also ends sessions past `agent.idle_timeout_secs` (with no
89 viewer attached *and* no output) or `agent.max_lifetime_secs`.
90- **Restart**: containers outlive anvil, and the live-session registry is
91 in-memory. Startup therefore reaps every container labelled `anvil.session`
92 and marks every row that still claims to be live. A terminal session cannot
93 be resumed the way `ci::requeue_interrupted` re-queues a job, so it ends.
94
95## The websocket protocol
96
97wterm ships a `WebSocketTransport`, and anvil deliberately does not use it: it
98is a raw byte pass-through with no control channel — no way to carry a resize —
99and a blind reconnect that would reattach without a repaint. The protocol here
100is a few lines instead:
101
102| Frame | Direction | Meaning |
103| ------ | --------- | ---------------------------------------------- |
104| binary | both | raw terminal bytes |
105| text | browser→ | `{"t":"resize","cols":N,"rows":M}` → `resize_exec` |
106| text | →browser | `{"t":"exit","code":N}` / `{"t":"error","message":…}` |
107
108## The terminal
109
110[wterm](https://wterm.dev) (Apache-2.0), vendored as published ESM under
111`crates/anvil-web/assets/wterm/` and embedded in the binary exactly as htmx is.
112**No bundler**: the tree has a single bare specifier (`@wterm/core`), which the
113page resolves with a three-line import map. The built-in core's WASM is inlined
114as base64, so there is no second request and no `import.meta.url` resolution to
115get wrong.
116
117It is DOM-first, which is why it was chosen over xterm.js: real browser text
118selection and find work on an agent transcript. The built-in core handles the
119alternate screen buffer, mouse tracking, bracketed paste, synchronized output
120and OSC 8 links, which is the surface a Claude Code TUI uses. If a TUI ever
121glitches, `@wterm/ghostty` (libghostty's VT parser, ~400K) is a documented
122drop-in: `new WTerm(el, { core })`.
123
124The session page tears the socket and the WASM instance down on
125`htmx:beforeSwap`. The layout sets `hx-boost` on `<body>`, so without that
126teardown both would leak on every navigation.
127
128## Configuration
129
130See the `[agent]` block in `anvil.example.toml`. The knobs that matter:
131`enabled`, `credentials_dir`, `max_concurrent` (each session holds a container
132open, so this is the real resource bound), `idle_timeout_secs` and
133`max_lifetime_secs`.
134
135`memory_mb` defaults to 4096 rather than CI's 2048 because Claude Code asks for
1364 GB. Unlike CI there is no network opt-out: a session cannot work without
137reaching the model API.
138
139## Not yet done
140
141- **A real checkout.** M1 seeds files only. Giving the container a clone with
142 history and a working remote needs a push credential, which does not exist
143 today (tokens are read-only, and Bearer is accepted only on GET/HEAD).
144- **Ref scoping for that credential.** Restricting a session to write only
145 `refs/heads/agent/*` needs a ref filter in receive-pack. Until it exists, any
146 push credential handed to a session could write `main`.
147- **Trigger surfaces** from a TODO item, an issue, or a red CI run.
148- **Rate limiting.** Nothing stops automated pushes from queueing sessions
149 once triggers land; `max_concurrent` bounds concurrency, not churn.