feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b) #70

Merged
didericis-claude merged 5 commits from prd-0023-chunk-4b-inner-plans into main 2026-05-27 13:21:42 -04:00
10 changed files with 99 additions and 124 deletions
Showing only changes of commit 73dc0d4a40 - Show all commits
+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
Outdated
Review

We don't care about preserving the legacy topology

We don't care about preserving the legacy topology
Outdated
Review

Dropped in 8ecba2b — deleted claude_bottle/backend/docker/supervise.py entirely (only legacy-name function left after the refactor), removed the network alias in compose.py, removed the orphan probe in prepare.py, and stopped asserting the long-form alias in test_compose.py. The bundle keeps the short supervise alias since SUPERVISE_HOSTNAME is what consumers actually use.

Dropped in 8ecba2b — deleted `claude_bottle/backend/docker/supervise.py` entirely (only legacy-name function left after the refactor), removed the network alias in `compose.py`, removed the orphan probe in `prepare.py`, and stopped asserting the long-form alias in `test_compose.py`. The bundle keeps the short `supervise` alias since `SUPERVISE_HOSTNAME` is what consumers actually use.
Outdated
Review

The other container name functions can probably go too, right?

The other container name functions can probably go too, right?
Outdated
Review

Yep — done in 727f30d. Killed pipelock_container_name, egress_container_name, git_gate_container_name, plus pipelock_proxy_url and git_gate_host since they only existed to build URLs with the legacy long-form names. Added PIPELOCK_HOSTNAME = "pipelock" and GIT_GATE_HOSTNAME = "git-gate" (mirroring EGRESS_HOSTNAME = "egress"), so the agent's HTTPS_PROXY is now http://pipelock:8888 and the gitconfig insteadOf rewrites are git://git-gate/<repo>.git. The bundle-internal egress→pipelock hop drops the DNS alias entirely and uses loopback (BUNDLE_LOCAL_PIPELOCK_URL = http://127.0.0.1:8888) — same pattern smolmachines was already using.

The prepare-time orphan probe collapses to one check on the bundle container name (claude-bottle-sidecars-<slug>) instead of the four legacy per-sidecar names nothing creates anymore.

One thing I left alone: capability_apply._per_bottle_container_names still lists the four legacy names in its teardown helper. Harmless (missing containers are silently skipped) but technically also dead. Easy to wipe in a follow-up if you want.

Yep — done in 727f30d. Killed `pipelock_container_name`, `egress_container_name`, `git_gate_container_name`, plus `pipelock_proxy_url` and `git_gate_host` since they only existed to build URLs with the legacy long-form names. Added `PIPELOCK_HOSTNAME = "pipelock"` and `GIT_GATE_HOSTNAME = "git-gate"` (mirroring `EGRESS_HOSTNAME = "egress"`), so the agent's HTTPS_PROXY is now `http://pipelock:8888` and the gitconfig insteadOf rewrites are `git://git-gate/<repo>.git`. The bundle-internal egress→pipelock hop drops the DNS alias entirely and uses loopback (`BUNDLE_LOCAL_PIPELOCK_URL = http://127.0.0.1:8888`) — same pattern smolmachines was already using. The prepare-time orphan probe collapses to one check on the bundle container name (`claude-bottle-sidecars-<slug>`) instead of the four legacy per-sidecar names nothing creates anymore. One thing I left alone: `capability_apply._per_bottle_container_names` still lists the four legacy names in its teardown helper. Harmless (missing containers are silently skipped) but technically also dead. Easy to wipe in a follow-up if you want.
Outdated
Review

wipe that out too

wipe that out too
Review

Done in 519a71f. _per_bottle_container_names is now just [agent, sidecar-bundle]; the four legacy per-sidecar literals are gone. Integration test follows — the fake supervise sidecar (which existed to give teardown an extra container to nuke) switches to a fake sidecar bundle.

Done in 519a71f. `_per_bottle_container_names` is now just `[agent, sidecar-bundle]`; the four legacy per-sidecar literals are gone. Integration test follows — the fake supervise sidecar (which existed to give teardown an extra container to nuke) switches to a fake sidecar bundle.
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)."""
6
+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
didericis marked this conversation as resolved Outdated
Outdated
Review

Do this refactor now

Do this refactor now
Outdated
Review

Done in 73dc0d4 — moved CA in-container path constants up to claude_bottle/pipelock.py, made PipelockProxy a regular class, and deleted the four empty Docker* sidecar subclasses. Both backends now instantiate the platform-neutral ABCs directly.

Done in 73dc0d4 — moved CA in-container path constants up to `claude_bottle/pipelock.py`, made `PipelockProxy` a regular class, and deleted the four empty `Docker*` sidecar subclasses. Both backends now instantiate the platform-neutral ABCs directly.
# 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()