| 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 |