# PRD 0024: Consolidate per-bottle sidecars into a single bundle

- **Status:** Active
- **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 — `bot-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.
- `egress` is `pipelock`'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-gate` and `supervise` run their own daemons but with the
  same "started + stopped with the bottle" lifecycle.

Three concrete costs of keeping them split:

1. **Compose-file surface area.** Five `services:` entries per
   bottle. The renderer in `backend/docker/compose.py` has 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.
2. **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.
3. **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 the `sidecars`
  container.
- 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 bot-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 `pipelock` binary from the upstream pipelock
    image (currently `ghcr.io/luckypipewrench/pipelock` pinned
    by digest in `bot_bottle/backend/docker/pipelock.py`).
  - Copies the `gitleaks` binary from `zricethezav/gitleaks`
    (currently pinned by digest in `Dockerfile.git-gate`).
  - Installs `mitmdump` (via `pip install mitmproxy==<pinned>`).
  - Installs the system deps `git-daemon` + `openssh-client`
    that git-gate needs.
  - Copies the existing addon + server Python from
    `bot_bottle/egress_addon.py`, `egress_addon_core.py`,
    `yaml_subset.py`, `supervise.py`, `supervise_server.py`.
  - Drops in a new `bot_bottle/sidecar_init.py` (stdlib
    Python) as the container's `ENTRYPOINT`.
- A new `bot_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.Popen` with prefixed
    line-buffered output.
  - Catches `SIGTERM` / `SIGINT`, propagates to each child,
    `waitpid()`s with a per-child grace deadline, escalates to
    `SIGKILL` past 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.)
- `bot_bottle/backend/docker/compose.py` renderer updated to
  emit one `sidecars` service 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.
- `bot_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
  collapsed: the platform-neutral pieces stay
  (`PipelockProxy`, `Egress`, `GitGate`, `Supervise` ABCs 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 single
  `sidecar_bundle_container_name(slug)`.
- `Dockerfile.egress`, `Dockerfile.git-gate`, and
  `Dockerfile.supervise` deleted. 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.
- `AGENTS.md` updated 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.supervise` flags 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 `bot_bottle/sidecar_init.py` supervising the four
  daemons.
- `backend/docker/compose.py` renderer collapse (five services
  → two).
- `backend/docker/{pipelock,egress,git_gate,supervise}.py`
  reshape: keep the abstract `Plan` / 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 + AGENTS.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_healthy` against 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:

```dockerfile
# 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 bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_addon.py      /app/egress_addon.py
COPY bot_bottle/yaml_subset.py       /app/yaml_subset.py
COPY bot_bottle/supervise.py         /app/supervise.py
COPY bot_bottle/supervise_server.py  /app/supervise_server.py
COPY bot_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

`bot_bottle/sidecar_init.py` (sketch — actual code lands as
part of implementation):

```python
"""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 `bot-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:

```yaml
# 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: bot-bottle-egress:latest }
  git-gate: { image: bot-bottle-git-gate:latest }
  supervise:{ image: bot-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: bot-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 `bot_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:

```python
# 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"bot-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:

1. Land the bundle image build (`Dockerfile.sidecars` +
   `sidecar_init.py`) without changing the renderer.
   Confirm `docker build -f Dockerfile.sidecars .` succeeds
   and the resulting container runs all four daemons.
2. Switch the renderer to emit the two-service shape behind an
   env-var feature flag (e.g.
   `BOT_BOTTLE_SIDECAR_BUNDLE=1`).
3. 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

1. **Bundle image + init supervisor.** Write `Dockerfile.sidecars`
   and `sidecar_init.py`, ship them, add a unit test that
   builds the image in CI and asserts the four daemons start.
   No renderer change yet.
2. **Compose renderer collapse.** Update
   `bottle_plan_to_compose` to 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.
3. **Backend Python collapse.** Drop the vestigial per-container
   `.start()` / `.stop()` methods from `DockerPipelockProxy`,
   `DockerEgress`, `DockerGitGate`, `DockerSupervise` (and from
   the ABCs in `bot_bottle/{pipelock,egress,git_gate,supervise}.py`).
   These were already documented as vestigial in PRD 0018 ch3.
   Strip vestigial sidecar-instance parameters from
   `launch.launch()` and `prepare.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`). Skip
   `test_pipelock_apply` pending 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 needs `Dockerfile.{egress,git-gate,supervise}` for
   compose `build:`.
4. **Integration test sweep.** Rewrite `test_pipelock_apply`'s
   bringup with direct `docker run` so the
   `apply_allowlist_change` hot-reload retains coverage. Add
   any bundle-specific integration smoke as needed. Confirm
   PRD 0022 stays green.
5. **Docs + flag removal.** Flip the default, remove the
   feature flag, delete `Dockerfile.{egress,git-gate,supervise}`,
   update README + AGENTS.md.

## Open questions

1. **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.
2. **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.
3. **Image pin policy.** Pin `bot-bottle-sidecars` by tag
   (`:latest` rebuilt per-release) or by digest written into a
   `BOT_BOTTLE_SIDECAR_IMAGE` env var like the existing
   `BOT_BOTTLE_PIPELOCK_IMAGE`? Default to env-var override
   + a documented tag; digest pinning is an operator opt-in.
4. **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.
5. **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.
6. **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 into `Dockerfile.sidecars`.
- `bot_bottle/backend/docker/compose.py` — the renderer this
  PRD slims down.
- `bot_bottle/backend/docker/pipelock.py` — current home of
  `PIPELOCK_IMAGE` and 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.
