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
23 changed files with 416 additions and 260 deletions
@@ -43,6 +43,7 @@ from .bottle_state import (
transcript_snapshot_dir, transcript_snapshot_dir,
write_per_bottle_dockerfile, write_per_bottle_dockerfile,
) )
from .sidecar_bundle import sidecar_bundle_container_name
# Agent home inside the container (per the repo Dockerfile's # Agent home inside the container (per the repo Dockerfile's
@@ -52,9 +53,7 @@ _AGENT_HOME_IN_CONTAINER = "/home/node"
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude" _AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace" _AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
# Per-bottle resource name patterns (mirroring prepare.py / # Per-bottle resource name patterns (mirroring prepare.py).
# the various sidecar modules). The agent container's name is the
# slug with no infix; sidecars carry an infix like cred-proxy.
def _agent_container_name(slug: str) -> str: def _agent_container_name(slug: str) -> str:
return f"claude-bottle-{slug}" return f"claude-bottle-{slug}"
@@ -65,10 +64,7 @@ def _per_bottle_container_names(slug: str) -> list[str]:
fine to include names that don't exist for a given bottle.""" fine to include names that don't exist for a given bottle."""
return [ return [
_agent_container_name(slug), _agent_container_name(slug),
f"claude-bottle-cred-proxy-{slug}", sidecar_bundle_container_name(slug),
f"claude-bottle-pipelock-{slug}",
f"claude-bottle-git-gate-{slug}",
f"claude-bottle-supervise-{slug}",
] ]
+8 -14
View File
@@ -49,8 +49,9 @@ from ...egress import (
EGRESS_HOSTNAME, EGRESS_HOSTNAME,
EGRESS_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
) )
from ...git_gate import GIT_GATE_HOSTNAME, git_gate_aggregate_extra_hosts
from ...log import die, warn from ...log import die, warn
from ...git_gate import git_gate_aggregate_extra_hosts from ...pipelock import PIPELOCK_HOSTNAME
from ...supervise import ( from ...supervise import (
CURRENT_CONFIG_DIR_IN_AGENT, CURRENT_CONFIG_DIR_IN_AGENT,
QUEUE_DIR_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER,
@@ -62,20 +63,17 @@ from .bottle_plan import DockerBottlePlan
from .egress import ( from .egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER,
egress_container_name,
) )
from .git_gate import ( from .git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER, GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
git_gate_container_name,
) )
from .pipelock import ( from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER,
PIPELOCK_PORT, PIPELOCK_PORT,
pipelock_container_name,
) )
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .sidecar_bundle import ( from .sidecar_bundle import (
@@ -83,7 +81,6 @@ from .sidecar_bundle import (
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
sidecar_bundle_container_name, sidecar_bundle_container_name,
) )
from .supervise import supervise_container_name
# Repo root, used as the build context for the bundle Dockerfile. # Repo root, used as the build context for the bundle Dockerfile.
@@ -233,20 +230,17 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"read_only": False, "read_only": False,
}) })
# Internal-network aliases: every shortname + long-form legacy # Internal-network aliases: the agent reaches each daemon through
# name routes to the bundle so the agent's HTTPS_PROXY URL # its short name (pipelock / egress / git-gate / supervise) which
# (which references either `pipelock` or `egress`) keeps # the bundle answers as if it were the daemon itself.
# resolving without an agent-side change.
internal_aliases = [ internal_aliases = [
pipelock_container_name(plan.slug), PIPELOCK_HOSTNAME,
EGRESS_HOSTNAME, EGRESS_HOSTNAME,
egress_container_name(plan.slug),
] ]
if gp.upstreams: if gp.upstreams:
internal_aliases.append(git_gate_container_name(plan.slug)) internal_aliases.append(GIT_GATE_HOSTNAME)
if sp is not None: if sp is not None:
internal_aliases.append(SUPERVISE_HOSTNAME) internal_aliases.append(SUPERVISE_HOSTNAME)
internal_aliases.append(supervise_container_name(plan.slug))
service: dict[str, Any] = { service: dict[str, Any] = {
"image": SIDECAR_BUNDLE_IMAGE, "image": SIDECAR_BUNDLE_IMAGE,
@@ -330,7 +324,7 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
if plan.egress_plan.routes: if plan.egress_plan.routes:
from .egress import EGRESS_PORT from .egress import EGRESS_PORT
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
return f"http://{pipelock_container_name(plan.slug)}:{PIPELOCK_PORT}" return f"http://{PIPELOCK_HOSTNAME}:{PIPELOCK_PORT}"
def _agent_no_proxy(plan: DockerBottlePlan) -> str: def _agent_no_proxy(plan: DockerBottlePlan) -> str:
+7 -22
View File
@@ -1,11 +1,11 @@
"""DockerEgress — the Docker-specific lifecycle for the """Docker-side egress helpers: port pin, in-container CA paths,
per-bottle egress sidecar (PRD 0017). Inherits the platform- container naming, and the host-side mitmproxy CA mint. The
agnostic prepare step (route lift + routes.yaml render + token-env prepare-time routes-yaml rendering itself lives on the
map derivation) from `Egress`. platform-neutral `Egress` ABC — backends instantiate it directly.
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into The per-container `.start()` / `.stop()` lifecycle was removed in
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy- PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
block remediation flow (PRD 0014).""" under its python init supervisor."""
from __future__ import annotations from __future__ import annotations
@@ -13,7 +13,6 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from ...egress import Egress
from ...log import die from ...log import die
@@ -33,14 +32,6 @@ EGRESS_PIPELOCK_CA_IN_CONTAINER = (
) )
def egress_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle — any code still referring to `claude-bottle-egress-<slug>`
resolves to the bundle's IP."""
return f"claude-bottle-egress-{slug}"
def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress MITM CA via host `openssl req`. """Mint the per-bottle egress MITM CA via host `openssl req`.
@@ -130,9 +121,3 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes()) mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
mitm.chmod(0o644) mitm.chmod(0o644)
return (mitm, cert_path) 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."""
+5 -28
View File
@@ -1,13 +1,11 @@
"""DockerGitGate — Docker-flavored git-gate config (PRD 0008). """Docker-side git-gate constants: in-container paths the renderer's
Inherits the platform-agnostic prepare step (upstream lift + bind-mounts target + the listening port. The prepare-time entrypoint
entrypoint/hook render) from `GitGate`. The git-gate daemon runs / hook render lives on the platform-neutral `GitGate` ABC — backends
inside the sidecar bundle (PRD 0024); this module just holds the instantiate it directly. The git-gate daemon's container lifecycle
in-container paths the renderer's bind-mounts target.""" is owned by the sidecar bundle (PRD 0024)."""
from __future__ import annotations from __future__ import annotations
from ...git_gate import GitGate
GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh" GIT_GATE_ENTRYPOINT_IN_CONTAINER = "/git-gate-entrypoint.sh"
GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive" GIT_GATE_HOOK_IN_CONTAINER = "/etc/git-gate/pre-receive"
@@ -16,24 +14,3 @@ GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds"
# git daemon's default listening port. # git daemon's default listening port.
GIT_GATE_PORT = 9418 GIT_GATE_PORT = 9418
def git_gate_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle — any code still dialing `claude-bottle-git-gate-<slug>`
resolves to the bundle's IP."""
return f"claude-bottle-git-gate-{slug}"
def git_gate_host(slug: str) -> str:
"""The hostname the agent's git client 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)
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)."""
+2 -2
View File
@@ -65,7 +65,7 @@ from .compose import (
) )
from .egress import egress_tls_init from .egress import egress_tls_init
from .pipelock import ( from .pipelock import (
pipelock_proxy_url, BUNDLE_LOCAL_PIPELOCK_URL,
pipelock_tls_init, pipelock_tls_init,
) )
@@ -149,7 +149,7 @@ def launch(
mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
pipelock_ca_host_path=ca_cert_host, pipelock_ca_host_path=ca_cert_host,
pipelock_proxy_url=pipelock_proxy_url(plan.slug), pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
) )
supervise_plan = plan.supervise_plan supervise_plan = plan.supervise_plan
if supervise_plan is not None: if supervise_plan is not None:
+18 -31
View File
@@ -1,14 +1,12 @@
"""DockerPipelockProxy — the Docker-specific implementation of the """Docker-side pipelock helpers: image pin, container naming, and
sidecar's `.prepare()` step + in-container CA path constants. the one-shot `pipelock tls init` host-side CA mint. The
Inherits the platform-agnostic YAML-config generation from prepare-time YAML rendering itself lives on the platform-neutral
PipelockProxy. `PipelockProxy` ABC — backends instantiate it directly.
The per-container `.start()` / `.stop()` lifecycle was deleted in 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 0018) and the bundle path (PRD 0024) collapses pipelock + egress
+ git-gate + supervise into one container. What remains here is + git-gate + supervise into one container."""
the prepare-time YAML rendering + the CA path constants the
compose renderer reads."""
from __future__ import annotations from __future__ import annotations
@@ -17,7 +15,13 @@ import subprocess
from pathlib import Path from pathlib import Path
from ...log import die 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 # Pipelock image, pinned by digest. The digest is the multi-arch image
@@ -30,19 +34,12 @@ PIPELOCK_IMAGE = os.environ.get(
# Listening port for pipelock's forward proxy. # Listening port for pipelock's forward proxy.
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888") 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"
# The URL egress dials for its upstream HTTPS_PROXY. egress and
def pipelock_container_name(slug: str) -> str: # pipelock share the same container's network namespace inside the
return f"claude-bottle-pipelock-{slug}" # sidecar bundle, so loopback reaches pipelock directly — no docker
# DNS aliases involved.
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
def pipelock_proxy_url(slug: str) -> str:
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
@@ -82,13 +79,3 @@ def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
key.chmod(0o600) key.chmod(0o600)
cert.chmod(0o644) cert.chmod(0o644)
return (cert, key) 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
+21 -32
View File
@@ -14,13 +14,15 @@ import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
from ...log import die from ...log import die
from ...pipelock import PipelockProxy
from ...supervise import Supervise
from .. import BottleSpec from .. import BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress import DockerEgress, egress_container_name
from .git_gate import DockerGitGate, git_gate_container_name
from .bottle_state import ( from .bottle_state import (
BottleMetadata, BottleMetadata,
agent_state_dir, agent_state_dir,
@@ -35,8 +37,7 @@ from .bottle_state import (
supervise_state_dir, supervise_state_dir,
write_metadata, write_metadata,
) )
from .pipelock import DockerPipelockProxy, pipelock_container_name from .sidecar_bundle import sidecar_bundle_container_name
from .supervise import DockerSupervise, supervise_container_name
def resolve_plan( def resolve_plan(
@@ -49,10 +50,10 @@ def resolve_plan(
validation already ran in the base class.""" validation already ran in the base class."""
docker_mod.require_docker() docker_mod.require_docker()
proxy = DockerPipelockProxy() proxy = PipelockProxy()
git_gate = DockerGitGate() git_gate = GitGate()
egress = DockerEgress() egress = Egress()
supervise = DockerSupervise() supervise = Supervise()
manifest = spec.manifest manifest = spec.manifest
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
@@ -123,30 +124,18 @@ def resolve_plan(
f"clean up old containers with 'docker rm -f <name>'" f"clean up old containers with 'docker rm -f <name>'"
) )
# Probe sidecar container names for orphans from a previous run. # Probe the sidecar-bundle container name for an orphan from a
# Sidecar names are deterministic from the slug; an orphan would # previous run. Otherwise a stale bundle surfaces as a
# surface as a docker-create conflict deep inside launch() with no # docker-create conflict deep inside launch() with no actionable
# actionable hint. Fail fast here with a cleanup pointer instead. # hint; failing fast here points at the cleanup command.
# Only probe sidecars this launch will actually try to create: bundle_name = sidecar_bundle_container_name(slug)
# pipelock always; git-gate when bottle.git is non-empty; if docker_mod.container_exists(bundle_name):
# egress when bottle.egress.routes is non-empty. die(
sidecar_probes: list[tuple[str, str]] = [ f"sidecar bundle container '{bundle_name}' already exists. "
("pipelock", pipelock_container_name(slug)), f"This is an orphan from a previous run; clean it up with "
] f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
if bottle.git: f"retry."
sidecar_probes.append(("git-gate", git_gate_container_name(slug))) )
if bottle.egress.routes:
sidecar_probes.append(("egress", egress_container_name(slug)))
if bottle.supervise:
sidecar_probes.append(("supervise", supervise_container_name(slug)))
for label, sidecar_name in sidecar_probes:
if docker_mod.container_exists(sidecar_name):
die(
f"{label} sidecar container '{sidecar_name}' already exists. "
f"This is an orphan from a previous run; clean it up with "
f"'./cli.py cleanup' (or 'docker rm -f {sidecar_name}') and "
f"retry."
)
# PRD 0018 chunk 2: prepare-time scratch files live under # PRD 0018 chunk 2: prepare-time scratch files live under
# ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose # ~/.claude-bottle/state/<slug>/<service>/ so chunk 3's compose
@@ -19,11 +19,11 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from ....git_gate import GIT_GATE_HOSTNAME
from ....log import info from ....log import info
from ....manifest import GitEntry from ....manifest import GitEntry
from .. import util as docker_mod from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
from ..git_gate import git_gate_host
def provision_git(plan: DockerBottlePlan, target: str) -> None: def provision_git(plan: DockerBottlePlan, target: str) -> None:
@@ -56,7 +56,7 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
) )
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str: def render_git_gate_gitconfig(entries: tuple[GitEntry, ...]) -> str:
"""Render the ~/.gitconfig content for git-gate `insteadOf` """Render the ~/.gitconfig content for git-gate `insteadOf`
rewrites. Pure host-side, no docker; exposed for tests. rewrites. Pure host-side, no docker; exposed for tests.
@@ -64,7 +64,6 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
cleanly without conditional formatting at the call site.""" cleanly without conditional formatting at the call site."""
if not entries: if not entries:
return "" return ""
gate = git_gate_host(slug)
out = [ out = [
"# claude-bottle git-gate (PRD 0008): every git operation against\n", "# claude-bottle git-gate (PRD 0008): every git operation against\n",
"# a declared upstream routes through the gate, which mirrors\n", "# a declared upstream routes through the gate, which mirrors\n",
@@ -72,7 +71,7 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
] ]
for entry in entries: for entry in entries:
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n') out.append(f'[url "git://{GIT_GATE_HOSTNAME}/{entry.Name}.git"]\n')
out.append(f"\tinsteadOf = {entry.Upstream}\n") out.append(f"\tinsteadOf = {entry.Upstream}\n")
return "".join(out) return "".join(out)
@@ -87,7 +86,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node") container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig" container_gitconfig = f"{container_home}/.gitconfig"
content = render_git_gate_gitconfig(plan.slug, bottle.git) content = render_git_gate_gitconfig(bottle.git)
config_file = plan.stage_dir / "agent_gitconfig" config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content) config_file.write_text(content)
config_file.chmod(0o600) config_file.chmod(0o600)
-23
View File
@@ -1,23 +0,0 @@
"""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
from ...supervise import Supervise
def supervise_container_name(slug: str) -> str:
"""The legacy per-sidecar container name. Kept as a function so
the renderer can register it as a docker-network alias on the
bundle any code still referring to
`claude-bottle-supervise-<slug>` resolves to the bundle's IP."""
return f"claude-bottle-supervise-{slug}"
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)."""
@@ -12,7 +12,11 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan
from ...supervise import SupervisePlan
from .. import BottlePlan from .. import BottlePlan
from ..print_util import print_multi from ..print_util import print_multi
@@ -57,6 +61,20 @@ class SmolmachinesBottlePlan(BottlePlan):
# empty when the agent has no prompt — claude-code reads it # empty when the agent has no prompt — claude-code reads it
# via --append-system-prompt-file only when non-empty. # via --append-system-prompt-file only when non-empty.
prompt_file: Path prompt_file: Path
# Inner Plans for the four bundle daemons. The same shape the
# docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the
# docker-specific network fields (internal_network,
# egress_network) because the smolmachines bundle isn't on
# docker's `--internal` + egress bridge topology; it's on a
# per-bottle bridge with a pinned IP. The unused fields stay
# at their dataclass defaults.
proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan
egress_plan: EgressPlan
# None when bottle.supervise is False, matching the docker
# backend's convention.
supervise_plan: SupervisePlan | None
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight. Same shape as the Docker """Compact y/N preflight. Same shape as the Docker
+178 -44
View File
@@ -1,24 +1,50 @@
"""End-to-end launch flow for the smolmachines backend """End-to-end launch flow for the smolmachines backend
(PRD 0023 chunk 2d). (PRD 0023 chunks 2d + 4b).
Brings up the per-bottle docker bridge + sidecar bundle, creates Brings up the per-bottle docker bridge + sidecar bundle (with
+ starts the smolvm guest pointed at the bundle's pinned IP via real daemons + their config files), creates + starts the smolvm
the Smolfile's TSI allowlist, yields a `SmolmachinesBottle` guest pointed at the bundle's pinned IP via TSI's
handle, tears everything down on context exit. `--allow-cidr <bundle-ip>/32` allowlist, yields a
`SmolmachinesBottle` handle, tears everything down on context
exit.
Chunk-2d scope: smoke-test plumbing for the launch + exec round The bundle's daemons consume the inner Plans the docker backend
trip. The bundle daemons aren't supplied with config files yet already produces: pipelock reads its yaml + CA from the
(pipelock.yaml, routes.yaml, etc.); the bundle's init supervisor PipelockProxyPlan; egress reads routes + CAs from the EgressPlan
exits cleanly when nothing is configured. Real provisioning + CA + EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle
install + the inner Plan plumbing land in chunk 4.""" local), since the agent dials pipelock first (not egress) on the
smolmachines path. Git-gate + supervise plumb through the same
plans the docker backend uses, minus the docker-network fields
that don't apply here."""
from __future__ import annotations from __future__ import annotations
import dataclasses
import os
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from typing import Callable, Generator from typing import Callable, Generator
from . import smolvm as _smolvm 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 (
EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
egress_tls_init,
)
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ..docker.pipelock import BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
@@ -34,40 +60,53 @@ def launch(
via the ExitStack.""" via the ExitStack."""
stack = ExitStack() stack = ExitStack()
try: try:
# 1. Per-bottle docker bridge + bundle container. # 1. Per-bottle docker bridge.
network = _bundle.bundle_network_name(plan.slug) network = _bundle.bundle_network_name(plan.slug)
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway) _bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
stack.callback(_bundle.remove_bundle_network, network) stack.callback(_bundle.remove_bundle_network, network)
bundle_spec = _bundle.BundleLaunchSpec( # 2. Mint per-bottle CAs and update the inner Plans with
slug=plan.slug, # their launch-time paths. pipelock always runs in the
network_name=network, # bundle; egress's CA is only minted when the bottle
subnet=plan.bundle_subnet, # declares routes (otherwise egress runs idle without
gateway=plan.bundle_gateway, # MITM and the CA files would be unused).
bundle_ip=plan.bundle_ip, ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent)
# Chunk 2d: empty daemon set — the init supervisor proxy_plan = dataclasses.replace(
# logs "no daemons selected" and idles. Real daemon plan.proxy_plan,
# bringup with inner-Plan-driven env + volumes lands ca_cert_host_path=ca_cert_host,
# in chunk 4 alongside provisioning. ca_key_host_path=ca_key_host,
daemons_csv="",
# PRD 0023 chunk 3: pin egress to localhost INSIDE the
# bundle so the agent's TSI-permitted `<bundle-ip>:*`
# connect to :9099 refuses at the socket level. Always
# set in smolmachines mode — agent dials pipelock, not
# egress, so egress is bundle-internal regardless of
# whether routes are declared. The docker backend
# doesn't set this env (egress on 0.0.0.0 by default)
# since the docker agent goes via the egress alias.
environment=("EGRESS_LISTEN_HOST=127.0.0.1",),
) )
_bundle.start_bundle(bundle_spec) egress_plan = plan.egress_plan
if egress_plan.routes:
egress_ca_host, egress_ca_cert_only = egress_tls_init(
plan.egress_plan.routes_path.parent,
)
egress_plan = dataclasses.replace(
egress_plan,
mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
pipelock_ca_host_path=ca_cert_host,
# On smolmachines, egress's upstream is pipelock
# on the bundle's localhost — they're in the same
# container's network namespace.
pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL,
)
plan = dataclasses.replace(
plan, proxy_plan=proxy_plan, egress_plan=egress_plan,
)
# 3. Build the BundleLaunchSpec from the (now-resolved)
# inner Plans: daemon subset, env, bind-mounts.
bundle_spec = _bundle_launch_spec(plan, network)
token_env = _resolve_token_env(plan, os.environ)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
stack.callback(_bundle.stop_bundle, plan.slug) stack.callback(_bundle.stop_bundle, plan.slug)
# 2. smolvm VM. --from carries the pre-packed # 4. smolvm VM. --from carries the pre-packed .smolmachine
# .smolmachine artifact (built by prepare); --allow-cidr # artifact (built by prepare); --allow-cidr + -e carry the
# + -e carry the per-bottle TSI allowlist + env. Smolfile # per-bottle TSI allowlist + env. Smolfile isn't usable
# isn't usable here — smolvm 0.8.0 makes `--from` and # here — smolvm 0.8.0 makes `--from` and `--smolfile`
# `--smolfile` mutually exclusive. # mutually exclusive.
_smolvm.machine_create( _smolvm.machine_create(
plan.machine_name, plan.machine_name,
from_path=plan.agent_from_path, from_path=plan.agent_from_path,
@@ -78,14 +117,109 @@ def launch(
_smolvm.machine_start(plan.machine_name) _smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name)
# 3. Provision (CA / prompt / skills / git / supervise). # 5. Provision (CA / prompt / skills / git / supervise).
# The orchestrator runs each one in order; provision_*
# methods left as stubs (chunk 4 follow-ons) are no-ops.
prompt_path = provision(plan, plan.machine_name) prompt_path = provision(plan, plan.machine_name)
# 4. Yield the handle. The prompt_path drives whether
# exec_claude adds --append-system-prompt-file to claude's
# argv (None → no flag).
yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path) yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path)
finally: finally:
stack.close() stack.close()
def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str
) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV:
- egress + pipelock are always present (pipelock is the
agent's first hop; egress is its upstream).
- git-gate is conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan.
Env + volumes are the union of the four daemons' needs, with
daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh see PRD 0024's bundle
bind-address PR)."""
daemons: list[str] = ["egress", "pipelock"]
env: list[str] = []
volumes: list[tuple[str, str, bool]] = []
# PRD 0023 chunk 3: egress binds 127.0.0.1 inside the bundle
# so TSI's IP-only allowlist can't bypass pipelock.
env.append("EGRESS_LISTEN_HOST=127.0.0.1")
# --- pipelock ---------------------------------------------
pp = plan.proxy_plan
volumes += [
(str(pp.yaml_path), "/etc/pipelock.yaml", True),
(str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True),
(str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True),
]
# --- egress -----------------------------------------------
ep = plan.egress_plan
if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}")
env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}")
volumes += [
(str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True),
(str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True),
(str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True),
]
# Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv.
for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env)
# --- git-gate ---------------------------------------------
extra_hosts: list[str] = []
gp = plan.git_gate_plan
if gp.upstreams:
daemons.append("git-gate")
volumes += [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER, True),
]
for u in gp.upstreams:
keypath = expand_tilde(u.identity_file)
volumes.append((
keypath,
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
True,
))
# --- supervise --------------------------------------------
sp = plan.supervise_plan
if sp is not None:
daemons.append("supervise")
env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}",
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
return _bundle.BundleLaunchSpec(
slug=plan.slug,
network_name=network,
subnet=plan.bundle_subnet,
gateway=plan.bundle_gateway,
bundle_ip=plan.bundle_ip,
daemons_csv=",".join(daemons),
environment=tuple(env),
volumes=tuple(volumes),
)
def _resolve_token_env(
plan: SmolmachinesBottlePlan, host_env: object
) -> dict[str, str]:
"""Resolve the egress token env-var values from the host's
environ so they reach the bundle's process env via docker's
`-e NAME` inheritance. Empty when no routes declare auth."""
ep = plan.egress_plan
if not ep.routes:
return {}
return egress_resolve_token_values(ep.token_env_map, dict(host_env))
@@ -15,8 +15,16 @@ from ...backend.docker.bottle_state import (
BottleMetadata, BottleMetadata,
agent_state_dir, agent_state_dir,
bottle_identity, bottle_identity,
egress_state_dir,
git_gate_state_dir,
pipelock_state_dir,
supervise_state_dir,
write_metadata, write_metadata,
) )
from ...egress import Egress
from ...git_gate import GitGate
from ...pipelock import PipelockProxy
from ...supervise import Supervise
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
@@ -86,6 +94,30 @@ def resolve_plan(
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}" f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
) )
# 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 = 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 = GitGate().prepare(bottle, slug, git_gate_dir)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
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 = Supervise().prepare(slug, supervise_dir)
# Prompt file is always written (mode 0o600) so the in-VM # Prompt file is always written (mode 0o600) so the in-VM
# path always exists. Content is the agent's `prompt` # path always exists. Content is the agent's `prompt`
# field (markdown body) — empty for agents with no prompt. # field (markdown body) — empty for agents with no prompt.
@@ -118,6 +150,10 @@ def resolve_plan(
agent_from_path=agent_from_path, agent_from_path=agent_from_path,
guest_env=guest_env, guest_env=guest_env,
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
) )
+5
View File
@@ -38,6 +38,11 @@ from .log import die
from .manifest import Bottle from .manifest import Bottle
# Short network alias for git-gate inside the sidecar bundle. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
GIT_GATE_HOSTNAME = "git-gate"
def _empty_str_map() -> dict[str, str]: def _empty_str_map() -> dict[str, str]:
return {} return {}
+35 -24
View File
@@ -17,7 +17,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -47,6 +46,21 @@ 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"
# Short network alias for pipelock inside the sidecar bundle. The
# agent's HTTP_PROXY (when no egress is declared) and any in-bundle
# consumer's URL both reference this name.
PIPELOCK_HOSTNAME = "pipelock"
# --- Allowlist resolution -------------------------------------------------- # --- Allowlist resolution --------------------------------------------------
@@ -301,44 +315,41 @@ class PipelockProxyPlan:
ca_key_host_path: Path = Path() ca_key_host_path: Path = Path()
class PipelockProxy(ABC): class PipelockProxy:
"""The pipelock egress proxy. Encapsulates the YAML-config """The pipelock egress proxy. Encapsulates the YAML-config
generation; the sidecar's start/stop lifecycle is backend-specific generation; the container lifecycle is owned by whatever
and lives on concrete subclasses. wraps the daemon (compose-managed pipelock container on docker,
sidecar-bundle PID 1 on smolmachines).
The class-level constants `CA_CERT_IN_CONTAINER` / Backends instantiate the class directly there are no
`CA_KEY_IN_CONTAINER` are the in-container paths the YAML config platform-specific subclasses; the in-container CA paths are
references they correspond to wherever the backend's `.start` universal module-level constants
places the CA cert and key inside the sidecar. Subclasses (`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`)."""
override the constants."""
CA_CERT_IN_CONTAINER: str = ""
CA_KEY_IN_CONTAINER: str = ""
def prepare( def prepare(
self, bottle: Bottle, slug: str, stage_dir: Path self, bottle: Bottle, slug: str, stage_dir: Path
) -> PipelockProxyPlan: ) -> PipelockProxyPlan:
"""Write the pipelock yaml config (mode 600) under `stage_dir` """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. subprocess.
`slug` is the agent-derived identifier (lowercased, `slug` is the agent-derived identifier (lowercased,
hyphen-normalized) used as the suffix in every per-agent hyphen-normalized) used as the suffix in every per-agent
resource name the agent container, the pipelock container resource name the agent container, the sidecar bundle
(`claude-bottle-pipelock-<slug>`), the internal/egress container, the internal/egress networks. It's stored on the
networks. It's stored on the returned plan so the backend's returned plan so the backend's launch step can derive those
start step can derive the sidecar's container name. names.
The CA paths the YAML references are the in-container paths The CA paths the YAML references are the module-level
from the concrete subclass's class-level constants. The in-container constants. The host-side counterparts are
host-side counterparts are generated by the launch step generated by the launch step (not here, so prepare stays
(not here, so prepare stays side-effect-free on docker) and side-effect-free on docker) and added to the plan via
added to the plan via `dataclasses.replace` before `.start`.""" `dataclasses.replace` before the daemon starts."""
yaml_path = stage_dir / "pipelock.yaml" yaml_path = stage_dir / "pipelock.yaml"
cfg = pipelock_build_config( cfg = pipelock_build_config(
bottle, bottle,
ca_cert_path=self.CA_CERT_IN_CONTAINER, ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
ca_key_path=self.CA_KEY_IN_CONTAINER, ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
) )
yaml_path.write_text(pipelock_render_yaml(cfg)) yaml_path.write_text(pipelock_render_yaml(cfg))
yaml_path.chmod(0o600) yaml_path.chmod(0o600)
+2 -2
View File
@@ -21,8 +21,8 @@ to the agent.
This module defines the host-side library: dataclasses for the queue This module defines the host-side library: dataclasses for the queue
file shapes, queue read/write helpers, the audit log writer, and the file shapes, queue read/write helpers, the audit log writer, and the
diff renderer. The in-container sidecar lives in diff renderer. The in-container sidecar lives in
claude_bottle/supervise_server.py; the Docker lifecycle in claude_bottle/supervise_server.py; the supervise daemon's container
claude_bottle/backend/docker/supervise.py. lifecycle is owned by the sidecar bundle (PRD 0024).
For 0013 the supervisor's approval handlers are deliberately no-ops: For 0013 the supervisor's approval handlers are deliberately no-ops:
on approval the audit log is written and the response file is on approval the audit log is written and the response file is
+5 -2
View File
@@ -39,6 +39,9 @@ from claude_bottle.backend.docker.network import (
network_create_internal, network_create_internal,
network_remove, network_remove,
) )
from claude_bottle.backend.docker.sidecar_bundle import (
sidecar_bundle_container_name,
)
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
@@ -101,9 +104,9 @@ class TestCapabilityApply(unittest.TestCase):
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
self.assertEqual(0, r.returncode, r.stderr) self.assertEqual(0, r.returncode, r.stderr)
# Also start a fake supervise sidecar so teardown has something # Also start a fake sidecar bundle so teardown has something
# extra to clean up (mirrors a real bottle's container set). # extra to clean up (mirrors a real bottle's container set).
sidecar = f"claude-bottle-supervise-{self.slug}" sidecar = sidecar_bundle_container_name(self.slug)
subprocess.run( subprocess.run(
[ [
"docker", "run", "-d", "docker", "run", "-d",
+2 -2
View File
@@ -35,9 +35,9 @@ from claude_bottle.backend.docker.network import (
from claude_bottle.backend.docker.pipelock import ( from claude_bottle.backend.docker.pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER,
DockerPipelockProxy,
pipelock_tls_init, pipelock_tls_init,
) )
from claude_bottle.pipelock import PipelockProxy
from claude_bottle.backend.docker.pipelock_apply import ( from claude_bottle.backend.docker.pipelock_apply import (
PipelockApplyError, PipelockApplyError,
apply_allowlist_change, apply_allowlist_change,
@@ -99,7 +99,7 @@ class TestPipelockApply(unittest.TestCase):
the updated config.""" the updated config."""
state_dir = pipelock_state_dir(self.slug) state_dir = pipelock_state_dir(self.slug)
state_dir.mkdir(parents=True, exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
prep = DockerPipelockProxy().prepare( prep = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], self.slug, state_dir, fixture_minimal().bottles["dev"], self.slug, state_dir,
) )
self.internal_net = network_create_internal(self.slug) self.internal_net = network_create_internal(self.slug)
+1 -1
View File
@@ -402,7 +402,7 @@ class TestSandboxEscape(unittest.TestCase):
("aws", "TEST_SECRET_AWS"), ("aws", "TEST_SECRET_AWS"),
("generic", "TEST_SECRET_GENERIC"), ("generic", "TEST_SECRET_GENERIC"),
] ]
gate_host = f"claude-bottle-git-gate-{self._identity}" gate_host = "git-gate"
for name, var in shapes: for name, var in shapes:
with self.subTest(secret=name): with self.subTest(secret=name):
@@ -124,6 +124,32 @@ class TestSmolmachinesLaunch(unittest.TestCase):
f"expected a connect-refusal message; got: {r.stdout!r}", f"expected a connect-refusal message; got: {r.stdout!r}",
) )
def test_pipelock_answers_on_bundle_ip(self):
# Chunk 4b: the bundle's pipelock daemon is now actually
# running (was daemons_csv="" in chunks 2d/3). From inside
# the guest, a TCP connect to <bundle-ip>:8888 must succeed
# — distinct from the egress-port-bypass probe below where
# the connect must FAIL.
#
# We don't try to speak proxy protocol here — pipelock will
# 4xx a bare GET — we just verify the socket answers.
r = self.bottle.exec(
f"wget -T 5 -t 1 -O - http://{self.plan.bundle_ip}:8888/ "
"2>&1 || true"
)
# Any HTTP response (even a 4xx) proves pipelock is up.
# "connection refused" / "unable to connect" / "timed out"
# would mean it isn't.
msg = r.stdout.lower()
self.assertNotIn(
"connection refused", msg,
f"pipelock connect refused — daemon not listening? {r.stdout!r}",
)
self.assertNotIn(
"timed out", msg,
f"pipelock connect timed out: {r.stdout!r}",
)
def test_prompt_file_lands_in_guest(self): def test_prompt_file_lands_in_guest(self):
# provision_prompt copies the host-side prompt.txt into the # provision_prompt copies the host-side prompt.txt into the
# guest at /root/.claude-bottle-prompt.txt. The content # guest at /root/.claude-bottle-prompt.txt. The content
+6 -8
View File
@@ -112,7 +112,7 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem", pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem",
pipelock_proxy_url=f"http://claude-bottle-pipelock-{SLUG}:8888", pipelock_proxy_url="http://127.0.0.1:8888",
) )
@@ -221,7 +221,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")]
self.assertEqual(1, len(proxy_lines)) self.assertEqual(1, len(proxy_lines))
self.assertEqual( self.assertEqual(
f"HTTPS_PROXY=http://claude-bottle-pipelock-{SLUG}:8888", "HTTPS_PROXY=http://pipelock:8888",
proxy_lines[0], proxy_lines[0],
) )
@@ -304,12 +304,11 @@ class TestSidecarBundleShape(unittest.TestCase):
def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): def test_internal_aliases_cover_pipelock_and_egress_shortnames(self):
# The agent's HTTPS_PROXY url references either `egress` or # The agent's HTTPS_PROXY url references either `egress` or
# `pipelock` (long form). Both must resolve to the bundle. # `pipelock`. Both must resolve to the bundle.
sc = self._render()["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn("egress", aliases) self.assertIn("egress", aliases)
self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases) self.assertIn("pipelock", aliases)
self.assertIn(f"claude-bottle-egress-{SLUG}", aliases)
def test_internal_aliases_omit_inactive_sidecars(self): def test_internal_aliases_omit_inactive_sidecars(self):
# With no git-gate / supervise, those names are NOT aliased # With no git-gate / supervise, those names are NOT aliased
@@ -317,15 +316,14 @@ class TestSidecarBundleShape(unittest.TestCase):
# listening inside the bundle. # listening inside the bundle.
sc = self._render()["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertNotIn(f"claude-bottle-git-gate-{SLUG}", aliases) self.assertNotIn("git-gate", aliases)
self.assertNotIn("supervise", aliases) self.assertNotIn("supervise", aliases)
def test_internal_aliases_include_active_sidecars(self): def test_internal_aliases_include_active_sidecars(self):
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn(f"claude-bottle-git-gate-{SLUG}", aliases) self.assertIn("git-gate", aliases)
self.assertIn("supervise", aliases) self.assertIn("supervise", aliases)
self.assertIn(f"claude-bottle-supervise-{SLUG}", aliases)
def test_daemons_csv_lists_only_active(self): def test_daemons_csv_lists_only_active(self):
# Egress + pipelock are always in the daemon set even when # Egress + pipelock are always in the daemon set even when
+7 -7
View File
@@ -12,10 +12,10 @@ import unittest
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
from claude_bottle.pipelock import ( from claude_bottle.pipelock import (
DEFAULT_TLS_PASSTHROUGH, DEFAULT_TLS_PASSTHROUGH,
PipelockProxy,
pipelock_build_config, pipelock_build_config,
pipelock_render_yaml, pipelock_render_yaml,
) )
@@ -54,7 +54,7 @@ class TestBuildConfig(unittest.TestCase):
self.assertNotIn("tls_interception", cfg) self.assertNotIn("tls_interception", cfg)
def test_tls_interception_block_emitted_when_paths_supplied(self): 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 # constants; this directly pins the dict shape. passthrough_domains
# is baked in so LLM provider endpoints (api.anthropic.com) skip # is baked in so LLM provider endpoints (api.anthropic.com) skip
# MITM — pipelock's docs explicitly recommend this for LLM hosts, # MITM — pipelock's docs explicitly recommend this for LLM hosts,
@@ -152,7 +152,7 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertNotIn("ssrf:", text) self.assertNotIn("ssrf:", text)
def test_prepare_writes_file_at_mode_600(self): def test_prepare_writes_file_at_mode_600(self):
plan = DockerPipelockProxy().prepare( plan = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], "demo", self.out_dir fixture_minimal().bottles["dev"], "demo", self.out_dir
) )
self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777) 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"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
plan = DockerPipelockProxy().prepare( plan = PipelockProxy().prepare(
manifest.bottles["dev"], "demo", self.out_dir manifest.bottles["dev"], "demo", self.out_dir
) )
content = plan.yaml_path.read_text() content = plan.yaml_path.read_text()
@@ -179,13 +179,13 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertNotIn("prompt-message", content) self.assertNotIn("prompt-message", content)
def test_render_emits_tls_interception_via_prepare(self): def test_render_emits_tls_interception_via_prepare(self):
"""`DockerPipelockProxy.prepare` plumbs its in-container CA """`PipelockProxy.prepare` plumbs the module-level in-container
constants through to the YAML. The block should land in the CA constants through to the YAML. The block should land in the
rendered output with `enabled: true`, the configured paths, rendered output with `enabled: true`, the configured paths,
and the baked LLM-provider passthrough list. The actual and the baked LLM-provider passthrough list. The actual
host-side CA generation happens in launch (not prepare), so host-side CA generation happens in launch (not prepare), so
this test exercises only the YAML rendering.""" this test exercises only the YAML rendering."""
plan = DockerPipelockProxy().prepare( plan = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], "demo", self.out_dir fixture_minimal().bottles["dev"], "demo", self.out_dir
) )
content = plan.yaml_path.read_text() content = plan.yaml_path.read_text()
+6 -6
View File
@@ -9,15 +9,15 @@ from tests.fixtures import fixture_minimal, fixture_with_git
class TestGitGateGitconfigRender(unittest.TestCase): class TestGitGateGitconfigRender(unittest.TestCase):
def test_empty_entries_renders_nothing(self): def test_empty_entries_renders_nothing(self):
bottle = fixture_minimal().bottles["dev"] bottle = fixture_minimal().bottles["dev"]
self.assertEqual("", render_git_gate_gitconfig("demo", bottle.git)) self.assertEqual("", render_git_gate_gitconfig(bottle.git))
def test_one_block_per_entry(self): def test_one_block_per_entry(self):
bottle = fixture_with_git().bottles["dev"] bottle = fixture_with_git().bottles["dev"]
out = render_git_gate_gitconfig("demo", bottle.git) out = render_git_gate_gitconfig(bottle.git)
# Both entries map to a [url ...] block keyed on the gate's # Both entries map to a [url ...] block keyed on the gate's
# container hostname (claude-bottle-git-gate-<slug>). # short network alias (`git-gate`) inside the sidecar bundle.
self.assertIn( self.assertIn(
'[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]', '[url "git://git-gate/claude-bottle.git"]',
out, out,
) )
self.assertIn( self.assertIn(
@@ -25,7 +25,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
out, out,
) )
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out) self.assertIn('[url "git://git-gate/foo.git"]', out)
self.assertIn( self.assertIn(
"\tinsteadOf = ssh://git@github.com/didericis/foo.git", "\tinsteadOf = ssh://git@github.com/didericis/foo.git",
out, out,
@@ -37,7 +37,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
# gate push and leave fetch on the original URL — exactly the # gate push and leave fetch on the original URL — exactly the
# v1 design we've moved past. # v1 design we've moved past.
bottle = fixture_with_git().bottles["dev"] bottle = fixture_with_git().bottles["dev"]
out = render_git_gate_gitconfig("demo", bottle.git) out = render_git_gate_gitconfig(bottle.git)
self.assertIn("\tinsteadOf", out) self.assertIn("\tinsteadOf", out)
self.assertNotIn("pushInsteadOf", out) self.assertNotIn("pushInsteadOf", out)
+21
View File
@@ -18,7 +18,10 @@ from claude_bottle.backend.smolmachines.provision import (
prompt as _prompt, prompt as _prompt,
skills as _skills, skills as _skills,
) )
from claude_bottle.egress import EgressPlan
from claude_bottle.git_gate import GitGatePlan
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
from claude_bottle.pipelock import PipelockProxyPlan
def _plan( def _plan(
@@ -53,6 +56,24 @@ def _plan(
agent_from_path=Path("/tmp/agent.smolmachine"), agent_from_path=Path("/tmp/agent.smolmachine"),
guest_env={}, guest_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"),
slug="demo-abc12",
),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
hook_script=Path("/tmp/git-gate-hook"),
access_hook_script=Path("/tmp/git-gate-access-hook"),
upstreams=(),
),
egress_plan=EgressPlan(
slug="demo-abc12",
routes_path=Path("/tmp/routes.yaml"),
routes=(),
token_env_map={},
),
supervise_plan=None,
) )