Compose-up has owned per-container lifecycle since PRD 0018 ch3;
the .start() / .stop() methods on DockerPipelockProxy /
DockerEgress / DockerGitGate / DockerSupervise (and their
abstractmethod declarations in the four base ABCs) were already
documented as vestigial. With the bundle path in flight
(PRD 0024 ch2), they are truly dead — collapse to nothing.
Changes:
- Removed start/stop methods from the four DockerSidecar
classes. Plan dataclasses, image/path constants,
container-name helpers, and the .prepare() methods all stay
(the renderer + apply path still need them).
- Removed the matching @abstractmethod declarations in the
base ABCs so concrete subclasses don't have to stub them.
- launch.launch() and prepare.resolve_plan() no longer take
proxy/git_gate/egress/supervise instance parameters. backend.py
loses the four instance attributes it threaded through.
prepare.resolve_plan() instantiates the four classes itself
to call their .prepare() methods.
- Deleted four integration tests that only exercised the
removed lifecycle: test_pipelock_sidecar_smoke,
test_supervise_sidecar, test_git_gate_sidecar,
test_git_gate_mirror.
- Dropped the .stop-idempotency case in test_orphan_cleanup;
the network-cleanup cases stay (those test real production
code).
- Marked test_pipelock_apply @skip pending chunk 4 — its
bringup helper used .start; chunk 4 rewrites it with direct
`docker run`.
Dockerfile deletion deferred to chunk 5 (when the bundle flag
default flips) — the legacy compose path still needs
Dockerfile.{egress,git-gate,supervise} until then.
Net: 708 lines removed, 80 added.
533 unit tests + 27 integration tests passing (5 skipped: the
chunk-4-pending case + existing GITEA_ACTIONS guards).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
20 KiB
PRD 0024: Consolidate per-bottle sidecars into a single bundle
- Status: Draft
- Author: didericis
- Created: 2026-05-26
Summary
Replace the four per-bottle sidecar containers in the Docker
backend (pipelock, egress, git-gate, supervise) with a single
container image — claude-bottle-sidecars — that runs all four
daemons under a small stdlib-Python init supervisor. Same
per-bottle lifetime, same scope, fewer containers per bottle,
one Dockerfile to maintain instead of three. Outcome: the
docker backend's compose file goes from five services
(agent, pipelock, egress, git-gate, supervise) to
two (agent, sidecars); the smolmachines backend defined in
PRD 0023 reuses the same image as its sole sidecar container.
Problem
The four sidecars are tightly coupled in lifetime and scope:
- All four start when a bottle starts and stop when it stops. There is no scenario where one runs without the others.
egressispipelock's upstream over the internal network — nothing on the agent side ever addresses egress directly. Its separateness today is a docker-compose-ism: one Dockerfile per service was the easiest way to ship the chunk-by-chunk rollouts of PRDs 0001, 0008, 0013, and 0017.git-gateandsuperviserun their own daemons but with the same "started + stopped with the bottle" lifecycle.
Three concrete costs of keeping them split:
- Compose-file surface area. Five
services:entries per bottle. The renderer inbackend/docker/compose.pyhas to know each one's image, env, healthcheck, port-mapping, dependency wiring (depends_on), and CA / config bind mounts. That's a lot of moving parts for what is really one logical sidecar. - Cold start latency. Docker creates and starts four containers in dependency order even for a trivial agent run. Each container costs ~50-100ms of compose orchestration even when the image is cached.
- Cross-backend duplication. PRD 0023's smolmachines backend would otherwise need its own four-process supervisor on the host side. A shared bundle image collapses both backends onto the same sidecar primitive.
This PRD is also the prerequisite for chunk 3 of PRD 0023.
Goals / Success Criteria
The feature works when all of the following are observable:
cli.py start <agent>on the Docker backend produces a compose project with exactly two services (agent,sidecars) and three published agent-facing ports (HTTPS_PROXY, git-gate, supervise) on thesidecarscontainer.- All existing integration tests pass with no behavior change visible to the agent. The four daemons inside the bundle speak the same protocols on the same well-known in-container ports as before; only the container hostname changes.
- The sandbox-escape suite from PRD 0022 stays green.
docker logs claude-bottle-sidecars-<slug>shows interleaved output from all four daemons, prefixed by the supervisor with the daemon name. Each daemon's exit propagates through the supervisor to the container's exit code.- Sending SIGTERM to the bundle container (the docker stop path) shuts down all four daemons cleanly within the existing compose stop-grace timeout (10s).
The feature is done when all of the following ship:
- A new
Dockerfile.sidecars(multi-stage) that:- Copies the
pipelockbinary from the upstream pipelock image (currentlyghcr.io/luckypipewrench/pipelockpinned by digest inclaude_bottle/backend/docker/pipelock.py). - Copies the
gitleaksbinary fromzricethezav/gitleaks(currently pinned by digest inDockerfile.git-gate). - Installs
mitmdump(viapip install mitmproxy==<pinned>). - Installs the system deps
git-daemon+openssh-clientthat git-gate needs. - Copies the existing addon + server Python from
claude_bottle/egress_addon.py,egress_addon_core.py,yaml_subset.py,supervise.py,supervise_server.py. - Drops in a new
claude_bottle/sidecar_init.py(stdlib Python) as the container'sENTRYPOINT.
- Copies the
- A new
claude_bottle/sidecar_init.py— a small Python init supervisor that:- Reads which daemons to run from env (defaults: all four).
- Spawns each as a
subprocess.Popenwith prefixed line-buffered output. - Catches
SIGTERM/SIGINT, propagates to each child,waitpid()s with a per-child grace deadline, escalates toSIGKILLpast the deadline. - Exits with code 0 only if every child exited 0; otherwise exits 1. (Or: any-child-died → tear down the rest and exit that child's code — see open question 2.)
claude_bottle/backend/docker/compose.pyrenderer updated to emit onesidecarsservice in place of the four. The four in-container ports (8888 / 9099 / 9418 / 9100, today) all land on the same container; the agent-facing ports (HTTPS_PROXY, git-gate-SSH, supervise-MCP) are published as before, just from one container instead of three.claude_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.pycollapsed: the platform-neutral pieces stay (PipelockProxy,Egress,GitGate,SuperviseABCs and their plans), the docker-specific subclasses lose their per-container start/stop / image-build / healthcheck logic and gain shared bundle-aware helpers. Container name helpers (pipelock_container_name(slug)etc.) become a singlesidecar_bundle_container_name(slug).Dockerfile.egress,Dockerfile.git-gate, andDockerfile.supervisedeleted. The bundle is the only image.- Tests:
- Unit: the compose renderer emits exactly two services and one sidecars service has all three published ports.
- Unit: the sidecar-init supervisor propagates SIGTERM and returns nonzero when a child crashes.
- Integration: existing PRD 0001 / 0008 / 0013 / 0017 integration tests run against the bundle and pass.
- Integration: PRD 0022 sandbox-escape suite stays green.
CLAUDE.mdupdated to describe the bundle and the daemons-inside layout.
Non-goals
- No protocol changes between sidecars. pipelock still speaks the same HTTPS-proxy protocol on the same port; egress is still pipelock's upstream; git-gate still listens on git-daemon's port; supervise still serves the same MCP HTTP endpoint. Only the container they run in changes.
- No config-schema changes.
pipelock.yaml,routes.yaml, the git-gate access-hook, and the supervise queue path all stay where they are; the bundle just bind-mounts them at the same in-container paths as before. - No host-bind-mount surgery. Each daemon's existing bind mounts (per-bottle CA paths, the supervise queue dir, the git-gate creds dir) remain. The bundle aggregates them onto one container.
- No supervisord / s6 / runit. A 50-line stdlib Python init is the supervisor. Adding a new init system for this is more weight than the problem deserves and conflicts with the project's stdlib-first ethos.
- No selective daemon disable surfaced to the manifest. The
init understands "skip git-gate / supervise when the bottle
doesn't use them" via env vars set by the compose renderer,
but operators don't get a manifest knob — the existing
bottle.git/bottle.superviseflags continue to drive it. - No agent-image changes. The agent container (PRD 0023's microVM in the smolmachines case) is unaffected; this PRD is strictly about consolidating the sidecar chain.
Scope
In scope
- New
Dockerfile.sidecars(multi-stage) bringing pipelock, mitmproxy, gitleaks, git-daemon, openssh-client, and the project's addon + server Python into one image. - New
claude_bottle/sidecar_init.pysupervising the four daemons. backend/docker/compose.pyrenderer collapse (five services → two).backend/docker/{pipelock,egress,git_gate,supervise}.pyreshape: keep the abstractPlan/ proxy classes; remove per-container lifecycle code that compose-up no longer needs.- Image name and tag pinning (env var override + default; see open question 3).
- Test updates: unit and integration tests that probe the four-container shape get rewritten against the one-container shape.
- README + CLAUDE.md doc updates.
Out of scope
- The smolmachines backend itself (PRD 0023). This PRD just produces the image; PRD 0023 consumes it.
- Per-daemon resource limits (CPU / memory caps) on the bundle. Today nothing in the project sets them; consolidation doesn't change that.
- Healthcheck redesign. The agent's
depends_on: service_healthyagainst the bundle covers all four daemons; defining a single bundle-level healthcheck that aggregates the per-daemon readiness is open question 4. - Multi-arch image builds (arm64 + amd64). The current per-sidecar images are amd64-only or whatever their bases ship; we keep that posture.
Proposed Design
Bundle image
Dockerfile.sidecars is a four-stage multi-stage build, one
stage per source binary, plus a final stage that assembles them:
# Stage 1: pull pipelock binary
FROM ghcr.io/luckypipewrench/pipelock@sha256:<pinned> AS pipelock-src
# pipelock binary is at /usr/local/bin/pipelock in this image.
# Stage 2: pull gitleaks binary
FROM zricethezav/gitleaks@sha256:<pinned> AS gitleaks-src
# gitleaks binary is at /usr/bin/gitleaks in this image.
# Stage 3: mitmproxy base (already has Python + mitmdump installed)
FROM mitmproxy/mitmproxy:11.1.3 AS final
USER root
# System deps for the git-gate daemon side
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git git-daemon-run openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Drop in the project's Python addon + server code
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY claude_bottle/egress_addon.py /app/egress_addon.py
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
COPY claude_bottle/supervise.py /app/supervise.py
COPY claude_bottle/supervise_server.py /app/supervise_server.py
COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
# Pull the standalone binaries into the final stage
COPY --from=pipelock-src /usr/local/bin/pipelock /usr/local/bin/pipelock
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Layout the bundle uses at runtime — preserved verbatim from the
# four previous Dockerfiles so existing docker-cp paths still work.
RUN mkdir -p \
/etc/pipelock \
/etc/egress \
/etc/git-gate \
/git-gate/creds \
/git \
/run/supervise/queue \
/home/mitmproxy/.mitmproxy
EXPOSE 8888 9099 9418 9100
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
The final stage starts from the mitmproxy image because mitmproxy has the heaviest install footprint (Python + mitmdump
- deps); copying the other two binaries in is cheaper than the reverse. Pinning each base by digest is unchanged from the existing Dockerfiles.
Init supervisor
claude_bottle/sidecar_init.py (sketch — actual code lands as
part of implementation):
"""Per-bottle sidecar supervisor.
Spawns the configured daemons, forwards SIGTERM/SIGINT, exits
with the first non-zero child code (or 0 if every child exited
cleanly during normal shutdown)."""
DAEMONS = [
("egress", ["sh", "-c", EGRESS_ENTRYPOINT_SH]),
("pipelock", ["/usr/local/bin/pipelock", "run",
"--config", "/etc/pipelock/pipelock.yaml"]),
("git-gate", ["/git-gate-entrypoint.sh"]),
("supervise", ["python3", "/app/supervise_server.py"]),
]
# Order matters only for first-launch race-window reasons:
# egress starts first so pipelock's upstream connect succeeds
# during pipelock startup. git-gate and supervise are
# independent.
The env-driven daemon subset is the same handshake as today's
compose renderer: bottles without git skip git-gate, bottles
with supervise: false skip supervise.
Compose renderer collapse
bottle_plan_to_compose emits one sidecars service in place
of the four. The service inherits the union of the four's
existing bind mounts; environment variables get prefixed by
daemon name where they clash (none clash today, but the renderer
becomes the central place to enforce that). Container hostname
becomes sidecars (or claude-bottle-sidecars-<slug> for the
externally-visible name). The agent service's HTTPS_PROXY and
git-gate URL move from per-sidecar hostnames to the single
sidecars hostname:
# Before (sketch — five services)
services:
agent:
environment:
HTTPS_PROXY: "http://pipelock:8888"
GIT_GATE_URL: "git://git-gate:9418/repo"
MCP_SUPERVISE_URL: "http://supervise:9100"
pipelock: { image: ghcr.io/luckypipewrench/pipelock:... }
egress: { image: claude-bottle-egress:latest }
git-gate: { image: claude-bottle-git-gate:latest }
supervise:{ image: claude-bottle-supervise:latest }
# After (two services)
services:
agent:
environment:
HTTPS_PROXY: "http://sidecars:8888"
GIT_GATE_URL: "git://sidecars:9418/repo"
MCP_SUPERVISE_URL: "http://sidecars:9100"
sidecars:
image: claude-bottle-sidecars:<pinned>
# union of the four prior services' volumes / env / ports
depends_on collapses: the agent depends on sidecars only.
Backend Python collapse
The four claude_bottle/backend/docker/<sidecar>.py files keep
their platform-neutral abstractions (proxy/plan classes) but
shed the docker-container-lifecycle code that compose-up
already owns. Container-name helpers consolidate:
# was:
def pipelock_container_name(slug): ...
def egress_container_name(slug): ...
def git_gate_container_name(slug): ...
def supervise_container_name(slug): ...
# becomes:
def sidecar_bundle_container_name(slug: str) -> str:
return f"claude-bottle-sidecars-{slug}"
Per-daemon "is the container up?" helpers used by orphan cleanup converge on a single check against the bundle name.
External dependencies
None new. The bundle build pulls the same upstream images we already pull; the consolidation is a packaging change.
Migration
This PRD's change is large but mechanical. A pre-merge dry-run:
- Land the bundle image build (
Dockerfile.sidecars+sidecar_init.py) without changing the renderer. Confirmdocker build -f Dockerfile.sidecars .succeeds and the resulting container runs all four daemons. - Switch the renderer to emit the two-service shape behind an
env-var feature flag (e.g.
CLAUDE_BOTTLE_SIDECAR_BUNDLE=1). - Update integration tests in-place; flip the default once green; delete the flag and the old Dockerfiles in a follow-up commit on the same branch.
The compose-per-instance work in PRD 0018 already separated sidecar lifecycle from agent lifecycle, so this PRD is materially a renderer + image-build change — not a backend rewrite.
Sizing — into chunks
- Bundle image + init supervisor. Write
Dockerfile.sidecarsandsidecar_init.py, ship them, add a unit test that builds the image in CI and asserts the four daemons start. No renderer change yet. - Compose renderer collapse. Update
bottle_plan_to_composeto emit two services. Feature flag it via env var. Update unit tests to assert on both shapes (flag on vs off) during the migration window. - Backend Python collapse. Drop the vestigial per-container
.start()/.stop()methods fromDockerPipelockProxy,DockerEgress,DockerGitGate,DockerSupervise(and from the ABCs inclaude_bottle/{pipelock,egress,git_gate,supervise}.py). These were already documented as vestigial in PRD 0018 ch3. Strip vestigial sidecar-instance parameters fromlaunch.launch()andprepare.resolve_plan(). Delete the integration tests that exclusively exercised those methods (test_pipelock_sidecar_smoke,test_supervise_sidecar,test_git_gate_sidecar,test_git_gate_mirror). Skiptest_pipelock_applypending chunk 4 bringup rewrite. Orphan cleanup already uses a prefix scan and catches the bundle for free; no change needed. Dockerfile deletion is deferred to chunk 5 — until the flag flips, the legacy path still needsDockerfile.{egress,git-gate,supervise}for composebuild:. - Integration test sweep. Rewrite
test_pipelock_apply's bringup with directdocker runso theapply_allowlist_changehot-reload retains coverage. Add any bundle-specific integration smoke as needed. Confirm PRD 0022 stays green. - Docs + flag removal. Flip the default, remove the
feature flag, delete
Dockerfile.{egress,git-gate,supervise}, update README + CLAUDE.md.
Open questions
- Init failure semantics. When one daemon crashes mid-run, the bundle does NOT tear down the survivors — the failure is logged, the surviving daemons keep running, and whatever the dead one served starts failing in a way the agent surfaces. The eventual design is restart-the-dead-daemon plus a notification to the supervise sidecar so the operator sees the event explicitly; chunk 1 ships only the "log and leave alone" half. Tear-down-the-bundle was considered and rejected: one sick daemon shouldn't take the bottle offline.
- Exit-code propagation. When the supervisor finally exits
(signal-driven shutdown, or every child having died on its
own), the container exits with
max(child returncodes)— the worst nonzero code wins. On graceful shutdown every child is signal-killed (negative returncode) so the max is 0; a crashed-before-signal daemon's nonzero code wins and reaches the operator on container exit. - Image pin policy. Pin
claude-bottle-sidecarsby tag (:latestrebuilt per-release) or by digest written into aCLAUDE_BOTTLE_SIDECAR_IMAGEenv var like the existingCLAUDE_BOTTLE_PIPELOCK_IMAGE? Default to env-var override- a documented tag; digest pinning is an operator opt-in.
- Healthcheck aggregation. Today each sidecar service has
its own compose healthcheck and
agent.depends_on: service_healthy: { pipelock: true, ... }. With one container, the bundle needs one healthcheck that returns ready iff all daemons are listening. Cheapest: TCP probe on pipelock's port + git-gate's port + supervise's port from inside the container, scripted into a small/app/healthcheck.sh. Resolve in chunk 1. - Log interleaving + debuggability. All four daemons' stdout/stderr merge into one container log. The init prefixes each line with the daemon name, but operators may want per-daemon log files for easier triage. Default: no per-daemon files in v1; revisit if debug-time pain shows up.
- Backwards compat for an installed-base test fixture. Some integration tests synthesize compose files by hand and assert on per-sidecar container names. They'll need touching in chunk 4. List them up front in the chunk-4 commit so the diff isn't a surprise.
References
Dockerfile.egress,Dockerfile.git-gate,Dockerfile.supervise— the three Dockerfiles this PRD collapses intoDockerfile.sidecars.claude_bottle/backend/docker/compose.py— the renderer this PRD slims down.claude_bottle/backend/docker/pipelock.py— current home ofPIPELOCK_IMAGEand the pinned digest the bundle's first stage reuses.- PRD 0017
(
docs/prds/0017-egress-proxy-via-mitmproxy.md) — defines egress's role as pipelock's upstream; this PRD relies on that being implementable over localhost just as easily as over the internal docker network. - PRD 0018
(
docs/prds/0018-compose-per-instance.md) — the compose-per-instance refactor this PRD builds on. PRD 0018 separated sidecar lifecycle from agent lifecycle, which is what makes a single-bundle compose service a renderer-only change instead of a backend rewrite. - PRD 0022
(
docs/prds/0022-sandbox-escape-integration-test.md) — must remain green through the migration. - PRD 0023
(
docs/prds/0023-smolmachines-backend.md) — the second consumer of this bundle; depends on this PRD's image being available before its chunk 3.