From 0d363cb90b27d0dfe044a601f35ef378ca5099d2 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 2 Oct 2026 12:19:44 -0500 Subject: [PATCH 1/2] Build Ruby 3.2.11 and Bundler 2.6.9 into the anvil image Debian trixie ships Ruby 3.3, so a project pinned to 3.2 needs the interpreter built the way Python already is: a ruby-builder stage compiles the release tarball into /opt/ruby, checked against its published SHA-256, and installs Bundler 2.6.9 so a lockfile bundled with it does not trigger a version switch. /opt/ruby belongs to root and the harness runs as the anvil user, so GEM_HOME points under the anvil home, where bundle install can write and which persists for every harness that mounts the home. Its bin directory goes at the end of PATH, not the front: the image PATH is also the root phase's, which runs chown, adduser, and gosu by bare name, so a session-writable directory ahead of /usr/bin would let a session plant a command for root to run on the next start. A test holds the Dockerfile's PATH to that. The yaml, ffi, gmp, and zlib headers go into the runtime stage too: libruby and its psych, fiddle, and zlib extensions link against those libraries, and native gems compile against the headers. --- anvil/Dockerfile | 45 +++++++++++++++++++++++++++++++++-- docs/installation.md | 2 +- docs/reference/environment.md | 6 ++++- tests/test_image_layout.py | 41 +++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/anvil/Dockerfile b/anvil/Dockerfile index 9ebd2ff..6395bb3 100644 --- a/anvil/Dockerfile +++ b/anvil/Dockerfile @@ -1,6 +1,9 @@ # syntax=docker/dockerfile:1 ARG DEBIAN_TAG=trixie-slim ARG PYTHON_VERSION=3.12.7 +ARG RUBY_VERSION=3.2.11 +ARG RUBY_SHA256=b3eeabd6636f334531db3ffdc3229eb05e524740e6c84fdc043720573cf2f8b2 +ARG BUNDLER_VERSION=2.6.9 ARG NODE_MAJOR=24 FROM debian:${DEBIAN_TAG} AS python-builder @@ -40,16 +43,50 @@ RUN set -eux; \ make install; \ rm -rf /tmp/python.tgz /tmp/Python-${PYTHON_VERSION} +FROM debian:${DEBIAN_TAG} AS ruby-builder + +ARG RUBY_VERSION +ARG RUBY_SHA256 +ARG BUNDLER_VERSION + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + libffi-dev \ + libgmp-dev \ + libssl-dev \ + libyaml-dev \ + tar \ + zlib1g-dev; \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + curl -fsSL "https://cache.ruby-lang.org/pub/ruby/${RUBY_VERSION%.*}/ruby-${RUBY_VERSION}.tar.gz" -o /tmp/ruby.tgz; \ + printf '%s %s\n' "${RUBY_SHA256}" /tmp/ruby.tgz | sha256sum -c -; \ + tar -xzf /tmp/ruby.tgz -C /tmp; \ + cd /tmp/ruby-${RUBY_VERSION}; \ + ./configure --prefix=/opt/ruby --enable-shared --disable-install-doc; \ + make -j"$(nproc)"; \ + make install; \ + /opt/ruby/bin/gem install --no-document bundler -v "${BUNDLER_VERSION}"; \ + rm -rf /tmp/ruby.tgz /tmp/ruby-${RUBY_VERSION} + FROM debian:${DEBIAN_TAG} AS runtime-base ARG PYTHON_VERSION ARG NODE_MAJOR ARG PLAYWRIGHT_VERSION=1.60.0 -ENV PATH="/opt/python/bin:${PATH}" +# /opt/ruby is root's, so gems go under the anvil home; its bin trails PATH so it shadows nothing root runs. +ENV GEM_HOME=/home/anvil/.gem +ENV PATH="/opt/python/bin:/opt/ruby/bin:${PATH}:${GEM_HOME}/bin" ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright COPY --from=python-builder /opt/python /opt/python +COPY --from=ruby-builder /opt/ruby /opt/ruby RUN set -eux; \ export DEBIAN_FRONTEND=noninteractive; \ @@ -69,7 +106,10 @@ RUN set -eux; \ less \ libclang-rt-dev \ libcurl4-openssl-dev \ + libffi-dev \ + libgmp-dev \ libssl-dev \ + libyaml-dev \ lldb \ ninja-build \ openssh-client \ @@ -77,7 +117,8 @@ RUN set -eux; \ ripgrep \ tar \ tini \ - tzdata; \ + tzdata \ + zlib1g-dev; \ curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash -; \ apt-get install -y --no-install-recommends nodejs; \ npx -y playwright@${PLAYWRIGHT_VERSION} install --with-deps chromium; \ diff --git a/docs/installation.md b/docs/installation.md index 32a279b..2b5a08f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -22,7 +22,7 @@ make build_opencode OPENCODE_VERSION=1.4.14 make update_opencode OPENCODE_VERSION=1.4.14 ``` -The four harness images share the same Debian base and toolchain (Node.js + Python; see `anvil/Dockerfile`). +The four harness images share the same Debian base and toolchain (Node.js, Python, and Ruby; see `anvil/Dockerfile`). ## Multiple aliases (work/personal) diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 7371f07..e931b83 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -110,6 +110,9 @@ Each harness also has plain-`=` `_`-named defaults in its fragment: `CLA | `AGENT` | `opencode` | The harness to install. A name with no `swarmforge/harness//install.sh` fails the build. | | `SWARMFORGE_HARNESS_VERSION` | empty | The version pin that harness's `install.sh` reads; `OPENCODE_VERSION` maps onto it. | | `PYTHON_VERSION` | `3.12.7` | The CPython release built into `/opt/python`. | +| `RUBY_VERSION` | `3.2.11` | The Ruby release built into `/opt/ruby`. | +| `RUBY_SHA256` | the `3.2.11` tarball's | The checksum the Ruby tarball must match. Change it together with `RUBY_VERSION`, or the build fails. | +| `BUNDLER_VERSION` | `2.6.9` | The Bundler release installed alongside Ruby's default one. | | `NODE_MAJOR` | `24` | The NodeSource major version. | | `PLAYWRIGHT_VERSION` | `1.60.0` | The Playwright release whose chromium is installed. | @@ -164,7 +167,8 @@ The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as | `HOME` | `/home/anvil` | Set by the user-phase driver before the exec. `make test-skills` sets the same value with `-e HOME`, since it runs the eval script in place of the entrypoint. | | `TZ` | `TIMEZONE` | The entrypoint's timezone step. | | `TERM` `COLORTERM` | forwarded from your shell | The harness binary. | -| `PATH` | `/opt/python/bin` ahead of the image default | Every process in the container. Claude's `pre_exec` prepends the git-wrapper directory on top of it when the root phase installed one. | +| `PATH` | `/opt/python/bin` and `/opt/ruby/bin` ahead of the image default, `$GEM_HOME/bin` after it, since the session can write there and root resolves commands through the same `PATH` | Every process in the container. Claude's `pre_exec` prepends the git-wrapper directory on top of it when the root phase installed one. | +| `GEM_HOME` | `/home/anvil/.gem` | RubyGems and Bundler, as where gems install. `/opt/ruby` belongs to root, and the anvil home persists for every harness but OpenCode. | | `PLAYWRIGHT_BROWSERS_PATH` | `/ms-playwright` | Playwright, for the chromium the image installs. | | `SWARMFORGE_CONFIG_{USER,ORG,REPO}_DIR` | `/tmp/swarmforge-config/{user,org,repo}` | `swarmforge.harness.init`, as the three config layers. | | `SWARMFORGE_ASSETS_{USER,ORG,REPO}_DIR` | `/tmp/swarmforge-assets/{user,org,repo}` | `swarmforge.harness.init`, as the agent-definition layers. | diff --git a/tests/test_image_layout.py b/tests/test_image_layout.py index f2593d8..02dad5d 100644 --- a/tests/test_image_layout.py +++ b/tests/test_image_layout.py @@ -209,6 +209,47 @@ def test_the_config_home_is_outside_every_host_mount(self): "claude config dir lands in a host mount: %s" % path) +class ImagePathOrder(unittest.TestCase): + """No directory a session can write comes ahead of the system's on PATH. + + The image PATH is the root phase's too: the entrypoint and the config + driver run id, adduser, chown, and gosu by bare name, and Claude's resolves + the git its wrapper execs. A writable directory ahead of /usr/bin lets a + session plant one of those for root to run on the next start, and the + persistent home carries it into every later project for that harness. + """ + + def env_paths(self): + """Each `ENV PATH=` value's entries ahead of `${PATH}`, expanded.""" + env = {} + found = [] + with open(DOCKERFILE) as handle: + for line in handle: + match = re.match(r'ENV (\w+)="?([^"\s]*)"?\s*$', line) + if not match: + continue + name, value = match.groups() + if name == "PATH": + found.append(value.split("${PATH}")[0].split(":")) + env[name] = re.sub( + r"\$\{(\w+)\}", lambda m: env.get(m.group(1), m.group(0)), + value) + return [ + [re.sub(r"\$\{(\w+)\}", lambda m: env.get(m.group(1), m.group(0)), + entry) for entry in entries if entry] + for entries in found + ] + + def test_path_prepends_nothing_under_a_host_mount(self): + paths = self.env_paths() + self.assertTrue(paths, "Dockerfile sets no PATH") + for entries in paths: + for entry in entries: + self.assertFalse( + entry.startswith(("/home/", "/workspace", "$")), + "PATH puts %s ahead of the system bin dirs" % entry) + + class StatusLineAgreement(unittest.TestCase): """The status line the Claude image ships must be the one it turns on. From 8d3c8ec6a9e4241460254887186d48f500ba451f Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 2 Oct 2026 12:19:34 -0500 Subject: [PATCH 2/2] Install the MySQL client headers and Yarn in the anvil image A Rails app on MySQL cannot bundle without them: the mysql2 gem compiles against libmysqlclient, which default-libmysqlclient-dev provides (as MariaDB's compatible client on trixie), and assets:precompile runs Shakapacker's JS build, which installs its packages with yarn. Yarn comes from corepack, which Node 24 bundles, rather than a global npm install: Yarn 1 refuses to run in a project whose packageManager names Yarn 2 or later, while corepack's shim runs whatever release that field names and falls back to Yarn 1 when there is none. The shim fetches the release on first use, so the download prompt is turned off for a session with no one to answer it, and so is auto-pinning, which would write packageManager into the project's package.json. --- anvil/Dockerfile | 4 ++++ docs/reference/environment.md | 1 + 2 files changed, 5 insertions(+) diff --git a/anvil/Dockerfile b/anvil/Dockerfile index 6395bb3..5cc0e36 100644 --- a/anvil/Dockerfile +++ b/anvil/Dockerfile @@ -84,6 +84,8 @@ ARG PLAYWRIGHT_VERSION=1.60.0 ENV GEM_HOME=/home/anvil/.gem ENV PATH="/opt/python/bin:/opt/ruby/bin:${PATH}:${GEM_HOME}/bin" ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +# No session is there to answer corepack's download prompt, and its auto-pin would edit package.json. +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_ENABLE_AUTO_PIN=0 COPY --from=python-builder /opt/python /opt/python COPY --from=ruby-builder /opt/ruby /opt/ruby @@ -99,6 +101,7 @@ RUN set -eux; \ clang-format \ cmake \ curl \ + default-libmysqlclient-dev \ gdb \ git \ gnupg \ @@ -121,6 +124,7 @@ RUN set -eux; \ zlib1g-dev; \ curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash -; \ apt-get install -y --no-install-recommends nodejs; \ + corepack enable yarn; \ npx -y playwright@${PLAYWRIGHT_VERSION} install --with-deps chromium; \ PYTHON_MINOR="${PYTHON_VERSION%.*}"; \ ln -sf /opt/python/bin/python${PYTHON_MINOR} /usr/local/bin/python3; \ diff --git a/docs/reference/environment.md b/docs/reference/environment.md index e931b83..abf021b 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -170,6 +170,7 @@ The launcher reads `SWARMFORGE_USER_ASSETS_DIR` from its own environment too, as | `PATH` | `/opt/python/bin` and `/opt/ruby/bin` ahead of the image default, `$GEM_HOME/bin` after it, since the session can write there and root resolves commands through the same `PATH` | Every process in the container. Claude's `pre_exec` prepends the git-wrapper directory on top of it when the root phase installed one. | | `GEM_HOME` | `/home/anvil/.gem` | RubyGems and Bundler, as where gems install. `/opt/ruby` belongs to root, and the anvil home persists for every harness but OpenCode. | | `PLAYWRIGHT_BROWSERS_PATH` | `/ms-playwright` | Playwright, for the chromium the image installs. | +| `COREPACK_ENABLE_DOWNLOAD_PROMPT` `COREPACK_ENABLE_AUTO_PIN` | `0` | Corepack, behind the image's `yarn`: it fetches the release a project's `packageManager` names, or Yarn 1 when there is none, without waiting on a prompt or writing that field into `package.json`. | | `SWARMFORGE_CONFIG_{USER,ORG,REPO}_DIR` | `/tmp/swarmforge-config/{user,org,repo}` | `swarmforge.harness.init`, as the three config layers. | | `SWARMFORGE_ASSETS_{USER,ORG,REPO}_DIR` | `/tmp/swarmforge-assets/{user,org,repo}` | `swarmforge.harness.init`, as the agent-definition layers. | | `SWARMFORGE_DOTAGENTS_{USER,ORG}_DIR` | `/tmp/swarmforge-dotagents/{user,org}` | `swarmforge.harness.init`, as the portable skill and command layers. |