|
| 1 | +# The dispatched-agent sandbox (Phase 1 of |
| 2 | +# docs/superpowers/specs/2026-08-20-agent-fleet-sandbox-router-design.md). |
| 3 | +# |
| 4 | +# One container per agent, per issue. Inside it a `claude` process runs the |
| 5 | +# `implement-issue` skill headless against a private clone bind-mounted at |
| 6 | +# /workspace, with a role-scoped gh token and a permissive permission mode -- |
| 7 | +# permissive being safe precisely because the blast radius is this disposable |
| 8 | +# container and that scoped token, not the maintainer's real filesystem and |
| 9 | +# real `gh auth`. |
| 10 | +# |
| 11 | +# --------------------------------------------------------------------------- |
| 12 | +# WHY DEPENDENCIES ARE BAKED IN HERE RATHER THAN MOUNTED FROM THE HOST |
| 13 | +# --------------------------------------------------------------------------- |
| 14 | +# The design's original instruction was to mount the host's .venv and |
| 15 | +# node_modules read-only, so a dispatch costs a container start instead of the |
| 16 | +# ~35 minutes scripts/worktree-setup.sh exists to avoid. That cannot work as |
| 17 | +# written: the host is macOS/arm64 and this container is Linux/arm64. |
| 18 | +# .venv/bin/python3 -> Mach-O 64-bit executable arm64 |
| 19 | +# frontend/node_modules -> @esbuild/darwin-arm64, @rollup/rollup-darwin-arm64 |
| 20 | +# Mounting those in gives a Linux container binaries it cannot execute. |
| 21 | +# |
| 22 | +# So the trees are built here, ONCE, into an image layer. The per-dispatch cost |
| 23 | +# is then lower than the mounted design's -- there is nothing to mount or check |
| 24 | +# -- and the build only repeats when a lockfile actually changes. The layer |
| 25 | +# order below exists for that: a requirements.txt change must not re-run |
| 26 | +# `npm ci` or re-fetch a browser. |
| 27 | +# |
| 28 | +# THE PATH TRICK. They are built at MAIN_CHECKOUT -- the host's own absolute |
| 29 | +# path to the main checkout, passed in by scripts/run-agent.sh. The clone that |
| 30 | +# gets mounted at /workspace carries the ordinary `.venv -> |
| 31 | +# <main-checkout>/.venv` symlinks scripts/worktree-setup.sh writes for every |
| 32 | +# worktree, so the SAME symlink resolves to macOS deps on the host and to these |
| 33 | +# Linux deps in here. Nothing in the checkout is container-aware, nothing gets |
| 34 | +# rewritten at start, and `cd .agent-clones/issue-<n> && .venv/bin/pytest` keeps |
| 35 | +# working on the host exactly as the design requires. |
| 36 | +# |
| 37 | +# WHAT REPLACES THE READ-ONLY MOUNT. The design mounted deps read-only because |
| 38 | +# two containers installing concurrently into one shared venv corrupts it. With |
| 39 | +# the trees in an image layer there is no shared tree left to corrupt: every |
| 40 | +# container gets its own copy-on-write view, so a task that really does change |
| 41 | +# requirements.txt installs into its own layer, affecting nobody, and the |
| 42 | +# writes vanish with the container. That is the property the read-only mount |
| 43 | +# was buying, obtained structurally instead of by permission bits -- |
| 44 | +# scripts/run-agent.sh --verify-isolation demonstrates it. |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | + |
| 47 | +# Node comes from the official image rather than a distro package: bookworm |
| 48 | +# ships 18, and frontend/e2e are built against 20 (see ci.yml's setup-node). |
| 49 | +FROM node:20-bookworm-slim AS node |
| 50 | + |
| 51 | +# Python 3.13 to match ci.yml's pin -- deliberately NOT the host venv's 3.12.13. |
| 52 | +# This container is what opens PRs, so matching CI means green here => green |
| 53 | +# there. (The host/CI drift is pre-existing and not this image's to resolve.) |
| 54 | +FROM python:3.13-slim-bookworm |
| 55 | + |
| 56 | +ARG TARGETARCH=arm64 |
| 57 | +ARG GH_VERSION=2.63.2 |
| 58 | + |
| 59 | +# MAIN_CHECKOUT must be the host's absolute path to the main bess-manager |
| 60 | +# checkout -- the symlink targets inside every clone point at it. Passed by |
| 61 | +# scripts/run-agent.sh; the default only keeps a bare `podman build` honest. |
| 62 | +ARG MAIN_CHECKOUT=/root/bess-manager |
| 63 | +ENV MAIN_CHECKOUT=${MAIN_CHECKOUT} |
| 64 | + |
| 65 | +ENV DEBIAN_FRONTEND=noninteractive \ |
| 66 | + PIP_DISABLE_PIP_VERSION_CHECK=1 \ |
| 67 | + NODE_PATH=/usr/local/lib/node_modules \ |
| 68 | + PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright |
| 69 | + |
| 70 | +# --- System layer ------------------------------------------------------------ |
| 71 | +# git + gh: the agent's whole output is commits and PRs. |
| 72 | +# sqlite3: scripts/fleet-manifest.sh, so the agent can report its own status. |
| 73 | +# nftables + iproute2: the egress allowlist in container/agent-entrypoint.sh. |
| 74 | +# ripgrep/jq/less: what the agent's own tools shell out to. |
| 75 | +RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 76 | + git ca-certificates curl jq less ripgrep sqlite3 \ |
| 77 | + nftables iproute2 procps \ |
| 78 | + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz" \ |
| 79 | + | tar -xz -C /tmp \ |
| 80 | + && mv "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh \ |
| 81 | + && rm -rf /tmp/gh_* /var/lib/apt/lists/* |
| 82 | + |
| 83 | +# Node 20, copied wholesale from the official image (npm and npx are symlinks |
| 84 | +# into /usr/local/lib/node_modules/npm, so the lib tree has to come too). |
| 85 | +COPY --from=node /usr/local/bin/node /usr/local/bin/node |
| 86 | +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules |
| 87 | +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ |
| 88 | + && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ |
| 89 | + && node --version && npm --version |
| 90 | + |
| 91 | +# The agent itself. |
| 92 | +RUN npm install -g @anthropic-ai/claude-code && claude --version |
| 93 | + |
| 94 | +# --- Python dependencies ----------------------------------------------------- |
| 95 | +# Only the two requirements files are copied, so editing any source file leaves |
| 96 | +# this layer cached. |
| 97 | +WORKDIR ${MAIN_CHECKOUT} |
| 98 | +COPY backend/requirements.txt backend/requirements.txt |
| 99 | +COPY requirements-dev.txt requirements-dev.txt |
| 100 | +RUN python -m venv "${MAIN_CHECKOUT}/.venv" \ |
| 101 | + && "${MAIN_CHECKOUT}/.venv/bin/pip" install --no-cache-dir \ |
| 102 | + -r backend/requirements.txt -r requirements-dev.txt |
| 103 | + |
| 104 | +# --- Node dependencies ------------------------------------------------------- |
| 105 | +# `npm ci`, not `npm install`: the lockfile is the input, and a build that |
| 106 | +# silently resolved something else would give the agent a tree CI does not have. |
| 107 | +COPY frontend/package.json frontend/package-lock.json frontend/ |
| 108 | +RUN cd frontend && npm ci |
| 109 | + |
| 110 | +COPY e2e/package.json e2e/package-lock.json e2e/ |
| 111 | +RUN cd e2e && npm ci |
| 112 | + |
| 113 | +# --- Playwright browsers ----------------------------------------------------- |
| 114 | +# Its own layer, last of the dependency layers: it is the slowest to rebuild and |
| 115 | +# the least likely to change. --with-deps pulls the system libraries chromium |
| 116 | +# needs, which a slim base does not have. |
| 117 | +RUN cd e2e && npx playwright install --with-deps chromium |
| 118 | + |
| 119 | +# --- Step 8 (local run & observe) -------------------------------------------- |
| 120 | +# Deliberately a LATE layer, after the slow dependency ones, so adding it does |
| 121 | +# not invalidate them. |
| 122 | +# |
| 123 | +# Step 8 stands the real stack up (docker-compose.ci.yml, mock-HA) and watches |
| 124 | +# it work, and it is the documented reason the local flow exists at all -- CI |
| 125 | +# mode skips it and says so in the PR body. A dispatched container is not CI: |
| 126 | +# it CAN run the stack, by talking to the same podman that started it (the |
| 127 | +# socket is bind-mounted in by scripts/run-agent.sh) rather than nesting a |
| 128 | +# second container runtime inside itself. Sibling containers, not children. |
| 129 | +# |
| 130 | +# That works only because run-agent.sh mounts the clone at its own HOST |
| 131 | +# absolute path: the compose file's relative volume paths then resolve to the |
| 132 | +# same real directory for the outer podman as for this container. Mounted at |
| 133 | +# /workspace they would resolve to paths only this container can see, and every |
| 134 | +# volume in the stack would come up empty. |
| 135 | +RUN apt-get update && apt-get install -y --no-install-recommends podman \ |
| 136 | + && pip install --no-cache-dir podman-compose \ |
| 137 | + && apt-get purge -y --auto-remove \ |
| 138 | + && rm -rf /var/lib/apt/lists/* \ |
| 139 | + && podman --version && podman-compose --version |
| 140 | + |
| 141 | +# --- The agent user ---------------------------------------------------------- |
| 142 | +# |
| 143 | +# NOT root, and this is a hard requirement rather than hygiene: Claude Code |
| 144 | +# refuses outright with "--dangerously-skip-permissions cannot be used with |
| 145 | +# root/sudo privileges for security reasons". Since that flag is the entire |
| 146 | +# point of the phase, a root container cannot run an agent at all. Observed on |
| 147 | +# the first live dispatch, which started cleanly and then died on that line. |
| 148 | +# |
| 149 | +# uid 1000 and the bind mount: podman's virtiofs presents the mounted clone as |
| 150 | +# owned by whatever uid the process currently has, so the agent user has full |
| 151 | +# write access without any chown of the host's files. The entrypoint still |
| 152 | +# STARTS as root -- it needs NET_ADMIN for the egress ruleset -- and drops here |
| 153 | +# with setpriv immediately afterwards. |
| 154 | +# |
| 155 | +# EXPECTED LOG LINE, NOT A DEFECT: every dispatch prints "Sandbox disabled: |
| 156 | +# ... bubblewrap (bwrap) not installed, socat not installed ... Commands will |
| 157 | +# run WITHOUT sandboxing." That is Claude Code's OWN in-process sandbox, which |
| 158 | +# this container deliberately does without -- the container IS the boundary, |
| 159 | +# and the agent runs bypassPermissions inside it on purpose. Installing bwrap |
| 160 | +# would add a second, narrower sandbox inside the first, with its own network |
| 161 | +# and filesystem restrictions to debug on top of the egress allowlist. Do that |
| 162 | +# only if there is a reason beyond quieting the warning. |
| 163 | +ARG AGENT_UID=1000 |
| 164 | +RUN useradd --uid ${AGENT_UID} --create-home --shell /bin/bash agent |
| 165 | + |
| 166 | +# The dependency trees above were baked in as root: MAIN_CHECKOUT/.venv and |
| 167 | +# frontend/node_modules, and the Playwright browsers under PLAYWRIGHT_BROWSERS_PATH. |
| 168 | +# The agent drops to uid 1000 and must be able to pip/npm install into its own |
| 169 | +# copy-on-write layer (--verify-isolation's measurement) and repair a browser |
| 170 | +# cache, so hand the trees over. The bind-mounted clone needs no chown -- podman's |
| 171 | +# virtiofs presents it as owned by the container's current uid (see above). |
| 172 | +RUN mkdir -p /opt/ms-playwright \ |
| 173 | + && chown -R ${AGENT_UID}:${AGENT_UID} "${MAIN_CHECKOUT}" /opt/ms-playwright |
| 174 | + |
| 175 | +# --- Agent configuration ----------------------------------------------------- |
| 176 | +# The permissive mode the whole phase is for. This is the container's OWN |
| 177 | +# ~/.claude/settings.json, not the repo's: the clone brings its own tracked |
| 178 | +# .claude/settings.json along, and that one still carries the host's macOS |
| 179 | +# sandbox block, which is meaningless in here. Project settings win over user |
| 180 | +# settings in Claude Code, so run-agent.sh also passes |
| 181 | +# --dangerously-skip-permissions; this file is the belt to that's braces, and |
| 182 | +# documents the intent where someone will actually look for it. |
| 183 | +RUN mkdir -p /home/agent/.claude \ |
| 184 | + && printf '%s\n' '{"permissions": {"defaultMode": "bypassPermissions"}}' \ |
| 185 | + > /home/agent/.claude/settings.json \ |
| 186 | + && chown -R agent:agent /home/agent |
| 187 | + |
| 188 | +COPY container/agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh |
| 189 | +RUN chmod +x /usr/local/bin/agent-entrypoint.sh |
| 190 | + |
| 191 | +# run-agent.sh overrides this with the clone's own host path (see there for |
| 192 | +# why); MAIN_CHECKOUT is the sane default for a bare `podman run`. |
| 193 | +WORKDIR ${MAIN_CHECKOUT} |
| 194 | +ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"] |
| 195 | +CMD ["bash"] |
0 commit comments