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 | |||
| 4 | 4 | *.db-wal | |
| 5 | 5 | *.db-shm | |
| 6 | 6 | 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 | |
| 10 | 11 | # CA bundle deploy/dev.sh builds so the dev container trusts portless (docs/oidc.md). | |
| 11 | 12 | /deploy/dev-ca.crt | |
| 12 | 13 | # Local-only API credentials for fetching attachments (never committed). | |
| ⋯ 1 unchanged line | |||
modifiedCLAUDE.md+1 −1
| ⋯ 19 unchanged lines | |||
| 20 | 20 | ||
| 21 | 21 | **The Rust version is set in exactly one place: `rust-toolchain.toml`.** It | |
| 22 | 22 | 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 | |
| 24 | 24 | same version into `anvil-runner:rust` — so a CI job and a checkout compile with | |
| 25 | 25 | the same rustc. Bumping Rust means editing that file and Cargo.toml's floor | |
| 26 | 26 | (the hook catches you if you forget the second), then rebuilding the image. | |
| ⋯ 110 unchanged lines | |||
modifiedDEPLOY.md+2 −2
| ⋯ 29 unchanged lines | |||
| 30 | 30 | ||
| 31 | 31 | 1. `deploy/build.sh` — `cargo zigbuild --release --target | |
| 32 | 32 | 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`, | |
| 34 | 34 | 2. `hag deploy` — `docker compose build --push` builds the thin image that just | |
| 35 | 35 | `COPY`s that binary in (the `Dockerfile` compiles nothing), pushes it, then | |
| 36 | 36 | drives the host. | |
| ⋯ 12 unchanged lines | |||
| 49 | 49 | The Rust toolchain itself needs no setup step: `rust-toolchain.toml` pins the | |
| 50 | 50 | version *and* the `x86_64-unknown-linux-musl` target, and rustup installs both | |
| 51 | 51 | 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 | |
| 53 | 53 | toolchain into `anvil-runner:rust`. | |
| 54 | 54 | ||
| 55 | 55 | 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 | |||
| 76 | 76 | # User inside the job container, e.g. "1000:1000". Empty keeps the image default. | |
| 77 | 77 | run_as = "" | |
| 78 | 78 | # 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, | |
| 80 | 80 | # git, fish and Claude Code. It is a LOCAL image with no registry behind it, so | |
| 81 | 81 | # anvil falls back to the local copy when the pull fails. Always allowed, | |
| 82 | 82 | # whatever allowed_images says. | |
| ⋯ 57 unchanged lines | |||
modifiedcompose.override.yaml+1 −1
| ⋯ 30 unchanged lines | |||
| 31 | 31 | image: anvil-worker-dev:latest | |
| 32 | 32 | build: | |
| 33 | 33 | context: . | |
| 34 | - | dockerfile: deploy/worker/Dockerfile | |
| 34 | + | dockerfile: docker/worker/Dockerfile | |
| 35 | 35 | platforms: | |
| 36 | 36 | - linux/amd64 | |
| 37 | 37 | restart: unless-stopped | |
| ⋯ 118 unchanged lines | |||
modifiedcompose.yaml+5 −2
| ⋯ 3 unchanged lines | |||
| 4 | 4 | # `docker login registry.vibe.richardscollin.com` once. | |
| 5 | 5 | # | |
| 6 | 6 | # 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 | |
| 9 | 9 | # ./deploy/deploy.sh, which stages the binary and then calls it. | |
| 10 | 10 | # | |
| 11 | 11 | # IMAGE_TAG picks which build runs. hag sets it to the git sha it just pushed, | |
| ⋯ 3 unchanged lines | |||
| 15 | 15 | anvil: | |
| 16 | 16 | image: registry.vibe.richardscollin.com/anvil:${IMAGE_TAG:-latest} | |
| 17 | 17 | 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. | |
| 18 | 20 | context: . | |
| 21 | + | dockerfile: docker/anvil/Dockerfile | |
| 19 | 22 | # hagrid is x86_64 and the staged binary is x86_64-musl. Pinning the | |
| 20 | 23 | # platform stops an arm64 workstation from producing an image whose | |
| 21 | 24 | # base layers the host cannot run. | |
| ⋯ 59 unchanged lines | |||
modifiedcrates/anvil-agent/src/container.rs+1 −1
| ⋯ 40 unchanged lines | |||
| 41 | 41 | ||
| 42 | 42 | /// The unprivileged user sessions run as rather than root; CI keeps the | |
| 43 | 43 | /// 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 | |
| 45 | 45 | /// base image already ships a uid-1000 user by that name, so reusing it saves | |
| 46 | 46 | /// a `useradd`. If the base image ever moves off Ubuntu, this needs an actual | |
| 47 | 47 | /// account created again. | |
| ⋯ 553 unchanged lines | |||
modifiedcrates/anvil-core/src/config.rs+2 −2
| ⋯ 74 unchanged lines | |||
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | /// 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 | |
| 79 | 79 | /// only in the host's local Docker image store — there is no registry to pull | |
| 80 | 80 | /// it from, so anything that starts a container from it must treat a failed | |
| 81 | 81 | /// pull as non-fatal when the image is already present locally. | |
| ⋯ 86 unchanged lines | |||
| 168 | 168 | /// simply omits `image:`. | |
| 169 | 169 | pub allowed_images: Vec<String>, | |
| 170 | 170 | /// 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 | |
| 172 | 172 | /// in, carrying tmux, git, fish and Claude Code. Built locally rather than | |
| 173 | 173 | /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller | |
| 174 | 174 | /// must tolerate a failed pull. | |
| ⋯ 562 unchanged lines | |||
modifiedcrates/anvil-docker/src/lib.rs+1 −1
| ⋯ 28 unchanged lines | |||
| 29 | 29 | /// Make sure `image` is present locally, pulling it if it is not. | |
| 30 | 30 | /// | |
| 31 | 31 | /// 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 | |
| 33 | 33 | /// host's image store and exists in no registry, so an unconditional pull — | |
| 34 | 34 | /// which is what this used to be — fails for the one image most jobs now use. | |
| 35 | 35 | /// `platform` is `os[/arch[/variant]]`, or empty for the daemon's native one. | |
| ⋯ 85 unchanged lines | |||
modifieddeploy/build.sh+15 −6
| ⋯ 2 unchanged lines | |||
| 3 | 3 | # never compiles anything. | |
| 4 | 4 | # | |
| 5 | 5 | # 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. | |
| 9 | 9 | # | |
| 10 | 10 | # ./deploy/build.sh release — what gets deployed | |
| 11 | 11 | # ./deploy/build.sh --debug unoptimized, for the local compose.override.yaml | |
| ⋯ 13 unchanged lines | |||
| 25 | 25 | TARGET="x86_64-unknown-linux-musl" | |
| 26 | 26 | PROFILE=release | |
| 27 | 27 | 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. | |
| 29 | 30 | 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 | + | } | |
| 30 | 38 | ||
| 31 | 39 | for arg in "$@"; do | |
| 32 | 40 | case "$arg" in | |
| ⋯ 16 unchanged lines | |||
| 49 | 57 | echo "==> cross-compiling $bin for $TARGET ($PROFILE, native, via zig)" | |
| 50 | 58 | cargo zigbuild --target "$TARGET" --bin "$bin" "${CARGO_FLAGS[@]}" | |
| 51 | 59 | ||
| 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" | |
| 54 | 63 | done | |
| 55 | 64 | ||
| 56 | 65 | # Deliberately left in place: `docker compose build` runs after this and needs | |
| ⋯ 2 unchanged lines | |||
modifieddeploy/deploy.sh+1 −1
| ⋯ 3 unchanged lines | |||
| 4 | 4 | # Two halves, because the image carries a prebuilt binary rather than compiling | |
| 5 | 5 | # in Docker: | |
| 6 | 6 | # | |
| 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, | |
| 8 | 8 | # 2. `hag deploy` does the rest from compose.yaml — build the image, push it | |
| 9 | 9 | # to the registry, copy compose.yaml to the host, pull and recreate there. | |
| 10 | 10 | # | |
| ⋯ 29 unchanged lines | |||
modifieddeploy/dev.sh+7 −5
| 1 | 1 | #!/usr/bin/env bash | |
| 2 | 2 | # Run anvil locally in Docker, reachable at https://anvil.localhost. | |
| 3 | 3 | # | |
| 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 | |
| 7 | 8 | # Docker rather than `cargo run` is what makes CI testable — the runner drives | |
| 8 | 9 | # the host's Docker socket, which is mounted in. | |
| 9 | 10 | # | |
| ⋯ 46 unchanged lines | |||
| 56 | 57 | # Static musl, exactly as in production: the runtime image is debian-slim and a | |
| 57 | 58 | # binary linked against Fedora's glibc would not run there. | |
| 58 | 59 | 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 | |
| 61 | 62 | ||
| 62 | 63 | echo "==> building $IMAGE" | |
| 63 | 64 | docker build --quiet --platform linux/amd64 \ | |
| 65 | + | -f docker/anvil/Dockerfile \ | |
| 64 | 66 | --build-arg CONFIG=deploy/anvil.dev.toml -t "$IMAGE" . >/dev/null | |
| 65 | 67 | ||
| 66 | 68 | 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 | |||
| 37 | 37 | ||
| 38 | 38 | ## The shared runner image | |
| 39 | 39 | ||
| 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 | |
| 41 | 41 | sessions, so a session can reproduce a build by hand and there is one thing to | |
| 42 | 42 | keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed | |
| 43 | 43 | from Anthropic's signed apt repository, with the release key's fingerprint | |
| 44 | 44 | pinned in the Dockerfile). | |
| 45 | 45 | ||
| 46 | 46 | ``` | |
| 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 | |
| 49 | 49 | ``` | |
| 50 | 50 | ||
| 51 | 51 | 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 | |||
| 343 | 343 | half of routing — are testable without a second machine: | |
| 344 | 344 | ||
| 345 | 345 | ```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 | |
| 347 | 347 | docker compose up -d --build | |
| 348 | 348 | docker compose logs -f runner-1 runner-2 | |
| 349 | 349 | # anvil-worker dev-1 (linux/amd64) → http://anvil:3000 | |
| ⋯ 104 unchanged lines | |||
modifiedrust-toolchain.toml+1 −1
| 1 | 1 | # The one place the Rust version is set. rustup reads this for every cargo | |
| 2 | 2 | # 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 | |
| 4 | 4 | # into the anvil-runner:rust image — so the toolchain a CI job compiles with is | |
| 5 | 5 | # the one this checkout compiles with. | |
| 6 | 6 | # | |
| ⋯ 12 unchanged lines | |||