From 62f6f8db34d9106265cb1cd019dd4d13dd173d89 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 27 May 2026 01:37:21 -0400 Subject: [PATCH] refactor(sidecars): bundle is the only shape (PRD 0024 chunk 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- + claude-bottle-sidecars-). Co-Authored-By: Claude Opus 4.7 --- Dockerfile.egress | 69 ----- Dockerfile.git-gate | 37 --- Dockerfile.supervise | 32 --- README.md | 26 +- claude_bottle/backend/docker/compose.py | 226 ++-------------- claude_bottle/backend/docker/egress.py | 41 +-- claude_bottle/backend/docker/git_gate.py | 47 ++-- .../backend/docker/pipelock_apply.py | 32 ++- .../backend/docker/sidecar_bundle.py | 33 +-- claude_bottle/backend/docker/supervise.py | 50 +--- tests/integration/test_pipelock_apply.py | 13 +- tests/unit/test_compose.py | 254 +----------------- 12 files changed, 117 insertions(+), 743 deletions(-) delete mode 100644 Dockerfile.egress delete mode 100644 Dockerfile.git-gate delete mode 100644 Dockerfile.supervise diff --git a/Dockerfile.egress b/Dockerfile.egress deleted file mode 100644 index 9993832..0000000 --- a/Dockerfile.egress +++ /dev/null @@ -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"] diff --git a/Dockerfile.git-gate b/Dockerfile.git-gate deleted file mode 100644 index 1132fe0..0000000 --- a/Dockerfile.git-gate +++ /dev/null @@ -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/-key — per-upstream identity, docker-cp'd -# /git-gate/creds/-known_hosts — per-upstream known_hosts, docker-cp'd -# /git/.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"] diff --git a/Dockerfile.supervise b/Dockerfile.supervise deleted file mode 100644 index 94aa371..0000000 --- a/Dockerfile.supervise +++ /dev/null @@ -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// -# 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"] diff --git a/README.md b/README.md index e3048bd..47d81b9 100644 --- a/README.md +++ b/README.md @@ -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 ) diff --git a/claude_bottle/backend/docker/compose.py b/claude_bottle/backend/docker/compose.py index 021ad58..ccec1ac 100644 --- a/claude_bottle/backend/docker/compose.py +++ b/claude_bottle/backend/docker/compose.py @@ -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--` 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 diff --git a/claude_bottle/backend/docker/egress.py b/claude_bottle/backend/docker/egress.py index 27b4b39..1844ed9 100644 --- a/claude_bottle/backend/docker/egress.py +++ b/claude_bottle/backend/docker/egress.py @@ -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:`. +# Listening port the egress daemon binds inside the bundle. The +# agent's HTTP_PROXY env var resolves to `http://egress:`, +# 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-` + 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`. diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index 5901817..58bf8fa 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -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-` + 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).""" diff --git a/claude_bottle/backend/docker/pipelock_apply.py b/claude_bottle/backend/docker/pipelock_apply.py index 95487cb..cfb7f27 100644 --- a/claude_bottle/backend/docker/pipelock_apply.py +++ b/claude_bottle/backend/docker/pipelock_apply.py @@ -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) diff --git a/claude_bottle/backend/docker/sidecar_bundle.py b/claude_bottle/backend/docker/sidecar_bundle.py index b8dafe9..a404489 100644 --- a/claude_bottle/backend/docker/sidecar_bundle.py +++ b/claude_bottle/backend/docker/sidecar_bundle.py @@ -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") diff --git a/claude_bottle/backend/docker/supervise.py b/claude_bottle/backend/docker/supervise.py index ab2f4ab..3c0d899 100644 --- a/claude_bottle/backend/docker/supervise.py +++ b/claude_bottle/backend/docker/supervise.py @@ -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-` 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).""" diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py index b3be368..86fbb9c 100644 --- a/tests/integration/test_pipelock_apply.py +++ b/tests/integration/test_pipelock_apply.py @@ -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, diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index b2949fa..c6aa212 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -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-` 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/-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.""" -- 2.52.0