Skip to content

Commit 55eb7ff

Browse files
johanzanderclaude
andauthored
feat: dispatch agents in containers with a fleet manifest (Phase 1) (#670)
* feat: run dispatched agents in containers, with a fleet manifest instead of git worktrees Phase 1 of docs/superpowers/specs/2026-08-20-agent-fleet-sandbox-router-design.md: dispatched work moves off the maintainer's filesystem and credentials, so permissions come from a boundary rather than from another allowlist pattern. - scripts/run-agent.sh <n> gives one agent one private clone (not a worktree: worktrees share a mutable ref namespace that only survives because a human paces dispatch by hand) and one container, alive for the whole issue lifecycle so Step 10's conflict self-heal and Step 11's review loop keep running unattended. - scripts/run-po.sh runs product-owner as a single long-lived looping container -- the one role with project memory, so a per-pass clone would trap every memory edit in a throwaway checkout. - scripts/fleet-manifest.sh replaces `git worktree list` as the registry, in SQLite because N containers write status concurrently. It enforces the product-owner singleton at register time. - scripts/wait-for-reply.sh lets a headless run block at a judgment gate instead of exiting, resuming in the same process on the maintainer's reply. - Containerfile.agent BAKES the Linux dependency trees rather than mounting the host's, which the design assumed: the host is macOS/arm64 (Mach-O venv, @esbuild/darwin-arm64) and the container is Linux. They are built at the host's own absolute path, so one unchanged symlink resolves to macOS deps on the host and Linux deps in the container, and the clone stays a normal checkout you can cd into and test. Per-container copy-on-write replaces the design's read-only mount, verified by `run-agent.sh --verify-isolation`. - The container's egress is an nftables allowlist re-resolved on an interval, and the entrypoint refuses to start rather than run unrestricted. - implement-issue gains a Headless local mode table, parallel to CI mode's, with a row per numbered step. Step 8 applies here unlike in CI: the container drives the compose stack as siblings through the outer podman. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * fix: make a dispatched container actually able to run an agent Four defects, all found by running the thing rather than reading it. - The container ran as root, and Claude Code refuses --dangerously-skip- permissions as root outright -- so the one flag this whole phase exists to enable could never be used. The image now has an `agent` user; the entrypoint still starts as root (it needs NET_ADMIN for the egress ruleset) and drops with setpriv immediately after. Podman's virtiofs presents the bind-mounted clone as owned by whatever uid the process has, so this costs no chown of the host's files. - Claude Code ignored the project's own .claude/settings.json ("this workspace has not been trusted") with no dialog to accept. The entrypoint writes the trust entry for the clone's path, which is per-dispatch and so cannot be baked into the image. - The podman socket pre-flight tested the path on the HOST. On macOS that socket lives inside the VM, so the check was a guaranteed false negative and refused every dispatch. Ask podman whether its own socket exists instead. - register refused a re-dispatch of a finished issue, because the container id is derived from the issue number and the old row still held it -- blocking exactly the resume implement-issue Step 0 is built around. It now reclaims a row whose status is `done`, and still refuses one that is live. It also accepts an empty issue number, which is what run-po.sh legitimately has. Verified live: the agent starts non-root, reports permissionMode bypassPermissions, loads the repo's skills, works in the clone, is egress- restricted, and gets as far as authenticating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * docs: note that the in-container bwrap sandbox warning is expected Every dispatch prints "Sandbox disabled: bubblewrap not installed". That is Claude Code's own in-process sandbox, which this container deliberately does without -- the container is the boundary. Say so where someone reading the Containerfile will look, so nobody installs bwrap to quiet a warning and ends up debugging a second, narrower sandbox nested inside the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * fix: restore the blanket gh api ask that #657's enumeration replaced #657 turned the blanket `Bash(gh api *)` ask into an allow plus an enumerated deny, to stop `gh api --jq` reads from prompting. The write forms put their marker at an arbitrary argument position, so the deny globs could not reach them, and the enumeration left real holes twice (quality-check.sh documents both). A blanket `Bash(gh *)` allow would invert the whole thing by letting `gh auth token` through, so there is none: `gh api` is a blanket ask again and every interactive call prompts once. Dispatched agents bypass permissions entirely, so headless runs are unaffected. Pin the blanket-ask profile in test_agent_permissions.py and annotate the test functions to pass the mypy gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: make the dispatched container's baked deps usable by the agent (PR #670 review) Two confirmed blockers from Stage 4, both in Containerfile.agent: - The dependency trees (.venv, frontend/node_modules) were baked in as root, but the entrypoint drops to uid 1000, so an in-container pip/npm install hit EACCES -- which also made run-agent.sh --verify-isolation unrunnable, since its measurement is exactly that pip install. Hand the trees to the agent with a chown after useradd. - The Playwright browsers were baked into /root/.cache/ms-playwright (build-time HOME=/root) but the runtime HOME is /home/agent and /root is drwx------, so the baked cache was unreachable and dispatches re-downloaded. Set PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright and hand that over too. Also the two heads-up notes: the Headless Step 8 row said `docker compose` (the image only has podman-compose) and implied localhost reachability (the siblings publish on the podman host; observe via the allowlisted host.containers.internal). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: gate the reply-wait on the repo owner, not a denylist (PR #670 review) wait-for-reply.sh accepted any comment newer than `since` that was not from one of the listed automation identities. On a public repo that is every account: a drive-by comment was returned as the maintainer's answer to an agent running --dangerously-skip-permissions with a write-scoped token. The rest of the repo gates on the owner explicitly (CLAUDE.md's rule); this was the one place that inverting it. Make --from an allowlist (repeatable) defaulting to the repo owner via `gh repo view --json owner`, with --ignore still subtracting the automation identities on top. An unrecognised author is skipped, never accepted, and a gate that cannot resolve its owner fails explicitly. Tests now cover the stranger-rejection cases that were RED under the old logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: mount the podman socket only for --with-compose dispatches (PR #670 review) The socket is authority over the whole podman host: whoever holds it can start sibling containers with arbitrary bind mounts that reach the host paths mounted in them -- including the main checkout's .env and the tokens in it (BESS_PO_TOKEN, BESS_AGENT_TOKEN, the agent's Claude auth) -- and those siblings get no nftables egress allowlist, since that lives per network namespace. So the socket is now opt-in: only run-agent.sh --with-compose mounts it (that is what makes Step 8's local run & observe possible from inside a container), run-po.sh never mounts it, and --dry-run no longer echoes the credentials baked into the run args. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f1c77a8 commit 55eb7ff

16 files changed

Lines changed: 2000 additions & 73 deletions

.claude/settings.json

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
"Bash(gh run list *)",
6161
"Bash(gh run view *)",
6262
"Bash(gh run watch *)",
63-
"Bash(gh api *)",
6463
"Bash(scripts/request-pr-review.sh *)",
6564
"Bash(scripts/gh-agent.sh *)",
6665
"Bash(./scripts/worktree-setup.sh*)",
@@ -142,32 +141,8 @@
142141
"Bash(git -* tag --delete*)",
143142
"Bash(git -* tag -f*)",
144143
"Bash(sudo *)",
145-
"Bash(gh api -X *)",
146-
"Bash(gh api * -X *)",
147-
"Bash(gh api --method *)",
148-
"Bash(gh api * --method *)",
149-
"Bash(gh api -f *)",
150-
"Bash(gh api * -f *)",
151-
"Bash(gh api -F *)",
152-
"Bash(gh api * -F *)",
153-
"Bash(gh api --input*)",
154-
"Bash(gh api * --input*)",
155-
"Bash(gh api --field *)",
156-
"Bash(gh api * --field *)",
157-
"Bash(gh api --raw-field *)",
158-
"Bash(gh api * --raw-field *)",
159-
"Bash(gh api --method=*)",
160-
"Bash(gh api * --method=*)",
161-
"Bash(gh api -X*)",
162-
"Bash(gh api * -X*)",
163-
"Bash(gh api -f*)",
164-
"Bash(gh api * -f*)",
165-
"Bash(gh api -F*)",
166-
"Bash(gh api * -F*)",
167-
"Bash(gh api --field=*)",
168-
"Bash(gh api * --field=*)",
169-
"Bash(gh api --raw-field=*)",
170-
"Bash(gh api * --raw-field=*)",
144+
"Bash(gh api)",
145+
"Bash(gh api *)",
171146
"Bash(gh issue close*)"
172147
]
173148
},

.claude/skills/implement-issue/SKILL.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,54 @@ runners — only repo-level `.claude/skills/` and `.claude/agents/` exist there.
7171
| 11. Independent review loop | Skip — CI opens the PR as a draft and the owner triggers Stage 4 by hand after reading it. A CI run that requested its own review would be the fix bot grading itself on a PR nobody has looked at yet. `advance-pr` is therefore not invoked in CI, and the PR stays a draft: `gh pr ready` belongs to that skill, and there is no approval in CI mode to earn it. |
7272
| 12. Hard constraints | Apply verbatim. |
7373

74+
## Headless local mode (dispatched container)
75+
76+
`scripts/run-agent.sh <n>` runs this skill headless inside a Podman container,
77+
against a private clone bind-mounted at its own host path (Phase 1 of
78+
`docs/superpowers/specs/2026-08-20-agent-fleet-sandbox-router-design.md`).
79+
Headless is **not** CI, and the differences run the opposite way to CI mode's:
80+
the container is long-lived, it *can* run the app, and it answers its own
81+
gates instead of ending the run at them. The numbered Process applies verbatim
82+
except per this table.
83+
84+
**You are in this mode when `BESS_HEADLESS_MODE=1` is set.** `run-agent.sh`
85+
sets it, along with `BESS_FLEET_CONTAINER` (this dispatch's id) and
86+
`BESS_FLEET_DB`.
87+
88+
Three things that hold across every row:
89+
90+
- **Never exit at a gate.** The whole point of a container that lives for the
91+
issue's whole lifecycle is that Step 10's recovery of a PR gone
92+
`CONFLICTING` under a merge, and Step 11's repeated `advance-pr` rounds,
93+
keep running with nobody watching. A run that exited at Step 3 and waited to
94+
be re-dispatched would turn that working loop back into a queue of manual
95+
restarts. Post, block on `scripts/wait-for-reply.sh`, resume in place.
96+
- **Report status as it changes**, so the dispatch is legible without reading
97+
logs: `scripts/fleet-manifest.sh update-status "$BESS_FLEET_CONTAINER" <status>`
98+
`needs_input` on entering a gate and `working` again on leaving one,
99+
`in_review` for Step 11, `escalated` for a Step 12 bailout, `done` at a
100+
terminal state.
101+
- **The plugin caveat is CI mode's, unchanged**: user-level plugins
102+
(`superpowers:*`, `code-review`) are not in the image. Only repo-level
103+
`.claude/skills/` and `.claude/agents/` exist, and those arrive inside the
104+
clone.
105+
106+
| Step | Headless local mode |
107+
|---|---|
108+
| 0. Resume check | Applies, with a different registry: this container has a private **clone**, not a worktree, so `git worktree list` on the host cannot see it and the host's worktrees are none of its business. `scripts/fleet-manifest.sh list` is the enumeration. The clone is reused across dispatches and is host-persistent, so prior commits in it are exactly the resumable work Step 0 exists to find — never discard them. |
109+
| 1. Fetch & scope | Verbatim. |
110+
| 2. Diagnose | Verbatim — **unlike CI mode**, self-diagnosis is correct here. The maintainer typing `run-agent.sh <n>` is the same explicit go-ahead as starting an interactive session; there is no analyze/fix split to protect, and no Stage 2 comment to require. |
111+
| 3. Confirm gate | Post the design + workaround check + scope assessment with `scripts/gh-agent.sh --as dev` (to the issue; to the PR once one exists), set status `needs_input`, then `scripts/wait-for-reply.sh <n> <now-iso8601>` and continue **in this process** on its reply — accepted only from the repo owner (`--from` defaults to it), so a stranger's drive-by comment cannot steer the dispatch. Not CI's behaviour of treating a trigger comment as pre-granted consent, and not a stop. The escalation path (dispatch a general-purpose `Agent` to critique) applies verbatim. |
112+
| 4. Worktree + branch | The clone *is* the isolation — `run-agent.sh` created it and ran `scripts/worktree-setup.sh --target-dir`, so create the branch directly and skip worktree creation. **Skip the merged-worktree prune too**: those worktrees belong to the host checkout, are not visible from in here, and are not this dispatch's to clean. Then report the branch: `scripts/fleet-manifest.sh set-branch "$BESS_FLEET_CONTAINER" <branch>` — the manifest could not know it at dispatch. |
113+
| 5. TDD | Substance verbatim (RED test first, required test shape); as in CI there is no `superpowers:test-driven-development` skill to invoke, so follow this section's own rules. |
114+
| 6. Quality gate + code review | Run `./scripts/quality-check.sh` inline, no background agent — one throwaway container, so the cost-discipline reason to background does not apply. The `code-review` plugin is unavailable; Step 11's independent review covers it. Checks 1–3 still apply. |
115+
| 7. Confirm gate 2 | Same mechanism as Step 3: `gh-agent.sh --as dev`, status `needs_input`, `wait-for-reply.sh`, resume in place. Not CI's "the draft PR is the gate" — there is a live process here that can act on the answer. |
116+
| 8. Local run & observe | **Applies only on a `--with-compose` dispatch — this is the row that most distinguishes headless from CI, and it is why the socket is opt-in.** CI skips it because a runner structurally cannot; a container *can*, but only when the dispatch was launched with `run-agent.sh --with-compose <n>`. The socket is authority over the podman host, so it is NOT mounted for a plain dispatch (see `scripts/lib/agent-dispatch.sh`); a plain dispatch cannot bring the stack up, so follow CI mode's rule for that step — say in the PR body that Step 8 was not run and why. On a `--with-compose` dispatch, `podman-compose -p <unique-name> -f docker-compose.ci.yml` brings up **sibling** containers, not nested ones (the image has `podman-compose`, not the `docker` CLI). This works because the clone is mounted at its own host path, so the compose file's relative volume paths mean the same directory inside and out. One difference from an interactive run: the siblings publish their ports on the podman host, not in this container's netns, so observe the stack via the allowlisted `host.containers.internal` host (e.g. `curl -s http://host.containers.internal:18180/api/system-health`) rather than `localhost`. Do not write "verification is still owed" in the PR body — do the verification. |
117+
| 9. Commit + draft PR | Verbatim, including the `CHANGELOG.md` `## [Unreleased]` entry and the documentation check. The PR is authored by the role-scoped `dev` token the container was given, never the maintainer's credential. |
118+
| 10. Watch this PR to green | Verbatim. `gh pr checks --watch` blocking for an hour is fine here; that is what a long-lived container is for. Never widen to other PRs. |
119+
| 11. Independent review loop | Verbatim — **unlike CI**, which skips it. Set status `in_review`, invoke `advance-pr`, and let it mark the PR ready on `APPROVED`. This is an interactive-equivalent run the maintainer dispatched deliberately, not a bot grading a PR nobody asked for; the 3-round `CHANGES_REQUESTED` cap is itself a gate, so treat hitting it as Step 3's mechanism (status `escalated`, ask, wait). |
120+
| 12. Hard constraints | Apply verbatim. On the 3-failed-quality-check bailout, set status `escalated` and post why with `gh-agent.sh --as dev` before waiting — a container that dies silently is indistinguishable from one still working. |
121+
74122
## Process
75123

76124
### 0. Resume check — is there prior work for this number?

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ e2e/ci-wizard-settings.json
8282
# Scratch fixtures generated by scripts/demo_health_recovery.sh
8383
.demo-scratch/
8484

85+
# Agent fleet (Phase 1 sandboxing). Per-agent private clones and the SQLite
86+
# dispatch manifest are host-persistent -- a killed container leaves its clone
87+
# behind for implement-issue Step 0 to find -- but never committed.
88+
.agent-clones/
89+
.fleet/
90+
8591
# Claude Code (temporary/session-specific)
8692
.claude/worktrees/
8793
.claude/scheduled_tasks.lock

Containerfile.agent

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)