anvilsign in

collin/anvil · 0edc9bd2

Gather the three Dockerfiles under docker/

Collin Richards · 2026-08-25 04:09 UTC · 0edc9bd2486de54abf4bd9975baf33c9a862b716 · parent 1404924c · browse files

modified.gitignore+4 −3
⋯ 3 unchanged lines
44 *.db-wal
55 *.db-shm
66 anvil.toml
7-/deploy/anvild
8-# Staged by `./deploy/build.sh --worker` for the local runner containers.
9-/deploy/anvil-worker
7+# Binaries staged by ./deploy/build.sh beside the Dockerfile that COPYs each.
8+/docker/anvil/anvild
9+# `--worker` only; for the local runner containers.
10+/docker/worker/anvil-worker
1011 # CA bundle deploy/dev.sh builds so the dev container trusts portless (docs/oidc.md).
1112 /deploy/dev-ca.crt
1213 # Local-only API credentials for fetching attachments (never committed).
⋯ 1 unchanged line
modifiedCLAUDE.md+1 −1
⋯ 19 unchanged lines
2020
2121 **The Rust version is set in exactly one place: `rust-toolchain.toml`.** It
2222 pins the toolchain and the musl cross target for every `cargo` invocation here,
23-and `deploy/runner/build.sh` parses `[toolchain] channel` out of it to bake the
23+and `docker/runner/build.sh` parses `[toolchain] channel` out of it to bake the
2424 same version into `anvil-runner:rust` — so a CI job and a checkout compile with
2525 the same rustc. Bumping Rust means editing that file and Cargo.toml's floor
2626 (the hook catches you if you forget the second), then rebuilding the image.
⋯ 110 unchanged lines
modifiedDEPLOY.md+2 −2
⋯ 29 unchanged lines
3030
3131 1. `deploy/build.sh` — `cargo zigbuild --release --target
3232 x86_64-unknown-linux-musl` cross-compiles a fully static binary natively
33- (~2 min, no emulation) and stages it at `deploy/anvild`,
33+ (~2 min, no emulation) and stages it at `docker/anvil/anvild`,
3434 2. `hag deploy` — `docker compose build --push` builds the thin image that just
3535 `COPY`s that binary in (the `Dockerfile` compiles nothing), pushes it, then
3636 drives the host.
⋯ 12 unchanged lines
4949 The Rust toolchain itself needs no setup step: `rust-toolchain.toml` pins the
5050 version *and* the `x86_64-unknown-linux-musl` target, and rustup installs both
5151 on the first `cargo` invocation in the repo. That file is the one place the
52-Rust version is set — `deploy/runner/build.sh` reads it to bake the same
52+Rust version is set — `docker/runner/build.sh` reads it to bake the same
5353 toolchain into `anvil-runner:rust`.
5454
5555 Every build is tagged twice: with the short git sha of the checkout (plus a
⋯ 223 unchanged lines
deletedDockerfile+0 −33
1-# anvil runtime image — just the prebuilt binary, no compilation in Docker.
2-#
3-# The binary is cross-compiled on the build host into a fully static
4-# x86_64-musl executable (see deploy/build.sh: `cargo zigbuild --target
5-# x86_64-unknown-linux-musl`), then staged at deploy/anvild and copied in here.
6-# So building this image is a fast `COPY` — no QEMU-emulated release build, and
7-# the VPS never compiles anything.
8-
9-FROM ubuntu:26.04
10-
11-# No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the
12-# push-mirroring client.
13-RUN apt-get update \
14- && apt-get install -y --no-install-recommends ca-certificates \
15- && rm -rf /var/lib/apt/lists/* \
16- && useradd --system --user-group --home-dir /data anvil \
17- && mkdir -p /data /etc/anvil \
18- && chown -R anvil:anvil /data
19-
20-# Prebuilt static binary staged by deploy/build.sh.
21-COPY deploy/anvild /usr/local/bin/anvild
22-
23-# Which baked config to ship: production's by default, the committed local one
24-# when deploy/dev.sh builds the image.
25-ARG CONFIG=deploy/anvil.toml
26-COPY ${CONFIG} /etc/anvil/anvil.toml
27-
28-EXPOSE 3000 2222
29-VOLUME /data
30-USER anvil
31-
32-ENTRYPOINT ["/usr/local/bin/anvild"]
33-CMD ["-c", "/etc/anvil/anvil.toml", "serve"]
modifiedanvil.example.toml+1 −1
⋯ 75 unchanged lines
7676 # User inside the job container, e.g. "1000:1000". Empty keeps the image default.
7777 run_as = ""
7878 # Image for a pipeline that omits `image:`. This is anvil's own runner, shared
79-# with agent sessions and built by deploy/runner/build.sh — it carries tmux,
79+# with agent sessions and built by docker/runner/build.sh — it carries tmux,
8080 # git, fish and Claude Code. It is a LOCAL image with no registry behind it, so
8181 # anvil falls back to the local copy when the pull fails. Always allowed,
8282 # whatever allowed_images says.
⋯ 57 unchanged lines
modifiedcompose.override.yaml+1 −1
⋯ 30 unchanged lines
3131 image: anvil-worker-dev:latest
3232 build:
3333 context: .
34- dockerfile: deploy/worker/Dockerfile
34+ dockerfile: docker/worker/Dockerfile
3535 platforms:
3636 - linux/amd64
3737 restart: unless-stopped
⋯ 118 unchanged lines
modifiedcompose.yaml+5 −2
⋯ 3 unchanged lines
44 # `docker login registry.vibe.richardscollin.com` once.
55 #
66 # The image carries a PREBUILT binary: the Dockerfile only COPYs in
7-# deploy/anvild, which deploy/build.sh cross-compiles as a static x86_64-musl
8-# executable. So `hag deploy` on its own is not enough -- use
7+# docker/anvil/anvild, which deploy/build.sh cross-compiles as a static
8+# x86_64-musl executable. So `hag deploy` on its own is not enough -- use
99 # ./deploy/deploy.sh, which stages the binary and then calls it.
1010 #
1111 # IMAGE_TAG picks which build runs. hag sets it to the git sha it just pushed,
⋯ 3 unchanged lines
1515 anvil:
1616 image: registry.vibe.richardscollin.com/anvil:${IMAGE_TAG:-latest}
1717 build:
18+ # Context is the repo root (the binary is staged under docker/, and the
19+ # baked config lives in deploy/), so the Dockerfile has to be named.
1820 context: .
21+ dockerfile: docker/anvil/Dockerfile
1922 # hagrid is x86_64 and the staged binary is x86_64-musl. Pinning the
2023 # platform stops an arm64 workstation from producing an image whose
2124 # base layers the host cannot run.
⋯ 59 unchanged lines
modifiedcrates/anvil-agent/src/container.rs+1 −1
⋯ 40 unchanged lines
4141
4242 /// The unprivileged user sessions run as rather than root; CI keeps the
4343 /// image's default (root) because plenty of pipelines expect to `apt-get`.
44-/// This is `ubuntu`, not a purpose-made account: `deploy/runner/Dockerfile`'s
44+/// This is `ubuntu`, not a purpose-made account: `docker/runner/Dockerfile`'s
4545 /// base image already ships a uid-1000 user by that name, so reusing it saves
4646 /// a `useradd`. If the base image ever moves off Ubuntu, this needs an actual
4747 /// account created again.
⋯ 553 unchanged lines
modifiedcrates/anvil-core/src/config.rs+2 −2
⋯ 74 unchanged lines
7575 }
7676
7777 /// The image both CI jobs and agent sessions default to: anvil's own runner,
78-/// built by `deploy/runner/build.sh` from `deploy/runner/Dockerfile`. It lives
78+/// built by `docker/runner/build.sh` from `docker/runner/Dockerfile`. It lives
7979 /// only in the host's local Docker image store — there is no registry to pull
8080 /// it from, so anything that starts a container from it must treat a failed
8181 /// pull as non-fatal when the image is already present locally.
⋯ 86 unchanged lines
168168 /// simply omits `image:`.
169169 pub allowed_images: Vec<String>,
170170 /// Image used by a pipeline that omits `image:`. This is the shared anvil
171- /// runner (`deploy/runner/Dockerfile`) — the same image agent sessions run
171+ /// runner (`docker/runner/Dockerfile`) — the same image agent sessions run
172172 /// in, carrying tmux, git, fish and Claude Code. Built locally rather than
173173 /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller
174174 /// must tolerate a failed pull.
⋯ 562 unchanged lines
modifiedcrates/anvil-docker/src/lib.rs+1 −1
⋯ 28 unchanged lines
2929 /// Make sure `image` is present locally, pulling it if it is not.
3030 ///
3131 /// A failed pull is only fatal when the image is *also* absent locally. anvil's
32-/// own runner image is built by `deploy/runner/build.sh` straight into the
32+/// own runner image is built by `docker/runner/build.sh` straight into the
3333 /// host's image store and exists in no registry, so an unconditional pull —
3434 /// which is what this used to be — fails for the one image most jobs now use.
3535 /// `platform` is `os[/arch[/variant]]`, or empty for the daemon's native one.
⋯ 85 unchanged lines
modifieddeploy/build.sh+15 −6
⋯ 2 unchanged lines
33 # never compiles anything.
44 #
55 # Uses cargo-zigbuild to cross-compile a fully static x86_64-musl executable at
6-# native speed (no QEMU) and leaves it at deploy/anvild, which is where the
7-# Dockerfile expects it. Building and pushing the image is compose's job from
8-# there (see compose.yaml); ./deploy/deploy.sh runs both halves.
6+# native speed (no QEMU) and leaves it in docker/, beside the Dockerfile that
7+# COPYs it. Building and pushing the image is compose's job from there (see
8+# compose.yaml); ./deploy/deploy.sh runs both halves.
99 #
1010 # ./deploy/build.sh release — what gets deployed
1111 # ./deploy/build.sh --debug unoptimized, for the local compose.override.yaml
⋯ 13 unchanged lines
2525 TARGET="x86_64-unknown-linux-musl"
2626 PROFILE=release
2727 CARGO_FLAGS=(--release)
28-# Binary name → where the Dockerfile that COPYs it expects to find it.
28+# Binary name → the image dir whose Dockerfile COPYs it. Both are gitignored
29+# and overwritten by the next build.
2930 BINS=(anvild)
31+stage_dir() {
32+ case "$1" in
33+ anvild) echo docker/anvil ;;
34+ anvil-worker) echo docker/worker ;;
35+ *) echo "no image dir for binary $1" >&2; exit 1 ;;
36+ esac
37+}
3038
3139 for arg in "$@"; do
3240 case "$arg" in
⋯ 16 unchanged lines
4957 echo "==> cross-compiling $bin for $TARGET ($PROFILE, native, via zig)"
5058 cargo zigbuild --target "$TARGET" --bin "$bin" "${CARGO_FLAGS[@]}"
5159
52- echo "==> staging binary at deploy/$bin"
53- cp "target/$TARGET/$PROFILE/$bin" "deploy/$bin"
60+ dir="$(stage_dir "$bin")"
61+ echo "==> staging binary at $dir/$bin"
62+ cp "target/$TARGET/$PROFILE/$bin" "$dir/$bin"
5463 done
5564
5665 # Deliberately left in place: `docker compose build` runs after this and needs
⋯ 2 unchanged lines
modifieddeploy/deploy.sh+1 −1
⋯ 3 unchanged lines
44 # Two halves, because the image carries a prebuilt binary rather than compiling
55 # in Docker:
66 #
7-# 1. build.sh cross-compiles anvild and stages it at deploy/anvild,
7+# 1. build.sh cross-compiles anvild and stages it at docker/anvil/anvild,
88 # 2. `hag deploy` does the rest from compose.yaml — build the image, push it
99 # to the registry, copy compose.yaml to the host, pull and recreate there.
1010 #
⋯ 29 unchanged lines
modifieddeploy/dev.sh+7 −5
11 #!/usr/bin/env bash
22 # Run anvil locally in Docker, reachable at https://anvil.localhost.
33 #
4-# The same image shape as production (deploy/build.sh + compose.yaml), but built and
5-# run on this machine: a container publishing 3000 to a fixed host port, with
6-# portless reverse-proxying a stable `.localhost` name onto it. Running in
4+# The same image shape as production (deploy/build.sh + docker/anvil/Dockerfile
5+# + compose.yaml), but built and run on this machine: a container publishing
6+# 3000 to a fixed host port, with portless reverse-proxying a stable
7+# `.localhost` name onto it. Running in
78 # Docker rather than `cargo run` is what makes CI testable — the runner drives
89 # the host's Docker socket, which is mounted in.
910 #
⋯ 46 unchanged lines
5657 # Static musl, exactly as in production: the runtime image is debian-slim and a
5758 # binary linked against Fedora's glibc would not run there.
5859 cargo zigbuild --target "$TARGET" --bin anvild "${CARGO_FLAGS[@]}"
59-cp "target/$TARGET/$PROFILE/anvild" deploy/anvild
60-trap 'rm -f deploy/anvild' EXIT
60+cp "target/$TARGET/$PROFILE/anvild" docker/anvil/anvild
61+trap 'rm -f docker/anvil/anvild' EXIT
6162
6263 echo "==> building $IMAGE"
6364 docker build --quiet --platform linux/amd64 \
65+ -f docker/anvil/Dockerfile \
6466 --build-arg CONFIG=deploy/anvil.dev.toml -t "$IMAGE" . >/dev/null
6567
6668 echo "==> (re)starting container $NAME"
⋯ 73 unchanged lines
deleteddeploy/runner/Dockerfile+0 −157
1-# anvil-runner — the shared execution image for BOTH CI jobs and agent
2-# sessions.
3-#
4-# CI pipelines that omit `image:` land here (config `ci.default_image`), and
5-# agent sessions always do (`agent.image`). Keeping them the same image means a
6-# session can reproduce a build by hand, and there is one thing to keep current.
7-#
8-# Parameterized so the same recipe produces a small general runner and a
9-# toolchain-carrying one:
10-#
11-# anvil-runner:latest RUST_VERSION= (the default)
12-# anvil-runner:rust RUST_VERSION=<channel> (builds anvil itself)
13-#
14-# build.sh reads `<channel>` out of the repo's rust-toolchain.toml, so the
15-# image's toolchain and the checkout's are the same by construction.
16-#
17-# Both sit on ubuntu:26.04. The official Rust image is Debian-based and has no
18-# Ubuntu variant, so rather than let one tag drift onto a different distro the
19-# toolchain is installed here with rustup; `RUST_VERSION` empty means the slim
20-# tag and skips that layer entirely.
21-#
22-# Build both with deploy/runner/build.sh. There is NO registry behind this
23-# image — it lives only in the host's local image store, which is why anvil
24-# treats a failed pull of the default image as non-fatal.
25-ARG BASE=ubuntu:26.04
26-FROM ${BASE}
27-
28-# Which Claude Code release channel to track. `stable` is roughly a week behind
29-# and skips releases with known major regressions; `latest` ships immediately.
30-ARG CLAUDE_CHANNEL=stable
31-
32-ENV DEBIAN_FRONTEND=noninteractive
33-
34-# A TUI (Claude Code's own, or anything a session runs) that thinks it's stuck
35-# on a 7-bit terminal falls back to ASCII line-drawing instead of real box-
36-# drawing/bullet glyphs. glibc's built-in C.UTF-8 needs no locale-gen step and
37-# is present on both Ubuntu and Debian bases.
38-ENV LANG=C.UTF-8
39-ENV LC_ALL=C.UTF-8
40-
41-# Fingerprint of the Claude Code release signing key, checked below so a
42-# substituted key fails the build rather than silently installing. Published at
43-# https://code.claude.com/docs/en/setup. Deliberately inlined rather than an
44-# ARG: a build arg could be overridden on the command line, which would defeat
45-# the pin it exists to enforce.
46-
47-# tmux — session persistence; the whole point of the agent design. Needs
48-# 3.4+ for OSC 8 hyperlink passthrough (a Claude Code TUI's login
49-# URL, most visibly) — see tmux.conf for the other half (telling
50-# tmux the attached client accepts it). Ubuntu 26.04 ships 3.6a;
51-# this is the reason BASE moved off Debian bookworm (stuck on
52-# 3.3a, pre-dating that support entirely).
53-# git — the container clones/pushes for itself (anvil's own code never
54-# shells out to git, but what a job runs is its own tooling)
55-# fish — the interactive shell you actually get when you attach
56-# neovim — a sensible $EDITOR for anything a session or an attached human
57-# shells out to (git commit messages, etc.)
58-# ripgrep — Claude Code's search backend
59-# curl/gnupg/ca-certificates — fetching and verifying the apt repo key
60-RUN apt-get update \
61- && apt-get install -y --no-install-recommends \
62- ca-certificates \
63- curl \
64- fish \
65- git \
66- gnupg \
67- less \
68- neovim \
69- ripgrep \
70- tmux \
71- && install -d -m 0755 /etc/apt/keyrings \
72- && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \
73- -o /etc/apt/keyrings/claude-code.asc \
74- && gpg --show-keys --with-colons /etc/apt/keyrings/claude-code.asc \
75- | awk -F: '/^fpr:/ {print $10}' \
76- | grep -qx 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \
77- && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc]" \
78- "https://downloads.claude.ai/claude-code/apt/${CLAUDE_CHANNEL}" \
79- "${CLAUDE_CHANNEL} main" > /etc/apt/sources.list.d/claude-code.list \
80- && apt-get update \
81- && apt-get install -y --no-install-recommends claude-code \
82- && rm -rf /var/lib/apt/lists/*
83-
84-# The Rust toolchain, for the `rust` tag only. Installed rather than inherited
85-# from `rust:<ver>-bookworm` so both tags share one base — Debian bookworm's
86-# tmux is the reason the slim tag left it (above), and one distro means a
87-# session can reproduce a CI build without accounting for the difference.
88-# Empty `RUST_VERSION` is the slim tag: no rustup, no compiler, no extra layer.
89-# The three ENVs are set unconditionally (Dockerfile has no conditional ENV);
90-# on the slim tag they just name directories that do not exist.
91-ARG RUST_VERSION=
92-ENV RUSTUP_HOME=/usr/local/rustup
93-ENV CARGO_HOME=/usr/local/cargo
94-ENV PATH=/usr/local/cargo/bin:$PATH
95-RUN if [ -n "${RUST_VERSION}" ]; then \
96- apt-get update \
97- && apt-get install -y --no-install-recommends \
98- build-essential \
99- pkg-config \
100- && rm -rf /var/lib/apt/lists/* \
101- && curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
102- | sh -s -- -y --no-modify-path --profile minimal \
103- --default-toolchain "${RUST_VERSION}" \
104- && chmod -R a+w "${RUSTUP_HOME}" "${CARGO_HOME}" \
105- && rustc --version && cargo --version; \
106- fi
107-
108-# apt installs never auto-update, but Claude Code still checks on startup and
109-# the check is pure noise in a container whose version is pinned by the image.
110-ENV DISABLE_AUTOUPDATER=1
111-
112-# What `git commit` (no -m) and anything else honouring $EDITOR drops you
113-# into. Ubuntu's neovim package doesn't register update-alternatives entries
114-# for vi/vim/editor, so this is the only thing that makes it the default.
115-ENV EDITOR=nvim
116-ENV VISUAL=nvim
117-
118-# An unprivileged user for agent sessions to run as. Deliberately NOT set as
119-# the image's USER: CI pipelines inherit this image's default user, and plenty
120-# of them expect root for apt-get. anvil passes `user: ubuntu` explicitly when
121-# it creates a *session* container, so CI keeps the behaviour it has today.
122-#
123-# Reusing the base image's own `ubuntu` user (uid 1000, matching
124-# [`container::RUN_AS_UID`]) rather than making a purpose-built account: it's
125-# already there, so this is just pointing its shell at fish and giving it a
126-# workspace under its own home ([`container::WORKDIR`]) — under `$HOME` rather
127-# than a bare `/workspace` so tools that assume a project lives there behave.
128-RUN usermod --shell /usr/bin/fish ubuntu \
129- && mkdir -p /home/ubuntu/workspace \
130- && chown ubuntu:ubuntu /home/ubuntu/workspace
131-
132-# Baseline Claude Code config for a session: auto theme (so it reads fine
133-# however the browser terminal is themed) and bypass-permissions, matching the
134-# `--dangerously-skip-permissions` flag the session launches with (see
135-# anvil-agent/src/supervisor.rs) — belt and suspenders for anything that reads
136-# the setting rather than the flag. `agent.credentials_dir`, when configured,
137-# uploads over `~/.claude` and can add to or override this file.
138-COPY claude-settings.json /home/ubuntu/.claude/settings.json
139-RUN chown -R ubuntu:ubuntu /home/ubuntu/.claude
140-
141-# settings.json's `theme` only sets the *value*; it does not mark the
142-# first-run wizard as done, and that state lives in a separate file. Without
143-# this, every session replays the theme picker and the per-project trust
144-# dialog regardless of what settings.json says. Keyed on [`container::WORKDIR`]
145-# — fixed for every session, so this is safe to bake in rather than derive at
146-# container-creation time. Login still prompts (there
147-# is nothing to skip: a fresh session has no credentials until
148-# `agent.credentials_dir` is configured), and that dialog's OSC 8 link is the
149-# one tmux.conf's `terminal-features` line exists for.
150-COPY claude-onboarding.json /home/ubuntu/.claude.json
151-RUN chown ubuntu:ubuntu /home/ubuntu/.claude.json
152-
153-COPY tmux.conf /etc/anvil/tmux.conf
154-COPY session-entrypoint.sh /usr/local/bin/anvil-session
155-RUN chmod 0755 /usr/local/bin/anvil-session
156-
157-WORKDIR /home/ubuntu/workspace
deleteddeploy/runner/build.sh+0 −69
1-#!/usr/bin/env bash
2-# Build the shared anvil runner image(s) — what BOTH CI jobs and agent sessions
3-# execute in.
4-#
5-# Two tags from one Dockerfile on one base, differing only in whether the Rust
6-# toolchain is installed:
7-#
8-# anvil-runner:latest ubuntu:26.04 general purpose, the default
9-# anvil-runner:rust ubuntu:26.04 + rustup carries the Rust toolchain
10-#
11-# There is deliberately no registry push: anvil resolves its default image from
12-# the LOCAL image store and treats a failed pull as non-fatal when the image is
13-# already present. So this must run on whichever host owns the Docker daemon
14-# anvil talks to — the VPS in production, your machine in dev.
15-#
16-# ./deploy/runner/build.sh # both tags
17-# ./deploy/runner/build.sh latest # just the slim one
18-# ./deploy/runner/build.sh rust # just the toolchain one
19-set -euo pipefail
20-
21-cd "$(dirname "$0")"
22-
23-NAME="${ANVIL_RUNNER_IMAGE:-anvil-runner}"
24-CHANNEL="${CLAUDE_CHANNEL:-stable}"
25-
26-# Toolchain baked into the `rust` tag, read from the repo's rust-toolchain.toml
27-# so the image compiles with the same rustc this checkout does. Not duplicated
28-# here on purpose: that file is the one place the version is set.
29-RUST_VERSION="${RUST_VERSION:-$(
30- sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' \
31- ../../rust-toolchain.toml
32-)}"
33-if [ -z "$RUST_VERSION" ]; then
34- echo "could not read [toolchain] channel from rust-toolchain.toml" >&2
35- exit 1
36-fi
37-
38-build() {
39- local tag="$1" rust="$2"
40- echo "==> building ${NAME}:${tag} (rust ${rust:-none}, claude channel ${CHANNEL})"
41- docker build \
42- --build-arg "RUST_VERSION=${rust}" \
43- --build-arg "CLAUDE_CHANNEL=${CHANNEL}" \
44- -t "${NAME}:${tag}" \
45- .
46-}
47-
48-case "${1:-all}" in
49- latest) build latest "" ;;
50- rust) build rust "${RUST_VERSION}" ;;
51- all)
52- build latest ""
53- build rust "${RUST_VERSION}"
54- ;;
55- *)
56- echo "usage: $0 [latest|rust|all]" >&2
57- exit 64
58- ;;
59-esac
60-
61-echo
62-echo "==> done"
63-docker images "${NAME}" --format ' {{.Repository}}:{{.Tag}} {{.Size}}'
64-echo
65-echo "Point anvil at it in anvil.toml if you use a non-default name:"
66-echo " [ci]"
67-echo " default_image = \"${NAME}:latest\""
68-echo " [agent]"
69-echo " image = \"${NAME}:latest\""
deleteddeploy/runner/claude-onboarding.json+0 −9
1-{
2- "hasCompletedOnboarding": true,
3- "projects": {
4- "/home/ubuntu/workspace": {
5- "hasTrustDialogAccepted": true,
6- "hasCompletedProjectOnboarding": true
7- }
8- }
9-}
deleteddeploy/runner/claude-settings.json+0 −8
1-{
2- "theme": "auto",
3- "tui": "fullscreen",
4- "permissions": {
5- "defaultMode": "bypassPermissions"
6- },
7- "skipDangerousModePermissionPrompt": true
8-}
deleteddeploy/runner/session-entrypoint.sh+0 −69
1-#!/bin/sh
2-# PID 1 of an agent-session container.
3-#
4-# Starts the agent under a detached tmux session, tees a byte-exact transcript,
5-# and then blocks until that session ends — so the container's lifetime is the
6-# agent's lifetime, and `docker wait` is a valid completion signal.
7-#
8-# Every browser attach is a SEPARATE `docker exec … tmux attach`, so a dropped
9-# websocket kills only that client. That is the reason tmux is here at all.
10-#
11-# Usage: anvil-session <command> [args...]
12-set -eu
13-
14-SESSION="${ANVIL_TMUX_SESSION:-agent}"
15-TRANSCRIPT="${ANVIL_TRANSCRIPT:-/tmp/anvil-transcript}"
16-WORKDIR="${ANVIL_WORKDIR:-/home/ubuntu/workspace}"
17-EXIT_FILE="${ANVIL_EXIT_FILE:-/tmp/anvil-exit}"
18-CMD_FILE="${ANVIL_CMD_FILE:-/tmp/anvil-cmd.sh}"
19-
20-if [ "$#" -eq 0 ]; then
21- echo "anvil-session: no command given" >&2
22- exit 64
23-fi
24-
25-# Single-quote one argument for safe re-parsing by /bin/sh.
26-quote() {
27- printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
28-}
29-
30-: > "$TRANSCRIPT"
31-rm -f "$EXIT_FILE"
32-
33-# Generate a script rather than nesting quotes inside tmux's command string:
34-# tmux hands that string to `sh -c`, so an argument containing spaces or quotes
35-# (an autonomous session's prompt, most obviously) would otherwise re-split.
36-{
37- printf '#!/bin/sh\n'
38- printf 'cd %s || exit 1\n' "$(quote "$WORKDIR")"
39- for arg in "$@"; do
40- printf '%s ' "$(quote "$arg")"
41- done
42- printf '\n'
43- printf 'printf %%s $? > %s\n' "$(quote "$EXIT_FILE")"
44-} > "$CMD_FILE"
45-chmod 0755 "$CMD_FILE"
46-
47-tmux -f /etc/anvil/tmux.conf new-session -d -s "$SESSION" -c "$WORKDIR" \
48- "sh $CMD_FILE"
49-
50-# Tee everything the pane emits into the transcript. `-o` means "only if not
51-# already piping", so a re-invocation is harmless.
52-tmux pipe-pane -o -t "$SESSION" "cat >> '$TRANSCRIPT'"
53-
54-# Block until the session is gone. Polling rather than `tmux wait-for` because
55-# the session can also be killed from outside (an operator stop, a sweep, an
56-# attached user typing `exit`), and has-session covers every one of those
57-# without needing a cooperating signaller.
58-while tmux has-session -t "$SESSION" 2>/dev/null; do
59- if [ -f "$EXIT_FILE" ]; then
60- # The command finished; the pane lingers because of remain-on-exit.
61- # Leave the final screen readable for a moment, then wind up.
62- sleep "${ANVIL_LINGER_SECS:-5}"
63- tmux kill-session -t "$SESSION" 2>/dev/null || true
64- break
65- fi
66- sleep 1
67-done
68-
69-exit "$(cat "$EXIT_FILE" 2>/dev/null || echo 0)"
deleteddeploy/runner/tmux.conf+0 −63
1-# tmux configuration for anvil agent sessions.
2-#
3-# The browser terminal is the only client, so the usual interactive niceties
4-# (status bar, prefix gymnastics) are noise. What matters is that the pane
5-# behaves like a real terminal for a TUI and that scrollback is deep enough to
6-# replay on reconnect.
7-
8-# Status bar at the top. Note: `capture-pane` (the reconnect snapshot) only
9-# ever sees pane content, never this bar — it's client chrome — so a browser
10-# that reconnects gets a one-row jump versus the live view until the next
11-# repaint. Cosmetic, not a correctness issue.
12-set -g status on
13-set -g status-position top
14-
15-# Mouse reporting through to the application, so a TUI's click targets and
16-# scroll work in the browser.
17-set -g mouse on
18-
19-# Forward focus in/out (DECSET 1004) to the pane. Without it Claude Code warns
20-# on startup and can't tell when the browser tab is focused — wterm's input
21-# layer already tracks real DOM focus/blur on the terminal element and turns
22-# it into the same escape sequences, this is what lets tmux pass them through.
23-set -g focus-events on
24-
25-# Deep scrollback — `capture-pane -S -` replays this on reconnect, and an agent
26-# session produces a lot of output before anyone looks at it.
27-set -g history-limit 50000
28-
29-# 256-colour + true-colour advertisement. wterm's core resolves 24-bit SGR, so
30-# there is no reason to let tmux downsample.
31-set -g default-terminal "tmux-256color"
32-set -ga terminal-overrides ",*256col*:Tc"
33-
34-# The attached client is always anvil's own websocket bridge (see
35-# container.rs's `attach`), never a real terminal tmux can query — so rather
36-# than rely on tmux's built-in detection for the client's TERM (xterm-256color,
37-# set in `attach`'s exec env), say explicitly that it understands OSC 8. wterm
38-# already parses OSC 8 (docs/agent-sessions.md); this is what lets those
39-# sequences reach it instead of being dropped in tmux.
40-set -ga terminal-features ",*:hyperlinks"
41-
42-# Keep the pane after its command exits, so the browser can still read the last
43-# screen; the entrypoint decides when the container is actually done.
44-set -g remain-on-exit on
45-
46-# With remain-on-exit on, a dead pane never actually closes — it just stops
47-# accepting input — so tmux never re-selects for it the way it would if the
48-# pane were destroyed. `pane-died` is the hook that fires for exactly that
49-# case (see tmux.1: "the program running in a pane exits, but remain-on-exit
50-# is on so the pane has not closed"). Only matters once something splits the
51-# window — a single-pane session has nowhere else to go — which is what a
52-# Claude Code teammate spawned with `teammateMode: tmux` would do.
53-set-hook -g pane-died 'select-pane -t :.+'
54-
55-# Do not renumber or rename out from under the supervisor, which addresses the
56-# session by name.
57-set -g allow-rename off
58-
59-# With several browsers attached, size the window to the most recently active
60-# client rather than the smallest. The default ("smallest") means one phone
61-# viewer squeezes everyone else's terminal down to its width for as long as it
62-# stays connected.
63-set -g window-size latest
deleteddeploy/worker/Dockerfile+0 −31
1-# anvil-worker image — LOCAL DEVELOPMENT ONLY.
2-#
3-# Production runners are native processes on their own host (a launchd agent on
4-# the Mac mini, see ../../docs/remote-runners.md § Isolation on macOS). This
5-# image exists so `compose.override.yaml` can bring up two runners next to the
6-# local forge and exercise concurrency and platform routing without a second
7-# machine. Do not deploy it: a containerized runner needs the host's Docker
8-# socket mounted in, which is the root-equivalent hold moving CI off the forge
9-# was meant to remove. That trade is already made locally — the dev compose
10-# file mounts the same socket into anvil for agent sessions.
11-#
12-# Same shape as ../../Dockerfile: no compilation here, just a COPY of the
13-# static x86_64-musl binary that `./deploy/build.sh --worker` stages.
14-
15-FROM ubuntu:26.04
16-
17-# ca-certificates so the claim loop can talk to an https:// forge. The local
18-# one is plain http over the compose network, but the image should not be the
19-# reason a runner cannot reach a real instance.
20-RUN apt-get update \
21- && apt-get install -y --no-install-recommends ca-certificates \
22- && rm -rf /var/lib/apt/lists/* \
23- && useradd --system --user-group --home-dir /nonexistent worker
24-
25-COPY deploy/anvil-worker /usr/local/bin/anvil-worker
26-
27-# Unprivileged in the container; compose grants the docker group separately
28-# (`group_add`), which is the only host access the runner needs.
29-USER worker
30-
31-ENTRYPOINT ["/usr/local/bin/anvil-worker"]
addeddocker/anvil/Dockerfile+34 −0
1+# anvil runtime image — just the prebuilt binary, no compilation in Docker.
2+#
3+# The binary is cross-compiled on the build host into a fully static
4+# x86_64-musl executable (see deploy/build.sh: `cargo zigbuild --target
5+# x86_64-unknown-linux-musl`), then staged next to this file and copied in.
6+# So building this image is a fast `COPY` — no QEMU-emulated release build, and
7+# the VPS never compiles anything.
8+
9+FROM ubuntu:26.04
10+
11+# No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the
12+# push-mirroring client.
13+RUN apt-get update \
14+ && apt-get install -y --no-install-recommends ca-certificates \
15+ && rm -rf /var/lib/apt/lists/* \
16+ && useradd --system --user-group --home-dir /data anvil \
17+ && mkdir -p /data /etc/anvil \
18+ && chown -R anvil:anvil /data
19+
20+# Prebuilt static binary staged by deploy/build.sh. The build context is the
21+# repo root (compose.yaml, deploy/dev.sh), so this path is repo-relative.
22+COPY docker/anvil/anvild /usr/local/bin/anvild
23+
24+# Which baked config to ship: production's by default, the committed local one
25+# when deploy/dev.sh builds the image.
26+ARG CONFIG=deploy/anvil.toml
27+COPY ${CONFIG} /etc/anvil/anvil.toml
28+
29+EXPOSE 3000 2222
30+VOLUME /data
31+USER anvil
32+
33+ENTRYPOINT ["/usr/local/bin/anvild"]
34+CMD ["-c", "/etc/anvil/anvil.toml", "serve"]
addeddocker/runner/Dockerfile+157 −0
1+# anvil-runner — the shared execution image for BOTH CI jobs and agent
2+# sessions.
3+#
4+# CI pipelines that omit `image:` land here (config `ci.default_image`), and
5+# agent sessions always do (`agent.image`). Keeping them the same image means a
6+# session can reproduce a build by hand, and there is one thing to keep current.
7+#
8+# Parameterized so the same recipe produces a small general runner and a
9+# toolchain-carrying one:
10+#
11+# anvil-runner:latest RUST_VERSION= (the default)
12+# anvil-runner:rust RUST_VERSION=<channel> (builds anvil itself)
13+#
14+# build.sh reads `<channel>` out of the repo's rust-toolchain.toml, so the
15+# image's toolchain and the checkout's are the same by construction.
16+#
17+# Both sit on ubuntu:26.04. The official Rust image is Debian-based and has no
18+# Ubuntu variant, so rather than let one tag drift onto a different distro the
19+# toolchain is installed here with rustup; `RUST_VERSION` empty means the slim
20+# tag and skips that layer entirely.
21+#
22+# Build both with deploy/runner/build.sh. There is NO registry behind this
23+# image — it lives only in the host's local image store, which is why anvil
24+# treats a failed pull of the default image as non-fatal.
25+ARG BASE=ubuntu:26.04
26+FROM ${BASE}
27+
28+# Which Claude Code release channel to track. `stable` is roughly a week behind
29+# and skips releases with known major regressions; `latest` ships immediately.
30+ARG CLAUDE_CHANNEL=stable
31+
32+ENV DEBIAN_FRONTEND=noninteractive
33+
34+# A TUI (Claude Code's own, or anything a session runs) that thinks it's stuck
35+# on a 7-bit terminal falls back to ASCII line-drawing instead of real box-
36+# drawing/bullet glyphs. glibc's built-in C.UTF-8 needs no locale-gen step and
37+# is present on both Ubuntu and Debian bases.
38+ENV LANG=C.UTF-8
39+ENV LC_ALL=C.UTF-8
40+
41+# Fingerprint of the Claude Code release signing key, checked below so a
42+# substituted key fails the build rather than silently installing. Published at
43+# https://code.claude.com/docs/en/setup. Deliberately inlined rather than an
44+# ARG: a build arg could be overridden on the command line, which would defeat
45+# the pin it exists to enforce.
46+
47+# tmux — session persistence; the whole point of the agent design. Needs
48+# 3.4+ for OSC 8 hyperlink passthrough (a Claude Code TUI's login
49+# URL, most visibly) — see tmux.conf for the other half (telling
50+# tmux the attached client accepts it). Ubuntu 26.04 ships 3.6a;
51+# this is the reason BASE moved off Debian bookworm (stuck on
52+# 3.3a, pre-dating that support entirely).
53+# git — the container clones/pushes for itself (anvil's own code never
54+# shells out to git, but what a job runs is its own tooling)
55+# fish — the interactive shell you actually get when you attach
56+# neovim — a sensible $EDITOR for anything a session or an attached human
57+# shells out to (git commit messages, etc.)
58+# ripgrep — Claude Code's search backend
59+# curl/gnupg/ca-certificates — fetching and verifying the apt repo key
60+RUN apt-get update \
61+ && apt-get install -y --no-install-recommends \
62+ ca-certificates \
63+ curl \
64+ fish \
65+ git \
66+ gnupg \
67+ less \
68+ neovim \
69+ ripgrep \
70+ tmux \
71+ && install -d -m 0755 /etc/apt/keyrings \
72+ && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \
73+ -o /etc/apt/keyrings/claude-code.asc \
74+ && gpg --show-keys --with-colons /etc/apt/keyrings/claude-code.asc \
75+ | awk -F: '/^fpr:/ {print $10}' \
76+ | grep -qx 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \
77+ && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc]" \
78+ "https://downloads.claude.ai/claude-code/apt/${CLAUDE_CHANNEL}" \
79+ "${CLAUDE_CHANNEL} main" > /etc/apt/sources.list.d/claude-code.list \
80+ && apt-get update \
81+ && apt-get install -y --no-install-recommends claude-code \
82+ && rm -rf /var/lib/apt/lists/*
83+
84+# The Rust toolchain, for the `rust` tag only. Installed rather than inherited
85+# from `rust:<ver>-bookworm` so both tags share one base — Debian bookworm's
86+# tmux is the reason the slim tag left it (above), and one distro means a
87+# session can reproduce a CI build without accounting for the difference.
88+# Empty `RUST_VERSION` is the slim tag: no rustup, no compiler, no extra layer.
89+# The three ENVs are set unconditionally (Dockerfile has no conditional ENV);
90+# on the slim tag they just name directories that do not exist.
91+ARG RUST_VERSION=
92+ENV RUSTUP_HOME=/usr/local/rustup
93+ENV CARGO_HOME=/usr/local/cargo
94+ENV PATH=/usr/local/cargo/bin:$PATH
95+RUN if [ -n "${RUST_VERSION}" ]; then \
96+ apt-get update \
97+ && apt-get install -y --no-install-recommends \
98+ build-essential \
99+ pkg-config \
100+ && rm -rf /var/lib/apt/lists/* \
101+ && curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
102+ | sh -s -- -y --no-modify-path --profile minimal \
103+ --default-toolchain "${RUST_VERSION}" \
104+ && chmod -R a+w "${RUSTUP_HOME}" "${CARGO_HOME}" \
105+ && rustc --version && cargo --version; \
106+ fi
107+
108+# apt installs never auto-update, but Claude Code still checks on startup and
109+# the check is pure noise in a container whose version is pinned by the image.
110+ENV DISABLE_AUTOUPDATER=1
111+
112+# What `git commit` (no -m) and anything else honouring $EDITOR drops you
113+# into. Ubuntu's neovim package doesn't register update-alternatives entries
114+# for vi/vim/editor, so this is the only thing that makes it the default.
115+ENV EDITOR=nvim
116+ENV VISUAL=nvim
117+
118+# An unprivileged user for agent sessions to run as. Deliberately NOT set as
119+# the image's USER: CI pipelines inherit this image's default user, and plenty
120+# of them expect root for apt-get. anvil passes `user: ubuntu` explicitly when
121+# it creates a *session* container, so CI keeps the behaviour it has today.
122+#
123+# Reusing the base image's own `ubuntu` user (uid 1000, matching
124+# [`container::RUN_AS_UID`]) rather than making a purpose-built account: it's
125+# already there, so this is just pointing its shell at fish and giving it a
126+# workspace under its own home ([`container::WORKDIR`]) — under `$HOME` rather
127+# than a bare `/workspace` so tools that assume a project lives there behave.
128+RUN usermod --shell /usr/bin/fish ubuntu \
129+ && mkdir -p /home/ubuntu/workspace \
130+ && chown ubuntu:ubuntu /home/ubuntu/workspace
131+
132+# Baseline Claude Code config for a session: auto theme (so it reads fine
133+# however the browser terminal is themed) and bypass-permissions, matching the
134+# `--dangerously-skip-permissions` flag the session launches with (see
135+# anvil-agent/src/supervisor.rs) — belt and suspenders for anything that reads
136+# the setting rather than the flag. `agent.credentials_dir`, when configured,
137+# uploads over `~/.claude` and can add to or override this file.
138+COPY claude-settings.json /home/ubuntu/.claude/settings.json
139+RUN chown -R ubuntu:ubuntu /home/ubuntu/.claude
140+
141+# settings.json's `theme` only sets the *value*; it does not mark the
142+# first-run wizard as done, and that state lives in a separate file. Without
143+# this, every session replays the theme picker and the per-project trust
144+# dialog regardless of what settings.json says. Keyed on [`container::WORKDIR`]
145+# — fixed for every session, so this is safe to bake in rather than derive at
146+# container-creation time. Login still prompts (there
147+# is nothing to skip: a fresh session has no credentials until
148+# `agent.credentials_dir` is configured), and that dialog's OSC 8 link is the
149+# one tmux.conf's `terminal-features` line exists for.
150+COPY claude-onboarding.json /home/ubuntu/.claude.json
151+RUN chown ubuntu:ubuntu /home/ubuntu/.claude.json
152+
153+COPY tmux.conf /etc/anvil/tmux.conf
154+COPY session-entrypoint.sh /usr/local/bin/anvil-session
155+RUN chmod 0755 /usr/local/bin/anvil-session
156+
157+WORKDIR /home/ubuntu/workspace
addeddocker/runner/build.sh+69 −0
1+#!/usr/bin/env bash
2+# Build the shared anvil runner image(s) — what BOTH CI jobs and agent sessions
3+# execute in.
4+#
5+# Two tags from one Dockerfile on one base, differing only in whether the Rust
6+# toolchain is installed:
7+#
8+# anvil-runner:latest ubuntu:26.04 general purpose, the default
9+# anvil-runner:rust ubuntu:26.04 + rustup carries the Rust toolchain
10+#
11+# There is deliberately no registry push: anvil resolves its default image from
12+# the LOCAL image store and treats a failed pull as non-fatal when the image is
13+# already present. So this must run on whichever host owns the Docker daemon
14+# anvil talks to — the VPS in production, your machine in dev.
15+#
16+# ./docker/runner/build.sh # both tags
17+# ./docker/runner/build.sh latest # just the slim one
18+# ./docker/runner/build.sh rust # just the toolchain one
19+set -euo pipefail
20+
21+cd "$(dirname "$0")"
22+
23+NAME="${ANVIL_RUNNER_IMAGE:-anvil-runner}"
24+CHANNEL="${CLAUDE_CHANNEL:-stable}"
25+
26+# Toolchain baked into the `rust` tag, read from the repo's rust-toolchain.toml
27+# so the image compiles with the same rustc this checkout does. Not duplicated
28+# here on purpose: that file is the one place the version is set.
29+RUST_VERSION="${RUST_VERSION:-$(
30+ sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\(.*\)".*/\1/p' \
31+ ../../rust-toolchain.toml
32+)}"
33+if [ -z "$RUST_VERSION" ]; then
34+ echo "could not read [toolchain] channel from rust-toolchain.toml" >&2
35+ exit 1
36+fi
37+
38+build() {
39+ local tag="$1" rust="$2"
40+ echo "==> building ${NAME}:${tag} (rust ${rust:-none}, claude channel ${CHANNEL})"
41+ docker build \
42+ --build-arg "RUST_VERSION=${rust}" \
43+ --build-arg "CLAUDE_CHANNEL=${CHANNEL}" \
44+ -t "${NAME}:${tag}" \
45+ .
46+}
47+
48+case "${1:-all}" in
49+ latest) build latest "" ;;
50+ rust) build rust "${RUST_VERSION}" ;;
51+ all)
52+ build latest ""
53+ build rust "${RUST_VERSION}"
54+ ;;
55+ *)
56+ echo "usage: $0 [latest|rust|all]" >&2
57+ exit 64
58+ ;;
59+esac
60+
61+echo
62+echo "==> done"
63+docker images "${NAME}" --format ' {{.Repository}}:{{.Tag}} {{.Size}}'
64+echo
65+echo "Point anvil at it in anvil.toml if you use a non-default name:"
66+echo " [ci]"
67+echo " default_image = \"${NAME}:latest\""
68+echo " [agent]"
69+echo " image = \"${NAME}:latest\""
addeddocker/runner/claude-onboarding.json+9 −0
1+{
2+ "hasCompletedOnboarding": true,
3+ "projects": {
4+ "/home/ubuntu/workspace": {
5+ "hasTrustDialogAccepted": true,
6+ "hasCompletedProjectOnboarding": true
7+ }
8+ }
9+}
addeddocker/runner/claude-settings.json+8 −0
1+{
2+ "theme": "auto",
3+ "tui": "fullscreen",
4+ "permissions": {
5+ "defaultMode": "bypassPermissions"
6+ },
7+ "skipDangerousModePermissionPrompt": true
8+}
addeddocker/runner/session-entrypoint.sh+69 −0
1+#!/bin/sh
2+# PID 1 of an agent-session container.
3+#
4+# Starts the agent under a detached tmux session, tees a byte-exact transcript,
5+# and then blocks until that session ends — so the container's lifetime is the
6+# agent's lifetime, and `docker wait` is a valid completion signal.
7+#
8+# Every browser attach is a SEPARATE `docker exec … tmux attach`, so a dropped
9+# websocket kills only that client. That is the reason tmux is here at all.
10+#
11+# Usage: anvil-session <command> [args...]
12+set -eu
13+
14+SESSION="${ANVIL_TMUX_SESSION:-agent}"
15+TRANSCRIPT="${ANVIL_TRANSCRIPT:-/tmp/anvil-transcript}"
16+WORKDIR="${ANVIL_WORKDIR:-/home/ubuntu/workspace}"
17+EXIT_FILE="${ANVIL_EXIT_FILE:-/tmp/anvil-exit}"
18+CMD_FILE="${ANVIL_CMD_FILE:-/tmp/anvil-cmd.sh}"
19+
20+if [ "$#" -eq 0 ]; then
21+ echo "anvil-session: no command given" >&2
22+ exit 64
23+fi
24+
25+# Single-quote one argument for safe re-parsing by /bin/sh.
26+quote() {
27+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
28+}
29+
30+: > "$TRANSCRIPT"
31+rm -f "$EXIT_FILE"
32+
33+# Generate a script rather than nesting quotes inside tmux's command string:
34+# tmux hands that string to `sh -c`, so an argument containing spaces or quotes
35+# (an autonomous session's prompt, most obviously) would otherwise re-split.
36+{
37+ printf '#!/bin/sh\n'
38+ printf 'cd %s || exit 1\n' "$(quote "$WORKDIR")"
39+ for arg in "$@"; do
40+ printf '%s ' "$(quote "$arg")"
41+ done
42+ printf '\n'
43+ printf 'printf %%s $? > %s\n' "$(quote "$EXIT_FILE")"
44+} > "$CMD_FILE"
45+chmod 0755 "$CMD_FILE"
46+
47+tmux -f /etc/anvil/tmux.conf new-session -d -s "$SESSION" -c "$WORKDIR" \
48+ "sh $CMD_FILE"
49+
50+# Tee everything the pane emits into the transcript. `-o` means "only if not
51+# already piping", so a re-invocation is harmless.
52+tmux pipe-pane -o -t "$SESSION" "cat >> '$TRANSCRIPT'"
53+
54+# Block until the session is gone. Polling rather than `tmux wait-for` because
55+# the session can also be killed from outside (an operator stop, a sweep, an
56+# attached user typing `exit`), and has-session covers every one of those
57+# without needing a cooperating signaller.
58+while tmux has-session -t "$SESSION" 2>/dev/null; do
59+ if [ -f "$EXIT_FILE" ]; then
60+ # The command finished; the pane lingers because of remain-on-exit.
61+ # Leave the final screen readable for a moment, then wind up.
62+ sleep "${ANVIL_LINGER_SECS:-5}"
63+ tmux kill-session -t "$SESSION" 2>/dev/null || true
64+ break
65+ fi
66+ sleep 1
67+done
68+
69+exit "$(cat "$EXIT_FILE" 2>/dev/null || echo 0)"
addeddocker/runner/tmux.conf+63 −0
1+# tmux configuration for anvil agent sessions.
2+#
3+# The browser terminal is the only client, so the usual interactive niceties
4+# (status bar, prefix gymnastics) are noise. What matters is that the pane
5+# behaves like a real terminal for a TUI and that scrollback is deep enough to
6+# replay on reconnect.
7+
8+# Status bar at the top. Note: `capture-pane` (the reconnect snapshot) only
9+# ever sees pane content, never this bar — it's client chrome — so a browser
10+# that reconnects gets a one-row jump versus the live view until the next
11+# repaint. Cosmetic, not a correctness issue.
12+set -g status on
13+set -g status-position top
14+
15+# Mouse reporting through to the application, so a TUI's click targets and
16+# scroll work in the browser.
17+set -g mouse on
18+
19+# Forward focus in/out (DECSET 1004) to the pane. Without it Claude Code warns
20+# on startup and can't tell when the browser tab is focused — wterm's input
21+# layer already tracks real DOM focus/blur on the terminal element and turns
22+# it into the same escape sequences, this is what lets tmux pass them through.
23+set -g focus-events on
24+
25+# Deep scrollback — `capture-pane -S -` replays this on reconnect, and an agent
26+# session produces a lot of output before anyone looks at it.
27+set -g history-limit 50000
28+
29+# 256-colour + true-colour advertisement. wterm's core resolves 24-bit SGR, so
30+# there is no reason to let tmux downsample.
31+set -g default-terminal "tmux-256color"
32+set -ga terminal-overrides ",*256col*:Tc"
33+
34+# The attached client is always anvil's own websocket bridge (see
35+# container.rs's `attach`), never a real terminal tmux can query — so rather
36+# than rely on tmux's built-in detection for the client's TERM (xterm-256color,
37+# set in `attach`'s exec env), say explicitly that it understands OSC 8. wterm
38+# already parses OSC 8 (docs/agent-sessions.md); this is what lets those
39+# sequences reach it instead of being dropped in tmux.
40+set -ga terminal-features ",*:hyperlinks"
41+
42+# Keep the pane after its command exits, so the browser can still read the last
43+# screen; the entrypoint decides when the container is actually done.
44+set -g remain-on-exit on
45+
46+# With remain-on-exit on, a dead pane never actually closes — it just stops
47+# accepting input — so tmux never re-selects for it the way it would if the
48+# pane were destroyed. `pane-died` is the hook that fires for exactly that
49+# case (see tmux.1: "the program running in a pane exits, but remain-on-exit
50+# is on so the pane has not closed"). Only matters once something splits the
51+# window — a single-pane session has nowhere else to go — which is what a
52+# Claude Code teammate spawned with `teammateMode: tmux` would do.
53+set-hook -g pane-died 'select-pane -t :.+'
54+
55+# Do not renumber or rename out from under the supervisor, which addresses the
56+# session by name.
57+set -g allow-rename off
58+
59+# With several browsers attached, size the window to the most recently active
60+# client rather than the smallest. The default ("smallest") means one phone
61+# viewer squeezes everyone else's terminal down to its width for as long as it
62+# stays connected.
63+set -g window-size latest
addeddocker/worker/Dockerfile+32 −0
1+# anvil-worker image — LOCAL DEVELOPMENT ONLY.
2+#
3+# Production runners are native processes on their own host (a launchd agent on
4+# the Mac mini, see ../../docs/remote-runners.md § Isolation on macOS; the
5+# plist for it stays in deploy/worker/, which is not a Docker artifact). This
6+# image exists so `compose.override.yaml` can bring up two runners next to the
7+# local forge and exercise concurrency and platform routing without a second
8+# machine. Do not deploy it: a containerized runner needs the host's Docker
9+# socket mounted in, which is the root-equivalent hold moving CI off the forge
10+# was meant to remove. That trade is already made locally — the dev compose
11+# file mounts the same socket into anvil for agent sessions.
12+#
13+# Same shape as ../anvil/Dockerfile: no compilation here, just a COPY of the
14+# static x86_64-musl binary that `./deploy/build.sh --worker` stages.
15+
16+FROM ubuntu:26.04
17+
18+# ca-certificates so the claim loop can talk to an https:// forge. The local
19+# one is plain http over the compose network, but the image should not be the
20+# reason a runner cannot reach a real instance.
21+RUN apt-get update \
22+ && apt-get install -y --no-install-recommends ca-certificates \
23+ && rm -rf /var/lib/apt/lists/* \
24+ && useradd --system --user-group --home-dir /nonexistent worker
25+
26+COPY docker/worker/anvil-worker /usr/local/bin/anvil-worker
27+
28+# Unprivileged in the container; compose grants the docker group separately
29+# (`group_add`), which is the only host access the runner needs.
30+USER worker
31+
32+ENTRYPOINT ["/usr/local/bin/anvil-worker"]
modifieddocs/agent-sessions.md+3 −3
⋯ 36 unchanged lines
3737
3838 ## The shared runner image
3939
40-`deploy/runner/Dockerfile` builds **one** image used by both CI jobs and agent
40+`docker/runner/Dockerfile` builds **one** image used by both CI jobs and agent
4141 sessions, so a session can reproduce a build by hand and there is one thing to
4242 keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed
4343 from Anthropic's signed apt repository, with the release key's fingerprint
4444 pinned in the Dockerfile).
4545
4646 ```
47-./deploy/runner/build.sh # anvil-runner:latest and :rust
48-./deploy/runner/build.sh latest # just the slim one
47+./docker/runner/build.sh # anvil-runner:latest and :rust
48+./docker/runner/build.sh latest # just the slim one
4949 ```
5050
5151 Two tags from one recipe on one base, differing only in whether the Rust
⋯ 105 unchanged lines
modifieddocs/remote-runners.md+1 −1
⋯ 342 unchanged lines
343343 half of routing — are testable without a second machine:
344344
345345 ```sh
346-./deploy/build.sh --debug --worker # stages deploy/anvild and deploy/anvil-worker
346+./deploy/build.sh --debug --worker # stages the anvild and anvil-worker binaries
347347 docker compose up -d --build
348348 docker compose logs -f runner-1 runner-2
349349 # anvil-worker dev-1 (linux/amd64) → http://anvil:3000
⋯ 104 unchanged lines
modifiedrust-toolchain.toml+1 −1
11 # The one place the Rust version is set. rustup reads this for every cargo
22 # invocation in the repo (installing the toolchain on first use), and
3-# deploy/runner/build.sh parses `channel` out of it to bake the same version
3+# docker/runner/build.sh parses `channel` out of it to bake the same version
44 # into the anvil-runner:rust image — so the toolchain a CI job compiles with is
55 # the one this checkout compiles with.
66 #
⋯ 12 unchanged lines