| 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 by base so the same recipe produces a small general runner and |
| 9 | # a toolchain-carrying one: |
| 10 | # |
| 11 | # anvil-runner:latest BASE=ubuntu:26.04 (the default) |
| 12 | # anvil-runner:rust BASE=rust:1.95-bookworm (builds anvil itself; the |
| 13 | # official Rust image has no |
| 14 | # Ubuntu variant, and the |
| 15 | # tmux/locale fixes below |
| 16 | # only matter for the |
| 17 | # interactive sessions this |
| 18 | # tag never runs) |
| 19 | # |
| 20 | # Build both with deploy/runner/build.sh. There is NO registry behind this |
| 21 | # image — it lives only in the host's local image store, which is why anvil |
| 22 | # treats a failed pull of the default image as non-fatal. |
| 23 | ARG BASE=ubuntu:26.04 |
| 24 | FROM ${BASE} |
| 25 | |
| 26 | # Which Claude Code release channel to track. `stable` is roughly a week behind |
| 27 | # and skips releases with known major regressions; `latest` ships immediately. |
| 28 | ARG CLAUDE_CHANNEL=stable |
| 29 | |
| 30 | ENV DEBIAN_FRONTEND=noninteractive |
| 31 | |
| 32 | # A TUI (Claude Code's own, or anything a session runs) that thinks it's stuck |
| 33 | # on a 7-bit terminal falls back to ASCII line-drawing instead of real box- |
| 34 | # drawing/bullet glyphs. glibc's built-in C.UTF-8 needs no locale-gen step and |
| 35 | # is present on both Ubuntu and Debian bases. |
| 36 | ENV LANG=C.UTF-8 |
| 37 | ENV LC_ALL=C.UTF-8 |
| 38 | |
| 39 | # Fingerprint of the Claude Code release signing key, checked below so a |
| 40 | # substituted key fails the build rather than silently installing. Published at |
| 41 | # https://code.claude.com/docs/en/setup. Deliberately inlined rather than an |
| 42 | # ARG: a build arg could be overridden on the command line, which would defeat |
| 43 | # the pin it exists to enforce. |
| 44 | |
| 45 | # tmux — session persistence; the whole point of the agent design. Needs |
| 46 | # 3.4+ for OSC 8 hyperlink passthrough (a Claude Code TUI's login |
| 47 | # URL, most visibly) — see tmux.conf for the other half (telling |
| 48 | # tmux the attached client accepts it). Ubuntu 26.04 ships 3.6a; |
| 49 | # this is the reason BASE moved off Debian bookworm (stuck on |
| 50 | # 3.3a, pre-dating that support entirely). |
| 51 | # git — the container clones/pushes for itself (anvil's own code never |
| 52 | # shells out to git, but what a job runs is its own tooling) |
| 53 | # fish — the interactive shell you actually get when you attach |
| 54 | # neovim — a sensible $EDITOR for anything a session or an attached human |
| 55 | # shells out to (git commit messages, etc.) |
| 56 | # ripgrep — Claude Code's search backend |
| 57 | # curl/gnupg/ca-certificates — fetching and verifying the apt repo key |
| 58 | RUN apt-get update \ |
| 59 | && apt-get install -y --no-install-recommends \ |
| 60 | ca-certificates \ |
| 61 | curl \ |
| 62 | fish \ |
| 63 | git \ |
| 64 | gnupg \ |
| 65 | less \ |
| 66 | neovim \ |
| 67 | ripgrep \ |
| 68 | tmux \ |
| 69 | && install -d -m 0755 /etc/apt/keyrings \ |
| 70 | && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \ |
| 71 | -o /etc/apt/keyrings/claude-code.asc \ |
| 72 | && gpg --show-keys --with-colons /etc/apt/keyrings/claude-code.asc \ |
| 73 | | awk -F: '/^fpr:/ {print $10}' \ |
| 74 | | grep -qx 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \ |
| 75 | && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc]" \ |
| 76 | "https://downloads.claude.ai/claude-code/apt/${CLAUDE_CHANNEL}" \ |
| 77 | "${CLAUDE_CHANNEL} main" > /etc/apt/sources.list.d/claude-code.list \ |
| 78 | && apt-get update \ |
| 79 | && apt-get install -y --no-install-recommends claude-code \ |
| 80 | && rm -rf /var/lib/apt/lists/* |
| 81 | |
| 82 | # apt installs never auto-update, but Claude Code still checks on startup and |
| 83 | # the check is pure noise in a container whose version is pinned by the image. |
| 84 | ENV DISABLE_AUTOUPDATER=1 |
| 85 | |
| 86 | # What `git commit` (no -m) and anything else honouring $EDITOR drops you |
| 87 | # into. Ubuntu's neovim package doesn't register update-alternatives entries |
| 88 | # for vi/vim/editor, so this is the only thing that makes it the default. |
| 89 | ENV EDITOR=nvim |
| 90 | ENV VISUAL=nvim |
| 91 | |
| 92 | # An unprivileged user for agent sessions to run as. Deliberately NOT set as |
| 93 | # the image's USER: CI pipelines inherit this image's default user, and plenty |
| 94 | # of them expect root for apt-get. anvil passes `user: ubuntu` explicitly when |
| 95 | # it creates a *session* container, so CI keeps the behaviour it has today. |
| 96 | # |
| 97 | # Reusing the base image's own `ubuntu` user (uid 1000, matching |
| 98 | # [`container::RUN_AS_UID`]) rather than making a purpose-built account: it's |
| 99 | # already there, so this is just pointing its shell at fish and giving it a |
| 100 | # workspace under its own home ([`container::WORKDIR`]) — under `$HOME` rather |
| 101 | # than a bare `/workspace` so tools that assume a project lives there behave. |
| 102 | RUN usermod --shell /usr/bin/fish ubuntu \ |
| 103 | && mkdir -p /home/ubuntu/workspace \ |
| 104 | && chown ubuntu:ubuntu /home/ubuntu/workspace |
| 105 | |
| 106 | # Baseline Claude Code config for a session: auto theme (so it reads fine |
| 107 | # however the browser terminal is themed) and bypass-permissions, matching the |
| 108 | # `--dangerously-skip-permissions` flag the session launches with (see |
| 109 | # anvil-agent/src/supervisor.rs) — belt and suspenders for anything that reads |
| 110 | # the setting rather than the flag. `agent.credentials_dir`, when configured, |
| 111 | # uploads over `~/.claude` and can add to or override this file. |
| 112 | COPY claude-settings.json /home/ubuntu/.claude/settings.json |
| 113 | RUN chown -R ubuntu:ubuntu /home/ubuntu/.claude |
| 114 | |
| 115 | # settings.json's `theme` only sets the *value*; it does not mark the |
| 116 | # first-run wizard as done, and that state lives in a separate file. Without |
| 117 | # this, every session replays the theme picker and the per-project trust |
| 118 | # dialog regardless of what settings.json says. Keyed on [`container::WORKDIR`] |
| 119 | # — fixed for every session, so this is safe to bake in rather than derive at |
| 120 | # container-creation time. Login still prompts (there |
| 121 | # is nothing to skip: a fresh session has no credentials until |
| 122 | # `agent.credentials_dir` is configured), and that dialog's OSC 8 link is the |
| 123 | # one tmux.conf's `terminal-features` line exists for. |
| 124 | COPY claude-onboarding.json /home/ubuntu/.claude.json |
| 125 | RUN chown ubuntu:ubuntu /home/ubuntu/.claude.json |
| 126 | |
| 127 | COPY tmux.conf /etc/anvil/tmux.conf |
| 128 | COPY session-entrypoint.sh /usr/local/bin/anvil-session |
| 129 | RUN chmod 0755 /usr/local/bin/anvil-session |
| 130 | |
| 131 | WORKDIR /home/ubuntu/workspace |