Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions anvil/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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; \
Expand All @@ -62,24 +101,30 @@ RUN set -eux; \
clang-format \
cmake \
curl \
default-libmysqlclient-dev \
gdb \
git \
gnupg \
gosu \
less \
libclang-rt-dev \
libcurl4-openssl-dev \
libffi-dev \
libgmp-dev \
libssl-dev \
libyaml-dev \
lldb \
ninja-build \
openssh-client \
pkg-config \
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; \
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ Each harness also has plain-`=` `<PREFIX>_`-named defaults in its fragment: `CLA
| `AGENT` | `opencode` | The harness to install. A name with no `swarmforge/harness/<name>/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. |

Expand Down Expand Up @@ -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. |
Expand Down
41 changes: 41 additions & 0 deletions tests/test_image_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading