refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5)
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 43s

The CLAUDE_BOTTLE_SIDECAR_BUNDLE feature flag is gone. Every
bottle ships with the agent + bundle pair — no opt-in, no legacy
four-sidecar fallback.

Changes:

- Renderer (compose.py): bottle_plan_to_compose unconditionally
  emits {agent, sidecars}. Deleted _pipelock_service,
  _git_gate_service, _egress_service, _supervise_service helpers.
  _agent_service.depends_on collapses to ["sidecars"].

- sidecar_bundle.py: deleted sidecar_bundle_enabled (the flag
  parser). SIDECAR_BUNDLE_IMAGE + container-name helper stay.

- pipelock_apply.py: docker cp + docker restart now target
  sidecar_bundle_container_name(slug). Bundle restart bounces
  all four daemons together (per-daemon reload is the eventual
  feature, not v1).

- Per-sidecar modules trimmed:
  - egress.py: dropped EGRESS_IMAGE, EGRESS_DOCKERFILE,
    build_egress_image, egress_url. Kept EGRESS_PORT, CA paths,
    egress_container_name (still used by the renderer's network
    aliases).
  - git_gate.py: dropped GIT_GATE_IMAGE, GIT_GATE_DOCKERFILE,
    build_git_gate_image. Kept git_gate_host + GIT_GATE_PORT.
  - supervise.py: dropped SUPERVISE_IMAGE, SUPERVISE_DOCKERFILE,
    build_supervise_image, supervise_url.

- Deleted Dockerfile.{egress,git-gate,supervise}. The bundle's
  Dockerfile.sidecars is the only sidecar image now.

- test_compose.py: deleted TestPipelockAlwaysPresent,
  TestConditionalGitGate, TestConditionalEgress,
  TestConditionalSupervise, TestFullMatrix (legacy-shape only),
  TestSidecarBundleFlag (flag is gone). TestSidecarBundleShape
  drops its patch.dict wrapper. TestAgentAlwaysPresent's
  depends_on cases collapse to one.

- test_pipelock_apply.py: bringup container name uses
  sidecar_bundle_container_name(slug) to match the production
  target.

- README.md Architecture section rewritten to describe the
  agent + bundle pair.

Net: -626 lines.

Test status: 498 unit + 27 integration + 1 skipped (chunk-4
pending — superseded by this chunk's rewrite). Locally verified
end-to-end bottle launch produces exactly 2 containers
(claude-bottle-<slug> + claude-bottle-sidecars-<slug>).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 01:37:21 -04:00
parent 9348d4b343
commit 62f6f8db34
12 changed files with 117 additions and 743 deletions
-69
View File
@@ -1,69 +0,0 @@
# Per-bottle egress sidecar image (PRD 0017).
#
# Replaces cred-proxy (PRD 0010). Sits on the agent's HTTP_PROXY /
# HTTPS_PROXY path (wiring lands in chunk 2) and owns three jobs:
# 1. MITM HTTPS using the per-bottle CA (chunk 2 moves the CA
# generation from pipelock).
# 2. Enforce manifest-declared path_allowlist per route.
# 3. Inject Authorization headers for routes that declare an auth
# block.
#
# Chunk 1 of PRD 0017 ships this image and the addon. Wiring it
# into the bottle launch (and the per-bottle CA + the pipelock
# upstream proxy) is chunk 2.
# mitmproxy base image. mitmdump + addon API are already there; we
# only need to drop our addon in. TODO: pin by digest.
FROM mitmproxy/mitmproxy:11.1.3
USER root
# The addon ships as three files. `_core.py` is pure-logic,
# importable both inside the container and from the host's tests;
# `_addon.py` is the mitmproxy hook wrapper; `yaml_subset.py` is
# the stdlib-only YAML parser the addon uses to read routes.yaml.
# All three land flat in /app/ so mitmdump's loader resolves them
# as top-level sibling modules (absolute imports).
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
# Pre-create the runtime directories the backend's start step will
# `docker cp` into. docker cp does not create intermediate dirs, so
# the mkdir must be baked into the image.
# /etc/egress routes.yaml lands here
# ~/.mitmproxy mitmproxy CA (cert+key concat) + the
# pipelock CA (cert only, for upstream
# trust on the HTTPS_PROXY=pipelock leg)
# Ownership lets the unprivileged mitmproxy user read the files.
RUN mkdir -p /etc/egress /home/mitmproxy/.mitmproxy \
&& chown -R mitmproxy:mitmproxy /etc/egress /home/mitmproxy/.mitmproxy /app
USER mitmproxy
# Listening port. Agents dial egress on this port via their
# HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required
# for the internal network to route to it.
EXPOSE 9099
# Entrypoint:
# - Upstream proxy: when EGRESS_UPSTREAM_PROXY is set,
# use mitmproxy's `--mode upstream:URL` to forward all
# post-MITM traffic through pipelock. (mitmproxy does NOT
# honor HTTPS_PROXY env vars on its outbound side — it's a
# proxy server, not a client.) Standalone runs without
# EGRESS_UPSTREAM_PROXY fall back to `regular@9099`
# direct-to-upstream — useful for unit tests of the image.
# - Upstream trust: when EGRESS_UPSTREAM_CA is set, build
# a combined trust bundle (system roots + pipelock CA) and
# point mitmproxy at it via
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
# mitmproxy's default trust store with the file we point it
# at — passing just pipelock's CA would break pipelock-
# passthrough hosts (api.anthropic.com etc.) where mitmproxy
# sees real upstream certs signed by public CAs. The combined
# bundle covers both pipelock-MITM'd and pipelock-passthrough
# hosts.
# - -s /app/egress_addon.py → loads our addon, reads
# /etc/egress/routes.yaml.
ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_UPSTREAM_CA\" ] && [ -f \"$EGRESS_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py"]
-37
View File
@@ -1,37 +0,0 @@
# Per-agent git-gate sidecar image (PRD 0008).
#
# Runs `git daemon --enable=receive-pack` so the agent in the bottle
# can push to it over git://. A shared pre-receive hook runs gitleaks
# against each incoming ref; on clean, it forwards the ref to the real
# upstream using a credential the gate holds. The agent never sees the
# upstream credential.
#
# The agent-facing leg sits on a Docker --internal network with no
# default route, so the image is fully self-contained: no apk pulls at
# boot, no remote registry lookups during the entrypoint.
# Base on the upstream gitleaks image (alpine + gitleaks v8.x);
# alpine doesn't package gitleaks so this avoids a separate
# install path. Pinned by digest for reproducibility.
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
# openssh-client supplies the upstream SSH transport the pre-receive
# hook uses to forward accepted refs. git-daemon is the listener the
# agent pushes to (alpine ships `git-daemon` as a sub-package, not
# part of `git`). The `git` core binary is already in the base image.
RUN apk add --no-cache openssh-client git-daemon
# Layout the gate uses at runtime:
# /git-gate-entrypoint.sh — docker-cp'd at start time
# /etc/git-gate/pre-receive — shared hook, docker-cp'd at start
# /git-gate/creds/<name>-key — per-upstream identity, docker-cp'd
# /git-gate/creds/<name>-known_hosts — per-upstream known_hosts, docker-cp'd
# /git/<name>.git — bare repos, created by the entrypoint
#
# The intermediate directories must exist before `docker cp` runs (cp
# does not create them); the bare-repo parent (/git) is also pre-created
# defensively.
RUN mkdir -p /etc/git-gate /git-gate/creds /git
# Base image's ENTRYPOINT is the gitleaks binary; override explicitly.
ENTRYPOINT ["/bin/sh", "/git-gate-entrypoint.sh"]
-32
View File
@@ -1,32 +0,0 @@
# Per-bottle supervise sidecar image (PRD 0013).
#
# Exposes three MCP tools (cred-proxy-block, pipelock-block,
# capability-block) the agent calls to propose config changes when
# stuck. Each tool call writes a Proposal to a host-mounted queue
# dir and blocks waiting for the operator's Response.
#
# Stdlib-only Python. The bottle slug arrives via
# SUPERVISE_BOTTLE_SLUG; the host's ~/.claude-bottle/queue/<slug>/
# is bind-mounted at /run/supervise/queue.
# python:3.13-alpine, pinned by digest (same image cred-proxy uses,
# so docker pulls / caches once for both sidecars).
FROM python@sha256:420cd0bf0f3998275875e02ecd5808168cf0843cbb4d3c536432f729247b2acc
# Both files ship as single files into /app; supervise_server.py
# imports supervise via same-directory resolution.
COPY claude_bottle/supervise.py /app/supervise.py
COPY claude_bottle/supervise_server.py /app/supervise_server.py
# Pre-create the queue mount point so docker's bind-mount has a
# parent dir. Matches Dockerfile.cred-proxy's pattern.
RUN mkdir -p /run/supervise/queue
EXPOSE 9100
# WORKDIR makes the in-app same-dir import deterministic regardless
# of how the container is launched.
WORKDIR /app
# PID 1 is python for clean signal handling and exit codes.
ENTRYPOINT ["python3", "/app/supervise_server.py"]
+16 -10
View File
@@ -69,16 +69,22 @@ pieces of v1.
## Architecture
A bottle is the agent container plus up to three per-protocol egress
sidecars on a per-agent Docker `--internal` network. The agent has no
default route off-box. All HTTP and HTTPS egress — from the agent
*and* from cred-proxy when it dials an upstream — funnels through
pipelock, where the egress allowlist, TLS interception, and
request-body DLP scanner enforce the manifest before any byte leaves
the host. The only egress that doesn't traverse pipelock is git-gate's
SSH push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
so git-gate is its own L4-style egress path with gitleaks doing the
pre-receive scan.
A bottle is two containers per agent: an `agent` container, and a
`sidecars` container that bundles pipelock + egress + git-gate +
supervise behind a Python init supervisor (PRD 0024). They share a
per-agent Docker `--internal` network; the agent has no default
route off-box. All HTTP and HTTPS egress funnels through pipelock,
where the egress allowlist, TLS interception, and request-body DLP
scanner enforce the manifest before any byte leaves the host. The
only egress that doesn't traverse pipelock is git-gate's SSH
push/fetch to `bottle.git` upstreams — pipelock can't proxy SSH,
so git-gate is its own L4-style egress path with gitleaks doing
the pre-receive scan.
The agent dials the bundle by the legacy short names (`pipelock`,
`egress`, `git-gate`, `supervise`); the renderer registers those as
docker-network aliases on the bundle so existing HTTPS_PROXY URLs
and MCP endpoints resolve without an agent-side change.
```
host ( ./cli.py )
+18 -208
View File
@@ -61,24 +61,19 @@ from ...util import expand_tilde
from .bottle_plan import DockerBottlePlan
from .egress import (
EGRESS_CA_IN_CONTAINER,
EGRESS_DOCKERFILE,
EGRESS_IMAGE,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
egress_container_name,
)
from .git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_DOCKERFILE,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
GIT_GATE_IMAGE,
git_gate_container_name,
)
from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
PIPELOCK_IMAGE,
PIPELOCK_PORT,
pipelock_container_name,
)
@@ -87,17 +82,11 @@ from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE,
sidecar_bundle_container_name,
sidecar_bundle_enabled,
)
from .supervise import (
SUPERVISE_DOCKERFILE,
SUPERVISE_IMAGE,
supervise_container_name,
)
from .supervise import supervise_container_name
# Repo root, used as the build context for sidecar Dockerfiles.
# Same derivation as the per-sidecar lifecycle modules.
# Repo root, used as the build context for the bundle Dockerfile.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
@@ -113,29 +102,10 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
spec back.
"""
project = f"claude-bottle-{plan.slug}"
services: dict[str, Any] = {}
if sidecar_bundle_enabled():
# PRD 0024 bundle shape: one `sidecars` service running all
# four daemons under the bundle image's init supervisor.
services["sidecars"] = _sidecar_bundle_service(plan)
else:
# Legacy four-sidecar shape. Kept side-by-side behind the
# flag through chunks 2-4 so existing operators don't have
# to migrate atomically.
services["pipelock"] = _pipelock_service(plan)
if plan.git_gate_plan.upstreams:
services["git-gate"] = _git_gate_service(plan)
if plan.egress_plan.routes:
services["egress"] = _egress_service(plan)
if plan.supervise_plan is not None:
services["supervise"] = _supervise_service(plan)
services["agent"] = _agent_service(plan)
services: dict[str, Any] = {
"sidecars": _sidecar_bundle_service(plan),
"agent": _agent_service(plan),
}
return {
"name": project,
"services": services,
@@ -173,47 +143,18 @@ def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str,
}
def _pipelock_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Pipelock sidecar. Pinned-digest image (no build). The
rendered YAML config + CA cert + key bind-mount in from the
paths the prepare step laid down on plan.proxy_plan."""
pp = plan.proxy_plan
name = pipelock_container_name(plan.slug)
return {
"image": PIPELOCK_IMAGE,
"container_name": name,
"command": [
"run",
"--config", "/etc/pipelock.yaml",
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
],
"networks": {
"internal": {"aliases": [name]},
"egress": None,
},
"volumes": [
_bind(pp.yaml_path, "/etc/pipelock.yaml"),
_bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER),
_bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER),
],
}
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""The single `sidecars` service that replaces the four
per-sidecar containers (PRD 0024). One container per bottle,
bundle image, all four daemons under a Python init supervisor.
"""The `sidecars` service: one container per bottle, bundle
image, all four daemons under a Python init supervisor.
Mechanics:
- Daemon subset narrows via `CLAUDE_BOTTLE_SIDECAR_DAEMONS`
env. pipelock is always present; egress / git-gate /
supervise are conditional on the plan, identical to the
legacy branching.
- Volumes are the UNION of what the four prior services
bind-mounted, preserving the same in-container paths so
every daemon finds its config / hooks / CA where it
expects.
supervise are conditional on the plan.
- Volumes are the union of the four daemons' bind-mounts,
preserving the same in-container paths so each daemon
finds its config / hooks / CA where it expects.
- Environment is the union of *daemon-private* env vars
(EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc).
HTTPS_PROXY is NOT propagated here — see the comment in
@@ -223,9 +164,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
- Network aliases register every legacy short/long
hostname (pipelock, egress, git-gate, supervise plus
their `claude-bottle-<service>-<slug>` long forms) so
any existing inter-service reference (notably the
agent's HTTPS_PROXY and depends_on lookups) resolves to
the bundle.
the agent's HTTPS_PROXY URL and any other inter-service
reference resolves to the bundle.
"""
daemons: list[str] = ["egress", "pipelock"]
if plan.git_gate_plan.upstreams:
@@ -327,126 +267,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
return service
def _git_gate_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Git-gate sidecar. Built from Dockerfile.git-gate. Entrypoint
+ pre-receive hook + access-hook bind-mount from the stage
paths the prepare step wrote. Per-upstream identity files
bind-mount from the user's ssh-key location after `~`
expansion. Per-upstream known_hosts files come in via chunk 2 —
the GitGatePlan doesn't carry those host paths yet (they're
currently materialized at start time by DockerGitGate.start).
"""
gp = plan.git_gate_plan
name = git_gate_container_name(plan.slug)
volumes: list[dict[str, Any]] = [
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
]
for u in gp.upstreams:
keypath = expand_tilde(u.identity_file)
volumes.append(_bind(
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
))
service: dict[str, Any] = {
"image": GIT_GATE_IMAGE,
"build": {
"context": _REPO_DIR,
"dockerfile": GIT_GATE_DOCKERFILE,
},
"container_name": name,
"networks": {
"internal": {"aliases": [name]},
"egress": None,
},
"volumes": volumes,
}
extra_hosts = git_gate_aggregate_extra_hosts(gp.upstreams)
if extra_hosts:
service["extra_hosts"] = [
f"{host}:{ip}" for host, ip in sorted(extra_hosts.items())
]
return service
def _egress_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Egress sidecar. Built from Dockerfile.egress. Routes
through pipelock on its upstream leg via `EGRESS_UPSTREAM_PROXY` +
`EGRESS_UPSTREAM_CA`. One env-list entry per upstream-token slot
(bare NAME inherits from the compose-up process env, so secret
values stay off argv and out of the compose file). routes.yaml +
mitmproxy CA + pipelock CA bind-mount from the stage paths."""
ep = plan.egress_plan
name = egress_container_name(plan.slug)
env: list[str] = [
f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}",
f"HTTPS_PROXY={ep.pipelock_proxy_url}",
f"HTTP_PROXY={ep.pipelock_proxy_url}",
"NO_PROXY=localhost,127.0.0.1",
f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
]
for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env)
return {
"image": EGRESS_IMAGE,
"build": {
"context": _REPO_DIR,
"dockerfile": EGRESS_DOCKERFILE,
},
"container_name": name,
"networks": {
"internal": {"aliases": [EGRESS_HOSTNAME]},
"egress": None,
},
"environment": env,
"volumes": [
_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER),
_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER),
_bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER),
],
"depends_on": ["pipelock"],
}
def _supervise_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Supervise sidecar. Internal network only — no upstream calls.
Queue dir bind-mounts read-write so the sidecar can append audit
events and the host-side capability handlers can drop new
proposals into it."""
sp = plan.supervise_plan
assert sp is not None
name = supervise_container_name(plan.slug)
return {
"image": SUPERVISE_IMAGE,
"build": {
"context": _REPO_DIR,
"dockerfile": SUPERVISE_DOCKERFILE,
},
"container_name": name,
"networks": {
"internal": {"aliases": [SUPERVISE_HOSTNAME]},
},
"environment": [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
],
"volumes": [
{
"type": "bind",
"source": str(sp.queue_dir),
"target": QUEUE_DIR_IN_CONTAINER,
"read_only": False,
},
],
}
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Agent container. Runs `sleep infinity`; claude is `docker
exec -it`'d into it later. No TTY at the container level —
@@ -494,20 +314,10 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
if volumes:
service["volumes"] = volumes
if sidecar_bundle_enabled():
# Bundle shape: a single dependency. The init supervisor
# owns intra-bundle daemon ordering, so the agent only
# waits for the bundle container itself.
service["depends_on"] = ["sidecars"]
else:
depends_on = ["pipelock"]
if plan.git_gate_plan.upstreams:
depends_on.append("git-gate")
if plan.egress_plan.routes:
depends_on.append("egress")
if plan.supervise_plan is not None:
depends_on.append("supervise")
service["depends_on"] = depends_on
# The init supervisor inside the bundle owns intra-bundle
# daemon ordering, so the agent only waits for the bundle
# container itself.
service["depends_on"] = ["sidecars"]
return service
+7 -34
View File
@@ -15,20 +15,11 @@ from pathlib import Path
from ...egress import Egress
from ...log import die
from . import util as docker_mod
EGRESS_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_EGRESS_IMAGE",
"claude-bottle-egress:latest",
)
EGRESS_DOCKERFILE = "Dockerfile.egress"
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
# resolves to `http://egress:<port>`.
# Listening port the egress daemon binds inside the bundle. The
# agent's HTTP_PROXY env var resolves to `http://egress:<port>`,
# and the bundle's network aliases route `egress` to itself.
EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM
@@ -41,33 +32,15 @@ EGRESS_PIPELOCK_CA_IN_CONTAINER = (
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
)
# Repo root, for `docker build` context. Resolved from this file's
# location: claude_bottle/backend/docker/egress.py → repo root.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def egress_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle — any code still referring to `claude-bottle-egress-<slug>`
resolves to the bundle's IP."""
return f"claude-bottle-egress-{slug}"
def egress_url() -> str:
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
across bottles because the sidecar attaches `--network-alias
egress` on the internal network; the container name (which
carries the slug) is not referenced by agent-side config."""
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
def build_egress_image() -> None:
"""Build the egress image from `Dockerfile.egress`.
Called by `DockerEgress.start`; exposed at module level so
integration tests can build it without running the full launch
pipeline."""
docker_mod.build_image(
EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE,
)
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress MITM CA via host `openssl req`.
+15 -32
View File
@@ -1,56 +1,39 @@
"""DockerGitGate — the Docker-specific lifecycle for the per-agent
git-gate sidecar (PRD 0008). Inherits the platform-agnostic prepare
step (upstream lift + entrypoint/hook render) from `GitGate`."""
"""DockerGitGate — Docker-flavored git-gate config (PRD 0008).
Inherits the platform-agnostic prepare step (upstream lift +
entrypoint/hook render) from `GitGate`. The git-gate daemon runs
inside the sidecar bundle (PRD 0024); this module just holds the
in-container paths the renderer's bind-mounts target."""
from __future__ import annotations
import os
from pathlib import Path
from ...git_gate import GitGate
from . import util as docker_mod
GIT_GATE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_GIT_GATE_IMAGE",
"claude-bottle-git-gate:latest",
)
GIT_GATE_DOCKERFILE = "Dockerfile.git-gate"
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
GIT_GATE_ACCESS_HOOK_IN_CONTAINER = "/etc/git-gate/access-hook"
GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
# git daemon's default listening port. Surfaced as a constant because
# integration tests probe the gate on it.
# git daemon's default listening port.
GIT_GATE_PORT = 9418
# Repo root, for `docker build` context. Resolved from this file's
# location: claude_bottle/backend/docker/git_gate.py → repo root.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def git_gate_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle — any code still dialing `claude-bottle-git-gate-<slug>`
resolves to the bundle's IP."""
return f"claude-bottle-git-gate-{slug}"
def git_gate_host(slug: str) -> str:
"""The hostname the agent's git client should connect to (same as
the container name — Docker's embedded DNS resolves it on the
`--internal` network)."""
"""The hostname the agent's git client connects to. Resolves via
the bundle's network alias to the bundle container, where the
git-gate daemon listens on GIT_GATE_PORT."""
return git_gate_container_name(slug)
def build_git_gate_image() -> None:
"""Build the git-gate image from `Dockerfile.git-gate`. Called by
`DockerGitGate.start`; exposed at module level so integration
tests can build it without running the full launch pipeline."""
docker_mod.build_image(GIT_GATE_IMAGE, _REPO_DIR, dockerfile=GIT_GATE_DOCKERFILE)
class DockerGitGate(GitGate):
"""Docker-flavored GitGate: inherits `.prepare()` from the base.
Container lifecycle is owned by compose; per-container
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
The git-gate daemon's container lifecycle is owned by the
sidecar bundle (PRD 0024)."""
+20 -12
View File
@@ -25,7 +25,7 @@ from pathlib import Path
from ...pipelock import pipelock_render_yaml
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
from .bottle_state import pipelock_state_dir
from .pipelock import pipelock_container_name
from .sidecar_bundle import sidecar_bundle_container_name
def _pipelock_yaml_host_path(slug: str) -> Path:
@@ -73,15 +73,15 @@ def render_allowlist_content(hosts: list[str]) -> str:
def fetch_current_yaml(slug: str) -> str:
"""Read the live /etc/pipelock.yaml from the pipelock sidecar.
"""Read the live /etc/pipelock.yaml from the sidecar bundle.
Uses `docker cp` (not `docker exec cat`) because the pipelock
image is distroless and has no shell utilities. `docker cp` is a
daemon-API tarball copy works on stopped containers too, and
doesn't need anything in the container's PATH.
Uses `docker cp` because pipelock inside the bundle is the
distroless pipelock binary with no shell, and `docker cp` is a
daemon-API tarball copy that works regardless of what's
available inside the container.
Raises PipelockApplyError if the read fails."""
container = pipelock_container_name(slug)
container = sidecar_bundle_container_name(slug)
fd, tmp_path = tempfile.mkstemp(prefix="cb-pipelock-fetch.", suffix=".yaml")
os.close(fd)
try:
@@ -125,19 +125,27 @@ def fetch_current_allowlist(slug: str) -> str:
def apply_allowlist_change(
slug: str, new_allowlist_content: str,
) -> tuple[str, str]:
"""Apply `new_allowlist_content` to the pipelock sidecar:
"""Apply `new_allowlist_content` to the sidecar bundle:
1. Parse the proposed hosts (one per line).
2. Fetch + parse current pipelock.yaml.
3. Replace api_allowlist with the proposed hosts; re-render.
4. docker cp the new yaml into the sidecar.
5. docker restart so pipelock reloads.
4. Write the new yaml to the bind-mount source.
5. `docker restart` the bundle so pipelock reloads.
The restart bounces ALL four daemons inside the bundle, not
just pipelock — pipelock has no in-process reload and the
bundle init re-spawns the four daemons on container restart.
Per-daemon reload would need a supervisor IPC channel (PRD
0024 open question 1's "eventually" path); the bundle-wide
restart is the v1 trade-off.
Returns (before, after) where both are one-per-line allowlist
strings (operator-facing format). Raises PipelockApplyError on
any failure; the sidecar's existing config stays in place until
docker cp succeeds, and the restart is what makes it live."""
the host write succeeds, and the restart is what makes it
live."""
new_hosts = parse_allowlist_content(new_allowlist_content)
container = pipelock_container_name(slug)
container = sidecar_bundle_container_name(slug)
current_yaml = fetch_current_yaml(slug)
try:
cfg = parse_yaml_subset(current_yaml)
+6 -27
View File
@@ -1,18 +1,11 @@
"""Sidecar bundle constants + helpers for the Docker backend
(PRD 0024 chunk 2).
(PRD 0024).
The bundle image (built by Dockerfile.sidecars, see PRD 0024
chunk 1) collapses pipelock + egress + git-gate + supervise into
one container per bottle. Whether the renderer emits the bundle
shape (one `sidecars` service) or the legacy four-sidecar shape
is controlled by `CLAUDE_BOTTLE_SIDECAR_BUNDLE`; chunk 2 ships
both shapes side by side behind the flag so existing operators
keep working unchanged while the bundle path soaks.
This module is intentionally tiny — just the constants + the
flag + the container-name helper. The compose-renderer branch
that consumes it lives in `compose.py`.
"""
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container
per bottle under a small Python init supervisor. As of chunk 5
the bundle is the only shape — the legacy four-sidecar topology
and its `CLAUDE_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
from __future__ import annotations
@@ -36,17 +29,3 @@ def sidecar_bundle_container_name(slug: str) -> str:
per-sidecar containers it replaces, so the dashboard's
discovery-by-prefix logic keeps working."""
return f"claude-bottle-sidecars-{slug}"
def sidecar_bundle_enabled(env: dict[str, str] | None = None) -> bool:
"""Feature-flag check. The flag is opt-in for chunk 2:
unset / "" / "0" / "false" → legacy four-sidecar shape;
anything else → bundle shape. Chunks 4-5 flip the default and
then delete the flag.
`env` defaults to `os.environ` at call time so tests can
monkey-patch the environment without re-importing the module."""
if env is None:
env = dict(os.environ)
raw = env.get("CLAUDE_BOTTLE_SIDECAR_BUNDLE", "").strip().lower()
return raw not in ("", "0", "false", "no", "off")
+12 -38
View File
@@ -1,49 +1,23 @@
"""DockerSupervise — the Docker-specific lifecycle for the per-bottle
supervise sidecar (PRD 0013). Inherits the platform-agnostic prepare
step (queue dir + current-config staging) from `Supervise`."""
"""DockerSupervise — Docker-flavored supervise config (PRD 0013).
Inherits the platform-agnostic prepare step (queue dir +
current-config staging) from `Supervise`. The supervise daemon
runs inside the sidecar bundle (PRD 0024); this module just holds
the container-name helper the renderer's network alias targets."""
from __future__ import annotations
import os
from pathlib import Path
from ...supervise import (
SUPERVISE_HOSTNAME,
SUPERVISE_PORT,
Supervise,
)
from . import util as docker_mod
SUPERVISE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_SUPERVISE_IMAGE",
"claude-bottle-supervise:latest",
)
SUPERVISE_DOCKERFILE = "Dockerfile.supervise"
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
from ...supervise import Supervise
def supervise_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle — any code still referring to
`claude-bottle-supervise-<slug>` resolves to the bundle's IP."""
return f"claude-bottle-supervise-{slug}"
def supervise_url() -> str:
"""Base URL the agent's MCP client dials. Stable across bottles
because the sidecar attaches `--network-alias supervise` on the
internal network."""
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}"
def build_supervise_image() -> None:
"""Build the supervise image from `Dockerfile.supervise`. Called
by `DockerSupervise.start`; exposed at module level so tests can
build it without running the full launch pipeline."""
docker_mod.build_image(SUPERVISE_IMAGE, _REPO_DIR, dockerfile=SUPERVISE_DOCKERFILE)
class DockerSupervise(Supervise):
"""Docker-flavored Supervise: inherits `.prepare()` from the base.
Container lifecycle is owned by compose; per-container
`.start()` / `.stop()` were removed in PRD 0024 chunk 3."""
The supervise daemon's container lifecycle is owned by the
sidecar bundle (PRD 0024)."""
+11 -2
View File
@@ -38,7 +38,6 @@ from claude_bottle.backend.docker.pipelock import (
PIPELOCK_IMAGE,
PIPELOCK_PORT,
DockerPipelockProxy,
pipelock_container_name,
pipelock_tls_init,
)
from claude_bottle.backend.docker.pipelock_apply import (
@@ -47,6 +46,9 @@ from claude_bottle.backend.docker.pipelock_apply import (
fetch_current_allowlist,
fetch_current_yaml,
)
from claude_bottle.backend.docker.sidecar_bundle import (
sidecar_bundle_container_name,
)
from claude_bottle.yaml_subset import parse_yaml_subset
from tests._docker import skip_unless_docker
from tests.fixtures import fixture_minimal
@@ -107,7 +109,14 @@ class TestPipelockApply(unittest.TestCase):
self.egress_net = network_create_egress(self.slug)
ca_cert_host, ca_key_host = pipelock_tls_init(state_dir)
self.sidecar_name = pipelock_container_name(self.slug)
# apply_allowlist_change targets sidecar_bundle_container_name
# (chunk 5 flipped the bundle to the only shape). Bringing the
# standalone pipelock up under that name keeps this test
# exercising the real production code path; the bundle's
# other three daemons aren't running here, but the
# apply/fetch code only touches /etc/pipelock.yaml + the
# pipelock binary, so the lighter setup is fine.
self.sidecar_name = sidecar_bundle_container_name(self.slug)
subprocess.run(
["docker", "create",
"--name", self.sidecar_name,
+12 -242
View File
@@ -196,55 +196,6 @@ class TestProjectAndNetworks(unittest.TestCase):
self.assertNotIn("internal", net)
class TestPipelockAlwaysPresent(unittest.TestCase):
"""Pipelock is unconditional — every bottle has the SSRF guard +
body scanner sitting on its upstream leg."""
def test_minimal_plan_has_pipelock(self):
spec = bottle_plan_to_compose(_plan())
self.assertIn("pipelock", spec["services"])
def test_pipelock_pinned_image_no_build(self):
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
self.assertTrue(s["image"].startswith("ghcr.io/luckypipewrench/pipelock"))
self.assertNotIn("build", s)
def test_pipelock_container_name(self):
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
self.assertEqual(f"claude-bottle-pipelock-{SLUG}", s["container_name"])
def test_pipelock_on_both_networks(self):
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
self.assertIn("internal", s["networks"])
self.assertIn("egress", s["networks"])
def test_pipelock_long_name_alias_on_internal(self):
# Backward compat: anything still dialing pipelock by
# `claude-bottle-pipelock-<slug>` resolves on the internal
# network.
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
aliases = s["networks"]["internal"]["aliases"]
self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases)
def test_pipelock_bind_mounts(self):
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
targets = {v["target"] for v in s["volumes"]}
self.assertEqual(
{"/etc/pipelock.yaml", "/etc/pipelock-ca.pem", "/etc/pipelock-ca-key.pem"},
targets,
)
for v in s["volumes"]:
self.assertEqual("bind", v["type"])
self.assertTrue(v["read_only"])
def test_pipelock_command(self):
s = bottle_plan_to_compose(_plan())["services"]["pipelock"]
self.assertEqual(
["run", "--config", "/etc/pipelock.yaml", "--listen", "0.0.0.0:8888"],
s["command"],
)
class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_in_services(self):
s = bottle_plan_to_compose(_plan())["services"]
@@ -298,18 +249,13 @@ class TestAgentAlwaysPresent(unittest.TestCase):
s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual("runsc", s["runtime"])
def test_agent_depends_on_pipelock(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"]
self.assertIn("pipelock", s["depends_on"])
def test_agent_depends_on_every_present_sidecar(self):
s = bottle_plan_to_compose(
_plan(with_git=True, with_egress=True, supervise=True)
)["services"]["agent"]
self.assertEqual(
{"pipelock", "git-gate", "egress", "supervise"},
set(s["depends_on"]),
)
def test_agent_depends_only_on_sidecars(self):
# Bundle shape: the init supervisor owns intra-bundle daemon
# ordering, so the agent waits on the bundle container alone.
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
with self.subTest(**kwargs):
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
self.assertEqual(["sidecars"], s["depends_on"])
def test_agent_current_config_mount_only_with_supervise(self):
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
@@ -325,146 +271,14 @@ class TestAgentAlwaysPresent(unittest.TestCase):
))
class TestConditionalGitGate(unittest.TestCase):
def test_absent_when_no_upstreams(self):
s = bottle_plan_to_compose(_plan(with_git=False))["services"]
self.assertNotIn("git-gate", s)
def test_present_when_upstreams(self):
s = bottle_plan_to_compose(_plan(with_git=True))["services"]
self.assertIn("git-gate", s)
def test_git_gate_built_from_dockerfile(self):
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
self.assertEqual("Dockerfile.git-gate", s["build"]["dockerfile"])
self.assertEqual("claude-bottle-git-gate:latest", s["image"])
def test_git_gate_extra_hosts(self):
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
self.assertIn("example.com:10.0.0.1", s["extra_hosts"])
def test_git_gate_identity_file_bind_mount(self):
s = bottle_plan_to_compose(_plan(with_git=True))["services"]["git-gate"]
# Per-upstream identity file is mounted at /git-gate/creds/<name>-key.
self.assertTrue(any(
v["target"] == "/git-gate/creds/upstream-key"
for v in s["volumes"]
))
class TestConditionalEgress(unittest.TestCase):
def test_absent_when_no_routes(self):
s = bottle_plan_to_compose(_plan(with_egress=False))["services"]
self.assertNotIn("egress", s)
def test_present_when_routes(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]
self.assertIn("egress", s)
def test_egress_alias_on_internal(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
self.assertIn("egress", s["networks"]["internal"]["aliases"])
def test_egress_upstream_envs(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
env = s["environment"]
self.assertIn(
f"EGRESS_UPSTREAM_PROXY=http://claude-bottle-pipelock-{SLUG}:8888",
env,
)
self.assertIn(
"EGRESS_UPSTREAM_CA=/home/mitmproxy/.mitmproxy/pipelock-ca.pem",
env,
)
def test_egress_token_slot_bare_name(self):
# Bare NAME entry in environment list → value inherits from
# compose process env, never lands in the rendered file.
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
self.assertIn("EGRESS_TOKEN_0", s["environment"])
def test_egress_depends_on_pipelock(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
self.assertIn("pipelock", s["depends_on"])
def test_egress_bind_mounts(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["egress"]
targets = {v["target"] for v in s["volumes"]}
self.assertEqual(
{
"/etc/egress/routes.yaml",
"/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem",
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem",
},
targets,
)
class TestConditionalSupervise(unittest.TestCase):
def test_absent_when_off(self):
s = bottle_plan_to_compose(_plan(supervise=False))["services"]
self.assertNotIn("supervise", s)
def test_present_when_on(self):
s = bottle_plan_to_compose(_plan(supervise=True))["services"]
self.assertIn("supervise", s)
def test_supervise_internal_only(self):
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
self.assertEqual({"internal"}, set(s["networks"].keys()))
def test_supervise_alias_on_internal(self):
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
self.assertIn("supervise", s["networks"]["internal"]["aliases"])
def test_supervise_queue_dir_mounted_rw(self):
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
queue_mount = [v for v in s["volumes"] if v["target"] == "/run/supervise/queue"]
self.assertEqual(1, len(queue_mount))
self.assertFalse(queue_mount[0]["read_only"])
def test_supervise_env_vars(self):
s = bottle_plan_to_compose(_plan(supervise=True))["services"]["supervise"]
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", s["environment"])
class TestFullMatrix(unittest.TestCase):
"""The eight combinations of git/egress/supervise toggles. Just
asserts which services appear content correctness is covered
per-service above."""
def test_matrix(self):
cases: list[tuple[bool, bool, bool, set[str]]] = []
for g in (False, True):
for e in (False, True):
for sv in (False, True):
expected = {"pipelock", "agent"}
if g:
expected.add("git-gate")
if e:
expected.add("egress")
if sv:
expected.add("supervise")
cases.append((g, e, sv, expected))
for g, e, sv, expected in cases:
with self.subTest(git=g, egress=e, supervise=sv):
s = bottle_plan_to_compose(
_plan(with_git=g, with_egress=e, supervise=sv)
)["services"]
self.assertEqual(expected, set(s.keys()))
class TestSidecarBundleShape(unittest.TestCase):
"""PRD 0024 chunk 2: with CLAUDE_BOTTLE_SIDECAR_BUNDLE=1 set,
the renderer emits a `sidecars` service in place of the four
per-sidecar services. The legacy four-sidecar tests above
cover the flag-off shape; these lock down the flag-on shape."""
"""The compose renderer emits exactly one `sidecars` service in
place of the four daemons it owns (pipelock + egress + git-gate
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar
shape entirely, so the bundle is the only thing exercised here."""
def _render(self, **plan_kwargs):
from unittest.mock import patch
with patch.dict("os.environ", {"CLAUDE_BOTTLE_SIDECAR_BUNDLE": "1"}):
return bottle_plan_to_compose(_plan(**plan_kwargs))
return bottle_plan_to_compose(_plan(**plan_kwargs))
def test_emits_two_services_minimal(self):
spec = self._render()
@@ -619,50 +433,6 @@ class TestSidecarBundleShape(unittest.TestCase):
spec["services"]["sidecars"]["networks"]["internal"]["aliases"])
class TestSidecarBundleFlag(unittest.TestCase):
"""The flag's parser: covers the cases sidecar_bundle_enabled
has to interpret correctly so an operator-supplied value lands
in the expected shape."""
def _enabled(self, value: str | None) -> bool:
from claude_bottle.backend.docker.sidecar_bundle import (
sidecar_bundle_enabled,
)
env: dict[str, str] = {}
if value is not None:
env["CLAUDE_BOTTLE_SIDECAR_BUNDLE"] = value
return sidecar_bundle_enabled(env)
def test_unset_disabled(self):
self.assertFalse(self._enabled(None))
def test_empty_disabled(self):
self.assertFalse(self._enabled(""))
def test_zero_disabled(self):
self.assertFalse(self._enabled("0"))
def test_false_disabled(self):
self.assertFalse(self._enabled("false"))
self.assertFalse(self._enabled("FALSE"))
def test_no_disabled(self):
self.assertFalse(self._enabled("no"))
self.assertFalse(self._enabled("off"))
def test_one_enabled(self):
self.assertTrue(self._enabled("1"))
def test_true_enabled(self):
self.assertTrue(self._enabled("true"))
def test_arbitrary_truthy_enabled(self):
# The flag treats anything other than the known falsy
# values as truthy — operator typos default to "enabled"
# which is the safer interpretation for an opt-in flag.
self.assertTrue(self._enabled("yes"))
class TestProjectNaming(unittest.TestCase):
"""The slug ↔ compose-project mapping is the contract dashboard,
cleanup, and launch all rely on. Lock it down."""