From 73dc0d4a40cf4de4786cf950251a617cf3703a41 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 05:42:20 -0400 Subject: [PATCH] refactor(sidecars): instantiate sidecar ABCs directly from any backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four sidecar prepare-time helpers (PipelockProxy, Egress, GitGate, Supervise) had docker-flavored subclasses that existed only as instantiation shims for ABCs that already had no abstract methods. PipelockProxy.prepare() reached for class-level CA path constants that were only defined on the docker subclass — so smolmachines had to import DockerPipelockProxy to render pipelock yaml, reaching across the backend boundary for what's actually a platform-neutral operation. This moves the universal in-container CA paths (PIPELOCK_CA_CERT_IN_CONTAINER / PIPELOCK_CA_KEY_IN_CONTAINER) to claude_bottle/pipelock.py, drops the class-attr indirection on the ABC, and deletes the four empty docker subclasses. Both backends now instantiate the ABCs directly; the docker-side modules keep the docker-flavored helpers (image pin, container naming, host CA mint) and re-export the moved pipelock constants for compat. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/docker/egress.py | 21 +++------ claude_bottle/backend/docker/git_gate.py | 19 +++----- claude_bottle/backend/docker/pipelock.py | 38 +++++---------- claude_bottle/backend/docker/prepare.py | 20 ++++---- claude_bottle/backend/docker/supervise.py | 20 +++----- claude_bottle/backend/smolmachines/launch.py | 10 ++-- claude_bottle/backend/smolmachines/prepare.py | 30 ++++++------ claude_bottle/pipelock.py | 47 ++++++++++--------- tests/integration/test_pipelock_apply.py | 4 +- tests/unit/test_pipelock_yaml.py | 14 +++--- 10 files changed, 99 insertions(+), 124 deletions(-) diff --git a/claude_bottle/backend/docker/egress.py b/claude_bottle/backend/docker/egress.py index 1844ed9..925161b 100644 --- a/claude_bottle/backend/docker/egress.py +++ b/claude_bottle/backend/docker/egress.py @@ -1,11 +1,11 @@ -"""DockerEgress — the Docker-specific lifecycle for the -per-bottle egress sidecar (PRD 0017). Inherits the platform- -agnostic prepare step (route lift + routes.yaml render + token-env -map derivation) from `Egress`. +"""Docker-side egress helpers: port pin, in-container CA paths, +container naming, and the host-side mitmproxy CA mint. The +prepare-time routes-yaml rendering itself lives on the +platform-neutral `Egress` ABC — backends instantiate it directly. -Chunks 1+2 of the PRD: the lifecycle is implemented and wired into -launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy- -block remediation flow (PRD 0014).""" +The per-container `.start()` / `.stop()` lifecycle was removed in +PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress +under its python init supervisor.""" from __future__ import annotations @@ -13,7 +13,6 @@ import os import subprocess from pathlib import Path -from ...egress import Egress from ...log import die @@ -130,9 +129,3 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes()) mitm.chmod(0o644) return (mitm, cert_path) - - -class DockerEgress(Egress): - """Docker-flavored Egress: inherits `.prepare()` from the base. - Container lifecycle is owned by compose; per-container - `.start()` / `.stop()` were removed in PRD 0024 chunk 3.""" diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index 58bf8fa..227b524 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -1,13 +1,12 @@ -"""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.""" +"""Docker-side git-gate helpers: in-container paths the renderer's +bind-mounts target, port pin, and container naming. The +prepare-time entrypoint/hook render lives on the platform-neutral +`GitGate` ABC — backends instantiate it directly. The git-gate +daemon's container lifecycle is owned by the sidecar bundle +(PRD 0024).""" from __future__ import annotations -from ...git_gate import GitGate - GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh" GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive" @@ -31,9 +30,3 @@ def git_gate_host(slug: str) -> str: 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) - - -class DockerGitGate(GitGate): - """Docker-flavored GitGate: inherits `.prepare()` from the base. - The git-gate daemon's container lifecycle is owned by the - sidecar bundle (PRD 0024).""" diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index 3bdd96e..3479f84 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -1,14 +1,12 @@ -"""DockerPipelockProxy — the Docker-specific implementation of the -sidecar's `.prepare()` step + in-container CA path constants. -Inherits the platform-agnostic YAML-config generation from -PipelockProxy. +"""Docker-side pipelock helpers: image pin, container naming, and +the one-shot `pipelock tls init` host-side CA mint. The +prepare-time YAML rendering itself lives on the platform-neutral +`PipelockProxy` ABC — backends instantiate it directly. The per-container `.start()` / `.stop()` lifecycle was deleted in -PRD 0024 chunk 3 — compose-up owns the container lifecycle (PRD +PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD 0018) and the bundle path (PRD 0024) collapses pipelock + egress -+ git-gate + supervise into one container. What remains here is -the prepare-time YAML rendering + the CA path constants the -compose renderer reads.""" ++ git-gate + supervise into one container.""" from __future__ import annotations @@ -17,7 +15,13 @@ import subprocess from pathlib import Path from ...log import die -from ...pipelock import PipelockProxy +# Re-exported for the compose renderer + smolmachines launch step +# (they used to import these from this module before they moved to +# the platform-neutral pipelock module). +from ...pipelock import ( # noqa: F401 + PIPELOCK_CA_CERT_IN_CONTAINER, + PIPELOCK_CA_KEY_IN_CONTAINER, +) # Pipelock image, pinned by digest. The digest is the multi-arch image @@ -30,12 +34,6 @@ PIPELOCK_IMAGE = os.environ.get( # Listening port for pipelock's forward proxy. PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") -# In-container paths where the per-bottle CA cert + key land via -# the compose renderer's bind-mounts. Pipelock's rendered YAML -# references these paths under `tls_interception`. -PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem" -PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem" - def pipelock_container_name(slug: str) -> str: return f"claude-bottle-pipelock-{slug}" @@ -82,13 +80,3 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: key.chmod(0o600) cert.chmod(0o644) return (cert, key) - - -class DockerPipelockProxy(PipelockProxy): - """Docker-flavored PipelockProxy: inherits `.prepare()` from the - base, exposes the in-container CA paths the renderer reads. - Container lifecycle is owned by compose.""" - - CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER - CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER - diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 882e2fc..5e3cf1c 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -14,13 +14,17 @@ import os from datetime import datetime, timezone from pathlib import Path +from ...egress import Egress from ...env import ResolvedEnv, resolve_env +from ...git_gate import GitGate from ...log import die +from ...pipelock import PipelockProxy +from ...supervise import Supervise from .. import BottleSpec from . import util as docker_mod from .bottle_plan import DockerBottlePlan -from .egress import DockerEgress, egress_container_name -from .git_gate import DockerGitGate, git_gate_container_name +from .egress import egress_container_name +from .git_gate import git_gate_container_name from .bottle_state import ( BottleMetadata, agent_state_dir, @@ -35,8 +39,8 @@ from .bottle_state import ( supervise_state_dir, write_metadata, ) -from .pipelock import DockerPipelockProxy, pipelock_container_name -from .supervise import DockerSupervise, supervise_container_name +from .pipelock import pipelock_container_name +from .supervise import supervise_container_name def resolve_plan( @@ -49,10 +53,10 @@ def resolve_plan( validation already ran in the base class.""" docker_mod.require_docker() - proxy = DockerPipelockProxy() - git_gate = DockerGitGate() - egress = DockerEgress() - supervise = DockerSupervise() + proxy = PipelockProxy() + git_gate = GitGate() + egress = Egress() + supervise = Supervise() manifest = spec.manifest agent = manifest.agents[spec.agent_name] diff --git a/claude_bottle/backend/docker/supervise.py b/claude_bottle/backend/docker/supervise.py index 3c0d899..5948b05 100644 --- a/claude_bottle/backend/docker/supervise.py +++ b/claude_bottle/backend/docker/supervise.py @@ -1,13 +1,13 @@ -"""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.""" +"""Docker-side supervise helpers: container naming for the legacy +per-sidecar topology (kept so the bundle's docker-network alias +resolves the old name to the bundle IP). The prepare-time +queue-dir + current-config staging lives on the platform-neutral +`Supervise` ABC — backends instantiate it directly. The +supervise daemon's container lifecycle is owned by the sidecar +bundle (PRD 0024).""" from __future__ import annotations -from ...supervise import Supervise - def supervise_container_name(slug: str) -> str: """The legacy per-sidecar container name. Kept as a function so @@ -15,9 +15,3 @@ def supervise_container_name(slug: str) -> str: bundle — any code still referring to `claude-bottle-supervise-` resolves to the bundle's IP.""" return f"claude-bottle-supervise-{slug}" - - -class DockerSupervise(Supervise): - """Docker-flavored Supervise: inherits `.prepare()` from the base. - The supervise daemon's container lifecycle is owned by the - sidecar bundle (PRD 0024).""" diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index 1bfe514..3c6998e 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -25,6 +25,10 @@ from contextlib import ExitStack, contextmanager from typing import Callable, Generator from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values +from ...pipelock import ( + PIPELOCK_CA_CERT_IN_CONTAINER, + PIPELOCK_CA_KEY_IN_CONTAINER, +) from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde from ..docker.egress import ( @@ -38,11 +42,7 @@ from ..docker.git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ..docker.pipelock import ( - PIPELOCK_CA_CERT_IN_CONTAINER, - PIPELOCK_CA_KEY_IN_CONTAINER, - pipelock_tls_init, -) +from ..docker.pipelock import pipelock_tls_init from . import sidecar_bundle as _bundle from . import smolvm as _smolvm from .bottle import SmolmachinesBottle diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index b0cf4e3..f023cee 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -21,10 +21,10 @@ from ...backend.docker.bottle_state import ( supervise_state_dir, write_metadata, ) -from ...backend.docker.egress import DockerEgress -from ...backend.docker.git_gate import DockerGitGate -from ...backend.docker.pipelock import DockerPipelockProxy -from ...backend.docker.supervise import DockerSupervise +from ...egress import Egress +from ...git_gate import GitGate +from ...pipelock import PipelockProxy +from ...supervise import Supervise from . import smolvm as _smolvm from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -94,31 +94,29 @@ def resolve_plan( f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}" ) - # Inner Plans for the four bundle daemons. Use the docker - # backend's concrete subclasses — the `.prepare()` method - # they inherit is platform-neutral (writes config files + - # returns a Plan dataclass); the docker-specific subclasses - # exist only to satisfy ABC instantiation. Future: factor - # the prepare logic out of the docker subpackage so - # smolmachines doesn't have to reach across the backend - # boundary. + # Inner Plans for the four bundle daemons. The ABCs are + # platform-neutral — `.prepare()` writes config files + returns + # a Plan dataclass with no backend-specific assumptions. State + # dirs are still keyed by slug under the docker backend's + # bottle_state layout (shared on-host convention; not a docker + # dependency). pipelock_dir = pipelock_state_dir(slug) pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = DockerPipelockProxy().prepare(bottle, slug, pipelock_dir) + proxy_plan = PipelockProxy().prepare(bottle, slug, pipelock_dir) git_gate_dir = git_gate_state_dir(slug) git_gate_dir.mkdir(parents=True, exist_ok=True) - git_gate_plan = DockerGitGate().prepare(bottle, slug, git_gate_dir) + git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir) egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = DockerEgress().prepare(bottle, slug, egress_dir) + egress_plan = Egress().prepare(bottle, slug, egress_dir) supervise_plan = None if bottle.supervise: supervise_dir = supervise_state_dir(slug) supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = DockerSupervise().prepare(slug, supervise_dir) + supervise_plan = Supervise().prepare(slug, supervise_dir) # Prompt file is always written (mode 0o600) so the in-VM # path always exists. Content is the agent's `prompt` diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index c640e47..64587ae 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -17,7 +17,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path from typing import cast @@ -47,6 +46,15 @@ DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ( ) +# In-container paths the rendered pipelock YAML references under +# `tls_interception`. The pipelock binary expects the per-bottle CA +# cert + key at these exact paths inside its container — independent +# of how the daemon is wrapped (own container, sidecar bundle, etc.), +# which is why they live in the platform-neutral module. +PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem" +PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem" + + # --- Allowlist resolution -------------------------------------------------- @@ -301,25 +309,22 @@ class PipelockProxyPlan: ca_key_host_path: Path = Path() -class PipelockProxy(ABC): +class PipelockProxy: """The pipelock egress proxy. Encapsulates the YAML-config - generation; the sidecar's start/stop lifecycle is backend-specific - and lives on concrete subclasses. + generation; the container lifecycle is owned by whatever + wraps the daemon (compose-managed pipelock container on docker, + sidecar-bundle PID 1 on smolmachines). - The class-level constants `CA_CERT_IN_CONTAINER` / - `CA_KEY_IN_CONTAINER` are the in-container paths the YAML config - references — they correspond to wherever the backend's `.start` - places the CA cert and key inside the sidecar. Subclasses - override the constants.""" - - CA_CERT_IN_CONTAINER: str = "" - CA_KEY_IN_CONTAINER: str = "" + Backends instantiate the class directly — there are no + platform-specific subclasses; the in-container CA paths are + universal module-level constants + (`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`).""" def prepare( self, bottle: Bottle, slug: str, stage_dir: Path ) -> PipelockProxyPlan: """Write the pipelock yaml config (mode 600) under `stage_dir` - and return the plan for `.start`. Pure host-side, no docker + and return the plan for launch. Pure host-side, no docker subprocess. `slug` is the agent-derived identifier (lowercased, @@ -327,18 +332,18 @@ class PipelockProxy(ABC): resource name — the agent container, the pipelock container (`claude-bottle-pipelock-`), the internal/egress networks. It's stored on the returned plan so the backend's - start step can derive the sidecar's container name. + launch step can derive the sidecar's container name. - The CA paths the YAML references are the in-container paths - from the concrete subclass's class-level constants. The - host-side counterparts are generated by the launch step - (not here, so prepare stays side-effect-free on docker) and - added to the plan via `dataclasses.replace` before `.start`.""" + The CA paths the YAML references are the module-level + in-container constants. The host-side counterparts are + generated by the launch step (not here, so prepare stays + side-effect-free on docker) and added to the plan via + `dataclasses.replace` before the daemon starts.""" yaml_path = stage_dir / "pipelock.yaml" cfg = pipelock_build_config( bottle, - ca_cert_path=self.CA_CERT_IN_CONTAINER, - ca_key_path=self.CA_KEY_IN_CONTAINER, + ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER, + ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER, ) yaml_path.write_text(pipelock_render_yaml(cfg)) yaml_path.chmod(0o600) diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py index 54523a3..8e2f64a 100644 --- a/tests/integration/test_pipelock_apply.py +++ b/tests/integration/test_pipelock_apply.py @@ -35,9 +35,9 @@ from claude_bottle.backend.docker.network import ( from claude_bottle.backend.docker.pipelock import ( PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER, - DockerPipelockProxy, pipelock_tls_init, ) +from claude_bottle.pipelock import PipelockProxy from claude_bottle.backend.docker.pipelock_apply import ( PipelockApplyError, apply_allowlist_change, @@ -99,7 +99,7 @@ class TestPipelockApply(unittest.TestCase): the updated config.""" state_dir = pipelock_state_dir(self.slug) state_dir.mkdir(parents=True, exist_ok=True) - prep = DockerPipelockProxy().prepare( + prep = PipelockProxy().prepare( fixture_minimal().bottles["dev"], self.slug, state_dir, ) self.internal_net = network_create_internal(self.slug) diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index d6a3df3..45772e0 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -12,10 +12,10 @@ import unittest from pathlib import Path from typing import Any, cast -from claude_bottle.backend.docker.pipelock import DockerPipelockProxy from claude_bottle.manifest import Manifest from claude_bottle.pipelock import ( DEFAULT_TLS_PASSTHROUGH, + PipelockProxy, pipelock_build_config, pipelock_render_yaml, ) @@ -54,7 +54,7 @@ class TestBuildConfig(unittest.TestCase): self.assertNotIn("tls_interception", cfg) def test_tls_interception_block_emitted_when_paths_supplied(self): - # PRD 0006: paths flow in via DockerPipelockProxy's in-container + # PRD 0006: paths flow in via the platform-neutral in-container # constants; this directly pins the dict shape. passthrough_domains # is baked in so LLM provider endpoints (api.anthropic.com) skip # MITM — pipelock's docs explicitly recommend this for LLM hosts, @@ -152,7 +152,7 @@ class TestRenderAndWrite(unittest.TestCase): self.assertNotIn("ssrf:", text) def test_prepare_writes_file_at_mode_600(self): - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( fixture_minimal().bottles["dev"], "demo", self.out_dir ) self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777) @@ -170,7 +170,7 @@ class TestRenderAndWrite(unittest.TestCase): }, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( manifest.bottles["dev"], "demo", self.out_dir ) content = plan.yaml_path.read_text() @@ -179,13 +179,13 @@ class TestRenderAndWrite(unittest.TestCase): self.assertNotIn("prompt-message", content) def test_render_emits_tls_interception_via_prepare(self): - """`DockerPipelockProxy.prepare` plumbs its in-container CA - constants through to the YAML. The block should land in the + """`PipelockProxy.prepare` plumbs the module-level in-container + CA constants through to the YAML. The block should land in the rendered output with `enabled: true`, the configured paths, and the baked LLM-provider passthrough list. The actual host-side CA generation happens in launch (not prepare), so this test exercises only the YAML rendering.""" - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( fixture_minimal().bottles["dev"], "demo", self.out_dir ) content = plan.yaml_path.read_text()