refactor(sidecars): instantiate sidecar ABCs directly from any backend
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 40s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 05:42:20 -04:00
parent 1dfc359141
commit 73dc0d4a40
10 changed files with 99 additions and 124 deletions
+7 -14
View File
@@ -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."""
+6 -13
View File
@@ -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)."""
+13 -25
View File
@@ -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
+12 -8
View File
@@ -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]
+7 -13
View File
@@ -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-<slug>` 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)."""
+5 -5
View File
@@ -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
+14 -16
View File
@@ -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`
+26 -21
View File
@@ -17,7 +17,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> 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-<slug>`), 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)
+2 -2
View File
@@ -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)
+7 -7
View File
@@ -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()