diff --git a/anvil/Dockerfile b/anvil/Dockerfile index 9ebd2ff..5cc0e36 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,52 @@ 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 +# 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 RUN set -eux; \ export DEBIAN_FRONTEND=noninteractive; \ @@ -62,6 +101,7 @@ RUN set -eux; \ clang-format \ cmake \ curl \ + default-libmysqlclient-dev \ gdb \ git \ gnupg \ @@ -69,7 +109,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,9 +120,11 @@ 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; \ + 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/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..abf021b 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,8 +167,10 @@ 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. | +| `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. | 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.